codehere 0.8.0 → 1.0.1

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 (134) hide show
  1. package/README.md +22 -13
  2. package/dist/application/services/verdict-service.js +154 -0
  3. package/dist/application/use-cases/assess-cross-vendor.js +126 -0
  4. package/dist/application/use-cases/build-cross-vendor-verdict.js +118 -0
  5. package/dist/application/use-cases/export-aat.js +107 -0
  6. package/dist/application/use-cases/generate-acceptance-criteria.js +223 -0
  7. package/dist/application/use-cases/generate-final-report.js +140 -15
  8. package/dist/application/use-cases/generate-narration.js +5 -3
  9. package/dist/application/use-cases/generate-thread-title.js +5 -2
  10. package/dist/application/use-cases/plan-auto-review.js +57 -0
  11. package/dist/application/use-cases/reconcile-audit.js +100 -0
  12. package/dist/application/use-cases/rerun-tests.js +226 -0
  13. package/dist/application/use-cases/search-sessions.js +70 -0
  14. package/dist/application/use-cases/should-auto-review.js +71 -0
  15. package/dist/application/use-cases/thread-export.js +90 -0
  16. package/dist/application/use-cases/verify-claim.js +187 -5
  17. package/dist/domain/audit.js +11 -0
  18. package/dist/domain/services/codebase-detector.js +0 -6
  19. package/dist/domain/task.js +91 -0
  20. package/dist/error-handler.js +3 -115
  21. package/dist/index.js +269 -25
  22. package/dist/infrastructure/completion/bash-completion.js +7 -3
  23. package/dist/infrastructure/completion/fish-completion.js +7 -12
  24. package/dist/infrastructure/completion/zsh-completion.js +7 -12
  25. package/dist/infrastructure/config/config-manager.js +4 -3
  26. package/dist/infrastructure/config/product-context.js +1 -19
  27. package/dist/infrastructure/context/adapters/figma-source.js +37 -2
  28. package/dist/infrastructure/context/adapters/github-issue-source.js +3 -2
  29. package/dist/infrastructure/context/github-api.js +110 -0
  30. package/dist/infrastructure/context/github-device-flow.js +65 -0
  31. package/dist/infrastructure/context/oauth-store.js +5 -2
  32. package/dist/infrastructure/context/registry-bootstrap.js +0 -4
  33. package/dist/infrastructure/cost/budget-config.js +3 -2
  34. package/dist/infrastructure/cost/cost-router.js +16 -23
  35. package/dist/infrastructure/cost/spend-aggregator.js +90 -15
  36. package/dist/infrastructure/cost/team-ledger.js +355 -0
  37. package/dist/infrastructure/discovery/discovery-log.js +85 -0
  38. package/dist/infrastructure/hooks/audit-tip-signature.js +336 -0
  39. package/dist/infrastructure/hooks/claude-sdk-hook.js +133 -3
  40. package/dist/infrastructure/hooks/hook-mode.js +20 -4
  41. package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +8 -2
  42. package/dist/infrastructure/hooks/pre-prompt-scan.js +3 -3
  43. package/dist/infrastructure/observability/execution-tracer.js +1 -1
  44. package/dist/infrastructure/orchestration/agents-config-seed.js +22 -10
  45. package/dist/infrastructure/orchestration/aider-adapter.js +188 -24
  46. package/dist/infrastructure/orchestration/artifacts-extractor.js +60 -2
  47. package/dist/infrastructure/orchestration/claude-code-adapter.js +117 -3
  48. package/dist/infrastructure/orchestration/codex-adapter.js +52 -16
  49. package/dist/infrastructure/orchestration/conductor-adapter.js +46 -7
  50. package/dist/infrastructure/orchestration/context-handoff.js +32 -14
  51. package/dist/infrastructure/orchestration/detect-ollama.js +75 -0
  52. package/dist/infrastructure/orchestration/failover-config.js +0 -10
  53. package/dist/infrastructure/orchestration/gemini-adapter.js +92 -12
  54. package/dist/infrastructure/orchestration/generic-cli-adapter.js +132 -1
  55. package/dist/infrastructure/orchestration/graphify-context.js +14 -59
  56. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +164 -9
  57. package/dist/infrastructure/orchestration/opencode-adapter.js +84 -14
  58. package/dist/infrastructure/orchestration/progress-aggregator.js +238 -21
  59. package/dist/infrastructure/orchestration/session-manager.js +593 -94
  60. package/dist/infrastructure/orchestration/session-persistence.js +177 -41
  61. package/dist/infrastructure/orchestration/thread-key-store.js +1 -1
  62. package/dist/infrastructure/orchestration/write-pass-store.js +0 -8
  63. package/dist/infrastructure/protocols/mcp-helpers.js +1 -1
  64. package/dist/infrastructure/protocols/mcp-server-impl.js +1462 -380
  65. package/dist/infrastructure/protocols/mcp-tools.js +401 -0
  66. package/dist/infrastructure/protocols/ui/app-script-artifacts.js +121 -0
  67. package/dist/infrastructure/protocols/ui/app-script-audit-footer.js +44 -0
  68. package/dist/infrastructure/protocols/ui/app-script-audit-verify.js +56 -0
  69. package/dist/infrastructure/protocols/ui/app-script-composer-cost.js +588 -0
  70. package/dist/infrastructure/protocols/ui/app-script-context-sources.js +168 -0
  71. package/dist/infrastructure/protocols/ui/app-script-conversation.js +67 -0
  72. package/dist/infrastructure/protocols/ui/app-script-drawer.js +115 -0
  73. package/dist/infrastructure/protocols/ui/app-script-failover.js +133 -0
  74. package/dist/infrastructure/protocols/ui/app-script-github.js +113 -0
  75. package/dist/infrastructure/protocols/ui/app-script-observer.js +104 -0
  76. package/dist/infrastructure/protocols/ui/app-script-onboarding.js +80 -0
  77. package/dist/infrastructure/protocols/ui/app-script-overlay.js +203 -0
  78. package/dist/infrastructure/protocols/ui/app-script-recovery.js +136 -0
  79. package/dist/infrastructure/protocols/ui/app-script-render.js +166 -0
  80. package/dist/infrastructure/protocols/ui/app-script-share.js +78 -0
  81. package/dist/infrastructure/protocols/ui/app-script-sidebar.js +194 -0
  82. package/dist/infrastructure/protocols/ui/app-script-tour.js +78 -0
  83. package/dist/infrastructure/protocols/ui/app-script-wpass.js +312 -0
  84. package/dist/infrastructure/protocols/ui/app-script.js +2998 -0
  85. package/dist/infrastructure/protocols/ui/font-faces.js +7 -0
  86. package/dist/infrastructure/protocols/ui/styles.js +2147 -0
  87. package/dist/infrastructure/protocols/ui/tokens.js +96 -0
  88. package/dist/infrastructure/protocols/ui-page-v2.js +116 -4214
  89. package/dist/infrastructure/protocols/verify-page.js +849 -155
  90. package/dist/infrastructure/security/fix-code.js +144 -0
  91. package/dist/infrastructure/security/fix-secret.js +89 -0
  92. package/dist/infrastructure/security/permissions-config.js +3 -2
  93. package/dist/infrastructure/security/scan-types.js +10 -0
  94. package/dist/infrastructure/security/ship-scan.js +195 -0
  95. package/dist/infrastructure/security/trust-scorer.js +70 -3
  96. package/dist/infrastructure/security/vibe-checks.js +99 -0
  97. package/dist/infrastructure/storage/atomic-write.js +86 -0
  98. package/dist/infrastructure/storage/project-thread-mirror.js +150 -3
  99. package/dist/infrastructure/storage/project-thread-reader.js +261 -24
  100. package/dist/monitoring.js +2 -2
  101. package/dist/presentation/cli/commands/audit-command.js +79 -4
  102. package/dist/presentation/cli/commands/budget-command.js +2 -2
  103. package/dist/presentation/cli/commands/check-command.js +96 -0
  104. package/dist/presentation/cli/commands/context-source-command.js +20 -1
  105. package/dist/presentation/cli/commands/help-command.js +5 -5
  106. package/dist/presentation/cli/commands/hook-command.js +5 -5
  107. package/dist/presentation/cli/commands/init-command.js +26 -14
  108. package/dist/presentation/cli/commands/memory-command.js +28 -28
  109. package/dist/presentation/cli/commands/permission-command.js +1 -12
  110. package/dist/presentation/cli/commands/protocols-builder.js +2 -2
  111. package/dist/presentation/cli/commands/protocols-command.js +4 -4
  112. package/dist/presentation/cli/commands/repo-map-builder.js +1 -1
  113. package/dist/presentation/cli/commands/report-command.js +48 -32
  114. package/dist/presentation/cli/commands/scan-command.js +52 -0
  115. package/dist/presentation/cli/commands/scan-tool-command.js +24 -8
  116. package/dist/presentation/cli/commands/search-command.js +51 -0
  117. package/dist/presentation/cli/commands/setup-command.js +31 -8
  118. package/dist/presentation/cli/commands/status-command.js +39 -86
  119. package/dist/presentation/cli/commands/team-command.js +256 -0
  120. package/dist/presentation/cli/commands/thread-command.js +87 -5
  121. package/dist/presentation/cli/commands/trust-command.js +53 -31
  122. package/dist/presentation/cli/commands/verify-command.js +74 -9
  123. package/dist/presentation/cli/error-display.js +0 -8
  124. package/dist/presentation/cli/repl.js +15 -7
  125. package/dist/presentation/cli/task-deliverable.js +36 -10
  126. package/dist/testing/replay-adapter.js +0 -19
  127. package/dist/ui.js +51 -239
  128. package/dist/utils/version.js +0 -6
  129. package/package.json +30 -10
  130. package/dist/domain/entities/task.js +0 -61
  131. package/dist/infrastructure/protocols/ui-page.js +0 -2377
  132. package/dist/infrastructure/security/input-validator.js +0 -152
  133. package/dist/infrastructure/storage/embeddings-path.js +0 -37
  134. package/dist/infrastructure/storage/task-repository.js +0 -206
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Write a file so a reader never sees it half-written.
3
+ *
4
+ * Why (measured 2026-08-08, not assumed): `writeFileSync` is open(O_TRUNC) then
5
+ * write. SIGKILL landing between the two leaves a truncated, unparseable file.
6
+ * Induced deliberately — 40 attempts at a ~290KB payload, **15 came back torn**.
7
+ * The window shrinks with file size but never closes, and codehere's state files
8
+ * only grow.
9
+ *
10
+ * The failure this prevents is not "the write was lost" — losing the write is
11
+ * survivable, the caller can write again. It is "the file that survived is
12
+ * garbage": a corrupt permissions list, an unreadable budget ceiling, an OAuth
13
+ * store that no longer parses. `rename(2)` is atomic on POSIX, so a reader sees
14
+ * either the whole old file or the whole new one, never a splice of both.
15
+ *
16
+ * NOT what this gives you: durability across power loss. That needs fsync on the
17
+ * file AND its directory, which costs a real syscall on every write. The failure
18
+ * mode there is different and much less bad — you lose the write, you do not get
19
+ * a corrupt file — so it is deliberately not paid for here. Say so rather than
20
+ * implying this makes writes crash-proof.
21
+ *
22
+ * Appends are NOT routed through this and do not need to be. `appendFileSync`
23
+ * opens O_APPEND, and each write is atomic with respect to the file offset:
24
+ * measured with 6 concurrent writers × 300 records, and again at 90KB records,
25
+ * with zero interleaving or loss both times. The JSONL chain and session logs
26
+ * were never at risk from this, and an earlier audit that counted appends
27
+ * alongside whole-file writes overstated the problem.
28
+ */
29
+ import { writeFileSync, renameSync, unlinkSync, readdirSync, statSync } from 'node:fs';
30
+ import { dirname, basename, join } from 'node:path';
31
+ /** A temp older than this was orphaned by a crash, not left by a live write. */
32
+ const STALE_TEMP_MS = 60_000;
33
+ /**
34
+ * Sweep temps this file's own writes orphaned. SIGKILL cannot run a catch block,
35
+ * so a hard crash mid-write always leaves one behind — measured at 16 after 40
36
+ * kills. Without this, trading a corrupt file for permanent litter in the user's
37
+ * state dir just moves the problem: a stray `.tmp` next to `permissions.yaml`
38
+ * reads as corruption to anyone opening the directory.
39
+ *
40
+ * Scoped to the target's own basename so it can never touch another file's
41
+ * in-flight temp, and age-gated so it can never race a concurrent writer.
42
+ */
43
+ function sweepStaleTemps(path) {
44
+ const dir = dirname(path);
45
+ const prefix = `${basename(path)}.`;
46
+ try {
47
+ for (const name of readdirSync(dir)) {
48
+ if (!name.startsWith(prefix) || !name.endsWith('.tmp'))
49
+ continue;
50
+ const p = join(dir, name);
51
+ try {
52
+ if (Date.now() - statSync(p).mtimeMs > STALE_TEMP_MS)
53
+ unlinkSync(p);
54
+ }
55
+ catch {
56
+ /* vanished under us, or not ours to remove — either way, leave it */
57
+ }
58
+ }
59
+ }
60
+ catch {
61
+ /* unreadable dir · sweeping is hygiene, never a reason to fail the write */
62
+ }
63
+ }
64
+ export function writeFileAtomic(path, data, opts) {
65
+ // pid + time keeps two concurrent writers of the SAME path from clobbering
66
+ // each other's temp file; the rename still decides who wins, and either whole
67
+ // file is valid.
68
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
69
+ try {
70
+ writeFileSync(tmp, data, opts?.mode !== undefined ? { mode: opts.mode } : undefined);
71
+ renameSync(tmp, path);
72
+ sweepStaleTemps(path);
73
+ }
74
+ catch (err) {
75
+ // Never leave the temp behind on a failed write — it would accumulate in the
76
+ // user's state dir forever and look like corruption to anyone reading it.
77
+ try {
78
+ unlinkSync(tmp);
79
+ }
80
+ catch {
81
+ /* the temp never existed, or is already gone */
82
+ }
83
+ throw err;
84
+ }
85
+ }
86
+ //# sourceMappingURL=atomic-write.js.map
@@ -31,6 +31,8 @@ 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
+ import { execFileSync } from 'child_process';
35
+ import { normalizeRemote } from './project-thread-reader.js';
34
36
  const PROJECTS_DIR = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'projects');
35
37
  /** Match migration module's defaults. Same regex set kept in sync. */
36
38
  const TEMP_DIR_PATTERNS = [/^\/tmp\//, /^\/var\/folders\//, /^\/private\/var\/folders\//, /^\/private\/tmp\//];
@@ -44,6 +46,27 @@ function slugForCwd(cwd) {
44
46
  return 'unknown';
45
47
  return createHash('sha1').update(cwd).digest('hex').slice(0, 12);
46
48
  }
49
+ /**
50
+ * Detect the git remote for a cwd → normalized `host/org/repo` (project
51
+ * identity). Best-effort + fast: a short-timeout `git remote get-url origin`,
52
+ * silent on not-a-repo / no-remote / git-missing. Called at most once per
53
+ * project (write-time seed + the daemon-start backfill), never on a read poll.
54
+ */
55
+ function detectRemote(cwd) {
56
+ if (!cwd)
57
+ return null;
58
+ try {
59
+ const out = execFileSync('git', ['-C', cwd, 'remote', 'get-url', 'origin'], {
60
+ encoding: 'utf-8',
61
+ timeout: 2000,
62
+ stdio: ['ignore', 'pipe', 'ignore'],
63
+ });
64
+ return normalizeRemote(out.trim());
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
47
70
  function ensureProjectDir(slug) {
48
71
  const dir = join(PROJECTS_DIR, slug);
49
72
  if (!existsSync(dir))
@@ -224,7 +247,7 @@ export async function backfillProjectTitles() {
224
247
  if (ok) {
225
248
  rewritten++;
226
249
  if (process.env.CODEHERE_DEBUG === '1') {
227
- console.error(`[backfill] ${slug}: "${latestTitle.slice(0, 40)}" → "${fallback}"`);
250
+ console.error(`[backfill] ${slug}: "${latestTitle.slice(0, 40)}" › "${fallback}"`);
228
251
  }
229
252
  }
230
253
  }
@@ -234,6 +257,79 @@ export async function backfillProjectTitles() {
234
257
  }
235
258
  return { scanned, rewritten };
236
259
  }
260
+ /**
261
+ * One-time backfill of the git remote into _meta for projects that predate
262
+ * write-time detection (or whose seed-time detection found none). Idempotent:
263
+ * any project carrying a non-null remote OR a `_remoteChecked` marker is
264
+ * skipped, so this runs the git probe at most once per project across the
265
+ * daemon's lifetime. Best-effort; per-project failures continue.
266
+ *
267
+ * Wired at daemon start alongside backfillProjectTitles. Returns the count of
268
+ * projects for which a real remote was detected.
269
+ */
270
+ export async function backfillProjectRemotes() {
271
+ let scanned = 0;
272
+ let detected = 0;
273
+ if (!existsSync(PROJECTS_DIR))
274
+ return { scanned, detected };
275
+ let entries;
276
+ try {
277
+ const { readdirSync } = await import('fs');
278
+ entries = readdirSync(PROJECTS_DIR);
279
+ }
280
+ catch {
281
+ return { scanned, detected };
282
+ }
283
+ for (const slug of entries) {
284
+ if (!/^[a-f0-9]{12}$|^unknown$/.test(slug))
285
+ continue;
286
+ scanned++;
287
+ try {
288
+ const file = join(PROJECTS_DIR, slug, 'thread.jsonl');
289
+ if (!existsSync(file))
290
+ continue;
291
+ const content = readFileSync(file, 'utf-8');
292
+ const lines = content.split('\n').filter((l) => l.trim());
293
+ let alreadyChecked = false;
294
+ let haveRemote = false;
295
+ let latestCwd = null;
296
+ for (const line of lines) {
297
+ try {
298
+ const rec = JSON.parse(line);
299
+ if (rec._meta === 'project' || rec._meta === 'project-update') {
300
+ if (rec._remoteChecked === true)
301
+ alreadyChecked = true;
302
+ if (typeof rec.remote === 'string' && rec.remote)
303
+ haveRemote = true;
304
+ if (typeof rec.cwd === 'string' && rec.cwd && !latestCwd)
305
+ latestCwd = rec.cwd;
306
+ }
307
+ if (rec.type === 'task' && typeof rec.cwd === 'string' && rec.cwd)
308
+ latestCwd = rec.cwd;
309
+ }
310
+ catch {
311
+ /* skip malformed line */
312
+ }
313
+ }
314
+ if (alreadyChecked || haveRemote)
315
+ continue; // idempotent — already settled
316
+ const remote = detectRemote(latestCwd);
317
+ // Stamp _remoteChecked even on a null result so we never re-probe a
318
+ // remote-less project. The reader ignores a null remote (keeps it null).
319
+ const ok = appendMetaUpdate(slug, { remote: remote ?? null, _remoteChecked: true });
320
+ if (ok && remote) {
321
+ detected++;
322
+ if (process.env.CODEHERE_DEBUG === '1') {
323
+ console.error(`[backfill-remote] ${slug}: ${remote}`);
324
+ }
325
+ }
326
+ }
327
+ catch {
328
+ /* per-project failure → continue */
329
+ }
330
+ }
331
+ return { scanned, detected };
332
+ }
237
333
  /**
238
334
  * One-shot reconciliation for stale-running tasks (Bug: project-thread.jsonl
239
335
  * entries can stay in `status: 'running'` forever when the daemon dies
@@ -330,7 +426,7 @@ export async function reconcileStaleProjectTasks(options) {
330
426
  reconciled++;
331
427
  if (process.env.CODEHERE_DEBUG === '1') {
332
428
  const ageMin = Math.round((now - state.startedAt) / 60000);
333
- console.error(`[reconcile] ${slug}/${state.sessionId.slice(0, 8)} stale running for ${ageMin}m → crashed`);
429
+ console.error(`[reconcile] ${slug}/${state.sessionId.slice(0, 8)} stale running for ${ageMin}m › crashed`);
334
430
  }
335
431
  }
336
432
  }
@@ -404,6 +500,29 @@ export function renameProject(slug, newTitle) {
404
500
  }
405
501
  return { ok: true };
406
502
  }
503
+ /**
504
+ * Archive (soft-delete) or restore a task. Append-only and audit-safe: writes an
505
+ * `{_meta:'archived', sessionId, archived}` event to the thread (latest wins).
506
+ * The task record is NEVER deleted — the proof stays; the room just hides it from
507
+ * the active list. Restore = the same event with archived:false.
508
+ */
509
+ export function setTaskArchived(slug, sessionId, archived) {
510
+ if (!/^([a-f0-9]{12}|unknown)$/.test(slug))
511
+ return { ok: false, error: 'Invalid project slug' };
512
+ if (!sessionId || typeof sessionId !== 'string' || sessionId.length > 200 || /[\n\r]/.test(sessionId)) {
513
+ return { ok: false, error: 'Invalid sessionId' };
514
+ }
515
+ const file = join(PROJECTS_DIR, slug, 'thread.jsonl');
516
+ if (!existsSync(file))
517
+ return { ok: false, error: 'Project not found' };
518
+ try {
519
+ appendFileSync(file, JSON.stringify({ _meta: 'archived', slug, sessionId, archived: !!archived, updatedAt: Date.now() }) + '\n', 'utf-8');
520
+ return { ok: true };
521
+ }
522
+ catch {
523
+ return { ok: false, error: 'Write failed' };
524
+ }
525
+ }
407
526
  /**
408
527
  * Mirror a spawn event to the project thread. Skips temp-dir cwds (matches
409
528
  * migration defaults) so test-artifact dirs don't pollute the project list.
@@ -426,6 +545,8 @@ export function mirrorSpawnedToThread(rec) {
426
545
  cwd: rec.cwd,
427
546
  startedAt: rec.startedAt,
428
547
  status: 'running',
548
+ ...(rec.batchId ? { batchId: rec.batchId } : {}),
549
+ ...(rec.parentSessionId ? { parentSessionId: rec.parentSessionId } : {}),
429
550
  });
430
551
  if (seeded) {
431
552
  // Seed an immediate cwd-derived title so the sidebar never shows a blank
@@ -436,6 +557,13 @@ export function mirrorSpawnedToThread(rec) {
436
557
  const fallback = titleFromCwd(rec.cwd);
437
558
  appendMetaUpdate(slug, { title: fallback, cwd: rec.cwd });
438
559
  }
560
+ // Detect + cache the git remote ONCE at project creation. Project identity
561
+ // (and clone-collapse in listProjects) reads this from _meta — no git
562
+ // shell-out on the hot read path. Only written when a remote is found;
563
+ // remote-less / non-repo projects are settled by the daemon-start backfill.
564
+ const remote = detectRemote(rec.cwd);
565
+ if (remote)
566
+ appendMetaUpdate(slug, { remote, _remoteChecked: true });
439
567
  if (process.env.CODEHERE_DISABLE_TITLE_AGENT !== '1') {
440
568
  // Fire Title agent in background; do not block the spawn path. Lazy-import
441
569
  // to avoid loading the LLM module on every spawn when not needed.
@@ -478,6 +606,25 @@ export function mirrorCompletedToThread(rec) {
478
606
  artifacts: rec.artifacts,
479
607
  });
480
608
  }
609
+ /**
610
+ * Persist a generated Final Report summary to the project thread. The report is
611
+ * produced ASYNC after the session exits, so mirrorCompletedToThread already ran
612
+ * without it — this second append fills finalReport in. The reader merges task
613
+ * records by sessionId field-by-field, so this partial entry layers onto the
614
+ * existing completion record. Without it the summary lives only in the in-memory
615
+ * cache + sessions.jsonl: the conversation view loses it on a daemon restart, and
616
+ * a cross-agent hand-off reads "(no summary recorded)" instead of the real work.
617
+ */
618
+ export function mirrorFinalReportToThread(rec) {
619
+ if (isTempDir(rec.cwd))
620
+ return;
621
+ const slug = slugForCwd(rec.cwd);
622
+ appendThreadEntry(slug, {
623
+ type: 'task',
624
+ sessionId: rec.sessionId,
625
+ finalReport: { summary: rec.summary, ...(rec.source ? { source: rec.source } : {}) },
626
+ });
627
+ }
481
628
  /** Label map for raw error codes → user-facing text. */
482
629
  const FAILOVER_LABELS = {
483
630
  rate_limit: 'hit a rate limit',
@@ -496,7 +643,7 @@ export function mirrorFailoverToThread(rec) {
496
643
  type: 'task',
497
644
  sessionId: rec.sessionId,
498
645
  agent: 'system',
499
- prompt: `↻ Switched from ${fromLabel} to ${toLabel} (${reasonLabel})`,
646
+ prompt: `Switched from ${fromLabel} to ${toLabel} (${reasonLabel}) — warm restart with context, not a state transfer`,
500
647
  status: 'completed',
501
648
  startedAt: Date.now(),
502
649
  completedAt: Date.now(),
@@ -21,7 +21,123 @@
21
21
  import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
22
22
  import { join } from 'path';
23
23
  import { homedir } from 'os';
24
+ import { estimateCostUsd } from '../cost/spend-aggregator.js';
24
25
  const DEFAULT_PROJECTS_DIR = join(process.env.CODEHERE_HOME || homedir(), '.codehere', 'projects');
26
+ /**
27
+ * Normalize a git remote URL to a stable identity key `host/org/repo`:
28
+ * - strips scheme (https/http/ssh/git) + any user@ info
29
+ * - handles scp-like `git@host:org/repo`
30
+ * - drops a trailing `.git` and trailing slashes
31
+ * - lowercases the host (path case preserved)
32
+ * Returns null for empty/garbage input. Shared by the reader (identity grouping)
33
+ * and the forward-mirror (write-time detection) so both compute the same id.
34
+ */
35
+ export function normalizeRemote(url) {
36
+ if (!url)
37
+ return null;
38
+ let s = String(url).trim();
39
+ if (!s)
40
+ return null;
41
+ const scp = s.match(/^[^@/]+@([^:/]+):(.+)$/); // git@host:org/repo(.git)
42
+ if (scp) {
43
+ s = scp[1] + '/' + scp[2];
44
+ }
45
+ else {
46
+ s = s.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, ''); // scheme://
47
+ s = s.replace(/^[^@/]+@/, ''); // user@ (ssh://user@host/…)
48
+ }
49
+ s = s.replace(/\.git$/i, '').replace(/\/+$/, '');
50
+ if (!s)
51
+ return null;
52
+ const slash = s.indexOf('/');
53
+ if (slash === -1)
54
+ return s.toLowerCase();
55
+ return s.slice(0, slash).toLowerCase() + s.slice(slash);
56
+ }
57
+ /** True for hosts a clone must never reach — loopback, link-local (incl. cloud
58
+ * metadata 169.254.169.254), and RFC-1918 private ranges. `host` is the
59
+ * `host[:port]` segment from a normalized remote. */
60
+ function isBlockedCloneHost(host) {
61
+ const h = String(host ?? '')
62
+ .split(':')[0]
63
+ .toLowerCase();
64
+ if (!h)
65
+ return true;
66
+ if (h === 'localhost' || h === '::1' || h === '0.0.0.0' || h.endsWith('.localhost'))
67
+ return true;
68
+ if (/^127\./.test(h))
69
+ return true; // loopback
70
+ if (/^169\.254\./.test(h))
71
+ return true; // link-local + cloud metadata
72
+ if (/^10\./.test(h))
73
+ return true; // RFC-1918
74
+ if (/^192\.168\./.test(h))
75
+ return true; // RFC-1918
76
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(h))
77
+ return true; // RFC-1918
78
+ if (/^0\./.test(h))
79
+ return true; // "this" network
80
+ return false;
81
+ }
82
+ /**
83
+ * Parse a user-pasted GitHub (or any git) URL into a cloneable URL + a
84
+ * predictable local target directory. Pure (no IO) so the clone route can be
85
+ * tested without a network or filesystem. Accepts the forms a developer
86
+ * actually pastes: full https, ssh (git@host:org/repo), bare host/org/repo,
87
+ * and the `org/repo` GitHub shorthand. Rejects anything without a real
88
+ * host/org/repo shape (or with whitespace — a git URL never has any).
89
+ *
90
+ * The target dir is `<baseDir>/<repo>` — visible + predictable, NOT buried in
91
+ * a hidden dir, so the user can `cd` to it. The caller decides what to do when
92
+ * the dir already exists (we open it rather than re-clone — see the route).
93
+ */
94
+ export function parseGitHubCloneTarget(rawUrl, baseDir) {
95
+ const raw = String(rawUrl ?? '').trim();
96
+ if (!raw)
97
+ return { ok: false, error: 'url_required' };
98
+ if (/\s/.test(raw))
99
+ return { ok: false, error: 'invalid_url' };
100
+ let cloneUrl;
101
+ if (/^[^@/]+@[^:/]+:.+/.test(raw)) {
102
+ cloneUrl = raw; // git@host:org/repo(.git)
103
+ }
104
+ else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(raw)) {
105
+ cloneUrl = raw; // already has a scheme (https://, ssh://, …)
106
+ }
107
+ else if (/^[\w.-]+\.[\w.-]+\/.+/.test(raw)) {
108
+ cloneUrl = 'https://' + raw; // host.tld/org/repo (no scheme)
109
+ }
110
+ else if (/^[\w.-]+\/[\w.-]+$/.test(raw)) {
111
+ cloneUrl = 'https://github.com/' + raw; // org/repo GitHub shorthand
112
+ }
113
+ else {
114
+ return { ok: false, error: 'invalid_url' };
115
+ }
116
+ // Scheme allowlist — only https + ssh reach `git clone`. Rejects file:// (local
117
+ // file read), http:// (plaintext + the cloud-metadata / localhost SSRF vector),
118
+ // and every other transport. The scp form (git@host:…) carries no scheme and is
119
+ // allowed. This is the SSRF/file:// surface a pre-push review flagged.
120
+ const scheme = cloneUrl.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//);
121
+ if (scheme && !['https', 'ssh'].includes(scheme[1].toLowerCase()))
122
+ return { ok: false, error: 'invalid_url' };
123
+ const norm = normalizeRemote(cloneUrl); // host/org/repo, lowercased host
124
+ if (!norm)
125
+ return { ok: false, error: 'invalid_url' };
126
+ const parts = norm.split('/');
127
+ if (parts.length < 3)
128
+ return { ok: false, error: 'invalid_url' }; // need host + org + repo
129
+ // Internal-host denylist — don't let a clone target loopback / link-local /
130
+ // RFC-1918 (the rest of the SSRF surface: metadata endpoints, internal probing).
131
+ if (isBlockedCloneHost(parts[0]))
132
+ return { ok: false, error: 'invalid_url' };
133
+ const repo = (parts[parts.length - 1] || '').replace(/[^\w.-]/g, '');
134
+ // Reject '.'/'..'/empty — '.' and '..' survive the char-class strip and would
135
+ // traverse out of baseDir (join(baseDir, '..') = its parent). The clone dir
136
+ // must be a fresh leaf inside baseDir, nothing else.
137
+ if (!repo || repo === '.' || repo === '..')
138
+ return { ok: false, error: 'invalid_url' };
139
+ return { ok: true, cloneUrl, repo, dir: join(baseDir, repo) };
140
+ }
25
141
  /** List all known projects with summary stats. Cheap; no full task content. */
26
142
  export function listProjects(options) {
27
143
  const root = options?.projectsDir ?? DEFAULT_PROJECTS_DIR;
@@ -45,23 +161,59 @@ export function listProjects(options) {
45
161
  return false;
46
162
  }
47
163
  });
48
- const summaries = [];
164
+ const perSlug = [];
49
165
  for (const slug of slugs) {
50
166
  // Pattern 4 — sidebar presence dots need agent attribution per project.
51
- // Pull the full task list (lightweight — agent + startedAt only) so we
52
- // can compute lastAgent + recentAgents (multi-agent stack). taskLimit:0
53
- // would drop the data we need; fetch unbounded but discard the heavy
54
- // fields (steps + artifacts) below.
167
+ // Pull the full task list so we can compute agents + per-project cost.
55
168
  const thread = readProjectThread(slug, { projectsDir: root });
56
- if (thread) {
57
- const agentChrono = thread.tasks
58
- .filter((t) => typeof t.agent === 'string' && t.agent)
59
- .sort((a, b) => b.startedAt - a.startedAt) // most-recent first
60
- .map((t) => t.agent);
61
- const lastAgent = agentChrono.length > 0 ? agentChrono[0] : null;
62
- const seen = new Set();
63
- const recentAgents = [];
64
- for (const a of agentChrono) {
169
+ if (!thread)
170
+ continue;
171
+ const agentChrono = thread.tasks
172
+ .filter((t) => typeof t.agent === 'string' && t.agent)
173
+ .sort((a, b) => b.startedAt - a.startedAt) // most-recent first
174
+ .map((t) => t.agent);
175
+ // Per-project spend — same estimator the per-task /api/tasks cost uses.
176
+ let totalCostCents = 0;
177
+ for (const t of thread.tasks) {
178
+ const u = t.artifacts?.usage;
179
+ if (!u)
180
+ continue;
181
+ totalCostCents += estimateCostUsd(t.agent, u) * 100;
182
+ }
183
+ perSlug.push({
184
+ slug: thread.slug,
185
+ id: normalizeRemote(thread.remote) ?? 'local:' + thread.slug,
186
+ remote: thread.remote,
187
+ cwd: thread.cwd,
188
+ title: thread.title,
189
+ taskCount: thread.taskCount,
190
+ totalCostCents,
191
+ lastActivityAt: thread.lastActivityAt,
192
+ agentChrono,
193
+ });
194
+ }
195
+ // Second pass — merge slugs that resolve to the same project id (clones of
196
+ // one git remote). Local-only projects have a unique `local:<slug>` id and
197
+ // never merge. The most-recently-active member is canonical (its slug + title
198
+ // win, so rename / room links keep working).
199
+ const byId = new Map();
200
+ for (const s of perSlug) {
201
+ const g = byId.get(s.id);
202
+ if (g)
203
+ g.push(s);
204
+ else
205
+ byId.set(s.id, [s]);
206
+ }
207
+ const summaries = [];
208
+ for (const [id, members] of byId) {
209
+ members.sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0));
210
+ const head = members[0];
211
+ const localPaths = [...new Set(members.map((m) => m.cwd).filter((c) => !!c))];
212
+ // Recency-ordered agent stack across all members (head first).
213
+ const recentAgents = [];
214
+ const seen = new Set();
215
+ for (const m of members) {
216
+ for (const a of m.agentChrono) {
65
217
  if (seen.has(a))
66
218
  continue;
67
219
  seen.add(a);
@@ -69,16 +221,22 @@ export function listProjects(options) {
69
221
  if (recentAgents.length >= 3)
70
222
  break;
71
223
  }
72
- summaries.push({
73
- slug: thread.slug,
74
- cwd: thread.cwd,
75
- title: thread.title,
76
- taskCount: thread.taskCount,
77
- lastActivityAt: thread.lastActivityAt,
78
- lastAgent,
79
- recentAgents,
80
- });
224
+ if (recentAgents.length >= 3)
225
+ break;
81
226
  }
227
+ summaries.push({
228
+ id,
229
+ remote: head.remote,
230
+ slug: head.slug,
231
+ cwd: head.cwd,
232
+ localPaths,
233
+ title: head.title,
234
+ taskCount: members.reduce((n, m) => n + m.taskCount, 0),
235
+ totalCostCents: Math.round(members.reduce((n, m) => n + m.totalCostCents, 0) * 100) / 100,
236
+ lastActivityAt: members.reduce((acc, m) => (m.lastActivityAt && (acc === null || m.lastActivityAt > acc) ? m.lastActivityAt : acc), null),
237
+ lastAgent: recentAgents[0] ?? null,
238
+ recentAgents,
239
+ });
82
240
  }
83
241
  // Sort by recency (most recent first)
84
242
  summaries.sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0));
@@ -99,7 +257,9 @@ export function readProjectThread(slug, options) {
99
257
  }
100
258
  let cwd = null;
101
259
  let title = null;
260
+ let remote = null;
102
261
  const tasksById = new Map();
262
+ const archivedState = new Map(); // sessionId -> archived (latest event wins)
103
263
  for (const line of content.split('\n')) {
104
264
  const trimmed = line.trim();
105
265
  if (!trimmed)
@@ -116,12 +276,21 @@ export function readProjectThread(slug, options) {
116
276
  cwd = rec.cwd;
117
277
  if (typeof rec.title === 'string')
118
278
  title = rec.title;
279
+ if (typeof rec.remote === 'string')
280
+ remote = rec.remote;
119
281
  }
120
282
  else if (rec._meta === 'project-update') {
121
283
  if (typeof rec.title === 'string')
122
284
  title = rec.title;
123
285
  if (typeof rec.cwd === 'string' && cwd === null)
124
286
  cwd = rec.cwd;
287
+ // Latest non-empty remote wins; an explicit null (checked, none found)
288
+ // leaves the prior value alone — we only ever upgrade toward a real remote.
289
+ if (typeof rec.remote === 'string' && rec.remote)
290
+ remote = rec.remote;
291
+ }
292
+ else if (rec._meta === 'archived' && typeof rec.sessionId === 'string') {
293
+ archivedState.set(rec.sessionId, rec.archived === true);
125
294
  }
126
295
  else if (rec.type === 'task' && typeof rec.sessionId === 'string') {
127
296
  const existing = tasksById.get(rec.sessionId) ?? {
@@ -143,6 +312,8 @@ export function readProjectThread(slug, options) {
143
312
  'completedAt',
144
313
  'steps',
145
314
  'artifacts',
315
+ 'parentSessionId',
316
+ 'finalReport',
146
317
  ]) {
147
318
  if (rec[k] !== undefined) {
148
319
  existing[k] = rec[k];
@@ -151,6 +322,12 @@ export function readProjectThread(slug, options) {
151
322
  tasksById.set(rec.sessionId, existing);
152
323
  }
153
324
  }
325
+ // Stamp the archived flag (soft-delete) from the latest archived event per task.
326
+ for (const t of tasksById.values()) {
327
+ const a = archivedState.get(t.sessionId);
328
+ if (a !== undefined)
329
+ t.archived = a;
330
+ }
154
331
  const tasks = [...tasksById.values()].sort((a, b) => a.startedAt - b.startedAt);
155
332
  const lastActivityAt = tasks.reduce((acc, t) => {
156
333
  const ts = t.completedAt ?? t.startedAt;
@@ -164,9 +341,69 @@ export function readProjectThread(slug, options) {
164
341
  slug,
165
342
  cwd,
166
343
  title,
167
- taskCount: tasks.length,
344
+ remote,
345
+ // Count only what the UI actually shows: the sidebar tree + room list both
346
+ // hide archived + demo/seed tasks, so counting them inflated the badge
347
+ // (a project read "420" while showing a handful).
348
+ taskCount: countVisibleTasks(tasks),
168
349
  tasks: cappedTasks,
169
350
  lastActivityAt,
170
351
  };
171
352
  }
353
+ /** The honest THREAD count: distinct top-level conversations a user started.
354
+ * Excludes soft-deleted (archived) and demo/seed tasks, and — the big one —
355
+ * tasks with a parentSessionId, which are reply turns + auto cross-vendor
356
+ * reviewers (continuations of a thread, not new threads). Counting those made a
357
+ * project read "400+" while showing a handful of real threads. */
358
+ function countVisibleTasks(tasks) {
359
+ return tasks.filter((t) => {
360
+ if (t.archived || t.parentSessionId)
361
+ return false;
362
+ if (/^demo-/.test(t.sessionId || ''))
363
+ return false;
364
+ // Failover-lineage marker ("↻ Switched from X to Y (hit a rate limit)") — a
365
+ // continuation edge, not a thread the user started. Matches the sidebar tree.
366
+ const p = t.prompt || '';
367
+ if (p.includes('Switched from') && p.includes('rate limit'))
368
+ return false;
369
+ return true;
370
+ }).length;
371
+ }
372
+ /**
373
+ * Like readProjectThread, but merges every clone of the same repo (slugs that share
374
+ * a git remote) into ONE thread — so a project room shows the repo's FULL history,
375
+ * not just the single slug the UI happened to open. A local-only project (no remote)
376
+ * has no clones to merge and is returned unchanged. Tasks are deduped by sessionId.
377
+ */
378
+ export function readMergedProjectThread(slug, options) {
379
+ const root = options?.projectsDir ?? DEFAULT_PROJECTS_DIR;
380
+ const head = readProjectThread(slug, { projectsDir: root });
381
+ if (!head || !head.remote)
382
+ return head;
383
+ const id = normalizeRemote(head.remote);
384
+ let entries;
385
+ try {
386
+ entries = readdirSync(root).filter((e) => /^[a-f0-9]{12}$/.test(e) && e !== slug);
387
+ }
388
+ catch {
389
+ return head;
390
+ }
391
+ const byId = new Map();
392
+ for (const t of head.tasks)
393
+ byId.set(t.sessionId, t);
394
+ for (const e of entries) {
395
+ const sib = readProjectThread(e, { projectsDir: root });
396
+ if (!sib || normalizeRemote(sib.remote) !== id)
397
+ continue;
398
+ for (const t of sib.tasks)
399
+ if (!byId.has(t.sessionId))
400
+ byId.set(t.sessionId, t);
401
+ }
402
+ const tasks = [...byId.values()].sort((a, b) => a.startedAt - b.startedAt);
403
+ const lastActivityAt = tasks.reduce((acc, t) => {
404
+ const ts = t.completedAt ?? t.startedAt;
405
+ return ts && (acc === null || ts > acc) ? ts : acc;
406
+ }, null);
407
+ return { ...head, tasks, taskCount: countVisibleTasks(tasks), lastActivityAt };
408
+ }
172
409
  //# sourceMappingURL=project-thread-reader.js.map
@@ -201,10 +201,10 @@ export function getMetrics() {
201
201
  * Format health status for display
202
202
  */
203
203
  export function formatHealthStatus(health) {
204
- const statusIcon = health.status === 'healthy' ? '✓' : health.status === 'degraded' ? '⚠' : '✗';
204
+ const statusIcon = health.status === 'healthy' ? '✓' : health.status === 'degraded' ? '!' : '✕';
205
205
  let output = ` ${statusIcon} ${health.status.toUpperCase()}\n\n`;
206
206
  for (const [component, check] of Object.entries(health.checks)) {
207
- const icon = check.status === 'ok' ? '✓' : check.status === 'warning' ? '⚠' : '✗';
207
+ const icon = check.status === 'ok' ? '✓' : check.status === 'warning' ? '!' : '✕';
208
208
  output += ` ${icon} ${component.padEnd(12)} ${check.message}\n`;
209
209
  }
210
210
  return output;