@gajae-code/coding-agent 0.15.6 → 0.16.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 (167) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/types/cli/args.d.ts +2 -0
  3. package/dist/types/cli/root-flags.d.ts +14 -0
  4. package/dist/types/cli/update-cli.d.ts +13 -0
  5. package/dist/types/cli.d.ts +7 -0
  6. package/dist/types/commands/launch.d.ts +10 -0
  7. package/dist/types/config/autorouting-tier-map.d.ts +24 -0
  8. package/dist/types/config/file-lock.d.ts +5 -0
  9. package/dist/types/config/model-registry.d.ts +6 -0
  10. package/dist/types/config/settings-schema.d.ts +10 -1
  11. package/dist/types/config/settings.d.ts +2 -0
  12. package/dist/types/coordinator-mcp/projection-scan.d.ts +8 -0
  13. package/dist/types/crash/record-loader.d.ts +15 -0
  14. package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
  15. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
  16. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +66 -1
  17. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
  18. package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
  19. package/dist/types/master-mode/context.d.ts +21 -0
  20. package/dist/types/master-mode/first-request.d.ts +34 -0
  21. package/dist/types/master-mode/snapshot.d.ts +32 -0
  22. package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
  23. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  24. package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
  25. package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
  26. package/dist/types/modes/components/status-line/segments.d.ts +10 -0
  27. package/dist/types/modes/print-mode.d.ts +17 -0
  28. package/dist/types/modes/ui-language.d.ts +7 -1
  29. package/dist/types/sdk/broker/broker.d.ts +77 -1
  30. package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
  31. package/dist/types/sdk/broker/index.d.ts +3 -0
  32. package/dist/types/sdk/broker/lifecycle.d.ts +26 -0
  33. package/dist/types/sdk/broker/master-capability.d.ts +10 -0
  34. package/dist/types/sdk/broker/session-index.d.ts +40 -11
  35. package/dist/types/sdk/broker/session-scope.d.ts +72 -0
  36. package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
  37. package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
  38. package/dist/types/sdk/broker/state-version.d.ts +4 -1
  39. package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
  40. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
  41. package/dist/types/sdk/bus/index.d.ts +6 -0
  42. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  43. package/dist/types/sdk/cli/index.d.ts +1 -0
  44. package/dist/types/sdk/cli/master-cli.d.ts +63 -0
  45. package/dist/types/sdk/cli/rows.d.ts +4 -3
  46. package/dist/types/sdk/cli/session-cli.d.ts +18 -2
  47. package/dist/types/sdk/client/client.d.ts +3 -2
  48. package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
  49. package/dist/types/sdk/host/control/index.d.ts +1 -1
  50. package/dist/types/sdk/host/control/operations.d.ts +2 -0
  51. package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
  52. package/dist/types/sdk/host/host.d.ts +10 -0
  53. package/dist/types/sdk/host/session-runtime.d.ts +52 -0
  54. package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
  55. package/dist/types/sdk/lifecycle/service.d.ts +15 -3
  56. package/dist/types/sdk/lifecycle-session.d.ts +2 -0
  57. package/dist/types/sdk/router/session-router.d.ts +2 -0
  58. package/dist/types/sdk/session.d.ts +16 -0
  59. package/dist/types/sdk/startup-capability.d.ts +2 -1
  60. package/dist/types/session/agent-session.d.ts +5 -0
  61. package/dist/types/session/credential-store-errors.d.ts +1 -0
  62. package/dist/types/session/session-manager.d.ts +65 -0
  63. package/dist/types/session/yield-queue.d.ts +5 -4
  64. package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
  65. package/dist/types/tools/bash.d.ts +4 -0
  66. package/dist/types/tools/gh.d.ts +8 -0
  67. package/dist/types/tools/index.d.ts +4 -0
  68. package/package.json +7 -7
  69. package/scripts/generate-sdk-operation-inventory.ts +6 -0
  70. package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
  71. package/src/cli/accounts-cli.ts +9 -5
  72. package/src/cli/args.ts +17 -0
  73. package/src/cli/fast-help.ts +1 -0
  74. package/src/cli/root-flags.ts +5 -0
  75. package/src/cli/update-cli.ts +77 -11
  76. package/src/commands/launch.ts +86 -18
  77. package/src/commands/sdk.ts +113 -21
  78. package/src/config/autorouting-tier-map.ts +12 -0
  79. package/src/config/file-lock.ts +158 -83
  80. package/src/config/model-preset-registry.ts +1 -5
  81. package/src/config/model-profiles.ts +5 -5
  82. package/src/config/model-registry.ts +23 -0
  83. package/src/config/model-resolver.ts +5 -0
  84. package/src/config/settings-schema.ts +8 -1
  85. package/src/config/settings.ts +5 -0
  86. package/src/coordinator-mcp/projection-scan.ts +21 -0
  87. package/src/coordinator-mcp/question-state.ts +9 -11
  88. package/src/coordinator-mcp/server.ts +94 -20
  89. package/src/crash/index-store.ts +2 -0
  90. package/src/crash/record-loader.ts +10 -0
  91. package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
  92. package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
  93. package/src/gjc-runtime/session-state-lock.ts +79 -6
  94. package/src/gjc-runtime/session-state-sidecar.ts +929 -23
  95. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
  96. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -2
  97. package/src/gjc-runtime/tmux-sessions.ts +252 -13
  98. package/src/hooks/skill-state.ts +6 -0
  99. package/src/internal-urls/docs-index.generated.ts +7 -7
  100. package/src/internal-urls/issue-pr-protocol.ts +4 -2
  101. package/src/main.ts +57 -2
  102. package/src/master-mode/context.ts +50 -0
  103. package/src/master-mode/first-request.ts +65 -0
  104. package/src/master-mode/snapshot.ts +205 -0
  105. package/src/modes/acp/acp-agent.ts +605 -156
  106. package/src/modes/components/footer.ts +4 -2
  107. package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
  108. package/src/modes/components/oauth-selector.ts +6 -1
  109. package/src/modes/components/settings-selector.ts +1 -0
  110. package/src/modes/components/status-line/model-name.ts +53 -0
  111. package/src/modes/components/status-line/priority-row.ts +107 -0
  112. package/src/modes/components/status-line/segments.ts +23 -9
  113. package/src/modes/components/tool-status-header.ts +151 -15
  114. package/src/modes/controllers/input-controller.ts +28 -0
  115. package/src/modes/controllers/selector-controller.ts +191 -96
  116. package/src/modes/print-mode.ts +64 -0
  117. package/src/modes/ui-language.ts +49 -2
  118. package/src/prompts/system/master-mode.md +28 -0
  119. package/src/sdk/broker/broker.ts +1636 -26
  120. package/src/sdk/broker/endpoint-authority.ts +86 -0
  121. package/src/sdk/broker/ensure.ts +5 -0
  122. package/src/sdk/broker/index.ts +3 -0
  123. package/src/sdk/broker/lifecycle.ts +186 -41
  124. package/src/sdk/broker/master-capability.ts +232 -0
  125. package/src/sdk/broker/session-index.ts +121 -17
  126. package/src/sdk/broker/session-scope.ts +272 -0
  127. package/src/sdk/broker/spawn-authority.ts +1031 -0
  128. package/src/sdk/broker/spawn-substrate.ts +548 -0
  129. package/src/sdk/broker/state-version.ts +26 -14
  130. package/src/sdk/broker/transport.ts +1 -0
  131. package/src/sdk/broker/worktree-occupancy.ts +90 -0
  132. package/src/sdk/bus/chat-daemon-control.ts +5 -2
  133. package/src/sdk/bus/chat-daemon-runtime.ts +35 -4
  134. package/src/sdk/bus/index.ts +51 -4
  135. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  136. package/src/sdk/cli/index.ts +1 -0
  137. package/src/sdk/cli/master-cli.ts +259 -0
  138. package/src/sdk/cli/rows.ts +11 -8
  139. package/src/sdk/cli/session-cli.ts +264 -5
  140. package/src/sdk/client/client.ts +14 -8
  141. package/src/sdk/host/control/dispatch.ts +99 -38
  142. package/src/sdk/host/control/index.ts +2 -0
  143. package/src/sdk/host/control/operations.ts +2 -0
  144. package/src/sdk/host/control/runtime-gate.ts +203 -11
  145. package/src/sdk/host/host.ts +27 -2
  146. package/src/sdk/host/session-runtime.test.ts +210 -6
  147. package/src/sdk/host/session-runtime.ts +291 -49
  148. package/src/sdk/lifecycle/broker-client.ts +28 -0
  149. package/src/sdk/lifecycle/service.ts +279 -20
  150. package/src/sdk/lifecycle-session.ts +10 -2
  151. package/src/sdk/protocol/operation-inventory.generated.json +51 -0
  152. package/src/sdk/protocol/operation-registry.ts +13 -3
  153. package/src/sdk/router/session-router.ts +67 -59
  154. package/src/sdk/session.ts +357 -155
  155. package/src/sdk/startup-capability.ts +1 -0
  156. package/src/session/agent-session.ts +640 -103
  157. package/src/session/blob-store.ts +15 -5
  158. package/src/session/credential-store-errors.ts +2 -0
  159. package/src/session/internal/managed-session-storage.ts +153 -78
  160. package/src/session/session-manager.ts +337 -24
  161. package/src/session/yield-queue.ts +24 -10
  162. package/src/setup/frictionless-onboarding.ts +2 -0
  163. package/src/telemetry/events.ts +1138 -12
  164. package/src/tools/bash.ts +256 -42
  165. package/src/tools/gh.ts +104 -48
  166. package/src/tools/github-cache.ts +2 -2
  167. package/src/tools/index.ts +4 -0
@@ -83,7 +83,7 @@ import {
83
83
  requireCoordinatorMutation,
84
84
  safeOpenCoordinatorArtifact,
85
85
  } from "./policy";
86
- import { listCoordinatorJsonFiles } from "./projection-scan";
86
+ import { listCoordinatorJsonFilesWithRetry } from "./projection-scan";
87
87
  import {
88
88
  answerBindingMatches,
89
89
  buildCoordinatorAskAnswerSchema,
@@ -835,7 +835,7 @@ function toolSchema(name: CoordinatorToolName): {
835
835
  properties: {
836
836
  after_seq: { type: "integer", minimum: 0 },
837
837
  session_id: sessionId,
838
- event_types: { type: "array", items: { type: "string" } },
838
+ event_types: { type: "array", items: { type: "string", enum: [...COORDINATOR_EVENT_KINDS] } },
839
839
  timeout_ms: {
840
840
  type: "number",
841
841
  description: "Bounded event long-poll timeout in milliseconds, capped at 30 seconds.",
@@ -1498,7 +1498,7 @@ function publicSdkAcknowledgement(result: RuntimePromptAcknowledgement): Record<
1498
1498
  }
1499
1499
 
1500
1500
  async function listJsonFiles(dir: string): Promise<unknown[]> {
1501
- const scan = await listCoordinatorJsonFiles(dir);
1501
+ const scan = await listCoordinatorJsonFilesWithRetry(dir);
1502
1502
  if (scan.capped) throw new Error("coordinator_projection_scan_incomplete");
1503
1503
  if (scan.skippedEmpty > 0 || scan.skippedDebris > 0) {
1504
1504
  logger.warn("Coordinator projection scan skipped debris", {
@@ -1530,7 +1530,10 @@ function brokerSessionId(record: Record<string, unknown>): string | null {
1530
1530
  }
1531
1531
 
1532
1532
  function brokerSessionScope(record: Record<string, unknown>): string | null {
1533
- return firstString(asRecord(record.locator) ?? {}, ["repo"]);
1533
+ // Locator v2 records the canonical workspace directory as `cwd`; the removed
1534
+ // `repo` field is never translated, so reading it here silently dropped every
1535
+ // broker row and failed open with an empty scoped listing.
1536
+ return firstString(asRecord(record.locator) ?? {}, ["cwd"]);
1534
1537
  }
1535
1538
 
1536
1539
  function sameCanonicalPath(left: string, right: string, platform: NodeJS.Platform): boolean {
@@ -2634,11 +2637,6 @@ async function writeActiveTurn(namespaceDir: string, turn: TurnRecord): Promise<
2634
2637
  });
2635
2638
  }
2636
2639
 
2637
- async function clearActiveTurn(namespaceDir: string, turn: TurnRecord): Promise<void> {
2638
- const active = asRecord(await readJsonFile(activeTurnFile(namespaceDir, turn.session_id)));
2639
- if (active?.turn_id === turn.turn_id) await removeCoordinatorFile(activeTurnFile(namespaceDir, turn.session_id));
2640
- }
2641
-
2642
2640
  async function readSessionState(namespaceDir: string, sessionId: string): Promise<CoordinatorSessionState | null> {
2643
2641
  return (await readJsonFile(sessionStateFile(namespaceDir, sessionId))) as CoordinatorSessionState | null;
2644
2642
  }
@@ -2658,9 +2656,6 @@ async function writeSessionStateUnlocked(
2658
2656
  } = {},
2659
2657
  ): Promise<CoordinatorSessionState> {
2660
2658
  const persisted = await readSessionState(namespaceDir, sessionId);
2661
- const session = asRecord(
2662
- await readJsonFile(path.join(namespaceDir, "sessions", `${safeExternalId("session", sessionId)}.json`)),
2663
- );
2664
2659
  // An overwrite is a canonical lifecycle repair, not proof that tool activity ended.
2665
2660
  const previous = options.overwrite ? null : persisted;
2666
2661
  const hasCurrentTurn = Object.hasOwn(options, "currentTurnId");
@@ -5044,6 +5039,58 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
5044
5039
  const keyDigest = createHash("sha256").update(idempotencyKey).digest("hex");
5045
5040
  return path.join(namespaceDir, "idempotency", `${keyDigest}.json`);
5046
5041
  }
5042
+ const idempotencyFlights = new Map<string, { requestDigest: string; promise: Promise<Record<string, unknown>> }>();
5043
+ type IdempotencyFlightAdmission =
5044
+ | { kind: "conflict"; response: Record<string, unknown> }
5045
+ | { kind: "joined"; promise: Promise<Record<string, unknown>> }
5046
+ | { kind: "owner"; run: (operation: Promise<Record<string, unknown>>) => Promise<Record<string, unknown>> };
5047
+ function admitIdempotencyFlight(
5048
+ tool: string,
5049
+ idempotencyKey: string,
5050
+ canonicalArgs: Record<string, unknown>,
5051
+ ): IdempotencyFlightAdmission {
5052
+ const keyDigest = createHash("sha256").update(idempotencyKey).digest("hex");
5053
+ const requestDigest = createHash("sha256")
5054
+ .update(canonicalJson({ tool, args: canonicalArgs }))
5055
+ .digest("hex");
5056
+ const existingFlight = idempotencyFlights.get(keyDigest);
5057
+ if (existingFlight) {
5058
+ if (existingFlight.requestDigest !== requestDigest)
5059
+ return {
5060
+ kind: "conflict",
5061
+ response: {
5062
+ ok: false,
5063
+ error: { code: "idempotency_conflict", message: "idempotency key was used with a different request" },
5064
+ },
5065
+ };
5066
+ return { kind: "joined", promise: existingFlight.promise };
5067
+ }
5068
+ const result = Promise.withResolvers<Record<string, unknown>>();
5069
+ const flight = { requestDigest, promise: result.promise };
5070
+ idempotencyFlights.set(keyDigest, flight);
5071
+ return {
5072
+ kind: "owner",
5073
+ run: async operation => {
5074
+ void operation.then(result.resolve, result.reject);
5075
+ try {
5076
+ return await result.promise;
5077
+ } finally {
5078
+ if (idempotencyFlights.get(keyDigest) === flight) idempotencyFlights.delete(keyDigest);
5079
+ }
5080
+ },
5081
+ };
5082
+ }
5083
+ async function withIdempotencyFlight(
5084
+ tool: string,
5085
+ idempotencyKey: string,
5086
+ canonicalArgs: Record<string, unknown>,
5087
+ operation: () => Promise<Record<string, unknown>>,
5088
+ ): Promise<Record<string, unknown>> {
5089
+ const admission = admitIdempotencyFlight(tool, idempotencyKey, canonicalArgs);
5090
+ if (admission.kind === "conflict") return admission.response;
5091
+ if (admission.kind === "joined") return await admission.promise;
5092
+ return await admission.run(operation());
5093
+ }
5047
5094
  async function withOrderedSessionStateLocks<T>(
5048
5095
  lockFiles: readonly string[],
5049
5096
  operation: () => Promise<T>,
@@ -5075,6 +5122,7 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
5075
5122
  recoverInProgress = false,
5076
5123
  isNonterminal: (response: Record<string, unknown>) => boolean = isRouterRequestAmbiguous,
5077
5124
  lockAlreadyHeld = false,
5125
+ flightAlreadyOwned = false,
5078
5126
  ): Promise<Record<string, unknown>> {
5079
5127
  const keyDigest = createHash("sha256").update(idempotencyKey).digest("hex");
5080
5128
  const requestDigest = createHash("sha256")
@@ -5179,7 +5227,11 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
5179
5227
  });
5180
5228
  return response;
5181
5229
  };
5182
- return lockAlreadyHeld ? execute() : withSessionStateLock(lockFile, execute);
5230
+ const executeWithLock = async (): Promise<Record<string, unknown>> =>
5231
+ lockAlreadyHeld ? await execute() : await withSessionStateLock(lockFile, execute);
5232
+ return flightAlreadyOwned
5233
+ ? await executeWithLock()
5234
+ : await withIdempotencyFlight(tool, idempotencyKey, canonicalArgs, executeWithLock);
5183
5235
  }
5184
5236
 
5185
5237
  async function brokerSession(
@@ -5281,11 +5333,28 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
5281
5333
  sessionId,
5282
5334
  );
5283
5335
  } catch (closeError) {
5284
- throw new SdkClientError(
5285
- UNOBSERVED_COMPENSATION_CODE,
5286
- `Coordinator creation failed after remote session ${sessionId} was created, and compensation failed.`,
5287
- { primary: error, compensation: closeError, session_id: sessionId },
5288
- );
5336
+ let listed: Record<string, unknown>;
5337
+ try {
5338
+ listed = await paginatedBrokerSessionList(cwd, { cwd });
5339
+ } catch (listError) {
5340
+ throw new SdkClientError(
5341
+ UNOBSERVED_COMPENSATION_CODE,
5342
+ `Coordinator creation failed after remote session ${sessionId} was created, and compensation failed.`,
5343
+ { primary: error, compensation: closeError, list: listError, session_id: sessionId },
5344
+ );
5345
+ }
5346
+ const rows = jsonRecords(Array.isArray(listed.sessions) ? listed.sessions : []);
5347
+ const row = rows.find(candidate => brokerSessionId(candidate) === sessionId);
5348
+ const deadUncertain =
5349
+ row !== undefined &&
5350
+ row.live !== true &&
5351
+ (row.terminalUncertain === true || (row as Record<string, unknown>).terminal_uncertain === true);
5352
+ if (!deadUncertain)
5353
+ throw new SdkClientError(
5354
+ UNOBSERVED_COMPENSATION_CODE,
5355
+ `Coordinator creation failed after remote session ${sessionId} was created, and compensation failed.`,
5356
+ { primary: error, compensation: closeError, session_id: sessionId },
5357
+ );
5289
5358
  }
5290
5359
  throw error;
5291
5360
  }
@@ -6713,7 +6782,7 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
6713
6782
  // any stale pointer directly: once the turn projection is rewritten
6714
6783
  // terminal, readActiveTurn() intentionally rejects it and cannot
6715
6784
  // discover the legacy active-turn file to clear.
6716
- await fs.rm(activeTurnFile(namespaceDir, sessionId), { force: true });
6785
+ await removeCoordinatorFile(activeTurnFile(namespaceDir, sessionId));
6717
6786
  }
6718
6787
  // A runtime sidecar can advance a live session while the canonical WAL still
6719
6788
  // needs projection repair. Preserve that observed lifecycle state (and its
@@ -8780,7 +8849,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
8780
8849
  code === "live_session"
8781
8850
  );
8782
8851
  };
8783
- return await withOrderedSessionStateLocks(
8852
+ const flight = admitIdempotencyFlight(name, retirementKey, canonicalArgs);
8853
+ if (flight.kind === "conflict") return flight.response;
8854
+ if (flight.kind === "joined") return await flight.promise;
8855
+ const retirementOperation = withOrderedSessionStateLocks(
8784
8856
  [idempotencyLockFile(creationKey), idempotencyLockFile(retirementKey)],
8785
8857
  async () =>
8786
8858
  await withToolIdempotency(
@@ -8964,8 +9036,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
8964
9036
  true,
8965
9037
  isRetirementRetryable,
8966
9038
  true,
9039
+ true,
8967
9040
  ),
8968
9041
  );
9042
+ return await flight.run(retirementOperation);
8969
9043
  }
8970
9044
  if (name === "gjc_coordinator_activate_session") {
8971
9045
  requireCoordinatorMutation(config, "sessions", args);
@@ -613,6 +613,7 @@ export function applyCrashEvent(index: CrashIndex, event: CrashEvent, now: numbe
613
613
  }
614
614
 
615
615
  // occurrence
616
+ if (event.provenance === "eval" || event.provenance === "bun_test") return false;
616
617
  // Clear before deduplication: a replayed occurrence after main-index publish
617
618
  // but before retirement-sidecar publish must still supersede a stale tombstone.
618
619
  index.retiredFingerprints = index.retiredFingerprints.filter(fingerprint => fingerprint !== event.fingerprint);
@@ -698,6 +699,7 @@ async function recoverAndRecomputeRetainedCounts(index: CrashIndex, crashLogPath
698
699
  const recordsByFingerprint = new Map<string, LoadedCrashRecord[]>();
699
700
  const seenRecordIds = new Set<string>();
700
701
  for (const record of parseRecoverableCrashRecords(contents)) {
702
+ if (record.provenance === "eval" || record.provenance === "bun_test") continue;
701
703
  if (seenRecordIds.has(record.recordId)) continue;
702
704
  seenRecordIds.add(record.recordId);
703
705
  const records = recordsByFingerprint.get(record.fingerprint) ?? [];
@@ -15,6 +15,7 @@ import {
15
15
  CRASH_FINGERPRINT_VERSION,
16
16
  CRASH_RECORD_MARKER,
17
17
  type CrashFingerprint,
18
+ type CrashProvenance,
18
19
  computeCrashFingerprint,
19
20
  parseCrashRecordMarker,
20
21
  } from "@gajae-code/utils";
@@ -33,6 +34,7 @@ export interface LoadedCrashRecord {
33
34
  readonly body: string;
34
35
  /** The `Name: message` part of the header line. */
35
36
  readonly headline: string;
37
+ readonly provenance?: CrashProvenance;
36
38
  }
37
39
 
38
40
  interface ParsedCrashRecord {
@@ -78,6 +80,7 @@ function parseCandidates(contents: string): ParsedCrashRecord[] {
78
80
  let at = 0;
79
81
  let buffer: string[] = [];
80
82
  let started = false;
83
+ let provenance: CrashProvenance | undefined;
81
84
  let pending: ParsedCrashRecord | undefined;
82
85
  for (const line of contents.split("\n")) {
83
86
  if (pending) {
@@ -92,6 +95,12 @@ function parseCandidates(contents: string): ParsedCrashRecord[] {
92
95
  continue;
93
96
  }
94
97
  headline = header[2] ?? "";
98
+ const label = line.slice(line.indexOf("[") + 1, line.indexOf("]"));
99
+ const provenanceValue = label
100
+ .split(";")
101
+ .find(part => part.startsWith("provenance="))
102
+ ?.slice(11);
103
+ provenance = provenanceValue === "eval" || provenanceValue === "bun_test" ? provenanceValue : undefined;
95
104
  at = parsedAt;
96
105
  buffer = [];
97
106
  started = true;
@@ -116,6 +125,7 @@ function parseCandidates(contents: string): ParsedCrashRecord[] {
116
125
  messageClass: fingerprint?.messageClass ?? "",
117
126
  body: lines.join("\n").trimEnd(),
118
127
  headline,
128
+ ...(provenance === undefined ? {} : { provenance }),
119
129
  },
120
130
  complete: false,
121
131
  bound: marker.version === CRASH_FINGERPRINT_VERSION && fingerprint !== undefined,
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Cross-process reservation for a deterministic launch-worktree path.
3
+ *
4
+ * The durable session index is the long-lived ownership record, but a newly
5
+ * planned worktree has no index row yet. This reservation closes that interval:
6
+ * it is acquired before preparation and removed after `host_registered` is
7
+ * durably appended to the index.
8
+ */
9
+
10
+ import { createHash, randomUUID } from "node:crypto";
11
+ import type { Dirent } from "node:fs";
12
+ import * as fs from "node:fs/promises";
13
+ import * as path from "node:path";
14
+ import { resolveEquivalentPath } from "@gajae-code/utils";
15
+ import {
16
+ type FileLockGcObservation,
17
+ processStartTime,
18
+ readFileLockObservationForGc,
19
+ removeFileLockDirForGc,
20
+ } from "../config/file-lock";
21
+ import { processIncarnation } from "../sdk/broker/process-incarnation";
22
+
23
+ const RESERVATION_VERSION = 1;
24
+ const RESERVATION_DIRECTORY = "launch-worktree-reservations";
25
+
26
+ interface LaunchWorktreeReservationRecord {
27
+ version: typeof RESERVATION_VERSION;
28
+ worktreePath: string;
29
+ pid: number;
30
+ start_time?: string;
31
+ processIncarnation: string | null;
32
+ timestamp: number;
33
+ reservationId: string;
34
+ }
35
+
36
+ export interface LaunchWorktreeReservation {
37
+ release(): Promise<void>;
38
+ }
39
+
40
+ function canonicalWorktreePath(worktreePath: string): string {
41
+ return resolveEquivalentPath(path.resolve(worktreePath));
42
+ }
43
+
44
+ function reservationRoot(agentDir: string): string {
45
+ return path.join(agentDir, "sdk", "sessions", RESERVATION_DIRECTORY);
46
+ }
47
+
48
+ function launchWorktreeReservationDirectory(agentDir: string, worktreePath: string): string {
49
+ const digest = createHash("sha256").update(canonicalWorktreePath(worktreePath)).digest("hex");
50
+ return path.join(reservationRoot(agentDir), `${digest}.lock`);
51
+ }
52
+
53
+ /** Test seam for seeding a dead-owner reservation at the exact production path. */
54
+ export function launchWorktreeReservationDirectoryForTest(agentDir: string, worktreePath: string): string {
55
+ return launchWorktreeReservationDirectory(agentDir, worktreePath);
56
+ }
57
+
58
+ function isErrno(error: unknown, code: string): boolean {
59
+ return typeof error === "object" && error !== null && "code" in error && error.code === code;
60
+ }
61
+
62
+ function isReservationRecord(value: unknown): value is LaunchWorktreeReservationRecord {
63
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
64
+ const record = value as Partial<LaunchWorktreeReservationRecord>;
65
+ return (
66
+ record.version === RESERVATION_VERSION &&
67
+ typeof record.worktreePath === "string" &&
68
+ record.worktreePath.length > 0 &&
69
+ typeof record.pid === "number" &&
70
+ Number.isSafeInteger(record.pid) &&
71
+ record.pid > 0 &&
72
+ (record.start_time === undefined || (typeof record.start_time === "string" && record.start_time.length > 0)) &&
73
+ (record.processIncarnation === null ||
74
+ (typeof record.processIncarnation === "string" && record.processIncarnation.length > 0)) &&
75
+ typeof record.timestamp === "number" &&
76
+ Number.isFinite(record.timestamp) &&
77
+ typeof record.reservationId === "string" &&
78
+ record.reservationId.length > 0
79
+ );
80
+ }
81
+
82
+ async function readReservation(lockDir: string): Promise<LaunchWorktreeReservationRecord | null> {
83
+ let value: unknown;
84
+ try {
85
+ value = await Bun.file(path.join(lockDir, "info")).json();
86
+ } catch (error) {
87
+ if (isErrno(error, "ENOENT") || error instanceof SyntaxError) return null;
88
+ throw error;
89
+ }
90
+ return isReservationRecord(value) ? value : null;
91
+ }
92
+
93
+ function ownerDefinitelyExited(record: LaunchWorktreeReservationRecord): boolean {
94
+ try {
95
+ process.kill(record.pid, 0);
96
+ } catch (error) {
97
+ return isErrno(error, "ESRCH");
98
+ }
99
+ if (record.processIncarnation !== null) {
100
+ const actualIncarnation = processIncarnation(record.pid);
101
+ if (actualIncarnation !== undefined) return actualIncarnation !== record.processIncarnation;
102
+ }
103
+ if (record.start_time) {
104
+ const actualStartTime = processStartTime(record.pid);
105
+ if (actualStartTime !== null) return actualStartTime !== record.start_time;
106
+ }
107
+ return false;
108
+ }
109
+
110
+ async function removeReservation(
111
+ lockDir: string,
112
+ record: LaunchWorktreeReservationRecord,
113
+ ): Promise<"removed" | "kept"> {
114
+ // Capture the exact on-disk identity BEFORE authorizing removal: the generic
115
+ // GC remover only deletes a tree whose pre-verdict identity it was handed,
116
+ // so a successor reservation published at the same path is never reaped.
117
+ let observation: FileLockGcObservation | null;
118
+ try {
119
+ observation = await readFileLockObservationForGc(lockDir);
120
+ } catch {
121
+ return "kept";
122
+ }
123
+ if (!observation) return "kept";
124
+ let onDisk: unknown;
125
+ try {
126
+ onDisk = JSON.parse(observation.bytes);
127
+ } catch {
128
+ return "kept";
129
+ }
130
+ if (
131
+ !isReservationRecord(onDisk) ||
132
+ onDisk.reservationId !== record.reservationId ||
133
+ onDisk.pid !== record.pid ||
134
+ onDisk.timestamp !== record.timestamp ||
135
+ canonicalWorktreePath(onDisk.worktreePath) !== canonicalWorktreePath(record.worktreePath)
136
+ )
137
+ return "kept";
138
+ const result = await removeFileLockDirForGc(lockDir, observation.info, observation.identity);
139
+ return result === "removed" ? "removed" : "kept";
140
+ }
141
+
142
+ async function writeReservation(lockDir: string, record: LaunchWorktreeReservationRecord): Promise<void> {
143
+ try {
144
+ await Bun.write(path.join(lockDir, "info"), JSON.stringify(record));
145
+ } catch (error) {
146
+ await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
147
+ throw error;
148
+ }
149
+ }
150
+
151
+ async function tryPublishReservation(lockDir: string, record: LaunchWorktreeReservationRecord): Promise<boolean> {
152
+ const pendingDir = `${lockDir}.pending.${process.pid}.${randomUUID()}`;
153
+ await fs.mkdir(pendingDir, { mode: 0o700 });
154
+ try {
155
+ await writeReservation(pendingDir, record);
156
+ try {
157
+ await fs.rename(pendingDir, lockDir);
158
+ return true;
159
+ } catch (error) {
160
+ if (isErrno(error, "EEXIST") || isErrno(error, "ENOTEMPTY")) return false;
161
+ if (isErrno(error, "EPERM")) {
162
+ try {
163
+ await fs.stat(lockDir);
164
+ return false;
165
+ } catch (statError) {
166
+ if (!isErrno(statError, "ENOENT")) throw statError;
167
+ }
168
+ }
169
+ throw error;
170
+ }
171
+ } finally {
172
+ await fs.rm(pendingDir, { recursive: true, force: true }).catch(() => undefined);
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Atomically claim `worktreePath`, returning `null` only when another live or
178
+ * unverified launch already owns the same deterministic target. A dead or
179
+ * PID-reused owner is reclaimed before retrying the exclusive directory create.
180
+ */
181
+ export async function reserveLaunchWorktree(
182
+ agentDir: string,
183
+ worktreePath: string,
184
+ ): Promise<LaunchWorktreeReservation | null> {
185
+ const canonicalPath = canonicalWorktreePath(worktreePath);
186
+ const lockDir = launchWorktreeReservationDirectory(agentDir, canonicalPath);
187
+ await fs.mkdir(path.dirname(lockDir), { recursive: true, mode: 0o700 });
188
+ const ownStartTime = processStartTime(process.pid) ?? undefined;
189
+ const ownIncarnation = processIncarnation(process.pid) ?? null;
190
+
191
+ for (let attempt = 0; attempt < 2; attempt += 1) {
192
+ const record: LaunchWorktreeReservationRecord = {
193
+ version: RESERVATION_VERSION,
194
+ worktreePath: canonicalPath,
195
+ pid: process.pid,
196
+ ...(ownStartTime ? { start_time: ownStartTime } : {}),
197
+ processIncarnation: ownIncarnation,
198
+ timestamp: Date.now(),
199
+ reservationId: randomUUID(),
200
+ };
201
+ if (await tryPublishReservation(lockDir, record)) {
202
+ return {
203
+ async release(): Promise<void> {
204
+ await removeReservation(lockDir, record);
205
+ },
206
+ };
207
+ }
208
+
209
+ const existing = await readReservation(lockDir);
210
+ if (
211
+ !existing ||
212
+ canonicalWorktreePath(existing.worktreePath) !== canonicalPath ||
213
+ !ownerDefinitelyExited(existing)
214
+ )
215
+ return null;
216
+ if ((await removeReservation(lockDir, existing)) !== "removed") return null;
217
+ }
218
+
219
+ return null;
220
+ }
221
+
222
+ /** Remove the transient reservation only after the matching worktree is indexed. */
223
+ export async function releaseLaunchWorktreeReservationAfterRegistration(
224
+ agentDir: string,
225
+ worktreePath: string,
226
+ ): Promise<void> {
227
+ const canonicalPath = canonicalWorktreePath(worktreePath);
228
+ let entries: Dirent[];
229
+ try {
230
+ entries = await fs.readdir(reservationRoot(agentDir), { withFileTypes: true });
231
+ } catch (error) {
232
+ if (isErrno(error, "ENOENT")) return;
233
+ throw error;
234
+ }
235
+ for (const entry of entries) {
236
+ if (!entry.isDirectory() || !entry.name.endsWith(".lock")) continue;
237
+ const lockDir = path.join(reservationRoot(agentDir), entry.name);
238
+ const record = await readReservation(lockDir);
239
+ if (!record || canonicalWorktreePath(record.worktreePath) !== canonicalPath) continue;
240
+ await removeReservation(lockDir, record);
241
+ }
242
+ }
@@ -15,6 +15,8 @@ export const MANAGED_OWNER_GENERATION_ENV = "GJC_TMUX_OWNER_GENERATION";
15
15
  export const MANAGED_OWNER_STATE_DIR_ENV = "GJC_TMUX_OWNER_STATE_DIR";
16
16
  export const MANAGED_OWNER_RUN_ID_ENV = "GJC_MANAGED_OWNER_RUN_ID";
17
17
  export const MANAGED_OWNER_INCARNATION_ENV = "GJC_MANAGED_OWNER_INCARNATION";
18
+ /** Suppresses command-derived durable artifacts for Broker-owned opaque spawn children. */
19
+ export const MANAGED_OWNER_REDACT_COMMAND_ENV = "GJC_MANAGED_OWNER_REDACT_COMMAND";
18
20
 
19
21
  let bootstrapSigtermPending = false;
20
22
  const captureBootstrapSigterm = () => {
@@ -140,26 +142,32 @@ export async function runManagedOwnerSupervisor(): Promise<void> {
140
142
  if (!supervisorStartTime) throw new Error("managed_owner_supervisor_start_time_unavailable");
141
143
  await fs.mkdir(root, { recursive: true, mode: 0o700 });
142
144
  const childToken = crypto.randomUUID();
143
- const binding: ManagedOwnerBinding = {
144
- schema_version: 2,
145
- generation,
146
- session_id: sessionId,
147
- run_id: runId,
148
- endpoint_incarnation: incarnation,
149
- child_token: childToken,
150
- command,
151
- command_sha256: commandDigest(command),
152
- supervisor_pid: process.pid,
153
- supervisor_start_time: supervisorStartTime,
154
- created_at: new Date().toISOString(),
155
- };
156
- await writeDurableExclusive(path.join(root, `child-${childToken}.binding.json`), binding);
145
+ const redactCommand = process.env[MANAGED_OWNER_REDACT_COMMAND_ENV] === "1";
146
+ const binding: ManagedOwnerBinding | null = redactCommand
147
+ ? null
148
+ : {
149
+ schema_version: 2,
150
+ generation,
151
+ session_id: sessionId,
152
+ run_id: runId,
153
+ endpoint_incarnation: incarnation,
154
+ child_token: childToken,
155
+ command,
156
+ command_sha256: commandDigest(command),
157
+ supervisor_pid: process.pid,
158
+ supervisor_start_time: supervisorStartTime,
159
+ created_at: new Date().toISOString(),
160
+ };
161
+ if (binding) await writeDurableExclusive(path.join(root, `child-${childToken}.binding.json`), binding);
162
+ const childEnvironment: NodeJS.ProcessEnv = { ...process.env, [MANAGED_OWNER_CHILD_TOKEN_ENV]: childToken };
163
+ delete childEnvironment[MANAGED_OWNER_COMMAND_ENV];
164
+ delete childEnvironment[MANAGED_OWNER_REDACT_COMMAND_ENV];
157
165
  const child = Bun.spawn({
158
166
  cmd: command,
159
167
  stdin: "inherit",
160
168
  stdout: "inherit",
161
169
  stderr: "inherit",
162
- env: { ...process.env, [MANAGED_OWNER_CHILD_TOKEN_ENV]: childToken },
170
+ env: childEnvironment,
163
171
  });
164
172
  const childStartTime = await managedOwnerProcessProvenance(child.pid);
165
173
  if (!childStartTime) throw new Error("managed_owner_child_start_time_unavailable");
@@ -167,24 +175,26 @@ export async function runManagedOwnerSupervisor(): Promise<void> {
167
175
  if (!childProcess) {
168
176
  const exitCode = await child.exited;
169
177
  if (child.signalCode === "SIGABRT") {
170
- const receipt: ManagedOwnerSigabrtReceipt = {
171
- schema_version: 2,
172
- generation,
173
- session_id: sessionId,
174
- run_id: runId,
175
- endpoint_incarnation: incarnation,
176
- child_token: childToken,
177
- command_sha256: binding.command_sha256,
178
- supervisor_pid: process.pid,
179
- supervisor_start_time: supervisorStartTime,
180
- child_pid: child.pid,
181
- child_start_time: childStartTime,
182
- signal: "SIGABRT",
183
- signal_number: 6,
184
- exit_code: exitCode,
185
- received_at: new Date().toISOString(),
186
- };
187
- await writeDurableExclusive(path.join(root, `sigabrt-${childToken}.receipt.json`), receipt);
178
+ if (binding) {
179
+ const receipt: ManagedOwnerSigabrtReceipt = {
180
+ schema_version: 2,
181
+ generation,
182
+ session_id: sessionId,
183
+ run_id: runId,
184
+ endpoint_incarnation: incarnation,
185
+ child_token: childToken,
186
+ command_sha256: binding.command_sha256,
187
+ supervisor_pid: process.pid,
188
+ supervisor_start_time: supervisorStartTime,
189
+ child_pid: child.pid,
190
+ child_start_time: childStartTime,
191
+ signal: "SIGABRT",
192
+ signal_number: 6,
193
+ exit_code: exitCode,
194
+ received_at: new Date().toISOString(),
195
+ };
196
+ await writeDurableExclusive(path.join(root, `sigabrt-${childToken}.receipt.json`), receipt);
197
+ }
188
198
  process.exitCode = 134;
189
199
  return;
190
200
  }
@@ -246,24 +256,26 @@ export async function runManagedOwnerSupervisor(): Promise<void> {
246
256
  });
247
257
  }
248
258
  if (child.signalCode === "SIGABRT") {
249
- const receipt: ManagedOwnerSigabrtReceipt = {
250
- schema_version: 2,
251
- generation,
252
- session_id: sessionId,
253
- run_id: runId,
254
- endpoint_incarnation: incarnation,
255
- child_token: childToken,
256
- command_sha256: binding.command_sha256,
257
- supervisor_pid: process.pid,
258
- supervisor_start_time: supervisorStartTime,
259
- child_pid: child.pid,
260
- child_start_time: childStartTime,
261
- signal: "SIGABRT",
262
- signal_number: 6,
263
- exit_code: exitCode,
264
- received_at: new Date().toISOString(),
265
- };
266
- await writeDurableExclusive(path.join(root, `sigabrt-${childToken}.receipt.json`), receipt);
259
+ if (binding) {
260
+ const receipt: ManagedOwnerSigabrtReceipt = {
261
+ schema_version: 2,
262
+ generation,
263
+ session_id: sessionId,
264
+ run_id: runId,
265
+ endpoint_incarnation: incarnation,
266
+ child_token: childToken,
267
+ command_sha256: binding.command_sha256,
268
+ supervisor_pid: process.pid,
269
+ supervisor_start_time: supervisorStartTime,
270
+ child_pid: child.pid,
271
+ child_start_time: childStartTime,
272
+ signal: "SIGABRT",
273
+ signal_number: 6,
274
+ exit_code: exitCode,
275
+ received_at: new Date().toISOString(),
276
+ };
277
+ await writeDurableExclusive(path.join(root, `sigabrt-${childToken}.receipt.json`), receipt);
278
+ }
267
279
  process.exitCode = 134;
268
280
  return;
269
281
  }