codehere 0.5.0-alpha.6 → 0.6.0

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.
Files changed (39) hide show
  1. package/README.md +18 -8
  2. package/dist/application/use-cases/generate-narration.js +111 -0
  3. package/dist/application/use-cases/generate-thread-title.js +58 -4
  4. package/dist/application/use-cases/narrator-prompt-template.js +158 -0
  5. package/dist/index.js +89 -6
  6. package/dist/infrastructure/cost/budget-config.js +113 -0
  7. package/dist/infrastructure/cost/spend-aggregator.js +147 -0
  8. package/dist/infrastructure/hooks/claude-sdk-hook.js +280 -4
  9. package/dist/infrastructure/hooks/hook-mode.js +71 -0
  10. package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +43 -0
  11. package/dist/infrastructure/hooks/pre-prompt-scan.js +122 -0
  12. package/dist/infrastructure/orchestration/aider-adapter.js +28 -0
  13. package/dist/infrastructure/orchestration/artifacts-extractor.js +128 -8
  14. package/dist/infrastructure/orchestration/claude-code-adapter.js +1 -0
  15. package/dist/infrastructure/orchestration/codex-adapter.js +14 -0
  16. package/dist/infrastructure/orchestration/context-handoff.js +143 -0
  17. package/dist/infrastructure/orchestration/failover-classifier.js +151 -0
  18. package/dist/infrastructure/orchestration/failover-config.js +77 -0
  19. package/dist/infrastructure/orchestration/failover-policy.js +106 -0
  20. package/dist/infrastructure/orchestration/gemini-adapter.js +116 -14
  21. package/dist/infrastructure/orchestration/graphify-context.js +256 -0
  22. package/dist/infrastructure/orchestration/opencode-adapter.js +100 -18
  23. package/dist/infrastructure/orchestration/progress-aggregator.js +141 -6
  24. package/dist/infrastructure/orchestration/session-manager.js +515 -15
  25. package/dist/infrastructure/protocols/cloud-sync.js +208 -0
  26. package/dist/infrastructure/protocols/mcp-server-impl.js +655 -24
  27. package/dist/infrastructure/protocols/ui-page-v2.js +505 -0
  28. package/dist/infrastructure/protocols/ui-page.js +2872 -220
  29. package/dist/infrastructure/security/permissions-config.js +234 -0
  30. package/dist/infrastructure/storage/project-thread-mirror.js +371 -39
  31. package/dist/infrastructure/storage/project-thread-reader.js +23 -1
  32. package/dist/infrastructure/storage/project-thread-title-backfill.js +28 -0
  33. package/dist/presentation/cli/commands/budget-command.js +79 -0
  34. package/dist/presentation/cli/commands/narrator-command.js +101 -0
  35. package/dist/presentation/cli/commands/permission-command.js +116 -0
  36. package/dist/presentation/cli/commands/scan-tool-command.js +36 -7
  37. package/dist/presentation/cli/repl.js +29 -0
  38. package/dist/utils/typo-guard.js +77 -17
  39. package/package.json +2 -1
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Per-cwd permissions config — P18 slice 1 primitive.
3
+ *
4
+ * Reads `.codehere/permissions.yaml` from a project's cwd and lets the
5
+ * trust-scorer SKIP findings whose matched text fits an allow pattern.
6
+ *
7
+ * Use case: codehere blocks `rm -rf node_modules` because the pattern
8
+ * matches `rm -rf /`. The user knows it's safe in THIS cwd. They run
9
+ * `codehere permission allow "rm -rf node_modules"` (slice 2 CLI) and
10
+ * future blocks for that exact pattern in this cwd are skipped.
11
+ *
12
+ * Schema (.codehere/permissions.yaml):
13
+ *
14
+ * allow:
15
+ * - pattern: "rm -rf node_modules"
16
+ * reason: "OK to nuke deps in this repo"
17
+ * addedAt: 2026-05-08T20:00:00Z
18
+ * - pattern: "ANTHROPIC_API_KEY"
19
+ * reason: "Doc explains env vars; not actually leaking"
20
+ *
21
+ * Allow patterns are STRINGS not regex — exact substring match against
22
+ * the audit-finding `matched` field. Keeps the schema simple +
23
+ * non-technical-user friendly. Regex support is a v0.7 follow-up.
24
+ *
25
+ * Per claim_user_persona HIGH: the YAML is human-readable; non-
26
+ * technical users can edit it directly. Per overconfidence-guard:
27
+ * we DON'T claim the allow-list is "secure" — it's user CONSENT,
28
+ * not codehere certification.
29
+ */
30
+ import { existsSync, readFileSync } from 'fs';
31
+ import { join } from 'path';
32
+ const EMPTY = { allow: [], deny: [] };
33
+ /**
34
+ * Load .codehere/permissions.yaml from a cwd. Returns empty config if
35
+ * the file doesn't exist or fails to parse. Defensive: never throws —
36
+ * caller treats absence as "no overrides", which is the safest default
37
+ * (the scanner blocks normally).
38
+ */
39
+ export function loadPermissions(cwd) {
40
+ if (!cwd || typeof cwd !== 'string')
41
+ return EMPTY;
42
+ const file = join(cwd, '.codehere', 'permissions.yaml');
43
+ if (!existsSync(file))
44
+ return EMPTY;
45
+ let raw;
46
+ try {
47
+ raw = readFileSync(file, 'utf-8');
48
+ }
49
+ catch {
50
+ return EMPTY;
51
+ }
52
+ return parsePermissionsYaml(raw);
53
+ }
54
+ /**
55
+ * Tiny YAML subset parser — only handles the shape this file uses
56
+ * (top-level allow/deny lists of {pattern, reason, addedAt} objects).
57
+ * No external dep; the codehere repo already avoids the `yaml` package
58
+ * for read paths to keep cold-start fast. Format errors yield empty
59
+ * config (same posture as missing file).
60
+ */
61
+ export function parsePermissionsYaml(raw) {
62
+ const cfg = { allow: [], deny: [] };
63
+ let bucket = null;
64
+ let pending = null;
65
+ for (const line of raw.split('\n')) {
66
+ const stripped = line.replace(/#.*$/, '').trimEnd();
67
+ if (!stripped.trim())
68
+ continue;
69
+ // Top-level key: "allow:" or "deny:"
70
+ const top = stripped.match(/^(allow|deny)\s*:\s*$/);
71
+ if (top) {
72
+ // Flush any pending entry into the previous bucket
73
+ if (pending && bucket && pending.pattern)
74
+ cfg[bucket].push(pending);
75
+ pending = null;
76
+ bucket = top[1];
77
+ continue;
78
+ }
79
+ if (!bucket)
80
+ continue;
81
+ // List entry start: " - pattern: ..."
82
+ const listStart = stripped.match(/^\s*-\s+pattern\s*:\s*(.+)$/);
83
+ if (listStart) {
84
+ if (pending && pending.pattern)
85
+ cfg[bucket].push(pending);
86
+ pending = { pattern: unquote(listStart[1]) };
87
+ continue;
88
+ }
89
+ // Continuation field on a pending entry
90
+ const cont = stripped.match(/^\s+(reason|addedAt)\s*:\s*(.+)$/);
91
+ if (cont && pending) {
92
+ const key = cont[1];
93
+ pending[key] = unquote(cont[2]);
94
+ }
95
+ }
96
+ if (pending && bucket && pending.pattern)
97
+ cfg[bucket].push(pending);
98
+ return cfg;
99
+ }
100
+ function unquote(s) {
101
+ const t = s.trim();
102
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
103
+ return t.slice(1, -1);
104
+ }
105
+ return t;
106
+ }
107
+ /**
108
+ * Check whether a finding's matched text is allow-listed in this cwd.
109
+ * Substring match — exact-text patterns are simpler for non-technical
110
+ * users than regex. Returns the matching entry (for audit-trail
111
+ * provenance) or null if no match.
112
+ */
113
+ export function isAllowed(matchedText, cwd) {
114
+ const cfg = loadPermissions(cwd);
115
+ for (const entry of cfg.allow) {
116
+ if (entry.pattern && matchedText.includes(entry.pattern))
117
+ return entry;
118
+ }
119
+ return null;
120
+ }
121
+ /**
122
+ * Filter a findings list against the allow-list for a cwd. Returns
123
+ * the findings that survived (i.e., were NOT allow-listed). Used by
124
+ * the trust-scorer / pre-prompt-scan to drop findings that the user
125
+ * has explicitly approved.
126
+ *
127
+ * Side-effect-free: the allow-listed findings are dropped from the
128
+ * caller's perspective. The audit log writer can still record the
129
+ * full finding list IF the caller wants (then mark which were
130
+ * allow-listed). This function is the simple "what should we still
131
+ * block on" filter.
132
+ */
133
+ export function filterAllowed(findings, cwd) {
134
+ if (findings.length === 0)
135
+ return { surviving: [], allowed: [] };
136
+ const cfg = loadPermissions(cwd);
137
+ if (cfg.allow.length === 0)
138
+ return { surviving: findings, allowed: [] };
139
+ const surviving = [];
140
+ const allowed = [];
141
+ for (const f of findings) {
142
+ const hit = cfg.allow.find((e) => e.pattern && f.matched.includes(e.pattern));
143
+ if (hit)
144
+ allowed.push({ finding: f, entry: hit });
145
+ else
146
+ surviving.push(f);
147
+ }
148
+ return { surviving, allowed };
149
+ }
150
+ // ── Write surface (P18 slice 3) ─────────────────────────────────────────────
151
+ // Shared by the CLI (`codehere permission allow ...`) and the HTTP write
152
+ // endpoint (POST /api/permissions). Single source of truth for the
153
+ // idempotent-add semantics + the YAML serializer.
154
+ import { mkdirSync, writeFileSync } from 'fs';
155
+ /**
156
+ * Idempotent add — if the pattern already exists in the bucket, returns
157
+ * status='already_present' with the existing entry. Otherwise appends +
158
+ * writes the file. Writes via Write tool semantics (overwrites the
159
+ * whole YAML each time; entries are O(N) so re-serialization is cheap).
160
+ */
161
+ export function addPermissionEntry(input) {
162
+ const bucket = input.bucket ?? 'allow';
163
+ const cfg = loadPermissions(input.cwd);
164
+ const list = cfg[bucket];
165
+ const existing = list.find((e) => e.pattern === input.pattern);
166
+ if (existing) {
167
+ return {
168
+ status: 'already_present',
169
+ bucket,
170
+ entry: existing,
171
+ filePath: join(input.cwd, '.codehere', 'permissions.yaml'),
172
+ };
173
+ }
174
+ const entry = {
175
+ pattern: input.pattern,
176
+ ...(input.reason ? { reason: input.reason } : {}),
177
+ addedAt: new Date().toISOString(),
178
+ };
179
+ cfg[bucket].push(entry);
180
+ const filePath = writePermissionsConfig(input.cwd, cfg);
181
+ return { status: 'added', bucket, entry, filePath };
182
+ }
183
+ /**
184
+ * Idempotent remove — drops every entry whose pattern matches across
185
+ * BOTH buckets. Returns count of removed entries.
186
+ */
187
+ export function removePermissionEntry(cwd, pattern) {
188
+ const cfg = loadPermissions(cwd);
189
+ const before = cfg.allow.length + cfg.deny.length;
190
+ cfg.allow = cfg.allow.filter((e) => e.pattern !== pattern);
191
+ cfg.deny = cfg.deny.filter((e) => e.pattern !== pattern);
192
+ const after = cfg.allow.length + cfg.deny.length;
193
+ const removed = before - after;
194
+ if (removed > 0)
195
+ writePermissionsConfig(cwd, cfg);
196
+ return { removed, filePath: join(cwd, '.codehere', 'permissions.yaml') };
197
+ }
198
+ function writePermissionsConfig(cwd, cfg) {
199
+ const dir = join(cwd, '.codehere');
200
+ if (!existsSync(dir))
201
+ mkdirSync(dir, { recursive: true });
202
+ const filePath = join(dir, 'permissions.yaml');
203
+ writeFileSync(filePath, serializePermissionsYaml(cfg), 'utf-8');
204
+ return filePath;
205
+ }
206
+ export function serializePermissionsYaml(cfg) {
207
+ const out = [];
208
+ out.push('# codehere permissions — per-folder allow/deny list');
209
+ out.push('# See: codehere permission --help');
210
+ out.push('');
211
+ for (const bucket of ['allow', 'deny']) {
212
+ out.push(`${bucket}:`);
213
+ if (cfg[bucket].length === 0) {
214
+ out.push(' []');
215
+ }
216
+ else {
217
+ for (const e of cfg[bucket]) {
218
+ out.push(` - pattern: ${quoteYaml(e.pattern)}`);
219
+ if (e.reason)
220
+ out.push(` reason: ${quoteYaml(e.reason)}`);
221
+ if (e.addedAt)
222
+ out.push(` addedAt: ${quoteYaml(e.addedAt)}`);
223
+ }
224
+ }
225
+ out.push('');
226
+ }
227
+ return out.join('\n');
228
+ }
229
+ function quoteYaml(s) {
230
+ if (s.includes('"') && !s.includes("'"))
231
+ return `'${s}'`;
232
+ return `"${s.replace(/"/g, '\\"')}"`;
233
+ }
234
+ //# sourceMappingURL=permissions-config.js.map
@@ -27,18 +27,13 @@
27
27
  * Iter 13 — built after v0.5.0-alpha.4 ship. Previously the only way new
28
28
  * sessions reached thread.jsonl was to re-run migration; this closes the gap.
29
29
  */
30
- import { existsSync, mkdirSync, appendFileSync } from 'fs';
30
+ import { existsSync, mkdirSync, appendFileSync, readFileSync } from 'fs';
31
31
  import { homedir } from 'os';
32
32
  import { join } from 'path';
33
33
  import { createHash } from 'crypto';
34
34
  const PROJECTS_DIR = join(homedir(), '.codehere', 'projects');
35
35
  /** Match migration module's defaults. Same regex set kept in sync. */
36
- const TEMP_DIR_PATTERNS = [
37
- /^\/tmp\//,
38
- /^\/var\/folders\//,
39
- /^\/private\/var\/folders\//,
40
- /^\/private\/tmp\//,
41
- ];
36
+ const TEMP_DIR_PATTERNS = [/^\/tmp\//, /^\/var\/folders\//, /^\/private\/var\/folders\//, /^\/private\/tmp\//];
42
37
  function isTempDir(cwd) {
43
38
  if (!cwd)
44
39
  return false;
@@ -84,18 +79,330 @@ function appendThreadEntry(slug, entry) {
84
79
  return false;
85
80
  }
86
81
  }
87
- /** Append a project-meta update line (used by Title agent to set the title). */
82
+ /** Append a project-meta update line (used by Title agent to set the title).
83
+ * Returns true on successful write, false on any fs failure. Title-agent
84
+ * callers ignore the return (best-effort); renameProject surfaces it as a
85
+ * 500 to the HTTP layer so users don't see "renamed" when nothing landed. */
88
86
  function appendMetaUpdate(slug, partial) {
89
87
  try {
90
88
  const dir = ensureProjectDir(slug);
91
89
  const file = join(dir, 'thread.jsonl');
92
90
  if (!existsSync(file))
93
- return; // can't update what doesn't exist
91
+ return false; // can't update what doesn't exist
94
92
  appendFileSync(file, JSON.stringify({ _meta: 'project-update', slug, ...partial, updatedAt: Date.now() }) + '\n', 'utf-8');
93
+ return true;
95
94
  }
96
95
  catch {
97
- // best-effort
96
+ return false;
97
+ }
98
+ }
99
+ /**
100
+ * Returns true if any prior project-update line for this slug carries
101
+ * userOverride:true. Used by the async Title agent (and the periodic backfill)
102
+ * to skip overwriting a title the user has manually set.
103
+ *
104
+ * The race this guards: spawn fires Title agent in background → user renames
105
+ * before LLM returns → Title agent's later write would clobber the manual
106
+ * rename because the reader takes the latest title. With this check, the
107
+ * Title agent reads its own outcome moments before writing and bails if the
108
+ * user got there first. Best-effort; on read failure returns false (Title
109
+ * agent proceeds) — same posture as every other appendThreadEntry caller.
110
+ */
111
+ /**
112
+ * Derive a clean Title-Cased name from a working directory path. Used as the
113
+ * immediate seed title (so the sidebar never shows blank / placeholder during
114
+ * the LLM call) and as the permanent fallback when the Title agent returns
115
+ * null / garbage. Examples:
116
+ * /Users/x/code/codehere → "Codehere"
117
+ * /Users/x/work/auth_service → "Auth Service"
118
+ * /Users/x/code/some-cool-thing → "Some Cool Thing"
119
+ * / → "Project"
120
+ */
121
+ export function titleFromCwd(cwd) {
122
+ if (!cwd)
123
+ return 'Project';
124
+ const segments = cwd.split('/').filter(Boolean);
125
+ const last = segments[segments.length - 1];
126
+ if (!last)
127
+ return 'Project';
128
+ // Replace separators with spaces, title-case each word, cap at 6 words
129
+ const words = last
130
+ .replace(/[._-]+/g, ' ')
131
+ .replace(/([a-z])([A-Z])/g, '$1 $2') // camelCase split
132
+ .split(/\s+/)
133
+ .filter(Boolean)
134
+ .slice(0, 6)
135
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase());
136
+ return words.join(' ') || 'Project';
137
+ }
138
+ /**
139
+ * One-shot backfill for legacy garbage project titles (Bug A4 retroactive).
140
+ *
141
+ * The original A4 fix (rejected garbage titles + cwd fallback) only applied
142
+ * to NEW projects. Existing thread.jsonl files written before the fix can
143
+ * still carry titles like "Request ID: req_abc123…" or "I cannot generate…"
144
+ * sitting in the latest project-update record. Those show up in the sidebar
145
+ * forever until the user manually renames.
146
+ *
147
+ * Walks every ~/.codehere/projects/<slug>/thread.jsonl, reads the latest
148
+ * project-update title, runs the same garbage detection used at write time.
149
+ * If garbage, appends a new project-update with the cwd-derived fallback
150
+ * title plus `_backfilled: true` marker so re-runs are no-ops.
151
+ *
152
+ * Manual overrides (set via Pattern 10 inline rename) are NEVER touched —
153
+ * hasManualOverride guards the rewrite.
154
+ *
155
+ * Best-effort: any per-project failure logs to stderr (when CODEHERE_DEBUG=1)
156
+ * and continues. Returns the count of titles rewritten.
157
+ */
158
+ export async function backfillProjectTitles() {
159
+ let scanned = 0;
160
+ let rewritten = 0;
161
+ if (!existsSync(PROJECTS_DIR))
162
+ return { scanned, rewritten };
163
+ // Lazy-load sanitizeTitle to avoid a static import cycle (use-case → infra).
164
+ const { sanitizeTitle } = await import('../../application/use-cases/generate-thread-title.js');
165
+ let entries;
166
+ try {
167
+ const { readdirSync } = await import('fs');
168
+ entries = readdirSync(PROJECTS_DIR);
169
+ }
170
+ catch {
171
+ return { scanned, rewritten };
172
+ }
173
+ for (const slug of entries) {
174
+ // Skip non-slug-shaped names + hidden files
175
+ if (!/^[a-f0-9]{12}$|^unknown$/.test(slug))
176
+ continue;
177
+ scanned++;
178
+ try {
179
+ const file = join(PROJECTS_DIR, slug, 'thread.jsonl');
180
+ if (!existsSync(file))
181
+ continue;
182
+ if (hasManualOverride(slug))
183
+ continue; // Never overwrite user renames
184
+ const content = readFileSync(file, 'utf-8');
185
+ const lines = content.split('\n').filter((l) => l.trim());
186
+ // Find the latest project-update + the most recent task cwd (for fallback)
187
+ let latestTitle = null;
188
+ let latestCwd = null;
189
+ let alreadyBackfilled = false;
190
+ for (const line of lines) {
191
+ try {
192
+ const rec = JSON.parse(line);
193
+ if (rec._meta === 'project-update' && typeof rec.title === 'string') {
194
+ latestTitle = rec.title;
195
+ if (rec._backfilled === true)
196
+ alreadyBackfilled = true;
197
+ else
198
+ alreadyBackfilled = false;
199
+ }
200
+ if (rec.type === 'task' && typeof rec.cwd === 'string' && rec.cwd) {
201
+ latestCwd = rec.cwd;
202
+ }
203
+ }
204
+ catch {
205
+ /* skip malformed line */
206
+ }
207
+ }
208
+ if (alreadyBackfilled)
209
+ continue; // idempotent — already cleaned
210
+ if (!latestTitle)
211
+ continue; // nothing to check
212
+ // Run the same garbage detection sanitizeTitle uses at write time.
213
+ // sanitizeTitle returns '' for garbage; non-empty means clean.
214
+ const cleaned = sanitizeTitle(latestTitle);
215
+ if (cleaned)
216
+ continue; // title is fine
217
+ // Garbage — write a new project-update with cwd-derived fallback.
218
+ const fallback = titleFromCwd(latestCwd);
219
+ const ok = appendMetaUpdate(slug, {
220
+ title: fallback,
221
+ cwd: latestCwd,
222
+ _backfilled: true,
223
+ });
224
+ if (ok) {
225
+ rewritten++;
226
+ if (process.env.CODEHERE_DEBUG === '1') {
227
+ console.error(`[backfill] ${slug}: "${latestTitle.slice(0, 40)}" → "${fallback}"`);
228
+ }
229
+ }
230
+ }
231
+ catch {
232
+ /* per-project failure → continue */
233
+ }
234
+ }
235
+ return { scanned, rewritten };
236
+ }
237
+ /**
238
+ * One-shot reconciliation for stale-running tasks (Bug: project-thread.jsonl
239
+ * entries can stay in `status: 'running'` forever when the daemon dies
240
+ * mid-task or the subprocess crashes without a clean completion event).
241
+ *
242
+ * The visible failure mode (per dogfood screenshots May 6): the threads
243
+ * view shows RUNNING pills on 3-day-old tasks. With Pattern 8's peach-
244
+ * accent running pill, the page reads as "codehere doesn't know what
245
+ * state its own sessions are in" — a credibility bomb.
246
+ *
247
+ * Walks every ~/.codehere/projects/<slug>/thread.jsonl, reconstructs each
248
+ * session's latest task state by sessionId, and for any session whose
249
+ * latest state is `running` AND startedAt > 60 minutes ago, appends a
250
+ * synthetic completion record marking it `crashed` with `_reconciled: true`.
251
+ *
252
+ * Idempotent — sessions already carrying `_reconciled: true` are skipped.
253
+ * Preserves history (append-only, never rewrites prior lines).
254
+ *
255
+ * Best-effort: per-project failures log to stderr (CODEHERE_DEBUG=1) and
256
+ * continue. Returns count of sessions reconciled.
257
+ */
258
+ export async function reconcileStaleProjectTasks(options) {
259
+ const STALE_AFTER_MS = options?.staleAfterMs ?? 60 * 60 * 1000; // 60 minutes
260
+ const now = Date.now();
261
+ let scanned = 0;
262
+ let reconciled = 0;
263
+ if (!existsSync(PROJECTS_DIR))
264
+ return { scanned, reconciled };
265
+ let entries;
266
+ try {
267
+ const { readdirSync } = await import('fs');
268
+ entries = readdirSync(PROJECTS_DIR);
269
+ }
270
+ catch {
271
+ return { scanned, reconciled };
272
+ }
273
+ for (const slug of entries) {
274
+ if (!/^[a-f0-9]{12}$|^unknown$/.test(slug))
275
+ continue;
276
+ scanned++;
277
+ try {
278
+ const file = join(PROJECTS_DIR, slug, 'thread.jsonl');
279
+ if (!existsSync(file))
280
+ continue;
281
+ const content = readFileSync(file, 'utf-8');
282
+ const lines = content.split('\n').filter((l) => l.trim());
283
+ const bySession = new Map();
284
+ for (const line of lines) {
285
+ try {
286
+ const rec = JSON.parse(line);
287
+ if (rec.type !== 'task' || typeof rec.sessionId !== 'string')
288
+ continue;
289
+ const cur = bySession.get(rec.sessionId) ?? {
290
+ sessionId: rec.sessionId,
291
+ status: '',
292
+ startedAt: 0,
293
+ reconciled: false,
294
+ };
295
+ if (typeof rec.status === 'string')
296
+ cur.status = rec.status;
297
+ if (typeof rec.startedAt === 'number' && rec.startedAt > 0)
298
+ cur.startedAt = rec.startedAt;
299
+ if (rec._reconciled === true)
300
+ cur.reconciled = true;
301
+ bySession.set(rec.sessionId, cur);
302
+ }
303
+ catch {
304
+ /* skip malformed line */
305
+ }
306
+ }
307
+ // Find stale-running candidates and append synthetic completions.
308
+ for (const state of bySession.values()) {
309
+ if (state.status !== 'running')
310
+ continue;
311
+ if (state.reconciled)
312
+ continue; // already cleaned
313
+ if (state.startedAt <= 0)
314
+ continue; // safety: no time data
315
+ if (now - state.startedAt < STALE_AFTER_MS)
316
+ continue;
317
+ // appendThreadEntry returns `seeded` (first-write flag), not a
318
+ // success signal. For an already-seeded file, the return is always
319
+ // false on both success and failure. Best-effort approach: count
320
+ // optimistically — write failures are rare and bounded by the
321
+ // function's own try/catch.
322
+ appendThreadEntry(slug, {
323
+ type: 'task',
324
+ sessionId: state.sessionId,
325
+ status: 'crashed',
326
+ completedAt: now,
327
+ durationMs: now - state.startedAt,
328
+ _reconciled: true,
329
+ });
330
+ reconciled++;
331
+ if (process.env.CODEHERE_DEBUG === '1') {
332
+ const ageMin = Math.round((now - state.startedAt) / 60000);
333
+ console.error(`[reconcile] ${slug}/${state.sessionId.slice(0, 8)} stale running for ${ageMin}m → crashed`);
334
+ }
335
+ }
336
+ }
337
+ catch {
338
+ /* per-project failure → continue */
339
+ }
340
+ }
341
+ return { scanned, reconciled };
342
+ }
343
+ export function hasManualOverride(slug) {
344
+ try {
345
+ const file = join(PROJECTS_DIR, slug, 'thread.jsonl');
346
+ if (!existsSync(file))
347
+ return false;
348
+ const content = readFileSync(file, 'utf-8');
349
+ for (const line of content.split('\n')) {
350
+ const t = line.trim();
351
+ if (!t)
352
+ continue;
353
+ try {
354
+ const rec = JSON.parse(t);
355
+ if (rec._meta === 'project-update' && rec.userOverride === true)
356
+ return true;
357
+ }
358
+ catch {
359
+ // skip malformed line
360
+ }
361
+ }
362
+ return false;
363
+ }
364
+ catch {
365
+ return false;
366
+ }
367
+ }
368
+ /**
369
+ * v0.6.x — user-initiated rename. The Title agent generates a title from the
370
+ * first prompt, but project intent drifts (e.g. /agent dir gets titled "Scan
371
+ * Auth File" forever even after 100+ unrelated tasks). This gives the user a
372
+ * mechanism to override that title without re-firing the LLM-based agent.
373
+ *
374
+ * Returns { ok: false, error } on validation/fs failure so the HTTP layer can
375
+ * surface a meaningful 4xx instead of a silent best-effort write. Note: this
376
+ * is the only project-update path that returns errors — Title agent
377
+ * appendMetaUpdate is silent because LLM hiccups are normal.
378
+ */
379
+ export function renameProject(slug, newTitle) {
380
+ // Validate slug shape — guards against path traversal in case the caller
381
+ // doesn't sanitize the URL parameter (HTTP layer validates this too, defense in depth).
382
+ if (!/^([a-f0-9]{12}|unknown)$/.test(slug)) {
383
+ return { ok: false, error: 'Invalid project slug' };
384
+ }
385
+ // Validate title — strip whitespace, enforce length and single-line.
386
+ const trimmed = newTitle.trim();
387
+ if (!trimmed)
388
+ return { ok: false, error: 'Title cannot be empty' };
389
+ if (trimmed.length > 100)
390
+ return { ok: false, error: 'Title too long (max 100 chars)' };
391
+ if (/[\n\r]/.test(trimmed))
392
+ return { ok: false, error: 'Title cannot contain newlines' };
393
+ // Verify project exists — appendMetaUpdate is silent on missing files but
394
+ // we want a clean 404 path for the HTTP caller.
395
+ const file = join(PROJECTS_DIR, slug, 'thread.jsonl');
396
+ if (!existsSync(file))
397
+ return { ok: false, error: 'Project not found' };
398
+ // userOverride:true tells the async Title agent + periodic backfill to skip
399
+ // writing for this slug — see hasManualOverride() above. Without this flag,
400
+ // a slow LLM call from spawn-time can land AFTER the user's rename and
401
+ // clobber it (the reader takes the latest project-update title).
402
+ if (!appendMetaUpdate(slug, { title: trimmed, userOverride: true })) {
403
+ return { ok: false, error: 'Write failed' };
98
404
  }
405
+ return { ok: true };
99
406
  }
100
407
  /**
101
408
  * Mirror a spawn event to the project thread. Skips temp-dir cwds (matches
@@ -120,21 +427,36 @@ export function mirrorSpawnedToThread(rec) {
120
427
  startedAt: rec.startedAt,
121
428
  status: 'running',
122
429
  });
123
- if (seeded && process.env.CODEHERE_DISABLE_TITLE_AGENT !== '1') {
124
- // Fire Title agent in background; do not block the spawn path. Lazy-import
125
- // to avoid loading the LLM module on every spawn when not needed.
126
- void (async () => {
127
- try {
128
- const { generateThreadTitle } = await import('../../application/use-cases/generate-thread-title.js');
129
- const title = await generateThreadTitle(rec.prompt, rec.cwd);
130
- if (title) {
131
- appendMetaUpdate(slug, { title, cwd: rec.cwd });
430
+ if (seeded) {
431
+ // Seed an immediate cwd-derived title so the sidebar never shows a blank
432
+ // / placeholder while the LLM call is in flight (or if it fails entirely).
433
+ // The Title agent can later overwrite this with a richer prompt-derived
434
+ // title; user manual rename always wins over both.
435
+ if (!hasManualOverride(slug)) {
436
+ const fallback = titleFromCwd(rec.cwd);
437
+ appendMetaUpdate(slug, { title: fallback, cwd: rec.cwd });
438
+ }
439
+ if (process.env.CODEHERE_DISABLE_TITLE_AGENT !== '1') {
440
+ // Fire Title agent in background; do not block the spawn path. Lazy-import
441
+ // to avoid loading the LLM module on every spawn when not needed.
442
+ void (async () => {
443
+ try {
444
+ const { generateThreadTitle } = await import('../../application/use-cases/generate-thread-title.js');
445
+ const title = await generateThreadTitle(rec.prompt, rec.cwd);
446
+ // Last-moment manual-override check: between spawn and now (LLM call
447
+ // can take seconds), user may have already renamed. Don't clobber.
448
+ // Also: if Title agent returned null (garbage rejected, provider
449
+ // unreachable, refusal), keep the cwd-derived fallback already seeded
450
+ // above — never write `(untitled)` or empty.
451
+ if (title && !hasManualOverride(slug)) {
452
+ appendMetaUpdate(slug, { title, cwd: rec.cwd });
453
+ }
132
454
  }
133
- }
134
- catch {
135
- // best-effort
136
- }
137
- })();
455
+ catch {
456
+ // best-effort — cwd fallback already seeded
457
+ }
458
+ })();
459
+ }
138
460
  }
139
461
  }
140
462
  /**
@@ -156,21 +478,31 @@ export function mirrorCompletedToThread(rec) {
156
478
  artifacts: rec.artifacts,
157
479
  });
158
480
  }
159
- /**
160
- * v0.6.x — mirror a successful appendMessage() reply to the project thread.
161
- *
162
- * Pre-fix: only initial spawn + completion wrote to thread.jsonl. Reply turns
163
- * (the `/api/task/:id/message` endpoint, the dashboard reply box, and the new
164
- * /threads reply bar) updated only the in-memory session. Result: /threads
165
- * showed the original spawn but every follow-up turn was invisible. Founder
166
- * flagged it 2026-05-03 as a v0.6.x cleanup item.
167
- *
168
- * Schema choice: write a single complete `{type: 'task', sessionId: <new>,
169
- * parentSessionId: <orig>, ...}` entry rather than splitting spawn+complete.
170
- * appendMessage is synchronous-from-caller's-POV (no streaming), so the whole
171
- * turn is known when this fires. The reader's sessionId merge logic surfaces
172
- * each reply as its own chronological row on /threads.
173
- */
481
+ /** Label map for raw error codes → user-facing text. */
482
+ const FAILOVER_LABELS = {
483
+ rate_limit: 'hit a rate limit',
484
+ quota_exceeded: 'exhausted its quota',
485
+ credit_low: 'ran low on credits',
486
+ error: 'encountered an error',
487
+ };
488
+ export function mirrorFailoverToThread(rec) {
489
+ if (isTempDir(rec.cwd))
490
+ return;
491
+ const slug = slugForCwd(rec.cwd);
492
+ const reasonLabel = FAILOVER_LABELS[rec.reason] ?? 'became unavailable';
493
+ const fromLabel = rec.fromAgent;
494
+ const toLabel = rec.toAgent;
495
+ appendThreadEntry(slug, {
496
+ type: 'task',
497
+ sessionId: rec.sessionId,
498
+ agent: 'system',
499
+ prompt: `↻ Switched from ${fromLabel} to ${toLabel} (${reasonLabel})`,
500
+ status: 'completed',
501
+ startedAt: Date.now(),
502
+ completedAt: Date.now(),
503
+ durationMs: 0,
504
+ });
505
+ }
174
506
  export function mirrorReplyToThread(rec) {
175
507
  if (isTempDir(rec.cwd))
176
508
  return;