SLOPSHOPPER

cc-sidecar

See what codex, claude and gemini do when Claude Code delegates to them from Bash

newpanespinnerguardcommandtimer
v1.0.0MITupdated 2026-09-20HoshimuraYuto/cc-sidecar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-sidecar
│ ┃ Sidecar ✕ › fix the failing auth test and add an audit log call │ ┃ No agent CLI is running. │ ┃ Runs of codex exec, claude -p and gemini ⏺ Read(src/auth.ts) │ ┃ -p show up here. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /cc-sidecar │ ⎿ cc-sidecar: mode pane — open the pane when an agent CLI starts │ ⎿ cc-sidecar: No resumable sessions yet. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sidecar
No agent CLI is running. Runs of codex exec, claude -p and gemini -p show up here.
README

cc-sidecar

日本語

A Claude Code plugin that monitors execution when Claude Code delegates work to another agent's CLI from Bash (codex exec, claude -p, gemini -p). Without this plugin, that delegation is just a plain shell command that runs silently until it finishes. With the plugin, the delegation becomes an agent run where you can see what the other agent is currently doing, what it has done, and which session to resume—while ensuring only its final answer goes back into Claude Code's context.

◆ Codex     ⠹ 0:23 · 4 steps · gpt-6-astra
  › $ rg --files -g '*.go' | wc -l
✦ Gemini    ✓ 0:41 · 6 steps · gemini-3.8-flash
  » 30 files.

Install

Add the marketplace once, then install the plugin from it:

/plugin marketplace add HoshimuraYuto/cc-sidecar
/plugin install cc-sidecar@cc-sidecar

Function hooks are an early-access feature, so start Claude Code with the flag that enables them:

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude

The plugin has no runtime dependencies. The monitored CLIs (codex, claude, and gemini) must be available in your PATH.

Configure

You can configure the mode option in /config or directly from the prompt:

/cc-sidecar          open the pane, print the mode and resumable sessions
/cc-sidecar hint     switch the mode (persisted through /config)
ModeWhat happens
pane (default)A pane opens as soon as an agent CLI starts, and remains open.
hintThe run is displayed on the prompt hint line with no pane.
bothDisplays both the side pane and the hint line.
offRuns are still tracked, but nothing opens automatically.

This setting is stored in settings.json under pluginConfigs, keyed by the plugin ID: cc-sidecar@cc-sidecar when installed from the marketplace, or cc-sidecar@inline when running from a checkout with --plugin-dir.

{
  "pluginConfigs": {
    "cc-sidecar@cc-sidecar": { "options": { "mode": "both" } }
  }
}

The /cc-sidecar command opens the pane in all modes, including off, keeping your history just a command away. If changing the configuration is rejected (for example, on an older Claude Code version or when a configuration row is locked), the plugin saves your preference in its local store and notifies you.

Manually closing the pane keeps it closed for the rest of the session, even in pane mode; you can bring it back at any time using /cc-sidecar. This prevents the pane from repeatedly reappearing after you have dismissed it.

Why

There are three ways to run another agent from Claude Code.

RouteTools the other agent usesWhat you see while it runs
proxyClaude Code's toolsThe execution loop is managed by Claude Code, so it is visible on screen.
MCPits own, behind one tool callNothing is displayed until the run finishes and returns a single tool result.
CLIits own native toolsNothing is displayed while running, and only the Bash output is shown when done.

The CLI route lets the other agent use its native tools, but normally leaves you blind while it runs, and whatever the CLI prints to stdout lands in Claude Code's context when the command returns (which for codex is the entire execution transcript). This plugin fills the first gap and solves the second. It is designed for one-shot delegation, as a CLI call blocks until it completes, meaning there is no way to intervene mid-run anyway.

Pane

The plugin interface consists of two screens: the list view and the run history.

List

Displays the status glyph, agent, state, elapsed time, step count, and model, followed by the agent's current action. The second line is color-coded by event type: » for messages, · for reasoning (dimmed), and › for tool calls (cyan). This line wraps to a maximum of two lines before truncating with …; the complete text can be viewed in the history screen. Completed runs show a ✓ or ✕ and remain visible for 10 seconds before disappearing.

History

Select the agent's name (by typing its corresponding digit when the pane has focus, or by clicking it) to view every step of that run, oldest first, with adjacent duplicates folded, showing up to the last 40 steps. The header displays the run state and model, followed by the exact command executed and the resume command for that CLI.

The run will not expire while you are viewing its history, and clicking ← back (or pressing the hotkey b) resets the 10-second expiration timer from that point. This ensures active runs do not disappear while you are reading them.

Hint line

In hint and both modes, active runs are displayed in the PromptHint row beneath the prompt footer:

⏵⏵ auto mode on (shift+tab to cycle) · ← for agents
✳ Claude ⠹ 0:18 · 1 step · claude-haiku-4-5 › $ sleep 20

This line displays the agent, state, elapsed time, step count, and model, followed by the agent's current activity. If multiple runs are active, each gets its own row (oldest first) in the same order as they appear in the pane. The engine's own drawing (next(e)) is kept inside the last row: a tree that leaves it out is placed to the right of the mode label instead, with its second line starting at the label's width.

The subagent list (● main / ◯ general-purpose ...) is displayed below the PromptHint area and is not a custom render target, which means CLI runs always appear above subagents regardless of the order in which they were started.

The plugin does not use: $.ui.status (which is drawn above the status line with a warning glyph and the plugin name in front), ToolProgress (a single indicator shared by all active calls and drawn under the last one), or ToolUse (since a Bash call folded into a Running 1 shell command group does not get its own row).

Resuming

Although a CLI call executes in a single turn, the session ID is preserved in the JSONL logs, allowing you to resume a run even after its row has disappeared. The /cc-sidecar command displays the five most recent runs.

CLIResumeWhere the id comes from
codexcodex exec resume <id>thread_id
claudeclaude --resume <id>session_id
geminigemini --resume latestinit.session_id (resuming by ID is not supported; only latest or an index can be used)

Detection

The plugin detects codex, gemini, and claude by matching the command name. If you add another CLI to AGENTS in hooks/hooks.tsx, the plugin will track its detection and elapsed time, but rich content parsing is only supported for these three primary agents.

Commands like cd x && FOO=1 codex exec ... are successfully detected because each segment is scanned past its environment variable assignments to its first executable token, which is then matched by its basename.

For codex, global flags placed between codex and exec (such as codex --search -m gpt-6-astra exec ...) are supported.

Because only the first token of a command segment is analyzed, wrapping the call (for example, timeout 240 codex exec ...) will prevent detection.

Quoted strings are masked before the command is split, ensuring that prompts containing newlines or semicolons inside quotes do not interfere with detection. Heredoc bodies are also skipped to prevent false positives, such as detecting codex inside a block like cat > f <<'EOF' ... codex ... EOF.

Command rewrite

The tool.call hook rewrites the Bash command to force the CLI to emit structured output.

CLIAddedCondition
codex--jsonthe command is codex exec
claude--output-format stream-json --verbose-p or --print is specified
gemini--output-format stream-json-p or --prompt is specified

Because claude and gemini accept these flags at the end of the command, their calls are only rewritten when the last command segment starts with that agent. For example, a command like claude -p ... ; echo done is left unmodified.

Any command that already specifies an output format (such as --output-format json) or contains a pipe is left unmodified to avoid overriding your explicit choices. Unrewritten commands will still show detection and elapsed time, but without step-by-step content parsing.

For more details on the final structure of rewritten commands and the reasoning behind each flag, see docs/internals.md.

What the model reads

When --json is enabled, the command's stdout consists entirely of JSONL data. If sent directly to the model, all execution details (including step-by-step reasoning, inner commands, and token usage) would flood back into the model's context, defeating the purpose of offloading the work to a sub-agent.

To prevent this, the tool.call hook intercepts and rewrites the return value of next(e), replacing the entire stdout stream with only the final answer. This ensures the model receives the exact same clean text it would have without --json, while the detailed run history remains accessible in the UI pane and the JSONL log files. The parsing logic runs entirely within the hook runtime, meaning the raw execution stream never enters the model's context.

The stdout of every rewritten command is replaced by a digest of either success or failure, ensuring raw JSONL data never reaches the model:

RunDigest
finished, okThe final answer, or the failure message if unavailable, or any non-JSON output lines.
finished, failedThe failure message (such as codex error/turn.failed, claude result.is_error, or gemini result.status != success), or the answer if available, or any non-JSON output lines.
not finished (interrupted, killed)The failure message, or any non-JSON output lines, or the fallback message "The run ended before it produced an answer."

Non-JSON lines include output written by the CLI outside the structured stream, such as a stack trace before a crash; the plugin retains the last 30 lines. Any unrewritten commands (such as those containing pipes or explicit output format flags) are returned completely untouched, just as the model would receive them without this plugin.

If the log file cannot be read, the plugin falls back to parsing the tool result's own stdout, which matches the output written by tee in the foreground.

For details on what else is excluded from the rewritten result and the reasoning behind it, see docs/internals.md.

Known issues

Claude Code's Bash sandbox. When the sandbox is enabled, the shell cannot write to $HOME, preventing the log directory at ~/.cache/cc-sidecar/runs from being created. As a result, the pane will show the run as starting… indefinitely, and background calls will fail with a "no answer was extracted" message instead of the actual answer. To fix this, run with the sandbox disabled or explicitly allow writes to that directory in your sandbox settings.

Killed background shells. Because the exit record is written by the shell itself, a background run killed by an external process will remain marked as running in the pane.

Development

To run the plugin from a checkout instead of the marketplace, pass the directory to Claude Code; the plugin ID is then cc-sidecar@inline.

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir .

Everything else goes through pnpm:

pnpm install          # dev dependencies and the lefthook git hooks
pnpm typecheck        # tsc against .claude/types/claude-code.d.ts
pnpm test             # claude plugin test, with the function-hooks flag set
pnpm format           # prettier over the whole tree
claude plugin validate .

.claude/types/claude-code.d.ts defines the plugin API as generated by the /plugin-types command in Claude Code. Avoid editing this file manually; instead, regenerate it using that command after updating Claude Code.

For information on how the test suite bypasses the kit's limitations and how to capture the screen during runs, refer to docs/internals.md.

Commits and releases

Commit messages must follow the Conventional Commits specification. Upon committing, lefthook automatically runs commitlint on the message and Prettier on staged files.

Every push to the main branch triggers semantic-release: feat commits bump the minor version, fix and perf bump the patch version, and a BREAKING CHANGE footer triggers a major version bump. The release workflow automatically writes the new version to .claude-plugin/plugin.json, updates CHANGELOG.md, commits both files with the message chore(release): x.y.z, and publishes a GitHub release. No packages are published to npm.

License

MIT

Source 1 files
hooks/hooks.tsx 872 lines
1import type { Register } from 'claude-code';
2
3const PANE_ID = 'cc-sidecar';
4const PLUGIN = 'cc-sidecar';
5const MODE_KEY = 'cc-sidecar.mode';
6const EXIT_EVENT = 'cc-sidecar.exit';
7const TICK_MS = 400;
8const LINGER_MS = 10_000;
9const PLAIN_MAX = 30;
10const ANSWER_WAIT_TICKS = 100;
11const HISTORY_MAX = 200;
12const DETAIL_ROWS = 40;
13const RUNS_MAX = 12;
14const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
15
16const MODES = ['pane', 'hint', 'both', 'off'] as const;
17type Mode = (typeof MODES)[number];
18
19const MODE_HELP: Record<Mode, string> = {
20  pane: 'open the pane when an agent CLI starts',
21  hint: 'show progress on the prompt hint line',
22  both: 'pane and hint line',
23  off: 'track runs silently; /cc-sidecar still opens the pane',
24};
25
26const AGENTS: Record<string, { label: string; glyph: string; color: string }> =
27  {
28    codex: { label: 'Codex', glyph: '◆', color: 'magenta' },
29    gemini: { label: 'Gemini', glyph: '✦', color: 'blue' },
30    claude: { label: 'Claude', glyph: '✳', color: 'cyan' },
31  };
32
33type Status = 'running' | 'ok' | 'error' | 'interrupted';
34type Kind = 'say' | 'think' | 'tool';
35type Entry = { kind: Kind; text: string };
36
37type Run = {
38  id: string;
39  key: string;
40  command: string;
41  startedAt: number;
42  endedAt?: number;
43  lingerFrom?: number;
44  status: Status;
45  logPath?: string;
46  threadId?: string;
47  answer?: string;
48  done?: boolean;
49  background?: boolean;
50  exitCode?: number;
51  failure?: string;
52  plain: string[];
53  pending?: string;
54  model?: string;
55  activity: Entry;
56  history: Entry[];
57  steps: number;
58  consumed: number;
59};
60
61const runs = new Map<string, Run>();
62const order: string[] = [];
63const threads: { key: string; threadId: string; at: number }[] = [];
64
65let mode: Mode = 'pane';
66let configured = false;
67let dismissed = false;
68let opened = false;
69let announced = false;
70let tick: { cancel: () => void } | null = null;
71let frame = 0;
72let viewing: string | null = null;
73let shown = '';
74
75const noop = () => {};
76
77function isMode(value: unknown): value is Mode {
78  return (
79    typeof value === 'string' && (MODES as readonly string[]).includes(value)
80  );
81}
82
83function wantsPane(): boolean {
84  return (mode === 'pane' || mode === 'both') && !dismissed;
85}
86
87function wantsHint(): boolean {
88  return mode === 'hint' || mode === 'both';
89}
90
91function scan(command: string): string {
92  const heads = /<<-?\s*(['"`]?)([A-Za-z_][A-Za-z0-9_]*)\1/g;
93  let kept = '';
94  let from = 0;
95  let head: RegExpExecArray | null;
96  while ((head = heads.exec(command))) {
97    const lineEnd = command.indexOf('\n', head.index);
98    if (lineEnd === -1) break;
99    const body = command.slice(lineEnd + 1);
100    const end = new RegExp(`^\\t*${head[2]}[ \\t]*$`, 'm').exec(body);
101    kept += command.slice(from, lineEnd + 1);
102    from = end ? lineEnd + 1 + end.index + end[0].length : command.length;
103    heads.lastIndex = from;
104  }
105  kept += command.slice(from);
106  return kept.replace(
107    /'[^']*'|"(?:[^"\\]|\\.)*"/g,
108    (quoted) => quoted[0]! + quoted[quoted.length - 1]!,
109  );
110}
111
112function agentFor(command: string): string | null {
113  for (const segment of scan(command).split(/\|\||&&|[;|\n]/)) {
114    const tokens = segment.trim().split(/\s+/).filter(Boolean);
115    let i = 0;
116    while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]!))
117      i++;
118    const token = tokens[i];
119    if (!token) continue;
120    const name = token.slice(token.lastIndexOf('/') + 1);
121    if (AGENTS[name]) return name;
122  }
123  return null;
124}
125
126function modelFlag(command: string): string | undefined {
127  const match = /(^|\s)(?:-m|--model)(?:\s+|=)("[^"]+"|'[^']+'|[^\s]+)/.exec(
128    command,
129  );
130  if (!match) return undefined;
131  return match[2]!.replace(/^["']|["']$/g, '');
132}
133
134function trailing(command: string, key: string): boolean {
135  const segments = scan(command)
136    .split(/\|\||&&|[;|\n]/)
137    .filter((part) => part.trim());
138  const tokens = (segments[segments.length - 1] ?? '')
139    .trim()
140    .split(/\s+/)
141    .filter(Boolean);
142  let i = 0;
143  while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]!)) i++;
144  const token = tokens[i];
145  if (!token) return false;
146  return token.slice(token.lastIndexOf('/') + 1) === key;
147}
148
149const CODEX_EXEC =
150  /(^|\s)(codex(?:\s+-\S+(?:\s+(?!exec\b)[^-\s]\S*)?)*\s+exec)\b/;
151
152function structured(key: string, command: string): string | null {
153  if (key === 'codex') {
154    if (!CODEX_EXEC.test(command)) return null;
155    return /(^|\s)--json(\s|$)/.test(command)
156      ? command
157      : command.replace(CODEX_EXEC, '$1$2 --json');
158  }
159  if (key === 'claude') {
160    if (!/(^|\s)(-p|--print)(\s|$)/.test(command)) return null;
161    if (/(^|\s)--output-format(\s|=)/.test(command)) return null;
162    if (!trailing(command, key)) return null;
163    const verbose = /(^|\s)--verbose(\s|$)/.test(command) ? '' : ' --verbose';
164    return `${command} --output-format stream-json${verbose}`;
165  }
166  if (key === 'gemini') {
167    if (!/(^|\s)(-p|--prompt)(\s|$)/.test(command)) return null;
168    if (/(^|\s)(-o|--output-format)(\s|=)/.test(command)) return null;
169    if (!trailing(command, key)) return null;
170    return `${command} --output-format stream-json`;
171  }
172  return null;
173}
174
175async function runsDir($: any): Promise<string> {
176  const home = await $.env.get('HOME').catch(() => undefined);
177  return `${home ?? '/tmp'}/.cache/${PLUGIN}/runs`;
178}
179
180function answerPath(logPath: string): string {
181  return logPath.replace(/\.jsonl$/, '.answer');
182}
183
184function rewrite(
185  key: string,
186  command: string,
187  dir: string,
188  logPath: string,
189  background: boolean,
190): string | null {
191  if (/\|/.test(command)) return null;
192  const shaped = structured(key, command);
193  if (!shaped) return null;
194  const withStdin = /(^|\s)<[^<]/.test(shaped)
195    ? shaped
196    : `${shaped} < /dev/null`;
197  const exit = `printf '{"type":"${EXIT_EVENT}","code":%s}\\n' "$?" >> ${logPath}`;
198  if (!background)
199    return `mkdir -p ${dir}; set -o pipefail; { ${withStdin} ; } 2>&1 | tee ${logPath}; ${exit}`;
200  const answer = answerPath(logPath);
201  const wait = `i=0; while [ ! -f ${answer} ] && [ $i -lt ${ANSWER_WAIT_TICKS} ]; do sleep 0.1; i=$((i+1)); done`;
202  const fallback = `echo '${PLUGIN}: no answer was extracted; the log is ${logPath}'`;
203  return `mkdir -p ${dir}; { ${withStdin} ; } > ${logPath} 2>&1; ${exit}; ${wait}; cat ${answer} 2>/dev/null || ${fallback}`;
204}
205
206function remember(run: Run): void {
207  runs.set(run.id, run);
208  order.push(run.id);
209  while (order.length > RUNS_MAX) {
210    const dropped = order.shift();
211    if (!dropped) continue;
212    runs.delete(dropped);
213    if (viewing === dropped) viewing = null;
214  }
215}
216
217function tidy(text: string): string {
218  return text.replace(/\s+/g, ' ').trim();
219}
220
221function note(run: Run, entry: Entry): void {
222  if (!entry.text) return;
223  run.activity = entry;
224  const last = run.history[run.history.length - 1];
225  if (!last || last.text !== entry.text) run.history.push(entry);
226  while (run.history.length > HISTORY_MAX) run.history.shift();
227}
228
229function toolLine(name: string, input: any): string {
230  if (typeof input?.command === 'string') return `$ ${tidy(input.command)}`;
231  const detail =
232    input?.file_path ?? input?.path ?? input?.pattern ?? input?.dir_path;
233  return typeof detail === 'string' ? `${name} ${tidy(detail)}` : name;
234}
235
236function describe(item: any): Entry | null {
237  if (item.type === 'agent_message' && typeof item.text === 'string') {
238    return { kind: 'say', text: tidy(item.text) };
239  }
240  if (item.type === 'reasoning' && typeof item.text === 'string') {
241    return { kind: 'think', text: tidy(item.text) };
242  }
243  if (item.type === 'command_execution' && typeof item.command === 'string') {
244    return { kind: 'tool', text: `$ ${tidy(item.command)}` };
245  }
246  return null;
247}
248
249function absorbCodex(run: Run, event: any): void {
250  if (event.thread_id) run.threadId = event.thread_id;
251  if (event.type === 'turn.completed') run.done = true;
252  if (event.type === 'error' && typeof event.message === 'string')
253    run.failure = event.message;
254  if (event.type === 'turn.failed') {
255    run.failure =
256      typeof event.error?.message === 'string'
257        ? event.error.message
258        : (run.failure ?? 'the turn failed');
259    run.done = true;
260  }
261  const item = event.item;
262  if (!item) return;
263  if (event.type === 'item.completed') {
264    run.steps++;
265    if (item.type === 'agent_message' && typeof item.text === 'string') {
266      run.answer = item.text;
267    }
268  }
269  const entry = describe(item);
270  if (entry) note(run, entry);
271}
272
273function absorbClaude(run: Run, event: any): void {
274  if (typeof event.session_id === 'string') run.threadId = event.session_id;
275  if (typeof event.model === 'string') run.model = event.model;
276  if (typeof event.message?.model === 'string') run.model = event.message.model;
277  if (event.type === 'assistant') {
278    for (const block of event.message?.content ?? []) {
279      if (block?.type === 'text' && typeof block.text === 'string') {
280        run.steps++;
281        note(run, { kind: 'say', text: tidy(block.text) });
282      } else if (block?.type === 'tool_use') {
283        run.steps++;
284        note(run, {
285          kind: 'tool',
286          text: toolLine(String(block.name ?? 'tool'), block.input),
287        });
288      }
289    }
290    return;
291  }
292  if (event.type === 'result') {
293    if (event.is_error === true)
294      run.failure =
295        typeof event.result === 'string'
296          ? event.result
297          : 'claude reported an error';
298    else if (typeof event.result === 'string') run.answer = event.result;
299    run.done = true;
300  }
301}
302
303function absorbGemini(run: Run, event: any): void {
304  if (event.type === 'init') {
305    if (typeof event.session_id === 'string') run.threadId = event.session_id;
306    if (typeof event.model === 'string') run.model = event.model;
307  }
308  const used = event.stats?.models;
309  if (used) {
310    const names = Array.isArray(used) ? used : Object.keys(used);
311    if (names.length > 0) run.model = names.join('/');
312  }
313  if (event.type === 'message') {
314    if (event.role !== 'assistant' || typeof event.content !== 'string') return;
315    const merged = event.delta
316      ? `${run.pending ?? ''}${event.content}`
317      : event.content;
318    run.pending = merged;
319    run.activity = { kind: 'say', text: tidy(merged) };
320    return;
321  }
322  const spoken = (run.pending ?? '').trim();
323  run.pending = undefined;
324  if (spoken) note(run, { kind: 'say', text: tidy(spoken) });
325  if (event.type === 'tool_use') {
326    run.steps++;
327    note(run, {
328      kind: 'tool',
329      text: toolLine(String(event.tool_name ?? 'tool'), event.parameters),
330    });
331    return;
332  }
333  if (event.type === 'result') {
334    if (event.status === 'success') {
335      if (spoken) run.answer = spoken;
336    } else {
337      run.failure =
338        typeof event.error?.message === 'string'
339          ? event.error.message
340          : 'gemini reported an error';
341    }
342    run.done = true;
343  }
344}
345
346function keepPlain(run: Run, line: string): void {
347  run.plain.push(line);
348  while (run.plain.length > PLAIN_MAX) run.plain.shift();
349}
350
351function absorb(run: Run, text: string): void {
352  const lines = text.split('\n');
353  for (let i = run.consumed; i < lines.length - 1; i++) {
354    const line = lines[i]!.trim();
355    if (!line) continue;
356    let event: any;
357    try {
358      event = JSON.parse(line);
359    } catch {
360      keepPlain(run, line);
361      continue;
362    }
363    if (event.type === EXIT_EVENT) {
364      run.exitCode = Number(event.code);
365      run.done = true;
366      continue;
367    }
368    if (run.key === 'claude') absorbClaude(run, event);
369    else if (run.key === 'gemini') absorbGemini(run, event);
370    else absorbCodex(run, event);
371  }
372  run.consumed = Math.max(run.consumed, lines.length - 1);
373}
374
375function resumeLine(key: string, threadId: string): string {
376  if (key === 'claude') return `claude --resume ${threadId}`;
377  if (key === 'gemini') return `gemini --resume latest`;
378  return `codex exec resume ${threadId}`;
379}
380
381function settle(run: Run, status: Status): void {
382  run.status = status;
383  run.endedAt = Date.now();
384  run.lingerFrom = viewing === run.id ? undefined : run.endedAt;
385  if (run.threadId)
386    threads.push({ key: run.key, threadId: run.threadId, at: run.endedAt });
387}
388
389function digest(run: Run, failed: boolean): string | null {
390  if (!run.done)
391    return (
392      run.failure ??
393      (run.plain.length > 0
394        ? run.plain.join('\n')
395        : 'The run ended before it produced an answer.')
396    );
397  const first = failed ? run.failure : run.answer;
398  const second = failed ? run.answer : run.failure;
399  return (
400    first ?? second ?? (run.plain.length > 0 ? run.plain.join('\n') : null)
401  );
402}
403
404function condense(run: Run, result: any): any {
405  if (!run.logPath || !result) return result;
406  const record = result.result;
407  if (!record || typeof record.stdout !== 'string') return result;
408  const text = digest(run, result.isError === true);
409  if (text === null) return result;
410  const trimmed: any = { ...record, stdout: text };
411  delete trimmed.persistedOutputPath;
412  delete trimmed.persistedOutputSize;
413  delete trimmed.rawOutputPath;
414  delete trimmed.structuredContent;
415  const replaced: any = { ...result, result: trimmed };
416  delete replaced.ref;
417  delete replaced.text;
418  return replaced;
419}
420
421function elapsed(run: Run): string {
422  const ms = (run.endedAt ?? Date.now()) - run.startedAt;
423  const total = Math.floor(ms / 1000);
424  return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
425}
426
427function agentOf(run: Run): { label: string; glyph: string; color: string } {
428  return AGENTS[run.key] ?? { label: run.key, glyph: '●', color: 'white' };
429}
430
431function mark(run: Run): string {
432  if (run.status === 'running') return SPINNER[frame % SPINNER.length]!;
433  if (run.status === 'ok') return '✓';
434  if (run.status === 'interrupted') return '⊘';
435  return '✕';
436}
437
438function markColor(run: Run): string {
439  if (run.status === 'running') return 'yellow';
440  if (run.status === 'ok') return 'green';
441  return 'red';
442}
443
444function kindColor(entry: Entry): string | undefined {
445  if (entry.kind === 'tool') return 'cyan';
446  return undefined;
447}
448
449function kindGlyph(entry: Entry): string {
450  if (entry.kind === 'tool') return '›';
451  if (entry.kind === 'think') return '·';
452  return '»';
453}
454
455function clamp(text: string, max: number): string {
456  return text.length > max ? `${text.slice(0, Math.max(1, max - 1))}…` : text;
457}
458
459function live(): Run[] {
460  const now = Date.now();
461  return order
462    .map((id) => runs.get(id))
463    .filter((run): run is Run => {
464      if (!run) return false;
465      if (run.status === 'running' || viewing === run.id) return true;
466      return run.lingerFrom !== undefined && now - run.lingerFrom < LINGER_MS;
467    });
468}
469
470function resolvePress(element: string): string | null {
471  if (element === 'back' || element === '← back') return 'back';
472  if (element.startsWith('row:')) return element.slice(4);
473  const match = live().find((run) => agentOf(run).label === element);
474  return match ? match.id : null;
475}
476
477function openDetail(id: string): void {
478  const run = runs.get(id);
479  if (!run) return;
480  viewing = id;
481  run.lingerFrom = undefined;
482}
483
484function closeDetail(): void {
485  const run = viewing ? runs.get(viewing) : undefined;
486  viewing = null;
487  if (run && run.status !== 'running') run.lingerFrom = Date.now();
488}
489
490function steps(run: Run): string {
491  return `${run.steps} ${run.steps === 1 ? 'step' : 'steps'}`;
492}
493
494async function pump($: any): Promise<void> {
495  frame++;
496  let moved = false;
497  for (const run of runs.values()) {
498    if (run.status !== 'running' || !run.logPath) continue;
499    const text = await $.fs.read(run.logPath).catch(() => null);
500    if (typeof text !== 'string') continue;
501    const before = run.history.length;
502    absorb(run, text);
503    if (run.history.length !== before) moved = true;
504    if (run.background && run.exitCode !== undefined) {
505      settle(run, run.exitCode === 0 ? 'ok' : 'error');
506      const text =
507        digest(run, run.exitCode !== 0) ?? 'The run ended without an answer.';
508      await $.fs.write(answerPath(run.logPath), text).catch(noop);
509      moved = true;
510    }
511  }
512  const rows = live();
513  const signature = rows.map((run) => `${run.id}:${run.status}`).join(',');
514  if (signature !== shown) {
515    shown = signature;
516    moved = true;
517  }
518  if (moved || rows.some((run) => run.status === 'running')) {
519    $.ui.invalidate('ui.render');
520  }
521  if (rows.length === 0 && tick) {
522    tick.cancel();
523    tick = null;
524  }
525}
526
527function start($: any): void {
528  if (tick) return;
529  try {
530    tick = $.clock.every(TICK_MS, () => void pump($));
531  } catch {
532    tick = null;
533  }
534}
535
536async function openPane($: any, focus: boolean): Promise<void> {
537  const panes = await $.ui.panes().catch(() => []);
538  const isUp = panes.some((pane: any) => pane.id === PANE_ID);
539  if (!focus) {
540    if (isUp) return;
541    if (opened) {
542      dismissed = true;
543      return;
544    }
545  }
546  dismissed = false;
547  await $.ui
548    .open({
549      id: PANE_ID,
550      title: 'Sidecar',
551      ...(focus ? { focus: true, rows: 18 } : {}),
552    })
553    .catch(noop);
554  opened = true;
555}
556
557async function closePane($: any): Promise<void> {
558  await $.ui.close({ id: PANE_ID }).catch(noop);
559  opened = false;
560}
561
562async function persistMode($: any, next: Mode): Promise<string | null> {
563  mode = next;
564  const answer = await $.config
565    .set({ key: MODE_KEY, value: next })
566    .catch(() => ({ deny: 'config unavailable' }));
567  if (!answer?.deny) return null;
568  await $.store.set('mode', next).catch(noop);
569  return String(answer.deny);
570}
571
572async function announce($: any): Promise<void> {
573  if (announced) return;
574  announced = true;
575  const answer = await $.command
576    .register({
577      name: PLUGIN,
578      description:
579        'Watch delegated agent CLIs (codex, claude, gemini); set where they show',
580      argumentHint: `[${MODES.join('|')}]`,
581    })
582    .catch(() => null);
583  if (!answer) announced = false;
584}
585
586function usage(): string {
587  const lines = MODES.map(
588    (name) => `  /cc-sidecar ${name.padEnd(5)} ${MODE_HELP[name]}`,
589  );
590  return ['Usage: /cc-sidecar [mode]', ...lines].join('\n');
591}
592
593function summary(): string {
594  const lines = [`mode ${mode} — ${MODE_HELP[mode]}`];
595  const resumable = threads.slice(-5).reverse();
596  if (resumable.length === 0) {
597    lines.push('No resumable sessions yet.');
598  } else {
599    lines.push('Resume a delegated session:');
600    for (const t of resumable) {
601      lines.push(
602        `  ${AGENTS[t.key]?.label ?? t.key}: ${resumeLine(t.key, t.threadId)}`,
603      );
604    }
605  }
606  return lines.join('\n');
607}
608
609export const register: Register = (on, options) => {
610  if (isMode(options?.mode)) {
611    mode = options.mode;
612    configured = true;
613  }
614
615  on('session.start', {}, async ($, e, next) => {
616    await announce($);
617    if (!configured) {
618      const saved = await $.store.get('mode').catch(() => undefined);
619      if (isMode(saved)) mode = saved;
620    }
621    return next(e);
622  });
623
624  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
625    const command =
626      typeof (e as any).command === 'string' ? (e as any).command : '';
627    const key = command ? agentFor(command) : null;
628    if (!key) return next(e);
629
630    const id = String((e as any).tool_use_id ?? `run_${Date.now()}`);
631    const dir = await runsDir($);
632    const logPath = `${dir}/${id}.jsonl`;
633    const background = (e as any).run_in_background === true;
634    const rewritten = rewrite(key, command, dir, logPath, background);
635
636    const run: Run = {
637      id,
638      key,
639      command,
640      startedAt: Date.now(),
641      status: 'running',
642      background,
643      logPath: rewritten ? logPath : undefined,
644      model: modelFlag(command),
645      activity: rewritten
646        ? { kind: 'say', text: 'starting…' }
647        : { kind: 'think', text: 'no structured output for this call' },
648      history: [],
649      plain: [],
650      steps: 0,
651      consumed: 0,
652    };
653    remember(run);
654    start($);
655    void announce($);
656
657    if (wantsPane()) await openPane($, false);
658    $.ui.invalidate('ui.render');
659
660    try {
661      const result = await next(rewritten ? { ...e, command: rewritten } : e);
662      if (run.background && !(result as any)?.isError) return result;
663      if (run.logPath) {
664        const stdout = (result as any)?.result?.stdout;
665        const text = await $.fs.read(run.logPath).catch(() => null);
666        absorb(
667          run,
668          typeof text === 'string'
669            ? text
670            : typeof stdout === 'string'
671              ? stdout
672              : '',
673        );
674      }
675      settle(run, (result as any)?.isError ? 'error' : 'ok');
676      $.ui.invalidate('ui.render');
677      return condense(run, result);
678    } catch (error) {
679      settle(run, 'error');
680      $.ui.invalidate('ui.render');
681      throw error;
682    }
683  });
684
685  on('ui.press', { plugin: PLUGIN, requestId: PANE_ID }, ($, e, next) => {
686    const target = resolvePress(String((e as any).element ?? ''));
687    if (target === 'back') closeDetail();
688    else if (target) openDetail(target);
689    else return next(e);
690    start($);
691    $.ui.invalidate('ui.render');
692    return next(e);
693  });
694
695  on('command.run', { command: PLUGIN }, async ($, e) => {
696    const arg = tidy(String((e as any).args ?? '')).toLowerCase();
697    const chosen = isMode(arg) ? arg : null;
698    if (arg && !chosen) return { text: `Unknown mode "${arg}".\n${usage()}` };
699    const warning = chosen ? await persistMode($, chosen) : null;
700    if (!chosen || wantsPane()) await openPane($, true);
701    else await closePane($);
702    $.ui.invalidate('ui.render');
703    const text = warning
704      ? `${summary()}\n(${warning}; kept for this machine in the plugin store)`
705      : summary();
706    return { text };
707  });
708
709  on('config.set', { key: MODE_KEY }, async ($, e, next) => {
710    const answer = await next(e);
711    if (isMode(answer?.value)) {
712      mode = answer.value;
713      if (!wantsPane() && !wantsHint()) await closePane($);
714      $.ui.invalidate('ui.render');
715    }
716    return answer;
717  });
718
719  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
720    void announce($);
721    const drawn = await next(e);
722    if (!wantsHint()) return drawn;
723    const rows = live();
724    if (rows.length === 0) return drawn;
725    const { Box, Text } = $.ui.resolve(e);
726    const columns = e.viewport?.columns ?? 80;
727    return (
728      <Box flexDirection="column">
729        <Text> </Text>
730        {rows.map((run, i) => {
731          const agent = agentOf(run);
732          return (
733            <Box key={run.id} flexDirection="row" gap={1}>
734              <Text color={agent.color}>
735                {agent.glyph} {agent.label.padEnd(6)}
736              </Text>
737              <Text color={markColor(run)}>{mark(run)}</Text>
738              <Text dimColor>{elapsed(run)}</Text>
739              {run.steps > 0 ? <Text dimColor>· {steps(run)}</Text> : null}
740              {run.model ? (
741                <Text dimColor>· {clamp(run.model, 24)}</Text>
742              ) : null}
743              <Text dimColor>{kindGlyph(run.activity)}</Text>
744              <Text
745                color={kindColor(run.activity)}
746                dimColor={run.activity.kind === 'think'}
747                wrap="truncate-end"
748              >
749                {clamp(run.activity.text, Math.max(20, columns - 60))}
750              </Text>
751              {i === rows.length - 1 ? drawn : null}
752            </Box>
753          );
754        })}
755      </Box>
756    );
757  });
758
759  on('ui.render', { component: 'Pane', requestId: PANE_ID }, ($, e) => {
760    void announce($);
761    const { Box, Text, Button } = $.ui.resolve(e);
762    const props = (e.props as any) ?? {};
763    const width = Math.max(30, (props.bodyColumns ?? 60) - 4);
764    const detail = viewing ? runs.get(viewing) : undefined;
765
766    if (detail) {
767      const agent = agentOf(detail);
768      const entries = detail.history.slice(-DETAIL_ROWS);
769      const offset = detail.history.length - entries.length;
770      return (
771        <Box flexDirection="column" paddingX={1}>
772          <Box flexDirection="row" gap={1}>
773            <Button key="back" plain hotkey="b" label="← back" onPress={noop} />
774            <Text color={agent.color} bold>
775              {agent.glyph} {agent.label}
776            </Text>
777            <Text color={markColor(detail)}>{mark(detail)}</Text>
778            <Text dimColor>{elapsed(detail)}</Text>
779            {detail.steps > 0 ? <Text dimColor>· {steps(detail)}</Text> : null}
780            {detail.model ? <Text dimColor>· {detail.model}</Text> : null}
781          </Box>
782          <Text dimColor wrap="truncate-end">
783            {detail.command}
784          </Text>
785          {detail.threadId ? (
786            <Text dimColor wrap="truncate-end">
787              resume: {resumeLine(detail.key, detail.threadId)}
788            </Text>
789          ) : null}
790          <Text> </Text>
791          {entries.length === 0 ? (
792            <Text dimColor>Nothing recorded yet.</Text>
793          ) : (
794            entries.map((entry, index) => (
795              <Box flexDirection="row" gap={1}>
796                <Text dimColor>
797                  {String(offset + index + 1).padStart(3, ' ')}
798                </Text>
799                <Text dimColor>{kindGlyph(entry)}</Text>
800                <Text
801                  color={kindColor(entry)}
802                  dimColor={entry.kind === 'think'}
803                  wrap="wrap"
804                >
805                  {entry.text}
806                </Text>
807              </Box>
808            ))
809          )}
810        </Box>
811      );
812    }
813
814    const rows = live();
815    return (
816      <Box flexDirection="column" paddingX={1}>
817        {rows.length === 0 ? (
818          <Box flexDirection="column">
819            <Text dimColor>No agent CLI is running.</Text>
820            <Text dimColor>
821              Runs of codex exec, claude -p and gemini -p show up here.
822            </Text>
823          </Box>
824        ) : (
825          rows.map((run, index) => {
826            const agent = agentOf(run);
827            return (
828              <Box flexDirection="column">
829                <Box flexDirection="row" gap={1}>
830                  <Text color={agent.color} bold>
831                    {agent.glyph}
832                  </Text>
833                  <Button
834                    key={`row:${run.id}`}
835                    plain
836                    hotkey={index < 9 ? String(index + 1) : undefined}
837                    label={agent.label.padEnd(8)}
838                    onPress={noop}
839                  />
840                  <Text color={markColor(run)}>{mark(run)}</Text>
841                  <Text dimColor>{elapsed(run)}</Text>
842                  {run.steps > 0 ? <Text dimColor>· {steps(run)}</Text> : null}
843                  {run.model ? (
844                    <Text dimColor>· {clamp(run.model, 24)}</Text>
845                  ) : null}
846                </Box>
847                <Box flexDirection="row" gap={1} paddingLeft={2}>
848                  <Text dimColor>{kindGlyph(run.activity)}</Text>
849                  <Text
850                    color={kindColor(run.activity)}
851                    dimColor={run.activity.kind === 'think'}
852                    wrap="wrap"
853                  >
854                    {clamp(run.activity.text, width * 2 - 4)}
855                  </Text>
856                </Box>
857              </Box>
858            );
859          })
860        )}
861        {rows.length > 0 ? (
862          <Text dimColor>
863            {props.isFocused === true
864              ? '1-9 open a run · esc back to the prompt'
865              : 'ctrl+x tab to focus · 1-9 open a run · /cc-sidecar for modes'}
866          </Text>
867        ) : null}
868      </Box>
869    );
870  });
871};
872