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

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.
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.
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)
| Mode | What happens |
|---|---|
pane (default) | A pane opens as soon as an agent CLI starts, and remains open. |
hint | The run is displayed on the prompt hint line with no pane. |
both | Displays both the side pane and the hint line. |
off | Runs 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.
There are three ways to run another agent from Claude Code.
| Route | Tools the other agent uses | What you see while it runs |
|---|---|---|
| proxy | Claude Code's tools | The execution loop is managed by Claude Code, so it is visible on screen. |
| MCP | its own, behind one tool call | Nothing is displayed until the run finishes and returns a single tool result. |
| CLI | its own native tools | Nothing 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.
The plugin interface consists of two screens: the list view and the run history.
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.
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.
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).
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.
| CLI | Resume | Where the id comes from |
|---|---|---|
| codex | codex exec resume <id> | thread_id |
| claude | claude --resume <id> | session_id |
| gemini | gemini --resume latest | init.session_id (resuming by ID is not supported; only latest or an index can be used) |
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.
The tool.call hook rewrites the Bash command to force the CLI to emit structured output.
| CLI | Added | Condition |
|---|---|---|
| codex | --json | the 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.
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:
| Run | Digest |
|---|---|
| finished, ok | The final answer, or the failure message if unavailable, or any non-JSON output lines. |
| finished, failed | The 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.
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.
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.
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.
hooks/hooks.tsx 872 lines1import 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