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.
- package/dist/application/use-cases/generate-final-report.js +22 -6
- package/dist/application/use-cases/generate-thread-title.js +120 -0
- package/dist/index.js +73 -0
- package/dist/infrastructure/orchestration/aider-adapter.js +59 -0
- package/dist/infrastructure/orchestration/claude-code-adapter.js +30 -7
- package/dist/infrastructure/orchestration/codex-adapter.js +76 -0
- package/dist/infrastructure/orchestration/conductor-adapter.js +11 -1
- package/dist/infrastructure/orchestration/gemini-adapter.js +58 -0
- package/dist/infrastructure/orchestration/opencode-adapter.js +62 -0
- package/dist/infrastructure/orchestration/session-manager.js +20 -0
- package/dist/infrastructure/protocols/mcp-server-impl.js +102 -4
- package/dist/infrastructure/protocols/ui-page.js +509 -19
- package/dist/infrastructure/storage/cross-tool-agents.js +251 -0
- package/dist/infrastructure/storage/project-thread-migration.js +232 -0
- package/dist/infrastructure/storage/project-thread-mirror.js +159 -0
- package/dist/infrastructure/storage/project-thread-reader.js +140 -0
- package/dist/infrastructure/storage/project-thread-title-backfill.js +182 -0
- package/package.json +4 -2
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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.
|
|
323
|
-
*
|
|
324
|
-
*
|
|
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
|
|
327
|
-
*
|
|
328
|
-
*
|
|
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, '
|
|
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
|
-
|
|
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 = {
|