codehere 0.5.0-alpha.3 → 0.5.0-alpha.5

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.
@@ -28,12 +28,28 @@ const PROMPT_TEMPLATE = (i) => `You are a PM reporting back to a developer about
28
28
  just did. Write 3 short paragraphs in plain English. No headers, no bullet
29
29
  lists, no markdown — just three paragraphs separated by blank lines.
30
30
 
31
- Paragraph 1: What the agent DID. Verb-led. Name files specifically. 1-3 sentences.
32
- Paragraph 2: What was TOUCHED. Files modified, line counts, anything that looks risky. 1-3 sentences.
33
- Paragraph 3: What the developer should LOOK AT. Open concerns, things to verify.
34
- If everything looks clean, say so explicitly. 1-2 sentences.
31
+ CRITICAL anti-hallucination rule: ground every claim in the structured data
32
+ below. You CANNOT see the contents of files, the output of commands, or
33
+ anything beyond what's listed under "Steps the agent took" and "Files
34
+ changed". If the data doesn't show specific filenames, do NOT invent any.
35
+ If a step ran a command but its output isn't shown to you, describe what
36
+ the step ATTEMPTED, not what it found. Saying "I don't have the output to
37
+ report on" is correct; making up plausible-sounding details is wrong and
38
+ the developer will catch the lie.
35
39
 
36
- Be specific. Don't pad. Don't editorialize. Don't praise the agent.
40
+ Paragraph 1: What the agent ATTEMPTED. Verb-led. Describe steps as listed.
41
+ Mention specific filenames ONLY if they appear in the data
42
+ below. 1-3 sentences.
43
+ Paragraph 2: What was TOUCHED. Reference ONLY the "Files changed" list
44
+ below. If the list is empty, say "No files were changed."
45
+ 1-2 sentences.
46
+ Paragraph 3: What the developer should LOOK AT. If steps + files data show
47
+ nothing risky, say so explicitly ("nothing to verify; the
48
+ session was read-only" or similar). 1-2 sentences.
49
+
50
+ Be specific where the data lets you. Don't pad. Don't editorialize. Don't
51
+ praise the agent. Don't fabricate file names, line counts, or content
52
+ descriptions you cannot derive from the data below.
37
53
 
38
54
  The task was: ${i.prompt}
39
55
  The agent used: ${i.agent}
@@ -50,7 +66,7 @@ ${i.artifacts?.files?.length
50
66
 
51
67
  Token usage: ${i.artifacts?.usage?.inputTokens ?? 0} in / ${i.artifacts?.usage?.outputTokens ?? 0} out
52
68
 
53
- Now write the 3 paragraphs.`;
69
+ Now write the 3 paragraphs, grounded ONLY in the data above.`;
54
70
  const REPORT_TIMEOUT_MS = 30_000;
55
71
  /** Provider chain: try free first, then paid. Override with CODEHERE_REPORT_BINARY. */
56
72
  function selectProviderBinary() {
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Hidden Title agent — v0.6.0 M9.
3
+ *
4
+ * Generates a 4-6 word title for a project thread from its first user prompt.
5
+ * Hidden = users don't pick it from any picker; fires automatically when the
6
+ * project thread gets its first task entry. The title surfaces in the browser
7
+ * tab + future thread switcher UI.
8
+ *
9
+ * Companion to generate-final-report.ts:
10
+ * - Same provider chain pattern (claude-free → claude → graceful skip)
11
+ * - Same single-shot, no streaming, returns string
12
+ * - Different prompt (terse title generation, NOT 3-paragraph narrative)
13
+ * - Cheaper budget (~50 tokens per title vs 200 per Final Report paragraph)
14
+ *
15
+ * Triggering: project-thread-mirror invokes this when seeding the project-meta
16
+ * header for a NEW project (i.e., this cwd's thread.jsonl didn't exist before).
17
+ * Idempotent: subsequent task entries do NOT regenerate the title.
18
+ *
19
+ * Cost: ~1 LLM call per NEW project (not per task). At a typical 1-2 new
20
+ * projects per week per user, the cost is negligible. FCC-routed when
21
+ * available; degrades silently to "(untitled)" when no provider responds.
22
+ */
23
+ import { spawn } from 'child_process';
24
+ const TITLE_TIMEOUT_MS = 15_000; // titles are short; abort fast if stuck
25
+ const PROMPT_TEMPLATE = (firstPrompt, cwd) => `Generate a 4-6 word project title from this first user prompt.
26
+
27
+ Project working directory: ${cwd}
28
+ First prompt: ${firstPrompt}
29
+
30
+ Rules:
31
+ - 4 to 6 words MAX
32
+ - No quotes, no punctuation, no trailing period
33
+ - Title-case (e.g. "Auth Bug Fix Sprint")
34
+ - Ground the title in the prompt + cwd ONLY. Do NOT invent project subject
35
+ matter you can't derive from the data above (e.g. don't infer "Thread
36
+ Interface Updates" from a "list files" prompt — say what the user
37
+ actually asked, not what you imagine the project is about).
38
+ - If the prompt is too generic to title meaningfully, prefer naming the
39
+ cwd's last path segment (e.g. "Taste V0.6 Workspace") over invention.
40
+ - Output ONLY the title — no preamble, no explanation
41
+
42
+ Title:`;
43
+ function selectProviderBinary() {
44
+ const override = process.env.CODEHERE_TITLE_BINARY;
45
+ if (override)
46
+ return [override];
47
+ return ['claude-free', 'claude'];
48
+ }
49
+ function tryProvider(binary, prompt) {
50
+ return new Promise((resolve) => {
51
+ let stdout = '';
52
+ let settled = false;
53
+ const finish = (result) => {
54
+ if (settled)
55
+ return;
56
+ settled = true;
57
+ resolve(result);
58
+ };
59
+ const child = spawn(binary, ['--bare', '-p', prompt], {
60
+ stdio: ['ignore', 'pipe', 'pipe'],
61
+ });
62
+ const timeout = setTimeout(() => {
63
+ child.kill('SIGTERM');
64
+ if (process.env.CODEHERE_DEBUG === '1') {
65
+ console.error(`[title-agent] ${binary} timeout after ${TITLE_TIMEOUT_MS}ms`);
66
+ }
67
+ finish(null);
68
+ }, TITLE_TIMEOUT_MS);
69
+ child.on('error', () => {
70
+ clearTimeout(timeout);
71
+ finish(null); // ENOENT etc. — silent fallback to next provider
72
+ });
73
+ child.stdout?.on('data', (chunk) => {
74
+ stdout += chunk.toString('utf-8');
75
+ });
76
+ child.on('exit', (code) => {
77
+ clearTimeout(timeout);
78
+ if (code !== 0) {
79
+ finish(null);
80
+ return;
81
+ }
82
+ const title = sanitizeTitle(stdout);
83
+ if (!title) {
84
+ finish(null);
85
+ return;
86
+ }
87
+ finish(title);
88
+ });
89
+ });
90
+ }
91
+ /** Strip whitespace / quotes / trailing punctuation. Cap at 80 chars (safety). */
92
+ function sanitizeTitle(raw) {
93
+ const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean);
94
+ if (lines.length === 0)
95
+ return '';
96
+ let title = lines[lines.length - 1]; // last non-empty line is the answer
97
+ // Strip surrounding quotes
98
+ title = title.replace(/^["'`]+|["'`]+$/g, '');
99
+ // Strip trailing punctuation
100
+ title = title.replace(/[.!?,;:]+$/, '');
101
+ // Cap at 80 chars
102
+ if (title.length > 80)
103
+ title = title.slice(0, 77) + '...';
104
+ return title.trim();
105
+ }
106
+ /**
107
+ * Generate a thread title from the first user prompt + cwd context.
108
+ * Returns the title string, or null if no provider is reachable.
109
+ * Never throws — failure modes return null.
110
+ */
111
+ export async function generateThreadTitle(firstPrompt, cwd) {
112
+ const prompt = PROMPT_TEMPLATE(firstPrompt, cwd);
113
+ for (const binary of selectProviderBinary()) {
114
+ const result = await tryProvider(binary, prompt);
115
+ if (result)
116
+ return result;
117
+ }
118
+ return null;
119
+ }
120
+ //# sourceMappingURL=generate-thread-title.js.map
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ import { Command } from 'commander';
14
14
  import { config } from 'dotenv';
15
15
  import { join, dirname } from 'path';
16
16
  import { fileURLToPath } from 'url';
17
+ import { homedir } from 'os';
17
18
  import { executeSetupCommand } from './presentation/cli/commands/setup-command.js';
18
19
  import { executeInitCommand } from './presentation/cli/commands/init-command.js';
19
20
  import { executeMemoryCommand } from './presentation/cli/commands/memory-command.js';
@@ -428,6 +429,43 @@ program
428
429
  }
429
430
  };
430
431
  try {
432
+ // v0.6.0 M1+M7 — migrate sessions.jsonl → ~/.codehere/projects/<slug>/thread.jsonl
433
+ // on daemon start. Idempotent: skips projects whose thread.jsonl already exists,
434
+ // so this is safe to run on every boot. Opt-out via CODEHERE_DISABLE_THREAD_MIGRATION=1.
435
+ // Failures are non-blocking — back-compat to v0.5 sessions.jsonl is preserved.
436
+ if (process.env.CODEHERE_DISABLE_THREAD_MIGRATION !== '1') {
437
+ try {
438
+ const { migrate } = await import('./infrastructure/storage/project-thread-migration.js');
439
+ const result = migrate({
440
+ sourceJsonl: join(homedir(), '.codehere/sessions.jsonl'),
441
+ targetDir: join(homedir(), '.codehere/projects'),
442
+ });
443
+ if (result.threadsWritten > 0) {
444
+ console.log(colors.dim(` v0.6 migration: created ${result.threadsWritten} project thread(s) from ${result.sessionsProcessed} sessions (${result.durationMs}ms)`));
445
+ }
446
+ }
447
+ catch (err) {
448
+ console.warn(colors.dim(` v0.6 migration skipped: ${err instanceof Error ? err.message : String(err)}`));
449
+ }
450
+ }
451
+ // v0.6.0 follow-up — backfill missing titles for projects that the
452
+ // M9 Title agent never fired on (migration-seeded ones). Runs in the
453
+ // background so daemon startup isn't blocked by ~5 LLM calls.
454
+ // Idempotent + best-effort. Same env-var gate as M9 itself.
455
+ if (process.env.CODEHERE_DISABLE_TITLE_AGENT !== '1') {
456
+ void (async () => {
457
+ try {
458
+ const { backfillThreadTitles } = await import('./infrastructure/storage/project-thread-title-backfill.js');
459
+ const r = await backfillThreadTitles();
460
+ if (r.titlesGenerated > 0 && process.env.CODEHERE_DEBUG === '1') {
461
+ console.log(colors.dim(` v0.6 title backfill: titled ${r.titlesGenerated}/${r.scanned} project(s) in ${r.durationMs}ms`));
462
+ }
463
+ }
464
+ catch {
465
+ /* best-effort — silent on failure */
466
+ }
467
+ })();
468
+ }
431
469
  const { startMCPServer } = await import('./infrastructure/protocols/mcp-server-impl.js');
432
470
  const server = startMCPServer({ port, host });
433
471
  // Wait for the server to either start listening or fail. The default
@@ -597,6 +635,41 @@ program
597
635
  r.end();
598
636
  });
599
637
  });
638
+ // ── migrate-threads — v0.6.0 schema migration sessions.jsonl → projects/<slug>/thread.jsonl
639
+ program
640
+ .command('migrate-threads', { hidden: true }) // hidden from --help (v0.6.0 internal — auto-runs on daemon start once wired)
641
+ .description('Migrate sessions.jsonl into per-project thread.jsonl files (v0.6.0 schema)')
642
+ .option('--source <path>', 'Source sessions.jsonl', `${homedir()}/.codehere/sessions.jsonl`)
643
+ .option('--target <dir>', 'Target projects dir', `${homedir()}/.codehere/projects`)
644
+ .option('--dry-run', 'Report what migrate WOULD do, write nothing', false)
645
+ .option('--rollback', 'Remove the projects dir; sessions.jsonl untouched', false)
646
+ .option('--keep-temp-dirs', 'Include /tmp + /var/folders cwds (default skips them)', false)
647
+ .action(async (options) => {
648
+ const { migrate, dryRun, rollback } = await import('./infrastructure/storage/project-thread-migration.js');
649
+ if (options.rollback) {
650
+ const r = rollback(options.target);
651
+ console.log(`Rollback: removed ${r.removedDirs} project dir(s) from ${options.target}`);
652
+ console.log(`Source ${options.source} untouched.`);
653
+ return;
654
+ }
655
+ const fn = options.dryRun ? dryRun : migrate;
656
+ const label = options.dryRun ? 'DRY RUN' : 'MIGRATION';
657
+ const result = fn({
658
+ sourceJsonl: options.source,
659
+ targetDir: options.target,
660
+ skipTempDirs: !options.keepTempDirs,
661
+ });
662
+ console.log(`=== ${label} ===`);
663
+ console.log(` source: ${options.source}`);
664
+ console.log(` target: ${options.target}`);
665
+ console.log(` sessions processed: ${result.sessionsProcessed}`);
666
+ console.log(` threads ${options.dryRun ? 'would write' : 'written'}: ${result.threadsWritten}`);
667
+ console.log(` threads skipped (exist): ${result.threadsSkippedExisting}`);
668
+ console.log(` unknown-cwd sessions: ${result.unknownCwdSessions}`);
669
+ console.log(` abandoned sessions: ${result.abandonedSessions}`);
670
+ console.log(` temp-dir sessions skipped: ${result.tempDirSessionsSkipped}`);
671
+ console.log(` duration: ${result.durationMs}ms`);
672
+ });
600
673
  // ── scan-tool (called by Claude Code hooks system, not users directly) ───────
601
674
  program
602
675
  .command('scan-tool', { hidden: true }) // hidden from --help (May 2 cut: internal hook handler, called by Claude Code via stdin)
@@ -266,6 +266,65 @@ export function spawnAider(prompt, options = {}) {
266
266
  }
267
267
  }, 3000);
268
268
  },
269
+ canAppendMessage: true,
270
+ /**
271
+ * v0.6.0 — Slice 2 of REPL persistence. Aider has no per-session UUID
272
+ * (unlike claude-code's session_id or codex's thread_id); it persists chat
273
+ * history to ./.aider.chat.history.md in the cwd. `--restore-chat-history`
274
+ * reads that file before processing the new --message, giving multi-turn
275
+ * continuity scoped to the cwd.
276
+ *
277
+ * Limitation: aider is single-cwd-single-session. Two concurrent aider
278
+ * sessions in the same cwd would collide on the same history file. Same
279
+ * limitation aider's own users have when running it interactively;
280
+ * codehere doesn't make it worse.
281
+ *
282
+ * Same shape as the other appendMessage implementations: one-shot Q&A,
283
+ * returns assistant text + duration, no streaming.
284
+ */
285
+ appendMessage(text) {
286
+ const startedAt = Date.now();
287
+ return new Promise((resolve, reject) => {
288
+ let stdout = '';
289
+ let stderr = '';
290
+ const followup = spawn('aider', ['--restore-chat-history', '--message', text, ...HEADLESS_FLAGS], {
291
+ cwd,
292
+ env: spawnEnv,
293
+ stdio: ['ignore', 'pipe', 'pipe'],
294
+ });
295
+ followup.on('error', (err) => reject(err));
296
+ followup.stdout?.on('data', (chunk) => {
297
+ stdout += chunk.toString('utf-8');
298
+ });
299
+ followup.stderr?.on('data', (chunk) => {
300
+ stderr += chunk.toString('utf-8');
301
+ });
302
+ followup.on('exit', (code) => {
303
+ if (code !== 0) {
304
+ reject(new Error(`aider --restore-chat-history exited ${code}: ${stderr.slice(0, 300)}`));
305
+ return;
306
+ }
307
+ // Aider's --no-stream + --no-pretty output is plain text; the
308
+ // assistant's reply lands as a top-level paragraph after any
309
+ // tool-execution lines. Strip aider's own prefix lines (Added,
310
+ // Tokens, Cost, Applied, etc.) and keep the conversational body.
311
+ const stripped = stdout
312
+ .split('\n')
313
+ .filter((l) => {
314
+ const t = l.trim();
315
+ if (!t)
316
+ return false;
317
+ if (/^(Added|Removed|Tokens?:|Cost:|Applied|Creating|Running|Commit|Aider|Model:|Main model:|Weak model:)/i.test(t))
318
+ return false;
319
+ return true;
320
+ })
321
+ .join('\n')
322
+ .trim();
323
+ const result = stripped || '(aider returned no conversational output)';
324
+ resolve({ result, durationMs: Date.now() - startedAt });
325
+ });
326
+ });
327
+ },
269
328
  };
270
329
  }
271
330
  export const AiderAdapter = {
@@ -221,6 +221,12 @@ export function spawnClaudeCode(prompt, options = {}) {
221
221
  }
222
222
  // ── stdout: newline-delimited JSON from `--output-format stream-json` ────────
223
223
  let stdoutBuf = '';
224
+ // Iter 13 — capture the claude-internal session_id from the init event so
225
+ // appendMessage can use `--resume <session-id>` (reliable) instead of `-c`
226
+ // (which picks "most recent in cwd" and can collide with other concurrent
227
+ // claude sessions OR with conversations containing thinking blocks the
228
+ // headless API can't replay).
229
+ let claudeSessionId;
224
230
  child.stdout?.on('data', (chunk) => {
225
231
  stdoutBuf += chunk.toString('utf-8');
226
232
  let idx;
@@ -231,6 +237,12 @@ export function spawnClaudeCode(prompt, options = {}) {
231
237
  continue;
232
238
  try {
233
239
  const data = JSON.parse(line);
240
+ if (claudeSessionId === undefined &&
241
+ data.type === 'system' &&
242
+ data.subtype === 'init' &&
243
+ typeof data.session_id === 'string') {
244
+ claudeSessionId = data.session_id;
245
+ }
234
246
  emit({ type: data.type ?? 'message', timestamp: Date.now(), data });
235
247
  }
236
248
  catch {
@@ -319,20 +331,31 @@ export function spawnClaudeCode(prompt, options = {}) {
319
331
  }, 3000);
320
332
  },
321
333
  /**
322
- * Iter 12 — follow-up message to a completed session. Spawns a fresh
323
- * `claude -p "<text>" -c` in the same cwd, which uses Claude Code's
324
- * native -c flag to continue the most recent conversation in that dir.
334
+ * Iter 12 (with iter-13 fix) — follow-up message to a completed session.
335
+ *
336
+ * Iter 12 used `claude -p "<text>" -c` which picks "most recent
337
+ * conversation in current directory". Iter 13 dogfood proved this
338
+ * fragile: in a cwd with multiple concurrent claude sessions OR with
339
+ * conversations containing thinking blocks, -c either picks the wrong
340
+ * session or hits an API-replay error ("Invalid signature in thinking
341
+ * block"). Fix: capture the claude session_id from this session's init
342
+ * event (above) and use `--resume <session-id>` for follow-ups —
343
+ * unambiguous and replay-safe.
325
344
  *
326
- * Scope: one-shot Q&A. No streaming, no event piping into the parent
327
- * emitter. Returns the assistant's response text. Iter 13 may upgrade
328
- * to a true thread-streaming model if the dogfood signal warrants.
345
+ * Scope: still one-shot Q&A. No streaming. Returns assistant's response
346
+ * text. If we never captured a session_id (init event never fired —
347
+ * shouldn't happen in healthy claude runs), reject explicitly.
329
348
  */
330
349
  appendMessage(text) {
331
350
  const startedAt = Date.now();
332
351
  return new Promise((resolve, reject) => {
352
+ if (!claudeSessionId) {
353
+ reject(new Error('Cannot reply: original claude session_id was never captured (init event missed)'));
354
+ return;
355
+ }
333
356
  let stdout = '';
334
357
  let stderr = '';
335
- const followup = spawn('claude', ['-p', text, '-c', ...HEADLESS_FLAGS], {
358
+ const followup = spawn('claude', ['-p', text, '--resume', claudeSessionId, ...HEADLESS_FLAGS], {
336
359
  cwd,
337
360
  env: spawnEnv,
338
361
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -187,6 +187,11 @@ export function spawnCodex(prompt, options = {}) {
187
187
  },
188
188
  };
189
189
  }
190
+ // v0.6.0 — capture codex's thread_id from the `thread.started` event so
191
+ // appendMessage() can use `codex exec resume <thread_id> <prompt>` for
192
+ // multi-turn continuity. Same pattern as claude-code-adapter capturing
193
+ // session_id from the init event for `claude --resume`.
194
+ let codexThreadId;
190
195
  let stdoutBuf = '';
191
196
  child.stdout?.on('data', (chunk) => {
192
197
  stdoutBuf += chunk.toString('utf-8');
@@ -198,6 +203,11 @@ export function spawnCodex(prompt, options = {}) {
198
203
  continue;
199
204
  try {
200
205
  const data = JSON.parse(line);
206
+ if (codexThreadId === undefined &&
207
+ data.type === 'thread.started' &&
208
+ typeof data.thread_id === 'string') {
209
+ codexThreadId = data.thread_id;
210
+ }
201
211
  emit({ type: data.type ?? 'codex_event', timestamp: Date.now(), data });
202
212
  }
203
213
  catch {
@@ -277,6 +287,72 @@ export function spawnCodex(prompt, options = {}) {
277
287
  }
278
288
  }, 3000);
279
289
  },
290
+ canAppendMessage: true,
291
+ /**
292
+ * v0.6.0 — Slice 1 of REPL persistence. Spawns `codex exec resume <thread_id>`
293
+ * with the follow-up prompt via stdin (codex exec resume accepts `-` to read
294
+ * prompt from stdin per `codex exec resume --help`). Re-uses the original
295
+ * thread context so this is a true continuation, not a fresh session.
296
+ *
297
+ * Same shape as claude-code's appendMessage:
298
+ * - One-shot Q&A (no streaming reply yet)
299
+ * - Returns assistant text + duration
300
+ * - Rejects if thread_id wasn't captured (init event missed)
301
+ *
302
+ * Founder hit codex usage cap at smoke time; the real-error path is
303
+ * exercised by the rate-limit fingerprint that lands in stderr/JSONL.
304
+ */
305
+ appendMessage(text) {
306
+ const startedAt = Date.now();
307
+ return new Promise((resolve, reject) => {
308
+ if (!codexThreadId) {
309
+ reject(new Error('Cannot reply: codex thread_id was never captured (thread.started event missed)'));
310
+ return;
311
+ }
312
+ let stdout = '';
313
+ let stderr = '';
314
+ // codex exec resume <SESSION_ID> - → reads prompt from stdin
315
+ const followup = spawn('codex', ['exec', '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox',
316
+ 'resume', codexThreadId, '-'], {
317
+ cwd,
318
+ env: spawnEnv,
319
+ stdio: ['pipe', 'pipe', 'pipe'],
320
+ });
321
+ followup.stdin?.write(text);
322
+ followup.stdin?.end();
323
+ followup.on('error', (err) => reject(err));
324
+ followup.stdout?.on('data', (chunk) => {
325
+ stdout += chunk.toString('utf-8');
326
+ });
327
+ followup.stderr?.on('data', (chunk) => {
328
+ stderr += chunk.toString('utf-8');
329
+ });
330
+ followup.on('exit', (code) => {
331
+ if (code !== 0) {
332
+ reject(new Error(`codex exec resume exited ${code}: ${stderr.slice(0, 300)}`));
333
+ return;
334
+ }
335
+ // Parse JSONL — extract assistant message text from item.completed
336
+ // events of type agent_message (same shape codex emits during the
337
+ // initial exec; reuse the textAccum logic).
338
+ const lines = stdout.split('\n').filter((l) => l.trim());
339
+ const fragments = [];
340
+ for (const line of lines) {
341
+ try {
342
+ const obj = JSON.parse(line);
343
+ if (obj.type === 'item.completed' && obj.item?.type === 'agent_message' && obj.item.text) {
344
+ fragments.push(obj.item.text);
345
+ }
346
+ }
347
+ catch {
348
+ /* non-JSON line — skip */
349
+ }
350
+ }
351
+ const result = fragments.join('\n').trim() || '(codex returned no agent_message text)';
352
+ resolve({ result, durationMs: Date.now() - startedAt });
353
+ });
354
+ });
355
+ },
280
356
  };
281
357
  }
282
358
  export const CodexAdapter = {
@@ -323,7 +323,17 @@ function spawnConductor(prompt, options, lookup) {
323
323
  get artifacts() {
324
324
  return artifactsSnapshot;
325
325
  },
326
- canAppendMessage: false, // v0.4-alpha doesn't support mid-session redirection
326
+ // v0.6.0 — Slice 3 of REPL persistence. Conductor inherits appendMessage
327
+ // capability from whichever specialist it routed to. With all 5 external
328
+ // adapters wired in Slices 1-2c, this becomes true for every routing
329
+ // decision. Follow-ups stay with the SAME specialist that handled the
330
+ // original turn — no re-routing mid-thread (intentional: the user's reply
331
+ // is part of an ongoing exchange with that specific specialist, not a
332
+ // fresh classification opportunity).
333
+ canAppendMessage: specialistSession.canAppendMessage === true,
334
+ appendMessage: specialistSession.appendMessage
335
+ ? (text) => specialistSession.appendMessage(text)
336
+ : undefined,
327
337
  subscribe(handler) {
328
338
  for (const e of buffer)
329
339
  handler(e);
@@ -354,6 +354,64 @@ export function spawnGemini(prompt, options = {}) {
354
354
  sendSignal('SIGKILL');
355
355
  }, 3000);
356
356
  },
357
+ canAppendMessage: true,
358
+ /**
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).
364
+ *
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.
370
+ */
371
+ appendMessage(text) {
372
+ const startedAt = Date.now();
373
+ return new Promise((resolve, reject) => {
374
+ let stdout = '';
375
+ let stderr = '';
376
+ const followup = spawn('gemini', ['-p', text, '--resume', 'latest', ...HEADLESS_FLAGS], {
377
+ cwd,
378
+ env: spawnEnv,
379
+ stdio: ['ignore', 'pipe', 'pipe'],
380
+ });
381
+ followup.on('error', (err) => reject(err));
382
+ followup.stdout?.on('data', (chunk) => {
383
+ stdout += chunk.toString('utf-8');
384
+ });
385
+ followup.stderr?.on('data', (chunk) => {
386
+ stderr += chunk.toString('utf-8');
387
+ });
388
+ followup.on('exit', (code) => {
389
+ if (code !== 0) {
390
+ reject(new Error(`gemini --resume latest exited ${code}: ${stderr.slice(0, 300)}`));
391
+ return;
392
+ }
393
+ // Parse stream-json — extract assistant message text from 'message'
394
+ // events with role='assistant'. Same source codex/claude-code use.
395
+ const fragments = [];
396
+ for (const line of stdout.split('\n')) {
397
+ const trimmed = line.trim();
398
+ if (!trimmed)
399
+ continue;
400
+ try {
401
+ const obj = JSON.parse(trimmed);
402
+ if (obj.type === 'message' && obj.role === 'assistant' && typeof obj.content === 'string') {
403
+ fragments.push(obj.content);
404
+ }
405
+ }
406
+ catch {
407
+ /* non-JSON — skip */
408
+ }
409
+ }
410
+ const result = fragments.join('\n').trim() || '(gemini returned no assistant message)';
411
+ resolve({ result, durationMs: Date.now() - startedAt });
412
+ });
413
+ });
414
+ },
357
415
  };
358
416
  }
359
417
  export const GeminiAdapter = {
@@ -245,6 +245,68 @@ export function spawnOpenCode(prompt, options = {}) {
245
245
  }
246
246
  }, 3000);
247
247
  },
248
+ canAppendMessage: true,
249
+ /**
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.
255
+ *
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.
262
+ *
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.
265
+ */
266
+ appendMessage(text) {
267
+ const startedAt = Date.now();
268
+ return new Promise((resolve, reject) => {
269
+ let stdout = '';
270
+ let stderr = '';
271
+ const followup = spawn('opencode', [...HEADLESS_FLAGS, '--continue', text], {
272
+ cwd,
273
+ env: spawnEnv,
274
+ stdio: ['ignore', 'pipe', 'pipe'],
275
+ });
276
+ followup.on('error', (err) => reject(err));
277
+ followup.stdout?.on('data', (chunk) => {
278
+ stdout += chunk.toString('utf-8');
279
+ });
280
+ followup.stderr?.on('data', (chunk) => {
281
+ stderr += chunk.toString('utf-8');
282
+ });
283
+ followup.on('exit', (code) => {
284
+ if (code !== 0) {
285
+ reject(new Error(`opencode --continue exited ${code}: ${stderr.slice(0, 300)}`));
286
+ return;
287
+ }
288
+ // Parse JSONL — extract assistant text from `text` events
289
+ // (same shape spawn() consumes via textAccum logic above).
290
+ const fragments = [];
291
+ for (const line of stdout.split('\n')) {
292
+ const trimmed = line.trim();
293
+ if (!trimmed)
294
+ continue;
295
+ try {
296
+ const obj = JSON.parse(trimmed);
297
+ if (obj.type === 'text' && obj.part?.text) {
298
+ fragments.push(obj.part.text);
299
+ }
300
+ }
301
+ catch {
302
+ /* non-JSON — skip */
303
+ }
304
+ }
305
+ const result = fragments.join('\n').trim() || '(opencode returned no text events)';
306
+ resolve({ result, durationMs: Date.now() - startedAt });
307
+ });
308
+ });
309
+ },
248
310
  };
249
311
  }
250
312
  export const OpenCodeAdapter = {