codehere 0.5.0-alpha.4 → 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 +18 -0
- package/dist/infrastructure/orchestration/aider-adapter.js +59 -0
- 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 +97 -4
- package/dist/infrastructure/protocols/ui-page.js +503 -1
- package/dist/infrastructure/storage/cross-tool-agents.js +251 -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
|
@@ -448,6 +448,24 @@ program
|
|
|
448
448
|
console.warn(colors.dim(` v0.6 migration skipped: ${err instanceof Error ? err.message : String(err)}`));
|
|
449
449
|
}
|
|
450
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
|
+
}
|
|
451
469
|
const { startMCPServer } = await import('./infrastructure/protocols/mcp-server-impl.js');
|
|
452
470
|
const server = startMCPServer({ port, host });
|
|
453
471
|
// Wait for the server to either start listening or fail. The default
|
|
@@ -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 = {
|
|
@@ -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 = {
|
|
@@ -24,6 +24,7 @@ import { seedAgentsConfig } from './agents-config-seed.js';
|
|
|
24
24
|
import { AUDIT_FILE } from '../hooks/claude-sdk-hook.js';
|
|
25
25
|
import { appendSpawned, appendCompleted, loadRecent } from './session-persistence.js';
|
|
26
26
|
import { generateFinalReport, printFinalReportToTerminal } from '../../application/use-cases/generate-final-report.js';
|
|
27
|
+
import { mirrorSpawnedToThread, mirrorCompletedToThread } from '../storage/project-thread-mirror.js';
|
|
27
28
|
// ── Agent registry ─────────────────────────────────────────────────────────────
|
|
28
29
|
// All backends codehere can spawn. Order here drives the default UI dropdown
|
|
29
30
|
// order. Built-in adapters first; user-config-driven ones appended from
|
|
@@ -130,6 +131,15 @@ export class SessionManager {
|
|
|
130
131
|
cwd,
|
|
131
132
|
startedAt: session.startedAt,
|
|
132
133
|
});
|
|
134
|
+
// v0.6.0 M1+M7 — forward-mirror to per-project thread.jsonl alongside
|
|
135
|
+
// sessions.jsonl. Best-effort; mirror failures don't break the spawn path.
|
|
136
|
+
mirrorSpawnedToThread({
|
|
137
|
+
sessionId: session.sessionId,
|
|
138
|
+
agent: session.agent,
|
|
139
|
+
prompt,
|
|
140
|
+
cwd,
|
|
141
|
+
startedAt: session.startedAt,
|
|
142
|
+
});
|
|
133
143
|
const unsubscribe = session.subscribe((event) => {
|
|
134
144
|
if (event.type !== 'exit')
|
|
135
145
|
return;
|
|
@@ -147,6 +157,16 @@ export class SessionManager {
|
|
|
147
157
|
steps: session.steps ? [...session.steps] : undefined,
|
|
148
158
|
artifacts: session.artifacts,
|
|
149
159
|
});
|
|
160
|
+
// v0.6.0 M1+M7 — forward-mirror completion to project thread.
|
|
161
|
+
mirrorCompletedToThread({
|
|
162
|
+
sessionId: session.sessionId,
|
|
163
|
+
cwd,
|
|
164
|
+
status: session.status,
|
|
165
|
+
durationMs: Number(data?.durationMs ?? completedAt - session.startedAt),
|
|
166
|
+
completedAt,
|
|
167
|
+
steps: session.steps ? [...session.steps] : undefined,
|
|
168
|
+
artifacts: session.artifacts,
|
|
169
|
+
});
|
|
150
170
|
// PM-UX Layer 3 · Final Report MVP. Fire-and-forget; the report appears
|
|
151
171
|
// in the terminal a few seconds after exit. Failure modes return null
|
|
152
172
|
// silently (claude CLI not installed, timeout, non-zero exit). No SSE,
|