codehere 0.5.0-alpha.4 → 0.5.0-alpha.6
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 +42 -1
- package/dist/infrastructure/orchestration/session-persistence.js +28 -0
- package/dist/infrastructure/protocols/mcp-server-impl.js +129 -4
- package/dist/infrastructure/protocols/ui-page.js +616 -17
- package/dist/infrastructure/storage/cross-tool-agents.js +251 -0
- package/dist/infrastructure/storage/project-thread-mirror.js +192 -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 = {
|
|
@@ -22,8 +22,9 @@ import { buildGenericAdapter, loadAgentsConfig } from './generic-cli-adapter.js'
|
|
|
22
22
|
import { buildConductorAdapter } from './conductor-adapter.js';
|
|
23
23
|
import { seedAgentsConfig } from './agents-config-seed.js';
|
|
24
24
|
import { AUDIT_FILE } from '../hooks/claude-sdk-hook.js';
|
|
25
|
-
import { appendSpawned, appendCompleted, loadRecent } from './session-persistence.js';
|
|
25
|
+
import { appendSpawned, appendCompleted, appendFinalReport, 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
|
|
@@ -104,6 +105,11 @@ export class SessionManager {
|
|
|
104
105
|
* Surfaced May 2 by the cold-start dogfood — `duration: NaNs` in /api/tasks
|
|
105
106
|
* for completed-but-not-yet-restarted sessions. Bug 2 sister-fix. */
|
|
106
107
|
completedAtBySession = new Map();
|
|
108
|
+
/** v0.6.x Slack-shape spike — cwd per session. The "Continue thread" intake
|
|
109
|
+
* button needs to find the most recent in-memory session for the current
|
|
110
|
+
* cwd; cwd isn't on the TaskSession interface and adapters don't all expose
|
|
111
|
+
* it, so we track it here at spawn time. */
|
|
112
|
+
cwdBySession = new Map();
|
|
107
113
|
/** PM-UX Layer 3 · Final Report cache (iteration 11 MVP, UI-integration iter 12).
|
|
108
114
|
* Populated when generateFinalReport resolves in the exit handler. Web UI
|
|
109
115
|
* reads via getFinalReport() through the /api/tasks serializer so non-technical
|
|
@@ -123,6 +129,7 @@ export class SessionManager {
|
|
|
123
129
|
// leaves an audit trail of what was attempted. Subscribe to exit so we can
|
|
124
130
|
// capture the final status + steps + artifacts when the session settles.
|
|
125
131
|
const cwd = options?.cwd ?? process.cwd();
|
|
132
|
+
this.cwdBySession.set(session.sessionId, cwd);
|
|
126
133
|
appendSpawned({
|
|
127
134
|
id: session.sessionId,
|
|
128
135
|
agent: session.agent,
|
|
@@ -130,6 +137,15 @@ export class SessionManager {
|
|
|
130
137
|
cwd,
|
|
131
138
|
startedAt: session.startedAt,
|
|
132
139
|
});
|
|
140
|
+
// v0.6.0 M1+M7 — forward-mirror to per-project thread.jsonl alongside
|
|
141
|
+
// sessions.jsonl. Best-effort; mirror failures don't break the spawn path.
|
|
142
|
+
mirrorSpawnedToThread({
|
|
143
|
+
sessionId: session.sessionId,
|
|
144
|
+
agent: session.agent,
|
|
145
|
+
prompt,
|
|
146
|
+
cwd,
|
|
147
|
+
startedAt: session.startedAt,
|
|
148
|
+
});
|
|
133
149
|
const unsubscribe = session.subscribe((event) => {
|
|
134
150
|
if (event.type !== 'exit')
|
|
135
151
|
return;
|
|
@@ -147,6 +163,16 @@ export class SessionManager {
|
|
|
147
163
|
steps: session.steps ? [...session.steps] : undefined,
|
|
148
164
|
artifacts: session.artifacts,
|
|
149
165
|
});
|
|
166
|
+
// v0.6.0 M1+M7 — forward-mirror completion to project thread.
|
|
167
|
+
mirrorCompletedToThread({
|
|
168
|
+
sessionId: session.sessionId,
|
|
169
|
+
cwd,
|
|
170
|
+
status: session.status,
|
|
171
|
+
durationMs: Number(data?.durationMs ?? completedAt - session.startedAt),
|
|
172
|
+
completedAt,
|
|
173
|
+
steps: session.steps ? [...session.steps] : undefined,
|
|
174
|
+
artifacts: session.artifacts,
|
|
175
|
+
});
|
|
150
176
|
// PM-UX Layer 3 · Final Report MVP. Fire-and-forget; the report appears
|
|
151
177
|
// in the terminal a few seconds after exit. Failure modes return null
|
|
152
178
|
// silently (claude CLI not installed, timeout, non-zero exit). No SSE,
|
|
@@ -167,6 +193,17 @@ export class SessionManager {
|
|
|
167
193
|
// Cache for web UI consumption via /api/tasks (iter 12 — non-technical
|
|
168
194
|
// users live in the web UI, not the terminal).
|
|
169
195
|
this.finalReportBySession.set(session.sessionId, report);
|
|
196
|
+
// v0.6.x — persist to disk so the report survives daemon restart.
|
|
197
|
+
// Pre-fix: founder's Mac restart 2026-05-03 left a session card
|
|
198
|
+
// showing "Generating report…" forever because reports lived only
|
|
199
|
+
// in this in-memory Map. Append-only finalReport record paired
|
|
200
|
+
// with the existing spawned/completed records by sessionId.
|
|
201
|
+
appendFinalReport({
|
|
202
|
+
id: session.sessionId,
|
|
203
|
+
summary: report.summary,
|
|
204
|
+
generatedAt: report.generatedAt,
|
|
205
|
+
durationMs: report.durationMs,
|
|
206
|
+
});
|
|
170
207
|
try {
|
|
171
208
|
printFinalReportToTerminal(reportInput, report);
|
|
172
209
|
}
|
|
@@ -193,6 +230,10 @@ export class SessionManager {
|
|
|
193
230
|
getCompletedAt(sessionId) {
|
|
194
231
|
return this.completedAtBySession.get(sessionId);
|
|
195
232
|
}
|
|
233
|
+
/** v0.6.x Slack-shape spike — return cwd for an in-memory session. */
|
|
234
|
+
getCwd(sessionId) {
|
|
235
|
+
return this.cwdBySession.get(sessionId);
|
|
236
|
+
}
|
|
196
237
|
/** PM-UX Layer 3 · prose Final Report for an in-memory exited session.
|
|
197
238
|
* Returns undefined while generation is in flight OR if the provider chain
|
|
198
239
|
* failed (no claude-free, no claude, no credit, etc). The web UI reads this
|
|
@@ -46,6 +46,27 @@ export function appendCompleted(rec) {
|
|
|
46
46
|
/* best-effort */
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* v0.6.x — persist a Final Report so it survives daemon restart.
|
|
51
|
+
*
|
|
52
|
+
* Pre-fix: Final Reports lived only in SessionManager.finalReportBySession
|
|
53
|
+
* (in-memory Map) and were lost on every daemon bounce. Founder hit this
|
|
54
|
+
* 2026-05-03 after a Mac restart: completed sessions showed
|
|
55
|
+
* "Generating report…" forever because there was no on-disk record + no
|
|
56
|
+
* "report-unavailable" state in the UI.
|
|
57
|
+
*
|
|
58
|
+
* Append-only: Final Report fires AFTER the CompletedRecord (generateFinalReport
|
|
59
|
+
* is async post-exit), so it gets its own record type. Reader merges by id.
|
|
60
|
+
*/
|
|
61
|
+
export function appendFinalReport(rec) {
|
|
62
|
+
try {
|
|
63
|
+
ensureDir();
|
|
64
|
+
appendFileSync(SESSIONS_FILE, JSON.stringify({ t: 'finalReport', ...rec }) + '\n', 'utf-8');
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
/* best-effort */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
49
70
|
/**
|
|
50
71
|
* Walk the session log backward, pair spawned+completed records by id, return
|
|
51
72
|
* the most recent `limit` completed pairs sorted newest-first.
|
|
@@ -67,6 +88,9 @@ export function loadRecent(limit = 20) {
|
|
|
67
88
|
}
|
|
68
89
|
const spawned = new Map();
|
|
69
90
|
const completed = [];
|
|
91
|
+
/** v0.6.x — Final Reports keyed by sessionId. Last write wins
|
|
92
|
+
* (rare in practice; reports fire once per session). */
|
|
93
|
+
const finalReports = new Map();
|
|
70
94
|
for (const line of content.split('\n')) {
|
|
71
95
|
const trimmed = line.trim();
|
|
72
96
|
if (!trimmed)
|
|
@@ -82,6 +106,8 @@ export function loadRecent(limit = 20) {
|
|
|
82
106
|
spawned.set(rec.id, rec);
|
|
83
107
|
else if (rec.t === 'completed')
|
|
84
108
|
completed.push(rec);
|
|
109
|
+
else if (rec.t === 'finalReport')
|
|
110
|
+
finalReports.set(rec.id, rec);
|
|
85
111
|
}
|
|
86
112
|
const merged = [];
|
|
87
113
|
// Walk completed in reverse so we return newest-first
|
|
@@ -90,6 +116,7 @@ export function loadRecent(limit = 20) {
|
|
|
90
116
|
const s = spawned.get(c.id);
|
|
91
117
|
if (!s)
|
|
92
118
|
continue; // orphan completed (shouldn't happen but harmless)
|
|
119
|
+
const fr = finalReports.get(c.id);
|
|
93
120
|
merged.push({
|
|
94
121
|
sessionId: s.id,
|
|
95
122
|
agent: s.agent,
|
|
@@ -113,6 +140,7 @@ export function loadRecent(limit = 20) {
|
|
|
113
140
|
completedAt: c.completedAt,
|
|
114
141
|
steps: c.steps,
|
|
115
142
|
artifacts: c.artifacts,
|
|
143
|
+
...(fr ? { finalReport: { summary: fr.summary, generatedAt: fr.generatedAt, durationMs: fr.durationMs } } : {}),
|
|
116
144
|
provenance: 'persisted',
|
|
117
145
|
});
|
|
118
146
|
}
|