@gajae-code/coding-agent 0.12.21 → 0.13.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 (222) hide show
  1. package/CHANGELOG.md +67 -2
  2. package/dist/types/cli/args.d.ts +5 -2
  3. package/dist/types/cli/root-flags.d.ts +246 -0
  4. package/dist/types/cli.d.ts +11 -19
  5. package/dist/types/commands/daemon.d.ts +0 -30
  6. package/dist/types/commands/launch.d.ts +11 -19
  7. package/dist/types/commands/quick-lane.d.ts +32 -0
  8. package/dist/types/commands/sdk.d.ts +21 -15
  9. package/dist/types/config/file-lock-gc.d.ts +2 -0
  10. package/dist/types/config/model-equivalence.d.ts +20 -0
  11. package/dist/types/config/model-profile-activation.d.ts +62 -17
  12. package/dist/types/config/model-profiles.d.ts +10 -0
  13. package/dist/types/config/model-registry.d.ts +68 -1
  14. package/dist/types/config/model-resolver.d.ts +15 -4
  15. package/dist/types/config/models-config-schema.d.ts +7 -2
  16. package/dist/types/config/provider-selection-policy.d.ts +62 -0
  17. package/dist/types/config/settings-schema.d.ts +36 -78
  18. package/dist/types/config/settings.d.ts +2 -0
  19. package/dist/types/edit/renderer.d.ts +5 -2
  20. package/dist/types/edit/streaming.d.ts +13 -0
  21. package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
  22. package/dist/types/extensibility/extensions/compact-handler.d.ts +2 -1
  23. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  24. package/dist/types/gjc-runtime/gc-runtime.d.ts +19 -5
  25. package/dist/types/internal-urls/local-protocol.d.ts +1 -0
  26. package/dist/types/modes/components/model-selector.d.ts +0 -2
  27. package/dist/types/modes/components/provider-order-context.d.ts +45 -0
  28. package/dist/types/modes/components/provider-order-editor.d.ts +24 -0
  29. package/dist/types/modes/components/settings-defs.d.ts +5 -1
  30. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  31. package/dist/types/quick-lane/classify.d.ts +40 -0
  32. package/dist/types/runtime/stt-settings-setup.d.ts +18 -0
  33. package/dist/types/sdk/broker/broker.d.ts +56 -2
  34. package/dist/types/sdk/broker/operation-receipt-ledger.d.ts +25 -0
  35. package/dist/types/sdk/broker/session-index.d.ts +83 -2
  36. package/dist/types/sdk/broker/state-version.d.ts +1 -1
  37. package/dist/types/sdk/bus/notification-service.d.ts +6 -0
  38. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +8 -1
  39. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -1
  40. package/dist/types/sdk/bus/telegram-daemon.d.ts +26 -0
  41. package/dist/types/sdk/cli/rows.d.ts +98 -0
  42. package/dist/types/sdk/cli/session-cli.d.ts +35 -3
  43. package/dist/types/sdk/elevation/allowlist.d.ts +20 -0
  44. package/dist/types/sdk/elevation/audit.d.ts +93 -0
  45. package/dist/types/sdk/elevation/capability.d.ts +3 -0
  46. package/dist/types/sdk/elevation/digest.d.ts +72 -0
  47. package/dist/types/sdk/elevation/dispatch-receipt.d.ts +85 -0
  48. package/dist/types/sdk/elevation/grant-ledger.d.ts +163 -0
  49. package/dist/types/sdk/elevation/owner.d.ts +48 -0
  50. package/dist/types/sdk/guides/cache.d.ts +98 -0
  51. package/dist/types/sdk/guides/catalog.d.ts +156 -0
  52. package/dist/types/sdk/guides/cli.d.ts +39 -0
  53. package/dist/types/sdk/guides/manifest.d.ts +66 -0
  54. package/dist/types/sdk/guides/verify.d.ts +66 -0
  55. package/dist/types/sdk/host/control/dispatch.d.ts +8 -0
  56. package/dist/types/sdk/host/control/index.d.ts +1 -1
  57. package/dist/types/sdk/host/control/operations.d.ts +3 -2
  58. package/dist/types/sdk/host/query/cursor.d.ts +10 -0
  59. package/dist/types/sdk/host/query/handlers.d.ts +21 -0
  60. package/dist/types/sdk/host/session-runtime.d.ts +9 -0
  61. package/dist/types/sdk/mcp/server.d.ts +2 -2
  62. package/dist/types/sdk/prompt-status.d.ts +11 -0
  63. package/dist/types/sdk/session.d.ts +4 -0
  64. package/dist/types/sdk/transport/index.d.ts +1 -0
  65. package/dist/types/sdk/transport/relay.d.ts +1 -0
  66. package/dist/types/sdk/transport/serve-cli.d.ts +20 -1
  67. package/dist/types/session/agent-session.d.ts +32 -18
  68. package/dist/types/session/blob-store.d.ts +13 -7
  69. package/dist/types/session/fallback-chain-controller.d.ts +15 -0
  70. package/dist/types/session/internal/managed-session-storage.d.ts +25 -1
  71. package/dist/types/session/internal/session-memory-sidecar.d.ts +5 -5
  72. package/dist/types/session/session-manager.d.ts +139 -10
  73. package/dist/types/session/session-storage.d.ts +48 -0
  74. package/dist/types/setup/provider-onboarding.d.ts +7 -1
  75. package/dist/types/slash-commands/builtin-registry.d.ts +14 -0
  76. package/dist/types/stt/setup.d.ts +2 -0
  77. package/dist/types/task/executor.d.ts +10 -2
  78. package/dist/types/tools/browser/runtime-diagnostics.d.ts +65 -0
  79. package/dist/types/tools/browser/tab-protocol.d.ts +4 -0
  80. package/dist/types/tools/browser/tab-supervisor.d.ts +2 -0
  81. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  82. package/dist/types/tools/browser.d.ts +2 -0
  83. package/dist/types/tools/image-gen.d.ts +14 -18
  84. package/dist/types/tools/index.d.ts +2 -0
  85. package/dist/types/tools/resource-gc.d.ts +2 -0
  86. package/dist/types/utils/pasted-image-path.d.ts +6 -0
  87. package/package.json +9 -9
  88. package/scripts/generate-sdk-operation-inventory.ts +8 -0
  89. package/scripts/run-test-manifest.ts +61 -21
  90. package/scripts/verify-gjc-sdk-canonicalization.ts +9 -4
  91. package/src/cli/args.ts +62 -9
  92. package/src/cli/completion-cli.ts +1 -0
  93. package/src/cli/fast-help.ts +1 -0
  94. package/src/cli/root-flags.ts +100 -0
  95. package/src/cli/setup-cli.ts +6 -2
  96. package/src/cli.ts +42 -65
  97. package/src/commands/acp.ts +1 -1
  98. package/src/commands/daemon.ts +2 -31
  99. package/src/commands/gc.ts +1 -1
  100. package/src/commands/launch.ts +8 -129
  101. package/src/commands/quick-lane.ts +51 -0
  102. package/src/commands/sdk.ts +200 -26
  103. package/src/commands/setup.ts +4 -2
  104. package/src/config/file-lock-gc.ts +4 -1
  105. package/src/config/model-equivalence.ts +76 -7
  106. package/src/config/model-profile-activation.ts +1115 -265
  107. package/src/config/model-profiles.ts +134 -2
  108. package/src/config/model-registry.ts +352 -50
  109. package/src/config/model-resolver.ts +289 -38
  110. package/src/config/models-config-schema.ts +11 -4
  111. package/src/config/provider-selection-policy.ts +146 -0
  112. package/src/config/settings-schema.ts +50 -77
  113. package/src/config/settings.ts +40 -0
  114. package/src/coordinator-mcp/server.ts +32 -2
  115. package/src/edit/renderer.ts +175 -223
  116. package/src/edit/streaming.ts +108 -1
  117. package/src/export/html/index.ts +57 -7
  118. package/src/extensibility/custom-tools/types.ts +2 -0
  119. package/src/extensibility/extensions/compact-handler.ts +3 -2
  120. package/src/extensibility/extensions/runner.ts +6 -0
  121. package/src/extensibility/extensions/types.ts +3 -0
  122. package/src/gjc-runtime/gc-runtime.ts +379 -20
  123. package/src/gjc-runtime/launch-worktree.ts +5 -1
  124. package/src/gjc-runtime/team-runtime.ts +59 -2
  125. package/src/gjc-runtime/tmux-owner-isolation.ts +19 -0
  126. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +16 -1
  127. package/src/internal-urls/docs-index.generated.ts +17 -13
  128. package/src/internal-urls/local-protocol.ts +1 -0
  129. package/src/main.ts +40 -3
  130. package/src/memories/index.ts +4 -2
  131. package/src/modes/acp/acp-agent.ts +235 -91
  132. package/src/modes/components/model-selector.ts +206 -42
  133. package/src/modes/components/provider-order-context.ts +197 -0
  134. package/src/modes/components/provider-order-editor.ts +441 -0
  135. package/src/modes/components/settings-defs.ts +21 -1
  136. package/src/modes/components/settings-selector.ts +45 -2
  137. package/src/modes/components/tool-execution.ts +47 -15
  138. package/src/modes/controllers/command-controller.ts +6 -3
  139. package/src/modes/controllers/extension-ui-controller.ts +2 -0
  140. package/src/modes/controllers/input-controller.ts +73 -2
  141. package/src/modes/controllers/selector-controller.ts +260 -153
  142. package/src/modes/prompt-suggestion-controller.ts +1 -1
  143. package/src/modes/runtime-init.ts +1 -0
  144. package/src/modes/utils/ui-helpers.ts +18 -0
  145. package/src/prompts/tools/browser.md +1 -0
  146. package/src/quick-lane/classify.ts +257 -0
  147. package/src/rlm/index.ts +1 -1
  148. package/src/runtime/stt-settings-setup.ts +57 -0
  149. package/src/sdk/acp/adapter.ts +2 -1
  150. package/src/sdk/broker/broker.ts +780 -12
  151. package/src/sdk/broker/lifecycle.ts +70 -29
  152. package/src/sdk/broker/operation-receipt-ledger.ts +144 -0
  153. package/src/sdk/broker/session-index.ts +463 -48
  154. package/src/sdk/broker/state-version.ts +13 -9
  155. package/src/sdk/broker/transport.ts +14 -1
  156. package/src/sdk/bus/index.ts +34 -13
  157. package/src/sdk/bus/kind-aware-reconciliation.ts +21 -2
  158. package/src/sdk/bus/notification-service.ts +47 -1
  159. package/src/sdk/bus/prompt-reconciliation.ts +22 -3
  160. package/src/sdk/bus/telegram-daemon-contract.ts +4 -1
  161. package/src/sdk/bus/telegram-daemon.ts +54 -3
  162. package/src/sdk/cli/rows.ts +247 -0
  163. package/src/sdk/cli/session-cli.ts +930 -79
  164. package/src/sdk/elevation/allowlist.ts +54 -0
  165. package/src/sdk/elevation/audit.ts +208 -0
  166. package/src/sdk/elevation/capability.ts +41 -0
  167. package/src/sdk/elevation/digest.ts +129 -0
  168. package/src/sdk/elevation/dispatch-receipt.ts +184 -0
  169. package/src/sdk/elevation/grant-ledger.ts +1142 -0
  170. package/src/sdk/elevation/owner.ts +109 -0
  171. package/src/sdk/guides/cache.ts +363 -0
  172. package/src/sdk/guides/catalog.ts +554 -0
  173. package/src/sdk/guides/cli.ts +235 -0
  174. package/src/sdk/guides/manifest.ts +152 -0
  175. package/src/sdk/guides/verify.ts +178 -0
  176. package/src/sdk/host/control/dispatch.ts +49 -1
  177. package/src/sdk/host/control/index.ts +1 -0
  178. package/src/sdk/host/control/operations.ts +8 -1
  179. package/src/sdk/host/query/cursor.ts +47 -3
  180. package/src/sdk/host/query/handlers.ts +155 -2
  181. package/src/sdk/host/sdk-surface-parity.test.ts +1 -0
  182. package/src/sdk/host/session-runtime.test.ts +391 -1
  183. package/src/sdk/host/session-runtime.ts +308 -73
  184. package/src/sdk/host/surface-policy.ts +3 -0
  185. package/src/sdk/host/websocket-transport.ts +7 -1
  186. package/src/sdk/mcp/server.ts +88 -15
  187. package/src/sdk/prompt-status.ts +8 -0
  188. package/src/sdk/protocol/operation-inventory.generated.json +62 -0
  189. package/src/sdk/protocol/operation-registry.ts +3 -2
  190. package/src/sdk/session.ts +87 -39
  191. package/src/sdk/transport/index.ts +1 -0
  192. package/src/sdk/transport/relay.ts +3 -0
  193. package/src/sdk/transport/serve-cli.ts +111 -24
  194. package/src/secrets/obfuscator.ts +92 -12
  195. package/src/session/agent-session.ts +322 -93
  196. package/src/session/blob-store.ts +60 -8
  197. package/src/session/fallback-chain-controller.ts +35 -0
  198. package/src/session/internal/managed-session-scope.ts +25 -3
  199. package/src/session/internal/managed-session-storage.ts +574 -73
  200. package/src/session/internal/session-memory-sidecar.ts +19 -29
  201. package/src/session/session-manager.ts +2166 -743
  202. package/src/session/session-storage.ts +715 -48
  203. package/src/setup/provider-onboarding.ts +19 -3
  204. package/src/setup/provider-presets.json +3 -1
  205. package/src/slash-commands/builtin-registry.ts +85 -3
  206. package/src/stt/downloader.ts +2 -3
  207. package/src/stt/setup.ts +29 -4
  208. package/src/task/executor.ts +22 -18
  209. package/src/task/index.ts +24 -1
  210. package/src/tools/archive-reader.ts +11 -2
  211. package/src/tools/browser/runtime-diagnostics.ts +249 -0
  212. package/src/tools/browser/tab-protocol.ts +4 -0
  213. package/src/tools/browser/tab-supervisor.ts +5 -0
  214. package/src/tools/browser/tab-worker.ts +36 -1
  215. package/src/tools/browser.ts +7 -0
  216. package/src/tools/image-gen.ts +110 -166
  217. package/src/tools/index.ts +2 -0
  218. package/src/tools/read.ts +1 -1
  219. package/src/tools/resource-gc.ts +6 -1
  220. package/src/tools/tool-catalog.generated.ts +5 -1
  221. package/src/utils/pasted-image-path.ts +93 -0
  222. package/src/web/search/index.ts +3 -3
@@ -528,15 +528,16 @@ export function gcHelpText(): string {
528
528
  " --dry-run Force report-only mode (overrides --prune/--force)",
529
529
  " -j, --json Emit machine-readable JSON",
530
530
  " --repair-session-index Explicitly quarantine a corrupt session-index suffix and retain its valid prefix",
531
- " --disk Also report on-disk retention (sessions, blobs, natives, backups)",
531
+ " --disk Also report on-disk retention (sessions, blobs, artifacts, natives, backups)",
532
532
  "",
533
533
  "Liveness-only: a record is removed only when its owning process is dead",
534
534
  "(ESRCH). Live / permission-denied / unknown processes are always kept.",
535
535
  "",
536
536
  "Disk retention (--disk) is a separate, opt-in axis. Without --prune it only",
537
537
  "reports reclaimable bytes per surface. Live, referenced, permission-denied or",
538
- "otherwise ambiguous state is always KEPT and the report says why. Managed",
539
- "worktrees under ~/.gjc/wt are never touched.",
538
+ "otherwise ambiguous state is always KEPT and the report says why. Session tool",
539
+ "artifacts (`*.<tool>.log`, `.artifact-id-*`, evicted output) are reported per",
540
+ "family. Managed worktrees under ~/.gjc/wt are never touched.",
540
541
  "",
541
542
  ].join("\n");
542
543
  }
@@ -580,15 +581,21 @@ export async function defaultGcAdapters(): Promise<GcStoreAdapter[]> {
580
581
  // needs evidence-based merge detection, which this axis does not have.
581
582
 
582
583
  /** On-disk surfaces the retention axis can reclaim. */
583
- export type GcDiskSurface = "sessions" | "blobs" | "natives" | "backups";
584
-
585
- export const GC_DISK_SURFACES: readonly GcDiskSurface[] = ["sessions", "blobs", "natives", "backups"] as const;
584
+ export type GcDiskSurface = "sessions" | "blobs" | "artifacts" | "natives" | "backups";
585
+
586
+ export const GC_DISK_SURFACES: readonly GcDiskSurface[] = [
587
+ "sessions",
588
+ "blobs",
589
+ "artifacts",
590
+ "natives",
591
+ "backups",
592
+ ] as const;
586
593
 
587
594
  export type GcDiskAction = "keep" | "would_reclaim" | "reclaimed" | "reclaim_failed";
588
595
 
589
596
  export interface GcDiskRecord {
590
597
  surface: GcDiskSurface;
591
- /** Session id, blob hash, natives version, or backup entry name. */
598
+ /** Session id, blob hash, `<session id>/<filename>` artifact, natives version, or backup entry name. */
592
599
  id: string;
593
600
  path: string;
594
601
  bytes: number;
@@ -603,8 +610,21 @@ export interface GcDiskRecord {
603
610
  }
604
611
 
605
612
  /**
606
- * Why a surface refused to reclaim anything, and how much it withheld to stay
607
- * fail-closed. Present only when the surface declined.
613
+ * Per-family rollup of a surface whose growth is many small files.
614
+ *
615
+ * The artifacts surface writes one record per file, which answers "what may go"
616
+ * but not "what filled the scope". A family is derived from the filename shape
617
+ * (`*.bash.log`, `.artifact-id-*`, …) rather than a fixed list, so a tool that
618
+ * starts writing a new kind of log is counted the day it ships.
619
+ */
620
+ export interface GcDiskFamilyUsage {
621
+ family: string;
622
+ count: number;
623
+ bytes: number;
624
+ }
625
+
626
+ /**
627
+ * Why a surface withheld reclaim candidates because its evidence was incomplete.
608
628
  */
609
629
  export interface GcDiskDeclined {
610
630
  reason: string;
@@ -624,8 +644,10 @@ export interface GcDiskSurfaceReport {
624
644
  kept: number;
625
645
  kept_bytes: number;
626
646
  failed: number;
627
- /** Set when the surface declined to reclaim anything because its evidence was incomplete. */
647
+ /** Set when the surface withheld reclaim candidates because its evidence was incomplete. */
628
648
  declined?: GcDiskDeclined;
649
+ /** Per-family counts and bytes. Only surfaces whose records are individual files set this. */
650
+ families?: GcDiskFamilyUsage[];
629
651
  records: GcDiskRecord[];
630
652
  }
631
653
 
@@ -673,6 +695,17 @@ const GC_DISK_BLOB_GRACE_MS = GC_DISK_DAY_MS;
673
695
  * sweep on, so the sweep withholds instead.
674
696
  */
675
697
  const GC_DISK_MARK_REMARK_ROUNDS = 2;
698
+ /**
699
+ * How long the store must be observed completely quiet before the sweep may
700
+ * remove anything. The mark rounds and their drift checks can each complete
701
+ * inside an inter-append gap of a live session, so a single clean observation
702
+ * is not evidence of quiescence: the sweep brackets its decision with a probe
703
+ * that spans this window, which is far larger than any realistic gap between
704
+ * two transcript appends. A store that still moves inside the window withholds
705
+ * the sweep with `sessions_changed_during_mark` instead of reclaiming on
706
+ * evidence it could not prove stable.
707
+ */
708
+ const GC_DISK_MARK_QUIESCENCE_MS = 50;
676
709
 
677
710
  /** Bound every recursive size walk so a pathological tree cannot stall `gjc gc`. */
678
711
  const GC_DISK_MAX_WALK_ENTRIES = 200_000;
@@ -1165,8 +1198,12 @@ interface GcDiskMarkedTranscript {
1165
1198
 
1166
1199
  /**
1167
1200
  * Read one transcript's blob references and bind the result to the bytes that
1168
- * produced it. An append landing after this lstat can introduce a reference the
1169
- * pass never saw, which is precisely what the drift check below looks for.
1201
+ * produced it. The read is fenced by a stat before it and a stat after it: an
1202
+ * append landing between the two (or after the read but before the fence) can
1203
+ * introduce a reference the pass never saw, so the mark is only recorded when
1204
+ * both stats agree on the bytes the read had to see. Anything that moved while
1205
+ * the read was in flight degrades `evidence` instead of silently shrinking the
1206
+ * reference set.
1170
1207
  */
1171
1208
  async function markGcDiskTranscript(
1172
1209
  transcriptPath: string,
@@ -1175,18 +1212,35 @@ async function markGcDiskTranscript(
1175
1212
  evidence: GcDiskEvidence,
1176
1213
  errors: GcDiskError[],
1177
1214
  ): Promise<void> {
1215
+ let before: Stats;
1216
+ try {
1217
+ before = await fsp.lstat(transcriptPath);
1218
+ } catch (error) {
1219
+ markGcDiskEvidenceIncomplete(evidence, "transcript_unstattable_before_mark");
1220
+ errors.push({ surface: "blobs", scope: transcriptPath, message: gcDiskErrorText(error) });
1221
+ return;
1222
+ }
1178
1223
  if (!(await markGcDiskBlobReferences(transcriptPath, referenced))) {
1179
1224
  markGcDiskEvidenceIncomplete(evidence, "transcript_unreadable_during_mark");
1180
1225
  errors.push({ surface: "blobs", scope: transcriptPath, message: "transcript_unreadable_during_mark" });
1181
1226
  return;
1182
1227
  }
1228
+ let after: Stats;
1183
1229
  try {
1184
- const stat = await fsp.lstat(transcriptPath);
1185
- marked.set(transcriptPath, { size: stat.size, mtimeMs: stat.mtimeMs });
1230
+ after = await fsp.lstat(transcriptPath);
1186
1231
  } catch (error) {
1187
1232
  markGcDiskEvidenceIncomplete(evidence, "transcript_unstattable_after_mark");
1188
1233
  errors.push({ surface: "blobs", scope: transcriptPath, message: gcDiskErrorText(error) });
1234
+ return;
1235
+ }
1236
+ if (before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
1237
+ // The transcript changed while its references were being read: the
1238
+ // reference set is not bound to a stable snapshot, so it cannot vouch
1239
+ // for any blob. Same posture as the drift rounds exhausting below.
1240
+ markGcDiskEvidenceIncomplete(evidence, "sessions_changed_during_mark");
1241
+ return;
1189
1242
  }
1243
+ marked.set(transcriptPath, { size: after.size, mtimeMs: after.mtimeMs });
1190
1244
  }
1191
1245
 
1192
1246
  /**
@@ -1223,6 +1277,53 @@ async function findGcDiskMarkDrift(input: {
1223
1277
  });
1224
1278
  return drifted;
1225
1279
  }
1280
+ /**
1281
+ * The sweep's evidence fence: re-walk the store and confirm every transcript
1282
+ * still matches the snapshot its references were read from. Returns false —
1283
+ * after degrading `evidence` to `sessions_changed_during_mark` — when anything
1284
+ * moved since the mark, so the sweep withholds rather than acting on stale
1285
+ * evidence. Run once after the mark loop converges and again immediately
1286
+ * before each removal, because the sweep itself is a window a live session can
1287
+ * write into.
1288
+ */
1289
+ async function verifyGcDiskMarkFence(input: {
1290
+ sessionsRoot: string;
1291
+ accounted: ReadonlySet<string>;
1292
+ marked: Map<string, GcDiskMarkedTranscript>;
1293
+ evidence: GcDiskEvidence;
1294
+ errors: GcDiskError[];
1295
+ }): Promise<boolean> {
1296
+ const drifted = await findGcDiskMarkDrift({
1297
+ sessionsRoot: input.sessionsRoot,
1298
+ accounted: input.accounted,
1299
+ marked: input.marked,
1300
+ evidence: input.evidence,
1301
+ errors: input.errors,
1302
+ });
1303
+ if (drifted.length === 0) return true;
1304
+ markGcDiskEvidenceIncomplete(input.evidence, "sessions_changed_during_mark");
1305
+ return false;
1306
+ }
1307
+ /**
1308
+ * Confirm the store is observably quiet before the sweep may remove anything.
1309
+ * A single fence pass can still complete inside an inter-append gap of a live
1310
+ * session, so this probes the store across {@link GC_DISK_MARK_QUIESCENCE_MS}
1311
+ * and only reports quiet when every transcript stayed unchanged for the whole
1312
+ * window. A store that keeps moving under the probe never satisfies it, which
1313
+ * is exactly the `sessions_changed_during_mark` condition the sweep must
1314
+ * withhold on instead of reclaiming on evidence it could not prove stable.
1315
+ */
1316
+ async function confirmGcDiskStoreQuiet(input: {
1317
+ sessionsRoot: string;
1318
+ accounted: ReadonlySet<string>;
1319
+ marked: Map<string, GcDiskMarkedTranscript>;
1320
+ evidence: GcDiskEvidence;
1321
+ errors: GcDiskError[];
1322
+ }): Promise<boolean> {
1323
+ if (!(await verifyGcDiskMarkFence(input))) return false;
1324
+ await Bun.sleep(GC_DISK_MARK_QUIESCENCE_MS);
1325
+ return await verifyGcDiskMarkFence(input);
1326
+ }
1226
1327
 
1227
1328
  /**
1228
1329
  * Mark and sweep the content-addressed blob store.
@@ -1240,6 +1341,14 @@ async function findGcDiskMarkDrift(input: {
1240
1341
  * grew, and only sweeps once the store stops moving under it. Drift that
1241
1342
  * outlasts {@link GC_DISK_MARK_REMARK_ROUNDS} is incomplete evidence, not a
1242
1343
  * licence to delete.
1344
+ *
1345
+ * The drift rounds are themselves a window: they can converge on a transcript
1346
+ * that is mid-append, and the sweep then takes time to run. So the mark is
1347
+ * additionally fenced — each transcript's references are bound to a stable
1348
+ * stat snapshot taken around its read, the store must be observed completely
1349
+ * quiet across {@link GC_DISK_MARK_QUIESCENCE_MS} before the sweep acts, and
1350
+ * the fence is re-verified immediately before every removal. Any transcript
1351
+ * that moved at any of those points withholds the whole sweep.
1243
1352
  */
1244
1353
  async function runGcDiskBlobs(input: {
1245
1354
  surface: GcDiskSurfaceReport;
@@ -1280,6 +1389,19 @@ async function runGcDiskBlobs(input: {
1280
1389
  }
1281
1390
  }
1282
1391
 
1392
+ // Gate the sweep on observable quiescence. The rounds can converge inside
1393
+ // an inter-append gap of a live session, and a single clean re-walk would
1394
+ // miss that; a store that keeps moving under the probe never satisfies it.
1395
+ // Only pay for the probe when a blob could actually be reclaimed.
1396
+ if (evidence.complete) {
1397
+ const couldReclaim = blobs.some(
1398
+ blob => !referenced.has(blob.hash) && now - blob.mtimeMs >= GC_DISK_BLOB_GRACE_MS,
1399
+ );
1400
+ if (couldReclaim && !(await confirmGcDiskStoreQuiet({ sessionsRoot, accounted, marked, evidence, errors }))) {
1401
+ markGcDiskEvidenceIncomplete(evidence, "sessions_changed_during_mark");
1402
+ }
1403
+ }
1404
+
1283
1405
  let withheld = 0;
1284
1406
  let withheldBytes = 0;
1285
1407
  for (const blob of blobs) {
@@ -1300,15 +1422,36 @@ async function runGcDiskBlobs(input: {
1300
1422
  withheld++;
1301
1423
  withheldBytes += blob.bytes;
1302
1424
  } else {
1303
- record.action = "would_reclaim";
1304
- record.reason = "unreferenced_by_any_surviving_session";
1425
+ // Fence again immediately before acting: the sweep itself is a
1426
+ // window, and a transcript that moved since the last check
1427
+ // invalidates the whole mark, not just this blob.
1428
+ if (!(await verifyGcDiskMarkFence({ sessionsRoot, accounted, marked, evidence, errors }))) {
1429
+ record.reason = `withheld_evidence_incomplete: ${evidence.notes.join(", ")}`;
1430
+ record.withheld = true;
1431
+ withheld++;
1432
+ withheldBytes += blob.bytes;
1433
+ } else {
1434
+ record.action = "would_reclaim";
1435
+ record.reason = "unreferenced_by_any_surviving_session";
1436
+ }
1305
1437
  }
1306
1438
  surface.records.push(record);
1307
1439
 
1308
1440
  if (!prune || record.action !== "would_reclaim") continue;
1309
- const removal = await removeCanonicalBlob(blob);
1441
+ const removal = await removeCanonicalBlob(blob, {
1442
+ // The blob identity check has its own asynchronous read window. Bind the
1443
+ // transcript fence after that check and before unlink, so a reference
1444
+ // appended while the blob is being revalidated withholds the sweep.
1445
+ beforeUnlink: async () => await verifyGcDiskMarkFence({ sessionsRoot, accounted, marked, evidence, errors }),
1446
+ });
1310
1447
  if (removal.removed) {
1311
1448
  record.action = "reclaimed";
1449
+ } else if (!evidence.complete) {
1450
+ record.action = "keep";
1451
+ record.reason = `withheld_evidence_incomplete: ${evidence.notes.join(", ")}`;
1452
+ record.withheld = true;
1453
+ withheld++;
1454
+ withheldBytes += blob.bytes;
1312
1455
  } else if (removal.failed) {
1313
1456
  record.action = "reclaim_failed";
1314
1457
  record.reason = removal.reason;
@@ -1328,6 +1471,199 @@ async function runGcDiskBlobs(input: {
1328
1471
  }
1329
1472
  }
1330
1473
 
1474
+ /**
1475
+ * Which family of GJC-written file a name belongs to, derived from the name's
1476
+ * shape rather than from a list of known tools.
1477
+ *
1478
+ * A published artifact is `<id>.<toolType>.log` and an ID claim is
1479
+ * `.artifact-id-<n>`, so a tool that starts writing `*.newthing.log` tomorrow is
1480
+ * counted the day it ships. Everything else falls back to its extension, so
1481
+ * nothing lands in the scope uncounted.
1482
+ */
1483
+ function gcDiskArtifactFamily(name: string): string {
1484
+ const toolLog = /^\d+\.([A-Za-z0-9_-]+)\.log$/.exec(name);
1485
+ if (toolLog) return `*.${toolLog[1]}.log`;
1486
+ if (/^\.artifact-id-\d+$/.test(name)) return ".artifact-id-*";
1487
+ const extension = path.extname(name);
1488
+ return extension ? `*${extension}` : "(no extension)";
1489
+ }
1490
+
1491
+ function countGcDiskArtifactFamily(families: Map<string, GcDiskFamilyUsage>, name: string, bytes: number): void {
1492
+ const family = gcDiskArtifactFamily(name);
1493
+ const usage = families.get(family);
1494
+ if (!usage) {
1495
+ families.set(family, { family, count: 1, bytes });
1496
+ return;
1497
+ }
1498
+ usage.count++;
1499
+ usage.bytes += bytes;
1500
+ }
1501
+
1502
+ /**
1503
+ * The per-session artifact directories GJC writes its own tool output into:
1504
+ * `<id>.<tool>.log`, `.artifact-id-<n>` ID claims, evicted-output generations.
1505
+ *
1506
+ * The sessions surface already counts these bytes, but only as one number per
1507
+ * session and only reclaimable by retiring the whole session — so a scope filled
1508
+ * by a retained session's own tool logs was both unattributable and
1509
+ * unreclaimable. They are reported per family always, and reclaimed only for a
1510
+ * transcript that (a) no live surface references, (b) is not the newest
1511
+ * resumable transcript left in its project directory, and (c) holds a file that
1512
+ * has been still for the write-grace window.
1513
+ *
1514
+ * The walk never leaves `sessionsRoot`: directories come from the same
1515
+ * transcript enumeration the sessions surface uses, and inside one only regular
1516
+ * non-symlink files are candidates. A symlink, a subdirectory, or an unstattable
1517
+ * entry is reported as unverified and left alone — never followed, never removed.
1518
+ */
1519
+ async function runGcDiskArtifacts(input: {
1520
+ surface: GcDiskSurfaceReport;
1521
+ survivors: GcDiskTranscript[];
1522
+ references: GcSessionReferences;
1523
+ agentDir: string;
1524
+ now: number;
1525
+ prune: boolean;
1526
+ errors: GcDiskError[];
1527
+ }): Promise<void> {
1528
+ const { surface, survivors, references, agentDir, now, prune, errors } = input;
1529
+ const sessionIndex = new SessionIndex(agentDir);
1530
+ try {
1531
+ await sessionIndex.withLocked(async () => {
1532
+ // Recomputed over survivors: whichever transcript the sessions surface left
1533
+ // newest in a project directory is the `--continue` target, and a resumed
1534
+ // session still reads its own `artifact://` handles.
1535
+ const newestPerDirectory = new Map<string, GcDiskTranscript>();
1536
+ for (const transcript of survivors) {
1537
+ const current = newestPerDirectory.get(transcript.directory);
1538
+ if (!current || transcript.mtimeMs > current.mtimeMs)
1539
+ newestPerDirectory.set(transcript.directory, transcript);
1540
+ }
1541
+
1542
+ const families = new Map<string, GcDiskFamilyUsage>();
1543
+ let withheld = 0;
1544
+ let withheldBytes = 0;
1545
+
1546
+ for (const transcript of survivors) {
1547
+ const directory = transcript.path.slice(0, -".jsonl".length);
1548
+ let entries: Dirent[];
1549
+ try {
1550
+ entries = await fsp.readdir(directory, { withFileTypes: true });
1551
+ } catch (error) {
1552
+ // No artifact directory is complete evidence: this session wrote none.
1553
+ if (!isEnoent(error)) {
1554
+ errors.push({ surface: "artifacts", scope: directory, message: gcDiskErrorText(error) });
1555
+ }
1556
+ continue;
1557
+ }
1558
+
1559
+ const retained = !references.complete
1560
+ ? `reference_scan_incomplete: ${references.notes.join(", ")}`
1561
+ : references.ids.has(transcript.sessionId)
1562
+ ? "referenced_by_live_surface"
1563
+ : newestPerDirectory.get(transcript.directory) === transcript
1564
+ ? "most_recent_resumable_session"
1565
+ : undefined;
1566
+
1567
+ for (const entry of entries) {
1568
+ if (surface.records.length >= GC_DISK_MAX_WALK_ENTRIES) {
1569
+ errors.push({ surface: "artifacts", scope: directory, message: "artifact_walk_capped" });
1570
+ break;
1571
+ }
1572
+ const target = path.join(directory, entry.name);
1573
+ const record: GcDiskRecord = {
1574
+ surface: "artifacts",
1575
+ id: `${transcript.sessionId}/${entry.name}`,
1576
+ path: target,
1577
+ bytes: 0,
1578
+ age_days: 0,
1579
+ action: "keep",
1580
+ reason: "",
1581
+ };
1582
+
1583
+ // A symlink is never followed and never removed: following one is exactly
1584
+ // how a scope cleanup reaches bytes that are not GJC's to reclaim.
1585
+ // Anything that is not a regular file is unverified state, not an artifact.
1586
+ if (entry.isSymbolicLink() || !entry.isFile()) {
1587
+ record.reason = `unverified_entry: ${entry.isSymbolicLink() ? "symlink" : "not_a_regular_file"}`;
1588
+ record.withheld = true;
1589
+ surface.records.push(record);
1590
+ countGcDiskArtifactFamily(families, entry.name, 0);
1591
+ withheld++;
1592
+ continue;
1593
+ }
1594
+
1595
+ let stat: Stats;
1596
+ try {
1597
+ stat = await fsp.lstat(target);
1598
+ } catch (error) {
1599
+ // Vanished mid-walk is the opposite of withheld: it is already gone.
1600
+ if (isEnoent(error)) continue;
1601
+ const message = gcDiskErrorText(error);
1602
+ errors.push({ surface: "artifacts", scope: target, message });
1603
+ record.reason = `entry_unverifiable: ${message}`;
1604
+ record.error = message;
1605
+ record.withheld = true;
1606
+ surface.records.push(record);
1607
+ countGcDiskArtifactFamily(families, entry.name, 0);
1608
+ withheld++;
1609
+ continue;
1610
+ }
1611
+
1612
+ record.bytes = stat.size;
1613
+ record.age_days = gcDiskAgeDays(now, stat.mtimeMs);
1614
+ surface.records.push(record);
1615
+ countGcDiskArtifactFamily(families, entry.name, stat.size);
1616
+
1617
+ if (retained !== undefined) {
1618
+ record.reason = retained;
1619
+ if (!references.complete) {
1620
+ record.withheld = true;
1621
+ withheld++;
1622
+ withheldBytes += record.bytes;
1623
+ }
1624
+ continue;
1625
+ }
1626
+ if (now - stat.mtimeMs < GC_DISK_BLOB_GRACE_MS) {
1627
+ record.reason = "within_write_grace_window";
1628
+ continue;
1629
+ }
1630
+ record.action = "would_reclaim";
1631
+ record.reason = "unreferenced_by_any_live_session";
1632
+
1633
+ if (!prune) continue;
1634
+ const removal = await removeGcDiskEntry(target, stat);
1635
+ if (removal.removed) record.action = "reclaimed";
1636
+ else if (removal.failed) {
1637
+ record.action = "reclaim_failed";
1638
+ record.reason = removal.reason;
1639
+ record.error = removal.reason;
1640
+ } else {
1641
+ record.action = "keep";
1642
+ record.reason = removal.reason;
1643
+ if (removal.withheld) record.withheld = true;
1644
+ }
1645
+ }
1646
+ }
1647
+
1648
+ surface.families = [...families.values()].sort(
1649
+ (a, b) => b.bytes - a.bytes || a.family.localeCompare(b.family),
1650
+ );
1651
+ if (!references.complete) {
1652
+ surface.declined = {
1653
+ reason: `reference_scan_incomplete: ${references.notes.join(", ")}`,
1654
+ withheld,
1655
+ withheld_bytes: withheldBytes,
1656
+ };
1657
+ }
1658
+ });
1659
+ } catch (error) {
1660
+ errors.push({
1661
+ surface: "artifacts",
1662
+ scope: surface.root,
1663
+ message: `artifact_liveness_authority_unavailable: ${gcDiskErrorText(error)}`,
1664
+ });
1665
+ }
1666
+ }
1331
1667
  /** Numeric `major.minor.patch` prefix, or undefined when the name is not a version. */
1332
1668
  function parseGcDiskVersion(value: string): [number, number, number] | undefined {
1333
1669
  const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(value);
@@ -1649,6 +1985,7 @@ export async function collectGcDiskReport(input: {
1649
1985
  const surfaces: Record<GcDiskSurface, GcDiskSurfaceReport> = {
1650
1986
  sessions: emptyGcDiskSurface("sessions", getSessionsDir(agentDir)),
1651
1987
  blobs: emptyGcDiskSurface("blobs", getBlobsDir(agentDir)),
1988
+ artifacts: emptyGcDiskSurface("artifacts", getSessionsDir(agentDir)),
1652
1989
  natives: emptyGcDiskSurface("natives", path.join(gjcRoot, "natives")),
1653
1990
  backups: emptyGcDiskSurface("backups", path.join(gjcRoot, "backups")),
1654
1991
  };
@@ -1673,6 +2010,15 @@ export async function collectGcDiskReport(input: {
1673
2010
  prune,
1674
2011
  errors,
1675
2012
  });
2013
+ await runGcDiskArtifacts({
2014
+ surface: surfaces.artifacts,
2015
+ survivors,
2016
+ references,
2017
+ agentDir,
2018
+ now,
2019
+ prune,
2020
+ errors,
2021
+ });
1676
2022
  await runGcDiskNatives({
1677
2023
  surface: surfaces.natives,
1678
2024
  policy,
@@ -1687,10 +2033,15 @@ export async function collectGcDiskReport(input: {
1687
2033
  for (const name of GC_DISK_SURFACES) {
1688
2034
  const surface = surfaces[name];
1689
2035
  summarizeGcDiskSurface(surface);
1690
- totals.scanned_bytes += surface.scanned_bytes;
2036
+ // The artifacts surface re-attributes bytes the sessions surface already
2037
+ // counted inside surviving sessions, so only what it can act on is added:
2038
+ // summing its scan too would report more bytes than exist on disk.
2039
+ if (name !== "artifacts") {
2040
+ totals.scanned_bytes += surface.scanned_bytes;
2041
+ totals.kept_bytes += surface.kept_bytes;
2042
+ }
1691
2043
  totals.reclaimable_bytes += surface.reclaimable_bytes;
1692
2044
  totals.reclaimed_bytes += surface.reclaimed_bytes;
1693
- totals.kept_bytes += surface.kept_bytes;
1694
2045
  totals.failed += surface.failed;
1695
2046
  }
1696
2047
 
@@ -1700,6 +2051,7 @@ export async function collectGcDiskReport(input: {
1700
2051
  const GC_DISK_SURFACE_HEADINGS: Record<GcDiskSurface, string> = {
1701
2052
  sessions: "Session transcripts",
1702
2053
  blobs: "Content-addressed blobs",
2054
+ artifacts: "Session tool artifacts",
1703
2055
  natives: "Cached native versions",
1704
2056
  backups: "Update/restore backups",
1705
2057
  };
@@ -1755,11 +2107,18 @@ export function buildGcDiskReportText(disk: GcDiskReport): string {
1755
2107
  `kept=${surface.kept} (${formatGcDiskBytes(surface.kept_bytes)})`,
1756
2108
  );
1757
2109
  if (surface.declined) {
2110
+ const reclaimState =
2111
+ surface.reclaimed === 0 ? "reclaimed nothing" : `reclaimed ${surface.reclaimed} before withholding`;
1758
2112
  lines.push(
1759
- ` declined: reclaimed nothing — ${surface.declined.reason} ` +
2113
+ ` declined: ${reclaimState} — ${surface.declined.reason} ` +
1760
2114
  `(withheld=${surface.declined.withheld} (${formatGcDiskBytes(surface.declined.withheld_bytes)}))`,
1761
2115
  );
1762
2116
  }
2117
+ // The per-file records below say what may go; the family rollup is what
2118
+ // answers "what filled this scope?", so it is never truncated.
2119
+ for (const family of surface.families ?? []) {
2120
+ lines.push(` family ${family.family} count=${family.count} (${formatGcDiskBytes(family.bytes)})`);
2121
+ }
1763
2122
  // Reclaim decisions first: they are what an operator has to audit.
1764
2123
  const ranked = [...surface.records].sort(
1765
2124
  (a, b) => Number(a.action === "keep") - Number(b.action === "keep") || b.bytes - a.bytes,
@@ -274,7 +274,7 @@ function isWorktreeDirty(worktreePath: string): boolean {
274
274
  function resolveOptionalWorktreeName(args: string[], index: number): { name: string | null; nextIndex: number } {
275
275
  const next = args[index + 1];
276
276
  if (!next) return { name: null, nextIndex: index };
277
- if (next === "--") return { name: null, nextIndex: index + 1 };
277
+ if (next === "--") return { name: null, nextIndex: index };
278
278
  if (next.startsWith("-")) return { name: null, nextIndex: index };
279
279
  return { name: next.trim() || null, nextIndex: index + 1 };
280
280
  }
@@ -285,6 +285,10 @@ export function parseLaunchWorktreeMode(args: string[]): ParsedLaunchWorktreeMod
285
285
 
286
286
  for (let index = 0; index < args.length; index += 1) {
287
287
  const arg = args[index] ?? "";
288
+ if (arg === "--") {
289
+ remainingArgs.push(...args.slice(index));
290
+ break;
291
+ }
288
292
  if (arg === "--worktree" || arg === "-w") {
289
293
  const parsed = resolveOptionalWorktreeName(args, index);
290
294
  mode = parsed.name
@@ -2892,13 +2892,16 @@ type TeamTmuxMutation =
2892
2892
  | { type: "profile-option"; target: string; name: string; value: string }
2893
2893
  | { type: "profile-window-option"; target: string; name: string; value: string };
2894
2894
 
2895
+ const TMUX_ENCODED_LAYOUT_PATTERN = /^[0-9a-f]{4},/i;
2896
+
2895
2897
  function readTeamTmuxValue(
2896
2898
  config: GjcTeamConfig,
2897
2899
  command: "show-options" | "show-window-options",
2898
2900
  target: string,
2899
2901
  name: string,
2900
2902
  ): string | undefined {
2901
- const result = Bun.spawnSync(teamTmuxArgs(config, command, ["-qv", "-t", target, name]), {
2903
+ const flags = command === "show-options" ? "-qv" : "-v";
2904
+ const result = Bun.spawnSync(teamTmuxArgs(config, command, [flags, "-t", target, name]), {
2902
2905
  stdout: "pipe",
2903
2906
  stderr: "pipe",
2904
2907
  });
@@ -2944,6 +2947,55 @@ function assertTeamTmuxMutationPreproof(config: GjcTeamConfig, operation: TeamTm
2944
2947
  // alone is never treated as delivery proof.
2945
2948
  }
2946
2949
 
2950
+ function hasMainVerticalPaneTopology(config: GjcTeamConfig, target: string): boolean {
2951
+ const result = Bun.spawnSync(
2952
+ teamTmuxArgs(config, "list-panes", ["-t", target, "-F", "#{pane_left} #{pane_top} #{pane_width} #{pane_height}"]),
2953
+ { stdout: "pipe", stderr: "pipe" },
2954
+ );
2955
+ if (result.exitCode !== 0) return false;
2956
+ const lines = result.stdout.toString().trim().split("\n");
2957
+ const tokens = lines.map(line => line.trim().split(/\s+/));
2958
+ if (
2959
+ tokens.length < 2 ||
2960
+ tokens.some(values => values.length !== 4 || values.some(value => !/^(?:0|[1-9]\d*)$/.test(value)))
2961
+ )
2962
+ return false;
2963
+ const geometry = tokens.map(values => values.map(value => Number.parseInt(value, 10))) as [
2964
+ number,
2965
+ number,
2966
+ number,
2967
+ number,
2968
+ ][];
2969
+ if (geometry.some(values => values.some(value => !Number.isSafeInteger(value) || value < 0))) return false;
2970
+ if (geometry.some(([, , width, height]) => width === 0 || height === 0)) return false;
2971
+ const right = Math.max(...geometry.map(([left, , width]) => left + width));
2972
+ const bottom = Math.max(...geometry.map(([, top, , height]) => top + height));
2973
+ const main = geometry.filter(
2974
+ ([left, top, width, height]) => left === 0 && top === 0 && height === bottom && width < right,
2975
+ );
2976
+ if (main.length !== 1) return false;
2977
+ const mainRight = main[0]![0] + main[0]![2];
2978
+ const stack = geometry.filter(pane => pane !== main[0]);
2979
+ const stackLeft = stack[0]![0];
2980
+ if (
2981
+ stack.some(([left, , width]) => left !== stackLeft || left + width !== right) ||
2982
+ stackLeft - mainRight < 0 ||
2983
+ stackLeft - mainRight > 1
2984
+ )
2985
+ return false;
2986
+ const ordered = [...stack].sort((a, b) => a[1] - b[1]);
2987
+ return (
2988
+ ordered[0]![1] === 0 &&
2989
+ ordered.every((pane, index) => {
2990
+ if (index === 0) return true;
2991
+ const previous = ordered[index - 1]!;
2992
+ const rowGap = pane[1] - (previous[1] + previous[3]);
2993
+ return rowGap >= 0 && rowGap <= 1;
2994
+ }) &&
2995
+ ordered.at(-1)![1] + ordered.at(-1)![3] === bottom
2996
+ );
2997
+ }
2998
+
2947
2999
  function executeTeamTmuxMutation(
2948
3000
  config: GjcTeamConfig,
2949
3001
  operation: TeamTmuxMutation,
@@ -2995,7 +3047,12 @@ function executeTeamTmuxMutation(
2995
3047
  teamTmuxArgs(config, "display-message", ["-p", "-t", operation.target, "#{window_layout}"]),
2996
3048
  { stdout: "pipe", stderr: "pipe" },
2997
3049
  );
2998
- if (layout.exitCode !== 0 || layout.stdout.toString().trim() !== operation.layout)
3050
+ const observedLayout = layout.stdout.toString().trim();
3051
+ if (
3052
+ layout.exitCode !== 0 ||
3053
+ !TMUX_ENCODED_LAYOUT_PATTERN.test(observedLayout) ||
3054
+ !hasMainVerticalPaneTopology(config, operation.target)
3055
+ )
2999
3056
  throw new Error("tmux_layout_postproof_failed");
3000
3057
  } else if (operation.type === "set-window-option" || operation.type === "profile-window-option") {
3001
3058
  if (readTeamTmuxValue(config, "show-window-options", operation.target, operation.name) !== operation.value)
@@ -1496,8 +1496,27 @@ function normalizeTerminalObservation(request: ObserveTerminalRequest): ObserveT
1496
1496
  };
1497
1497
  }
1498
1498
 
1499
+ /** One process must not race its own duplicate terminal observers through SQLite. */
1500
+ const liveOwnerTerminalObservations = new Map<string, Promise<OwnerVerdict>>();
1501
+
1499
1502
  /** Publish exactly one terminal verdict. Existing valid verdicts always win. */
1500
1503
  export async function observeOwnerTerminal(request: ObserveTerminalRequest): Promise<OwnerVerdict> {
1504
+ if (!isObserveTerminalRequest(request)) throw new Error("terminal_observation_invalid");
1505
+ const paths = lifecyclePaths(request.state_dir, request.session_id, request.owner_generation);
1506
+ const existing = liveOwnerTerminalObservations.get(paths.root);
1507
+ if (existing) return existing;
1508
+ const observation = observeOwnerTerminalExclusive(request);
1509
+ liveOwnerTerminalObservations.set(paths.root, observation);
1510
+ try {
1511
+ return await observation;
1512
+ } finally {
1513
+ if (liveOwnerTerminalObservations.get(paths.root) === observation)
1514
+ liveOwnerTerminalObservations.delete(paths.root);
1515
+ }
1516
+ }
1517
+
1518
+ /** Publish exactly one terminal verdict. Existing valid verdicts always win. */
1519
+ async function observeOwnerTerminalExclusive(request: ObserveTerminalRequest): Promise<OwnerVerdict> {
1501
1520
  if (!isObserveTerminalRequest(request)) throw new Error("terminal_observation_invalid");
1502
1521
  const paths = lifecyclePaths(request.state_dir, request.session_id, request.owner_generation);
1503
1522
  let observation = normalizeTerminalObservation(request);