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.
@@ -28,12 +28,28 @@ const PROMPT_TEMPLATE = (i) => `You are a PM reporting back to a developer about
28
28
  just did. Write 3 short paragraphs in plain English. No headers, no bullet
29
29
  lists, no markdown — just three paragraphs separated by blank lines.
30
30
 
31
- Paragraph 1: What the agent DID. Verb-led. Name files specifically. 1-3 sentences.
32
- Paragraph 2: What was TOUCHED. Files modified, line counts, anything that looks risky. 1-3 sentences.
33
- Paragraph 3: What the developer should LOOK AT. Open concerns, things to verify.
34
- If everything looks clean, say so explicitly. 1-2 sentences.
31
+ CRITICAL anti-hallucination rule: ground every claim in the structured data
32
+ below. You CANNOT see the contents of files, the output of commands, or
33
+ anything beyond what's listed under "Steps the agent took" and "Files
34
+ changed". If the data doesn't show specific filenames, do NOT invent any.
35
+ If a step ran a command but its output isn't shown to you, describe what
36
+ the step ATTEMPTED, not what it found. Saying "I don't have the output to
37
+ report on" is correct; making up plausible-sounding details is wrong and
38
+ the developer will catch the lie.
35
39
 
36
- Be specific. Don't pad. Don't editorialize. Don't praise the agent.
40
+ Paragraph 1: What the agent ATTEMPTED. Verb-led. Describe steps as listed.
41
+ Mention specific filenames ONLY if they appear in the data
42
+ below. 1-3 sentences.
43
+ Paragraph 2: What was TOUCHED. Reference ONLY the "Files changed" list
44
+ below. If the list is empty, say "No files were changed."
45
+ 1-2 sentences.
46
+ Paragraph 3: What the developer should LOOK AT. If steps + files data show
47
+ nothing risky, say so explicitly ("nothing to verify; the
48
+ session was read-only" or similar). 1-2 sentences.
49
+
50
+ Be specific where the data lets you. Don't pad. Don't editorialize. Don't
51
+ praise the agent. Don't fabricate file names, line counts, or content
52
+ descriptions you cannot derive from the data below.
37
53
 
38
54
  The task was: ${i.prompt}
39
55
  The agent used: ${i.agent}
@@ -50,7 +66,7 @@ ${i.artifacts?.files?.length
50
66
 
51
67
  Token usage: ${i.artifacts?.usage?.inputTokens ?? 0} in / ${i.artifacts?.usage?.outputTokens ?? 0} out
52
68
 
53
- Now write the 3 paragraphs.`;
69
+ Now write the 3 paragraphs, grounded ONLY in the data above.`;
54
70
  const REPORT_TIMEOUT_MS = 30_000;
55
71
  /** Provider chain: try free first, then paid. Override with CODEHERE_REPORT_BINARY. */
56
72
  function selectProviderBinary() {
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Hidden Title agent — v0.6.0 M9.
3
+ *
4
+ * Generates a 4-6 word title for a project thread from its first user prompt.
5
+ * Hidden = users don't pick it from any picker; fires automatically when the
6
+ * project thread gets its first task entry. The title surfaces in the browser
7
+ * tab + future thread switcher UI.
8
+ *
9
+ * Companion to generate-final-report.ts:
10
+ * - Same provider chain pattern (claude-free → claude → graceful skip)
11
+ * - Same single-shot, no streaming, returns string
12
+ * - Different prompt (terse title generation, NOT 3-paragraph narrative)
13
+ * - Cheaper budget (~50 tokens per title vs 200 per Final Report paragraph)
14
+ *
15
+ * Triggering: project-thread-mirror invokes this when seeding the project-meta
16
+ * header for a NEW project (i.e., this cwd's thread.jsonl didn't exist before).
17
+ * Idempotent: subsequent task entries do NOT regenerate the title.
18
+ *
19
+ * Cost: ~1 LLM call per NEW project (not per task). At a typical 1-2 new
20
+ * projects per week per user, the cost is negligible. FCC-routed when
21
+ * available; degrades silently to "(untitled)" when no provider responds.
22
+ */
23
+ import { spawn } from 'child_process';
24
+ const TITLE_TIMEOUT_MS = 15_000; // titles are short; abort fast if stuck
25
+ const PROMPT_TEMPLATE = (firstPrompt, cwd) => `Generate a 4-6 word project title from this first user prompt.
26
+
27
+ Project working directory: ${cwd}
28
+ First prompt: ${firstPrompt}
29
+
30
+ Rules:
31
+ - 4 to 6 words MAX
32
+ - No quotes, no punctuation, no trailing period
33
+ - Title-case (e.g. "Auth Bug Fix Sprint")
34
+ - Ground the title in the prompt + cwd ONLY. Do NOT invent project subject
35
+ matter you can't derive from the data above (e.g. don't infer "Thread
36
+ Interface Updates" from a "list files" prompt — say what the user
37
+ actually asked, not what you imagine the project is about).
38
+ - If the prompt is too generic to title meaningfully, prefer naming the
39
+ cwd's last path segment (e.g. "Taste V0.6 Workspace") over invention.
40
+ - Output ONLY the title — no preamble, no explanation
41
+
42
+ Title:`;
43
+ function selectProviderBinary() {
44
+ const override = process.env.CODEHERE_TITLE_BINARY;
45
+ if (override)
46
+ return [override];
47
+ return ['claude-free', 'claude'];
48
+ }
49
+ function tryProvider(binary, prompt) {
50
+ return new Promise((resolve) => {
51
+ let stdout = '';
52
+ let settled = false;
53
+ const finish = (result) => {
54
+ if (settled)
55
+ return;
56
+ settled = true;
57
+ resolve(result);
58
+ };
59
+ const child = spawn(binary, ['--bare', '-p', prompt], {
60
+ stdio: ['ignore', 'pipe', 'pipe'],
61
+ });
62
+ const timeout = setTimeout(() => {
63
+ child.kill('SIGTERM');
64
+ if (process.env.CODEHERE_DEBUG === '1') {
65
+ console.error(`[title-agent] ${binary} timeout after ${TITLE_TIMEOUT_MS}ms`);
66
+ }
67
+ finish(null);
68
+ }, TITLE_TIMEOUT_MS);
69
+ child.on('error', () => {
70
+ clearTimeout(timeout);
71
+ finish(null); // ENOENT etc. — silent fallback to next provider
72
+ });
73
+ child.stdout?.on('data', (chunk) => {
74
+ stdout += chunk.toString('utf-8');
75
+ });
76
+ child.on('exit', (code) => {
77
+ clearTimeout(timeout);
78
+ if (code !== 0) {
79
+ finish(null);
80
+ return;
81
+ }
82
+ const title = sanitizeTitle(stdout);
83
+ if (!title) {
84
+ finish(null);
85
+ return;
86
+ }
87
+ finish(title);
88
+ });
89
+ });
90
+ }
91
+ /** Strip whitespace / quotes / trailing punctuation. Cap at 80 chars (safety). */
92
+ function sanitizeTitle(raw) {
93
+ const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean);
94
+ if (lines.length === 0)
95
+ return '';
96
+ let title = lines[lines.length - 1]; // last non-empty line is the answer
97
+ // Strip surrounding quotes
98
+ title = title.replace(/^["'`]+|["'`]+$/g, '');
99
+ // Strip trailing punctuation
100
+ title = title.replace(/[.!?,;:]+$/, '');
101
+ // Cap at 80 chars
102
+ if (title.length > 80)
103
+ title = title.slice(0, 77) + '...';
104
+ return title.trim();
105
+ }
106
+ /**
107
+ * Generate a thread title from the first user prompt + cwd context.
108
+ * Returns the title string, or null if no provider is reachable.
109
+ * Never throws — failure modes return null.
110
+ */
111
+ export async function generateThreadTitle(firstPrompt, cwd) {
112
+ const prompt = PROMPT_TEMPLATE(firstPrompt, cwd);
113
+ for (const binary of selectProviderBinary()) {
114
+ const result = await tryProvider(binary, prompt);
115
+ if (result)
116
+ return result;
117
+ }
118
+ return null;
119
+ }
120
+ //# sourceMappingURL=generate-thread-title.js.map
package/dist/index.js CHANGED
@@ -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
- canAppendMessage: false, // v0.4-alpha doesn't support mid-session redirection
326
+ // v0.6.0 — Slice 3 of REPL persistence. Conductor inherits appendMessage
327
+ // capability from whichever specialist it routed to. With all 5 external
328
+ // adapters wired in Slices 1-2c, this becomes true for every routing
329
+ // decision. Follow-ups stay with the SAME specialist that handled the
330
+ // original turn — no re-routing mid-thread (intentional: the user's reply
331
+ // is part of an ongoing exchange with that specific specialist, not a
332
+ // fresh classification opportunity).
333
+ canAppendMessage: specialistSession.canAppendMessage === true,
334
+ appendMessage: specialistSession.appendMessage
335
+ ? (text) => specialistSession.appendMessage(text)
336
+ : undefined,
327
337
  subscribe(handler) {
328
338
  for (const e of buffer)
329
339
  handler(e);
@@ -354,6 +354,64 @@ export function spawnGemini(prompt, options = {}) {
354
354
  sendSignal('SIGKILL');
355
355
  }, 3000);
356
356
  },
357
+ canAppendMessage: true,
358
+ /**
359
+ * v0.6.0 — Slice 2b of REPL persistence. Gemini's --resume flag accepts
360
+ * either a numeric INDEX (per-cwd ordinal of past sessions) or the literal
361
+ * string "latest" — NOT the session_id UUID that the init JSONL event
362
+ * emits. UUID→index mapping requires --list-sessions which has its own
363
+ * race condition (indices shift as sessions are added/deleted).
364
+ *
365
+ * For the v0.6 cut we use --resume latest. Limitation: if the user spawns
366
+ * another gemini session in this cwd between the original spawn and this
367
+ * follow-up, the follow-up routes to the wrong session. Same caveat as
368
+ * opencode's --continue. Acceptable for the typical reply-within-minutes
369
+ * flow; the rigorous UUID→index helper is queued for v0.6.x.
370
+ */
371
+ appendMessage(text) {
372
+ const startedAt = Date.now();
373
+ return new Promise((resolve, reject) => {
374
+ let stdout = '';
375
+ let stderr = '';
376
+ const followup = spawn('gemini', ['-p', text, '--resume', 'latest', ...HEADLESS_FLAGS], {
377
+ cwd,
378
+ env: spawnEnv,
379
+ stdio: ['ignore', 'pipe', 'pipe'],
380
+ });
381
+ followup.on('error', (err) => reject(err));
382
+ followup.stdout?.on('data', (chunk) => {
383
+ stdout += chunk.toString('utf-8');
384
+ });
385
+ followup.stderr?.on('data', (chunk) => {
386
+ stderr += chunk.toString('utf-8');
387
+ });
388
+ followup.on('exit', (code) => {
389
+ if (code !== 0) {
390
+ reject(new Error(`gemini --resume latest exited ${code}: ${stderr.slice(0, 300)}`));
391
+ return;
392
+ }
393
+ // Parse stream-json — extract assistant message text from 'message'
394
+ // events with role='assistant'. Same source codex/claude-code use.
395
+ const fragments = [];
396
+ for (const line of stdout.split('\n')) {
397
+ const trimmed = line.trim();
398
+ if (!trimmed)
399
+ continue;
400
+ try {
401
+ const obj = JSON.parse(trimmed);
402
+ if (obj.type === 'message' && obj.role === 'assistant' && typeof obj.content === 'string') {
403
+ fragments.push(obj.content);
404
+ }
405
+ }
406
+ catch {
407
+ /* non-JSON — skip */
408
+ }
409
+ }
410
+ const result = fragments.join('\n').trim() || '(gemini returned no assistant message)';
411
+ resolve({ result, durationMs: Date.now() - startedAt });
412
+ });
413
+ });
414
+ },
357
415
  };
358
416
  }
359
417
  export const GeminiAdapter = {
@@ -245,6 +245,68 @@ export function spawnOpenCode(prompt, options = {}) {
245
245
  }
246
246
  }, 3000);
247
247
  },
248
+ canAppendMessage: true,
249
+ /**
250
+ * v0.6.0 — Slice 2c of REPL persistence. OpenCode's `run --format json`
251
+ * output is fully buffered until the LLM call completes; the session ID
252
+ * (ses_*) is not observable mid-stream like claude-code's session_id or
253
+ * codex's thread_id. The cleanest available alternative is `-c/--continue`
254
+ * which targets the last session.
255
+ *
256
+ * Limitation: same as gemini's --resume latest — if the user spawns
257
+ * another opencode session in this cwd between the original spawn and the
258
+ * follow-up, the follow-up routes to the wrong session. Acceptable for
259
+ * the typical reply-within-minutes flow; the rigorous version (capture
260
+ * session ID via post-spawn `opencode session list` filtered by --title
261
+ * tag) is queued for v0.6.x.
262
+ *
263
+ * Output parsing: `--format json` here uses the same JSONL event shape as
264
+ * the spawn path; we extract assistant message text from `text` events.
265
+ */
266
+ appendMessage(text) {
267
+ const startedAt = Date.now();
268
+ return new Promise((resolve, reject) => {
269
+ let stdout = '';
270
+ let stderr = '';
271
+ const followup = spawn('opencode', [...HEADLESS_FLAGS, '--continue', text], {
272
+ cwd,
273
+ env: spawnEnv,
274
+ stdio: ['ignore', 'pipe', 'pipe'],
275
+ });
276
+ followup.on('error', (err) => reject(err));
277
+ followup.stdout?.on('data', (chunk) => {
278
+ stdout += chunk.toString('utf-8');
279
+ });
280
+ followup.stderr?.on('data', (chunk) => {
281
+ stderr += chunk.toString('utf-8');
282
+ });
283
+ followup.on('exit', (code) => {
284
+ if (code !== 0) {
285
+ reject(new Error(`opencode --continue exited ${code}: ${stderr.slice(0, 300)}`));
286
+ return;
287
+ }
288
+ // Parse JSONL — extract assistant text from `text` events
289
+ // (same shape spawn() consumes via textAccum logic above).
290
+ const fragments = [];
291
+ for (const line of stdout.split('\n')) {
292
+ const trimmed = line.trim();
293
+ if (!trimmed)
294
+ continue;
295
+ try {
296
+ const obj = JSON.parse(trimmed);
297
+ if (obj.type === 'text' && obj.part?.text) {
298
+ fragments.push(obj.part.text);
299
+ }
300
+ }
301
+ catch {
302
+ /* non-JSON — skip */
303
+ }
304
+ }
305
+ const result = fragments.join('\n').trim() || '(opencode returned no text events)';
306
+ resolve({ result, durationMs: Date.now() - startedAt });
307
+ });
308
+ });
309
+ },
248
310
  };
249
311
  }
250
312
  export const OpenCodeAdapter = {
@@ -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
  }