codehere 0.5.0-alpha.5 → 0.6.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +18 -8
  2. package/dist/application/use-cases/generate-narration.js +111 -0
  3. package/dist/application/use-cases/generate-thread-title.js +58 -4
  4. package/dist/application/use-cases/narrator-prompt-template.js +158 -0
  5. package/dist/index.js +75 -6
  6. package/dist/infrastructure/cost/budget-config.js +113 -0
  7. package/dist/infrastructure/cost/spend-aggregator.js +147 -0
  8. package/dist/infrastructure/hooks/claude-sdk-hook.js +280 -4
  9. package/dist/infrastructure/hooks/hook-mode.js +71 -0
  10. package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +43 -0
  11. package/dist/infrastructure/hooks/pre-prompt-scan.js +122 -0
  12. package/dist/infrastructure/orchestration/aider-adapter.js +28 -0
  13. package/dist/infrastructure/orchestration/artifacts-extractor.js +128 -8
  14. package/dist/infrastructure/orchestration/claude-code-adapter.js +1 -0
  15. package/dist/infrastructure/orchestration/codex-adapter.js +14 -0
  16. package/dist/infrastructure/orchestration/failover-classifier.js +151 -0
  17. package/dist/infrastructure/orchestration/failover-policy.js +106 -0
  18. package/dist/infrastructure/orchestration/gemini-adapter.js +116 -14
  19. package/dist/infrastructure/orchestration/opencode-adapter.js +100 -18
  20. package/dist/infrastructure/orchestration/progress-aggregator.js +141 -6
  21. package/dist/infrastructure/orchestration/session-manager.js +117 -10
  22. package/dist/infrastructure/orchestration/session-persistence.js +28 -0
  23. package/dist/infrastructure/protocols/mcp-server-impl.js +564 -13
  24. package/dist/infrastructure/protocols/ui-page.js +2728 -245
  25. package/dist/infrastructure/security/permissions-config.js +234 -0
  26. package/dist/infrastructure/storage/project-thread-mirror.js +379 -24
  27. package/dist/infrastructure/storage/project-thread-reader.js +23 -1
  28. package/dist/infrastructure/storage/project-thread-title-backfill.js +28 -0
  29. package/dist/presentation/cli/commands/budget-command.js +79 -0
  30. package/dist/presentation/cli/commands/narrator-command.js +101 -0
  31. package/dist/presentation/cli/commands/permission-command.js +116 -0
  32. package/dist/presentation/cli/commands/scan-tool-command.js +36 -7
  33. package/dist/utils/typo-guard.js +77 -17
  34. package/package.json +1 -1
@@ -28,7 +28,9 @@
28
28
  * fingerprints. Finalizes on `close` (not `exit`) so trailing stdout flushes.
29
29
  * Kills the process group (negative pid) so subagents under gemini exit too.
30
30
  */
31
- import { spawn, execFileSync } from 'child_process';
31
+ import { spawn, execFile, execFileSync } from 'child_process';
32
+ import { promisify } from 'util';
33
+ const execFileAsync = promisify(execFile);
32
34
  import { EventEmitter } from 'events';
33
35
  import { randomUUID } from 'crypto';
34
36
  import { existsSync } from 'fs';
@@ -36,6 +38,7 @@ import { homedir } from 'os';
36
38
  import { join } from 'path';
37
39
  import { ProgressAggregator } from './progress-aggregator.js';
38
40
  import { applyMemoryPrefix } from './memory-injector.js';
41
+ import { mirrorToolCallToAudit } from '../hooks/mirror-tool-call-to-audit.js';
39
42
  import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
40
43
  // `--approval-mode=yolo` auto-accepts every tool action without prompting (the
41
44
  // older `--yolo` flag is deprecated). codehere's safety hook is the external
@@ -87,6 +90,41 @@ function scanForKnownError(text) {
87
90
  }
88
91
  return null;
89
92
  }
93
+ /**
94
+ * Parse the plain-text output of `gemini --list-sessions` and return the
95
+ * 1-based index for a given session UUID, or null if not found.
96
+ *
97
+ * Format (verified against gemini-cli source — packages/cli/src/utils/sessions.ts):
98
+ * Available sessions for this project (3):
99
+ *
100
+ * 1. Some Title (5 minutes ago) [a1b2c3d4-...]
101
+ * 2. Other Title (1 hour ago, current) [e5f6g7h8-...]
102
+ * 3. Third One (yesterday) [9i0j1k2l-...]
103
+ *
104
+ * Indices are 1-based and stable within one run (sort-by-startTime); the UUID
105
+ * is the durable handle. We exported as a pure function so tests don't need
106
+ * the real gemini binary or a real session store.
107
+ */
108
+ export function parseGeminiSessionIndexForUuid(listOutput, uuid) {
109
+ if (!uuid)
110
+ return null;
111
+ // Match ` N. <title> ... [<uuid>]` — UUID is always the last bracketed
112
+ // segment on the line. Title can contain anything (including bracketed
113
+ // text), so we match `[uuid]` at end-of-line, not the first `[...]`.
114
+ const lineRegex = /^\s*(\d+)\.\s.*\[([0-9a-fA-F-]{8,})\]\s*$/;
115
+ for (const raw of listOutput.split('\n')) {
116
+ const line = raw.replace(/\r$/, '');
117
+ const m = line.match(lineRegex);
118
+ if (!m)
119
+ continue;
120
+ const idx = Number.parseInt(m[1], 10);
121
+ if (!Number.isFinite(idx) || idx <= 0)
122
+ continue;
123
+ if (m[2] === uuid)
124
+ return idx;
125
+ }
126
+ return null;
127
+ }
90
128
  export function spawnGemini(prompt, options = {}) {
91
129
  const sessionId = randomUUID();
92
130
  const startedAt = Date.now();
@@ -137,6 +175,27 @@ export function spawnGemini(prompt, options = {}) {
137
175
  if (typeof t === 'string')
138
176
  textAccum += t + '\n';
139
177
  }
178
+ // #110 slice 4 — mirror gemini tool calls to the audit log. Same
179
+ // shape Codex uses (post-hoc, no block). gemini emits tool_use
180
+ // BEFORE the call lands; mirror at that point so the audit row
181
+ // appears around when the action happens.
182
+ if (event.type === 'tool_use') {
183
+ const data = event.data;
184
+ const toolName = String(data?.tool_name ?? 'tool');
185
+ const params = data?.parameters ?? {};
186
+ const input = (typeof params.command === 'string' && params.command) ||
187
+ (typeof params.file_path === 'string' && params.file_path) ||
188
+ (typeof params.path === 'string' && params.path) ||
189
+ (typeof params.query === 'string' && params.query) ||
190
+ JSON.stringify(params).slice(0, 500);
191
+ mirrorToolCallToAudit({
192
+ sessionId,
193
+ agent: 'gemini',
194
+ toolName,
195
+ toolInput: String(input),
196
+ cwd,
197
+ });
198
+ }
140
199
  // Token usage comes ONLY in the final `result` event with status:'success',
141
200
  // shape { stats: { input_tokens, output_tokens, total_tokens, cached } }.
142
201
  if (event.type === 'result') {
@@ -234,6 +293,11 @@ export function spawnGemini(prompt, options = {}) {
234
293
  };
235
294
  }
236
295
  let stdoutBuf = '';
296
+ // Captured from the gemini-cli `init` event so appendMessage can resolve
297
+ // it to a session-list index (gemini's --resume only takes index or
298
+ // 'latest', NOT the UUID itself). See parseGeminiSessionIndexForUuid +
299
+ // the appendMessage block below.
300
+ let geminiSessionUuid = null;
237
301
  child.stdout?.on('data', (chunk) => {
238
302
  stdoutBuf += chunk.toString('utf-8');
239
303
  let idx;
@@ -244,6 +308,9 @@ export function spawnGemini(prompt, options = {}) {
244
308
  continue;
245
309
  try {
246
310
  const data = JSON.parse(line);
311
+ if (data.type === 'init' && typeof data.session_id === 'string') {
312
+ geminiSessionUuid = data.session_id;
313
+ }
247
314
  emit({ type: data.type ?? 'gemini_event', timestamp: Date.now(), data });
248
315
  }
249
316
  catch {
@@ -356,24 +423,58 @@ export function spawnGemini(prompt, options = {}) {
356
423
  },
357
424
  canAppendMessage: true,
358
425
  /**
359
- * v0.6.0 — Slice 2b of REPL persistence. Gemini's --resume flag accepts
360
- * either a numeric INDEX (per-cwd ordinal of past sessions) or the literal
361
- * string "latest" — NOT the session_id UUID that the init JSONL event
362
- * emits. UUID→index mapping requires --list-sessions which has its own
363
- * race condition (indices shift as sessions are added/deleted).
426
+ * v0.6.x — UUID→index resolution (replaces the prior --resume latest
427
+ * shortcut). Gemini's --resume flag accepts a numeric INDEX (per-cwd
428
+ * ordinal of past sessions) or the literal 'latest' — NOT the session_id
429
+ * UUID that the init JSONL event emits.
364
430
  *
365
- * For the v0.6 cut we use --resume latest. Limitation: if the user spawns
366
- * another gemini session in this cwd between the original spawn and this
367
- * follow-up, the follow-up routes to the wrong session. Same caveat as
368
- * opencode's --continue. Acceptable for the typical reply-within-minutes
369
- * flow; the rigorous UUID→index helper is queued for v0.6.x.
431
+ * Strategy: capture session_id on spawn (see geminiSessionUuid above) →
432
+ * here, run `gemini --list-sessions` (plain text, no --json flag), parse
433
+ * each line for `[uuid]` at end of line, find the index that matches our
434
+ * captured UUID, and use it in --resume <N>. If anything fails (binary
435
+ * missing, list times out, UUID not found), fall back to --resume latest
436
+ * — same posture as before for safety, but with a chance to nail the
437
+ * exact session in the common case.
438
+ *
439
+ * Concurrency win: if a sibling session is spawned in the same cwd between
440
+ * spawn and reply, we no longer route the reply to the wrong one.
370
441
  */
371
- appendMessage(text) {
442
+ async appendMessage(text) {
372
443
  const startedAt = Date.now();
444
+ // Resolve UUID→index ASYNCHRONOUSLY before spawning the followup. Must
445
+ // be async (not execFileSync) because this runs in the daemon's HTTP
446
+ // request path; a sync call would block the event loop and freeze every
447
+ // other in-flight session for up to the timeout.
448
+ let resumeArg = 'latest';
449
+ if (geminiSessionUuid) {
450
+ try {
451
+ // gemini-cli's --list-sessions runs an internal generateSummary()
452
+ // first, which itself has a 5000ms LLM-call abort timeout (see
453
+ // sessionSummaryService.ts DEFAULT_TIMEOUT_MS in upstream). Our
454
+ // wrapper timeout MUST be comfortably greater than that or we kill
455
+ // the child mid-summary, get empty stdout, and silently fall back
456
+ // to --resume latest — which would defeat the whole UUID→index fix.
457
+ // 15s = 5s upstream LLM cap + parse/flush/spawn overhead + slack.
458
+ // Override via CODEHERE_GEMINI_LIST_TIMEOUT_MS for slow boxes.
459
+ const timeoutMs = Number(process.env.CODEHERE_GEMINI_LIST_TIMEOUT_MS) || 15000;
460
+ const { stdout: listOut } = await execFileAsync('gemini', ['--list-sessions'], {
461
+ cwd,
462
+ env: spawnEnv,
463
+ encoding: 'utf-8',
464
+ timeout: timeoutMs,
465
+ });
466
+ const idx = parseGeminiSessionIndexForUuid(listOut, geminiSessionUuid);
467
+ if (idx !== null)
468
+ resumeArg = String(idx);
469
+ }
470
+ catch {
471
+ // Binary missing, slow auth, no sessions yet — fall through to latest.
472
+ }
473
+ }
373
474
  return new Promise((resolve, reject) => {
374
475
  let stdout = '';
375
476
  let stderr = '';
376
- const followup = spawn('gemini', ['-p', text, '--resume', 'latest', ...HEADLESS_FLAGS], {
477
+ const followup = spawn('gemini', ['-p', text, '--resume', resumeArg, ...HEADLESS_FLAGS], {
377
478
  cwd,
378
479
  env: spawnEnv,
379
480
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -387,7 +488,7 @@ export function spawnGemini(prompt, options = {}) {
387
488
  });
388
489
  followup.on('exit', (code) => {
389
490
  if (code !== 0) {
390
- reject(new Error(`gemini --resume latest exited ${code}: ${stderr.slice(0, 300)}`));
491
+ reject(new Error(`gemini --resume ${resumeArg} exited ${code}: ${stderr.slice(0, 300)}`));
391
492
  return;
392
493
  }
393
494
  // Parse stream-json — extract assistant message text from 'message'
@@ -417,6 +518,7 @@ export function spawnGemini(prompt, options = {}) {
417
518
  export const GeminiAdapter = {
418
519
  id: 'gemini',
419
520
  label: 'Gemini',
521
+ description: 'Google\'s coding agent. Has a free tier and can read very large amounts of code or docs at once. Useful when the paid agents are out of credits.',
420
522
  // Match `gemini`, `node /path/.../gemini`, and `./gemini` invocations.
421
523
  processPattern: /(?:^|\/)gemini(?:\s|$)|node\s+\S*gemini/,
422
524
  isInstalled() {
@@ -10,16 +10,64 @@
10
10
  *
11
11
  * Same spawn-pattern pattern as Codex.
12
12
  */
13
- import { spawn, execFileSync } from 'child_process';
13
+ import { spawn, execFile, execFileSync } from 'child_process';
14
14
  import { EventEmitter } from 'events';
15
15
  import { randomUUID } from 'crypto';
16
16
  import { existsSync } from 'fs';
17
17
  import { homedir } from 'os';
18
18
  import { join } from 'path';
19
+ import { promisify } from 'util';
19
20
  import { ProgressAggregator } from './progress-aggregator.js';
20
21
  import { applyMemoryPrefix } from './memory-injector.js';
21
22
  import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
23
+ const execFileAsync = promisify(execFile);
22
24
  const HEADLESS_FLAGS = ['run', '--format', 'json'];
25
+ /**
26
+ * Tag prefix written into opencode's `--title` so we can find a session by
27
+ * codehere's own UUID later (opencode's `ses_*` ID isn't observable until
28
+ * the LLM call completes — see appendMessage docblock for the full story).
29
+ */
30
+ const CODEHERE_TITLE_PREFIX = 'codehere-';
31
+ /**
32
+ * Parse `opencode session list --format json` output and return the opencode
33
+ * session ID (`ses_*`) for the row whose title starts with our codehere
34
+ * tag. Pure function so tests don't need a real opencode binary.
35
+ *
36
+ * Schema verified live (2026-05-04):
37
+ * [{ "id": "ses_212f...", "title": "...", "updated": <ms>, "created": <ms>,
38
+ * "projectId": "...", "directory": "..." }, ...]
39
+ *
40
+ * Returns the session whose title is exactly `<prefix><codehereSessionId>`.
41
+ * Falls back to null if not found, the input is malformed, or the array is
42
+ * empty. Caller falls back to --continue in that case.
43
+ */
44
+ export function parseOpenCodeSessionIdByTitle(listJson, codehereSessionId) {
45
+ if (!codehereSessionId)
46
+ return null;
47
+ const wantedTitle = `${CODEHERE_TITLE_PREFIX}${codehereSessionId}`;
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(listJson);
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ if (!Array.isArray(parsed))
56
+ return null;
57
+ for (const row of parsed) {
58
+ if (!row || typeof row !== 'object')
59
+ continue;
60
+ const r = row;
61
+ if (typeof r.id === 'string' && typeof r.title === 'string' && r.title === wantedTitle) {
62
+ // Defense in depth: opencode's IDs are always `ses_<base32-ish>`. If
63
+ // an attacker (or upstream bug) injects a row with a poisoned id like
64
+ // ` --rm-rf` we don't want to feed it back as a CLI arg.
65
+ if (/^ses_[A-Za-z0-9]+$/.test(r.id))
66
+ return r.id;
67
+ }
68
+ }
69
+ return null;
70
+ }
23
71
  const ERROR_PATTERNS = [
24
72
  {
25
73
  code: 'auth_invalid',
@@ -122,7 +170,12 @@ export function spawnOpenCode(prompt, options = {}) {
122
170
  let child;
123
171
  try {
124
172
  // OpenCode's `run` expects the message as positional args, not stdin.
125
- child = spawn('opencode', [...HEADLESS_FLAGS, finalPrompt, ...(options.extraArgs ?? [])], {
173
+ // --title 'codehere-<sessionId>' tags the session with codehere's own
174
+ // UUID; appendMessage later looks it up by this title (see
175
+ // parseOpenCodeSessionIdByTitle for the why — opencode buffers the
176
+ // ses_* id until LLM completion, so we can't capture it mid-stream).
177
+ const titleTag = `${CODEHERE_TITLE_PREFIX}${sessionId}`;
178
+ child = spawn('opencode', [...HEADLESS_FLAGS, '--title', titleTag, finalPrompt, ...(options.extraArgs ?? [])], {
126
179
  cwd,
127
180
  env: spawnEnv,
128
181
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -247,28 +300,56 @@ export function spawnOpenCode(prompt, options = {}) {
247
300
  },
248
301
  canAppendMessage: true,
249
302
  /**
250
- * v0.6.0 — Slice 2c of REPL persistence. OpenCode's `run --format json`
251
- * output is fully buffered until the LLM call completes; the session ID
252
- * (ses_*) is not observable mid-stream like claude-code's session_id or
253
- * codex's thread_id. The cleanest available alternative is `-c/--continue`
254
- * which targets the last session.
303
+ * v0.6.x — UUID→ses_* resolution (replaces the prior --continue shortcut).
304
+ * OpenCode's `run --format json` buffers stdout until the LLM call
305
+ * completes, so the ses_* session ID isn't observable mid-stream like
306
+ * claude-code's session_id or codex's thread_id.
307
+ *
308
+ * Strategy: spawn() above tags the session with `--title codehere-<our
309
+ * UUID>`. Here, run `opencode session list --format json` (clean array,
310
+ * no parsing of column-aligned text), find the row whose title matches
311
+ * our tag, capture its ses_* id, and use `-s <ses_*>`. Falls back to
312
+ * --continue if anything fails (binary missing, no match, parse error)
313
+ * — same posture as before for safety, never worse.
255
314
  *
256
- * Limitation: same as gemini's --resume latest — if the user spawns
257
- * another opencode session in this cwd between the original spawn and the
258
- * follow-up, the follow-up routes to the wrong session. Acceptable for
259
- * the typical reply-within-minutes flow; the rigorous version (capture
260
- * session ID via post-spawn `opencode session list` filtered by --title
261
- * tag) is queued for v0.6.x.
315
+ * Concurrency win: spawning a sibling opencode session in the same cwd
316
+ * between original spawn and reply no longer hijacks the reply target.
262
317
  *
263
- * Output parsing: `--format json` here uses the same JSONL event shape as
264
- * the spawn path; we extract assistant message text from `text` events.
318
+ * Output parsing: same shape as spawn — text events carry assistant text.
265
319
  */
266
- appendMessage(text) {
320
+ async appendMessage(text) {
267
321
  const startedAt = Date.now();
322
+ // Resolve our codehere session UUID → opencode ses_* id asynchronously.
323
+ // Async (not execFileSync) because this runs in the daemon's HTTP
324
+ // request path; sync exec would block the event loop for up to the
325
+ // timeout, freezing every other in-flight session.
326
+ let resumeArgs = ['--continue'];
327
+ try {
328
+ // Override timeout via env for slow boxes; default 15s mirrors the
329
+ // gemini adapter's choice (and is well above any LLM-tied internal
330
+ // timer opencode might have).
331
+ const timeoutMs = Number(process.env.CODEHERE_OPENCODE_LIST_TIMEOUT_MS) || 15000;
332
+ const { stdout: listOut } = await execFileAsync('opencode', ['session', 'list', '--format', 'json'], {
333
+ cwd,
334
+ env: spawnEnv,
335
+ encoding: 'utf-8',
336
+ timeout: timeoutMs,
337
+ // opencode's session-list output can grow as the user accumulates
338
+ // sessions. Default exec maxBuffer is 1MB; bump to 8MB so a long
339
+ // history doesn't ENOBUFS the lookup.
340
+ maxBuffer: 8 * 1024 * 1024,
341
+ });
342
+ const sesId = parseOpenCodeSessionIdByTitle(listOut, sessionId);
343
+ if (sesId)
344
+ resumeArgs = ['-s', sesId];
345
+ }
346
+ catch {
347
+ // Binary missing, slow auth, or no list — fall through to --continue.
348
+ }
268
349
  return new Promise((resolve, reject) => {
269
350
  let stdout = '';
270
351
  let stderr = '';
271
- const followup = spawn('opencode', [...HEADLESS_FLAGS, '--continue', text], {
352
+ const followup = spawn('opencode', [...HEADLESS_FLAGS, ...resumeArgs, text], {
272
353
  cwd,
273
354
  env: spawnEnv,
274
355
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -282,7 +363,7 @@ export function spawnOpenCode(prompt, options = {}) {
282
363
  });
283
364
  followup.on('exit', (code) => {
284
365
  if (code !== 0) {
285
- reject(new Error(`opencode --continue exited ${code}: ${stderr.slice(0, 300)}`));
366
+ reject(new Error(`opencode ${resumeArgs.join(' ')} exited ${code}: ${stderr.slice(0, 300)}`));
286
367
  return;
287
368
  }
288
369
  // Parse JSONL — extract assistant text from `text` events
@@ -312,6 +393,7 @@ export function spawnOpenCode(prompt, options = {}) {
312
393
  export const OpenCodeAdapter = {
313
394
  id: 'opencode',
314
395
  label: 'OpenCode',
396
+ description: 'Runs several agents at once for genuinely independent jobs. You won\'t see live progress while it works — the trade-off for the speed of doing things in parallel.',
315
397
  processPattern: /(?:^|\/)opencode(?:\s|$)/,
316
398
  isInstalled() {
317
399
  try {
@@ -40,21 +40,65 @@ function labelForClaudeToolUse(name, input) {
40
40
  return truncate('Run ' + cmd);
41
41
  }
42
42
  case 'Grep':
43
- return 'Grep ' + truncate(String(i.pattern ?? ''), 40);
43
+ // M2 plain-English pass: "Grep" is a unix command name, jargon
44
+ // for non-technical users. "Search code for" tells the user what
45
+ // it actually does.
46
+ return 'Search code for ' + truncate(String(i.pattern ?? ''), 40);
44
47
  case 'Glob':
45
- return 'List ' + truncate(String(i.pattern ?? ''), 40);
48
+ return 'List files matching ' + truncate(String(i.pattern ?? ''), 40);
46
49
  case 'WebFetch':
47
50
  return 'Fetch ' + truncate(String(i.url ?? ''), 50);
48
51
  case 'WebSearch':
49
- return 'Search ' + truncate(String(i.query ?? ''), 50);
52
+ return 'Search the web for ' + truncate(String(i.query ?? ''), 50);
50
53
  case 'TodoWrite':
51
54
  return 'Plan ' + (Array.isArray(i.todos) ? `${i.todos.length} items` : 'tasks');
52
55
  case 'Task':
53
- return 'Delegate to subagent';
56
+ // M2 plain-English pass: "subagent" is internal vocab. "Specialist"
57
+ // matches the team-roster framing the user already sees in the
58
+ // dashboard ("codehere · Conductor", "Claude Code specialist", etc.)
59
+ return 'Hand off to a specialist';
54
60
  default:
55
61
  return truncate(String(name || 'tool'));
56
62
  }
57
63
  }
64
+ /**
65
+ * Extract human-readable text from a Claude tool_result content block.
66
+ * Handles the two shapes the SDK emits:
67
+ * { content: 'plain text' } (most)
68
+ * { content: [{type:'text', text:'...'}, {type:'image', ...}, ...] } (multi-block)
69
+ */
70
+ function extractClaudeToolResultText(b) {
71
+ const c = b.content;
72
+ if (typeof c === 'string')
73
+ return c;
74
+ if (Array.isArray(c)) {
75
+ const parts = [];
76
+ for (const part of c) {
77
+ if (part && typeof part === 'object') {
78
+ const p = part;
79
+ if (p.type === 'text' && typeof p.text === 'string')
80
+ parts.push(p.text);
81
+ }
82
+ else if (typeof part === 'string') {
83
+ parts.push(part);
84
+ }
85
+ }
86
+ return parts.join('\n');
87
+ }
88
+ return '';
89
+ }
90
+ /**
91
+ * Truncate output for step preview. Keeps the LAST ~400 chars (tail is
92
+ * usually more useful than head for command output — errors land
93
+ * there). Adds a leading ellipsis when truncation occurs.
94
+ */
95
+ function truncateOutput(s) {
96
+ const max = 400;
97
+ const stripped = s.replace(/\[[0-9;]*m/g, ''); // strip ANSI
98
+ if (stripped.length <= max)
99
+ return stripped;
100
+ return '…' + stripped.slice(-(max - 1));
101
+ }
58
102
  function unwrapShellWrapper(command) {
59
103
  // Codex wraps its shell calls in `/bin/zsh -lc "…"` or `sh -c "…"`. Strip that
60
104
  // so the step label reads as the actual command instead of the wrapper.
@@ -73,8 +117,20 @@ function labelForCodexCommand(command) {
73
117
  }
74
118
  if (/^(?:ls|find)\b/.test(raw))
75
119
  return truncate('List ' + raw.replace(/^(?:ls|find)\s*/, ''));
76
- if (/^(?:mkdir|cp|mv|rm|touch)\b/.test(raw) || /\becho\s+.+>/.test(raw))
77
- return truncate('File op: ' + raw);
120
+ // M2 plain-English pass: "File op:" is awkward; describe what's
121
+ // happening per command kind.
122
+ if (/^mkdir\b/.test(raw))
123
+ return truncate('Create folder: ' + raw.replace(/^mkdir\s+(?:-\S+\s+)*/, ''));
124
+ if (/^cp\b/.test(raw))
125
+ return truncate('Copy: ' + raw.replace(/^cp\s+(?:-\S+\s+)*/, ''));
126
+ if (/^mv\b/.test(raw))
127
+ return truncate('Move/rename: ' + raw.replace(/^mv\s+(?:-\S+\s+)*/, ''));
128
+ if (/^rm\b/.test(raw))
129
+ return truncate('Remove: ' + raw.replace(/^rm\s+(?:-\S+\s+)*/, ''));
130
+ if (/^touch\b/.test(raw))
131
+ return truncate('Create file: ' + raw.replace(/^touch\s+/, ''));
132
+ if (/\becho\s+.+>/.test(raw))
133
+ return truncate('Write to file: ' + raw);
78
134
  if (/^git\s+/.test(raw))
79
135
  return truncate(raw);
80
136
  if (/^npm\s+test\b/.test(raw) || /^pytest\b/.test(raw) || /^vitest\b/.test(raw))
@@ -153,6 +209,15 @@ export class ProgressAggregator {
153
209
  const step = this._steps[idx];
154
210
  step.state = b.is_error ? 'failed' : 'done';
155
211
  step.completedAt = Date.now();
212
+ // P22 / Track B #8 — capture tool-result text for the
213
+ // dashboard's bash-output preview. Claude SDK shape:
214
+ // content: string (most cases)
215
+ // content: Array<{type:'text', text:string}> (multi-block)
216
+ // Truncate to last ~400 chars so the SSE payload + render
217
+ // cost stay bounded.
218
+ const out = extractClaudeToolResultText(b);
219
+ if (out)
220
+ step.output = truncateOutput(out);
156
221
  changed = true;
157
222
  }
158
223
  }
@@ -192,6 +257,17 @@ export class ProgressAggregator {
192
257
  const isError = data.error !== undefined && data.error !== null;
193
258
  step.state = isError ? 'failed' : 'done';
194
259
  step.completedAt = Date.now();
260
+ // P22 slice 3 — Gemini tool_result output. Schema is sparse;
261
+ // accept the common output/result/content fields, with error
262
+ // text as fallback when isError is true.
263
+ const out = (typeof data.output === 'string' && data.output) ||
264
+ (typeof data.result === 'string' && data.result) ||
265
+ (typeof data.content === 'string' && data.content) ||
266
+ (isError && data.error && typeof data.error === 'object'
267
+ ? `${data.error.type ?? ''} ${data.error.message ?? ''}`
268
+ : '');
269
+ if (out)
270
+ step.output = truncateOutput(String(out));
195
271
  return true;
196
272
  }
197
273
  }
@@ -252,6 +328,34 @@ export class ProgressAggregator {
252
328
  * once, not `Write` followed by `Edit`.
253
329
  */
254
330
  aiderJustCreated = new Set();
331
+ /**
332
+ * P22 slice 4 (Aider) — output buffering. Aider's command output
333
+ * streams as raw stdout lines AFTER a "Running X" announcement and
334
+ * BEFORE the next aider-pattern (or end of session). This index is
335
+ * the most-recent "Run" step that's still collecting its tail. When
336
+ * a new aider-pattern fires we flush the buffer onto step.output and
337
+ * reset.
338
+ *
339
+ * Only "Running" steps collect output — file-edit/write actions
340
+ * don't have a captured stdout; their target IS the visible state.
341
+ */
342
+ aiderRunningStepIdx = null;
343
+ aiderOutputBuffer = [];
344
+ aiderFlushOutput() {
345
+ if (this.aiderRunningStepIdx === null)
346
+ return;
347
+ const step = this._steps[this.aiderRunningStepIdx];
348
+ if (!step) {
349
+ this.aiderRunningStepIdx = null;
350
+ this.aiderOutputBuffer = [];
351
+ return;
352
+ }
353
+ if (this.aiderOutputBuffer.length > 0) {
354
+ step.output = truncateOutput(this.aiderOutputBuffer.join('\n'));
355
+ }
356
+ this.aiderRunningStepIdx = null;
357
+ this.aiderOutputBuffer = [];
358
+ }
255
359
  ingestAider(type, data) {
256
360
  if (type !== 'stdout')
257
361
  return false;
@@ -270,18 +374,26 @@ export class ProgressAggregator {
270
374
  // "Running <command>" — base_coder.py:2472
271
375
  let m = line.match(/^Running\s+(.+)$/);
272
376
  if (m) {
377
+ // P22 slice 4 — flush the previous Running step's buffer before
378
+ // starting the new one. New step is the active output target.
379
+ this.aiderFlushOutput();
380
+ const idx = this._steps.length;
273
381
  this._steps.push({
274
382
  label: truncate('Run ' + m[1]),
275
383
  state: 'done',
276
384
  startedAt: Date.now(),
277
385
  completedAt: Date.now(),
278
386
  });
387
+ this.aiderRunningStepIdx = idx;
279
388
  return true;
280
389
  }
281
390
  // "Applied edit to <path>" — base_coder.py:2334
282
391
  m = line.match(/^Applied edit to\s+(.+)$/);
283
392
  if (m) {
284
393
  const path = m[1];
394
+ // P22 slice 4 — flush any in-flight running-step output before
395
+ // recording the edit (which is a new aider-pattern signal).
396
+ this.aiderFlushOutput();
285
397
  // If aider JUST created this file, the Write step already represents the
286
398
  // action; skip the Edit step that aider emits for the initial content.
287
399
  // Clear from the set after consuming so subsequent edits to the same
@@ -302,6 +414,7 @@ export class ProgressAggregator {
302
414
  m = line.match(/^Creating empty file\s+(.+)$/);
303
415
  if (m) {
304
416
  const path = m[1];
417
+ this.aiderFlushOutput();
305
418
  this.aiderJustCreated.add(path);
306
419
  // Use "Write" to align with Claude/Gemini's net-new-file label vocabulary.
307
420
  this._steps.push({
@@ -312,6 +425,20 @@ export class ProgressAggregator {
312
425
  });
313
426
  return true;
314
427
  }
428
+ // P22 slice 4 — non-pattern stdout line WITHIN a Running window
429
+ // gets buffered as that step's output. Cap at 50 lines so a long-
430
+ // running command (e.g., `npm test` printing thousands) doesn't
431
+ // bloat memory; truncateOutput at flush time keeps the on-step
432
+ // value bounded too.
433
+ if (this.aiderRunningStepIdx !== null && this.aiderOutputBuffer.length < 50) {
434
+ this.aiderOutputBuffer.push(line);
435
+ // Update the active step's output incrementally so the dashboard
436
+ // sees the tail before the next aider-pattern flushes.
437
+ const step = this._steps[this.aiderRunningStepIdx];
438
+ if (step)
439
+ step.output = truncateOutput(this.aiderOutputBuffer.join('\n'));
440
+ return true; // mark as "step changed" so the SSE re-emits codehere:steps
441
+ }
315
442
  return false;
316
443
  }
317
444
  ingestCodex(type, data) {
@@ -322,21 +449,29 @@ export class ProgressAggregator {
322
449
  if (itemType === 'command_execution') {
323
450
  const label = labelForCodexCommand(String(item.command ?? ''));
324
451
  const ok = item.exit_code === 0 || item.status === 'completed';
452
+ // P22 slice 2 — capture aggregated_output for the bash preview.
453
+ // Codex emits the full stdout+stderr in this field on completion.
454
+ const aggregated = typeof item.aggregated_output === 'string' ? item.aggregated_output : '';
325
455
  this._steps.push({
326
456
  label,
327
457
  state: ok ? 'done' : 'failed',
328
458
  startedAt: Date.now(),
329
459
  completedAt: Date.now(),
460
+ ...(aggregated ? { output: truncateOutput(aggregated) } : {}),
330
461
  });
331
462
  return true;
332
463
  }
333
464
  if (itemType === 'tool_call' || itemType === 'local_shell_call') {
334
465
  const name = String(item.tool_name ?? item.name ?? 'tool');
466
+ // Tool-call items may carry a `result` or `output` field too.
467
+ const result = (typeof item.result === 'string' ? item.result : '') ||
468
+ (typeof item.output === 'string' ? item.output : '');
335
469
  this._steps.push({
336
470
  label: truncate(name),
337
471
  state: 'done',
338
472
  startedAt: Date.now(),
339
473
  completedAt: Date.now(),
474
+ ...(result ? { output: truncateOutput(result) } : {}),
340
475
  });
341
476
  return true;
342
477
  }