@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
@@ -0,0 +1,86 @@
1
+ import * as fsSync from "node:fs";
2
+ import * as fs from "node:fs/promises";
3
+
4
+ const MAX_ENDPOINT_BYTES = 4_096n;
5
+
6
+ export type EndpointFileRead = {
7
+ source: string;
8
+ dev: bigint;
9
+ ino: bigint;
10
+ nlink: bigint;
11
+ size: bigint;
12
+ mtimeNs: bigint;
13
+ mtimeMs: number;
14
+ };
15
+
16
+ export type EndpointAuthorityFilesystem = Pick<typeof fs, "open">;
17
+
18
+ export type IndexedEndpointAuthority = {
19
+ endpointMtimeMs?: number;
20
+ endpointFileId?: string;
21
+ };
22
+
23
+ function readFlags(): number {
24
+ return fsSync.constants.O_RDONLY | (fsSync.constants.O_NOFOLLOW ?? 0) | (fsSync.constants.O_NONBLOCK ?? 0);
25
+ }
26
+
27
+ /**
28
+ * Reads one endpoint from the descriptor opened for its pathname. The opened
29
+ * object must remain a small, singly-linked regular file with stable identity
30
+ * and metadata for the whole read; a pathname replacement cannot affect the
31
+ * bytes returned by this function.
32
+ */
33
+ export async function readEndpointFile(
34
+ filePath: string,
35
+ filesystem: EndpointAuthorityFilesystem = fs,
36
+ ): Promise<EndpointFileRead | undefined> {
37
+ let handle: fs.FileHandle | undefined;
38
+ try {
39
+ handle = await filesystem.open(filePath, readFlags());
40
+ const before = await handle.stat({ bigint: true });
41
+ if (!before.isFile() || before.nlink !== 1n || before.size > MAX_ENDPOINT_BYTES) return undefined;
42
+ if (before.size > BigInt(Number.MAX_SAFE_INTEGER)) return undefined;
43
+ const bytes = Buffer.alloc(Number(before.size));
44
+ let offset = 0;
45
+ while (offset < bytes.length) {
46
+ const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset);
47
+ if (bytesRead <= 0) return undefined;
48
+ offset += bytesRead;
49
+ }
50
+ const after = await handle.stat({ bigint: true });
51
+ if (
52
+ before.dev !== after.dev ||
53
+ before.ino !== after.ino ||
54
+ before.nlink !== after.nlink ||
55
+ before.size !== after.size ||
56
+ before.mtimeNs !== after.mtimeNs
57
+ )
58
+ return undefined;
59
+ return {
60
+ source: bytes.toString("utf8"),
61
+ dev: before.dev,
62
+ ino: before.ino,
63
+ nlink: before.nlink,
64
+ size: before.size,
65
+ mtimeNs: before.mtimeNs,
66
+ mtimeMs: Number(before.mtimeNs) / 1_000_000,
67
+ };
68
+ } catch {
69
+ return undefined;
70
+ } finally {
71
+ await handle?.close().catch(() => undefined);
72
+ }
73
+ }
74
+
75
+ /** Match a descriptor-bound endpoint read to the index representation. */
76
+ export function matchesIndexedEndpointFile(
77
+ file: Pick<EndpointFileRead, "dev" | "ino" | "mtimeMs">,
78
+ authority: IndexedEndpointAuthority,
79
+ ): boolean {
80
+ return (
81
+ authority.endpointMtimeMs !== undefined &&
82
+ Number.isFinite(authority.endpointMtimeMs) &&
83
+ Math.abs(file.mtimeMs - authority.endpointMtimeMs) <= 0.001 &&
84
+ (authority.endpointFileId === undefined || authority.endpointFileId === `${file.dev}:${file.ino}`)
85
+ );
86
+ }
@@ -232,6 +232,11 @@ function registerBrokerOwner(
232
232
  function brokerSpawnEnvironment(command: SdkInternalSpawnCommand, override?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
233
233
  const environment = { ...(override ?? command.env) };
234
234
  delete environment.BUN_OPTIONS;
235
+ // The master capability is a transient in-memory dispatch input. A broker
236
+ // cold-started from the master's own Bash environment would otherwise inherit
237
+ // it and pass it on to every substrate child it later launches, so it is
238
+ // stripped at the lifecycle boundary exactly as lifecycle children strip it.
239
+ delete environment.GJC_MASTER_CAPABILITY;
235
240
  if (command.kind === "bun-source") {
236
241
  delete environment.PI_COMPILED;
237
242
  delete environment.GJC_COMPILED;
@@ -4,6 +4,9 @@ export * from "./ensure";
4
4
  export * from "./identity";
5
5
  export * from "./lifecycle";
6
6
  export * from "./lifecycle-ledger";
7
+ export * from "./master-capability";
7
8
  export * from "./session-index";
9
+ export * from "./spawn-authority";
10
+ export * from "./spawn-substrate";
8
11
  export * from "./startup-budget";
9
12
  export * from "./transport";
@@ -66,6 +66,7 @@ import {
66
66
  sanitizeSdkStartupMessage,
67
67
  } from "../startup-capability";
68
68
  import type { Broker, BrokerCleanupEvidence, BrokerCleanupIdentity, BrokerResponse } from "./broker";
69
+ import { matchesIndexedEndpointFile, readEndpointFile } from "./endpoint-authority";
69
70
  import { decodeLifecycleUtf8, parseLifecycleJson } from "./lifecycle-codec";
70
71
  import type {
71
72
  LifecycleCleanupProof,
@@ -81,7 +82,12 @@ import {
81
82
  processIncarnation,
82
83
  } from "./process-incarnation";
83
84
  import { resolveSdkInternalSpawnCommand, type SdkInternalSpawnCommand } from "./runtime";
84
- import { type IndexedSession, isSessionAuthorityEligible } from "./session-index";
85
+ import {
86
+ type IndexedSession,
87
+ isSessionAuthorityEligible,
88
+ resolveSessionLocator,
89
+ type SessionLocatorV2,
90
+ } from "./session-index";
85
91
  import {
86
92
  cancellableSleep,
87
93
  DEFAULT_READINESS_TIMEOUT_MS,
@@ -89,6 +95,7 @@ import {
89
95
  READINESS_TIMEOUT_INVALID_MESSAGE,
90
96
  startupQueueWaitMs,
91
97
  } from "./startup-budget";
98
+ import { worktreeOccupant } from "./worktree-occupancy";
92
99
 
93
100
  export {
94
101
  type ProcessIncarnationCommandRunner,
@@ -612,7 +619,7 @@ function text(value: unknown): string | undefined {
612
619
  return typeof value === "string" && value ? value : undefined;
613
620
  }
614
621
 
615
- function validateBrokerModelPresetSync(agentDir: string, requestedProfile: string): string | BrokerResponse {
622
+ export function validateBrokerModelPresetSync(agentDir: string, requestedProfile: string): string | BrokerResponse {
616
623
  const modelsConfigFile = ModelsConfigFile.relocate(path.join(agentDir, "models.yml"));
617
624
  modelsConfigFile.invalidate();
618
625
  const loaded = modelsConfigFile.tryLoad();
@@ -733,10 +740,13 @@ function lifecycleWorktreeTarget(input: Input): SessionLifecycleWorktreeTarget |
733
740
  }
734
741
 
735
742
  type LiveResumeRecord = {
736
- locator: { repo: string; stateRoot: string };
743
+ sessionId: string;
744
+ locator: SessionLocatorV2;
737
745
  endpointGeneration: number;
738
746
  pid: number;
739
747
  endpointMtimeMs?: number;
748
+ processIncarnation?: string;
749
+ hostIncarnation?: string;
740
750
  live: boolean;
741
751
  ambiguous: boolean;
742
752
  };
@@ -755,7 +765,7 @@ type ResumeScope = {
755
765
  };
756
766
  function sameResumeLocator(record: LiveResumeRecord, cwd: string, root: string): boolean {
757
767
  return (
758
- resolveEquivalentPath(record.locator.repo) === resolveEquivalentPath(cwd) &&
768
+ resolveEquivalentPath(record.locator.cwd) === resolveEquivalentPath(cwd) &&
759
769
  resolveEquivalentPath(record.locator.stateRoot) === resolveEquivalentPath(root)
760
770
  );
761
771
  }
@@ -777,10 +787,24 @@ function sameLiveResumeRecord(expected: LiveResumeRecord, current: LiveResumeRec
777
787
  current.endpointGeneration === expected.endpointGeneration &&
778
788
  current.pid === expected.pid &&
779
789
  current.endpointMtimeMs === expected.endpointMtimeMs &&
780
- sameResumeLocator(current, expected.locator.repo, expected.locator.stateRoot)
790
+ (current.hostIncarnation ?? current.processIncarnation) ===
791
+ (expected.hostIncarnation ?? expected.processIncarnation) &&
792
+ sameResumeLocator(current, expected.locator.cwd, expected.locator.stateRoot)
781
793
  );
782
794
  }
783
795
 
796
+ function liveResumeAuthority(
797
+ sessions: readonly LiveResumeRecord[],
798
+ sessionId: string,
799
+ ): { kind: "none" } | { kind: "ambiguous" } | { kind: "live"; record: LiveResumeRecord } {
800
+ const indexedCandidates = sessions.filter(session => session.sessionId === sessionId);
801
+ if (indexedCandidates.some(session => !isSessionAuthorityEligible(session))) return { kind: "ambiguous" };
802
+ const liveCandidates = indexedCandidates.filter(session => session.live);
803
+ if (liveCandidates.length > 1) return { kind: "ambiguous" };
804
+ const record = liveCandidates[0];
805
+ return record ? { kind: "live", record } : { kind: "none" };
806
+ }
807
+
784
808
  type ValidatedTranscript = {
785
809
  path: string;
786
810
  id: string;
@@ -912,18 +936,36 @@ async function validateLiveResumeScope(
912
936
  sessionIdentity: session.identity,
913
937
  };
914
938
  }
915
- async function reconcileReadyScope(broker: Broker, id: string, scope: string | undefined): Promise<void> {
939
+ async function reconcileReadyScope(
940
+ broker: Broker,
941
+ id: string,
942
+ scope: string | undefined,
943
+ root: string,
944
+ expected: EffectMarker,
945
+ ): Promise<void> {
916
946
  if (!scope) return;
917
947
  await broker.index.refresh();
918
- const record = broker.index.listSessions().sessions.find(session => session.sessionId === id);
919
- if (!record || record.locator.repo === scope) return;
920
- // The host records its physical cwd, which Darwin canonicalizes from /var to
921
- // /private/var. Preserve the lifecycle caller's lexical cwd for ACP's scoped
922
- // listing while retaining the host-provided state root for endpoint binding.
948
+ const record = broker.index
949
+ .listSessions()
950
+ .sessions.find(
951
+ session =>
952
+ session.sessionId === id &&
953
+ resolveEquivalentPath(session.locator.stateRoot) === resolveEquivalentPath(root) &&
954
+ session.pid === expected.pid &&
955
+ (session.hostIncarnation ?? session.processIncarnation) === expected.incarnation &&
956
+ session.lifecycleRequestId === expected.effectMarker,
957
+ );
958
+ if (!record) return;
959
+ const cwd = canonicalExistingPath(scope);
960
+ if (record.locator.cwd === cwd) return;
961
+ // Locator cwd is canonical everywhere. Reconcile only a pre-existing host row
962
+ // whose canonical registration race observed a different path; it never retains
963
+ // a caller's lexical spelling for ACP or any other scope consumer.
964
+ const locator = await resolveSessionLocator(scope, record.locator.stateRoot);
923
965
  await broker.index.append({
924
966
  type: "record_reconciled",
925
967
  sessionId: id,
926
- locator: { ...record.locator, repo: scope },
968
+ locator,
927
969
  endpointGeneration: record.endpointGeneration,
928
970
  pid: record.pid,
929
971
  // Reconciliation only re-scopes the locator, so every identity fact the host
@@ -933,6 +975,8 @@ async function reconcileReadyScope(broker: Broker, id: string, scope: string | u
933
975
  ...(record.hostIncarnation === undefined ? {} : { hostIncarnation: record.hostIncarnation }),
934
976
  ...(record.lifecycleRequestId === undefined ? {} : { lifecycleRequestId: record.lifecycleRequestId }),
935
977
  endpointMtimeMs: record.endpointMtimeMs,
978
+ ...(record.endpointFileId === undefined ? {} : { endpointFileId: record.endpointFileId }),
979
+ ...(record.masterRole === undefined ? {} : { masterRole: record.masterRole }),
936
980
  });
937
981
  }
938
982
 
@@ -964,6 +1008,66 @@ function command(broker: Broker): LifecycleCommand {
964
1008
  return lifecycleCommandResolversForTest.get(broker)?.() ?? resolveSdkInternalSpawnCommand("session-host-internal");
965
1009
  }
966
1010
 
1011
+ /**
1012
+ * Reuses the ordinary lifecycle host bootstrap without performing its direct
1013
+ * process spawn. Broker-owned substrate providers receive this exact argv and
1014
+ * split inherited/child-specific environment contract as their only launch
1015
+ * authority for session.spawn.
1016
+ */
1017
+ export type SpawnChildHostLaunch = {
1018
+ childId: string;
1019
+ cwd: string;
1020
+ stateRoot: string;
1021
+ argv: readonly string[];
1022
+ inheritedEnv: Readonly<Record<string, string>>;
1023
+ env: Readonly<Record<string, string>>;
1024
+ effectMarker: string;
1025
+ };
1026
+
1027
+ export function prepareSpawnChildHostLaunch(
1028
+ broker: Broker,
1029
+ input: { cwd: string; modelId?: string; modelPreset?: string; childId?: string; receivedAt?: number },
1030
+ ): SpawnChildHostLaunch {
1031
+ const cwd = path.resolve(input.cwd);
1032
+ const childId = input.childId ?? randomUUID();
1033
+ const stateRoot = defaultStateRoot(cwd);
1034
+ const effectMarker = randomUUID();
1035
+ const deadlines = deriveLifecycleDeadlines(input.receivedAt ?? Date.now(), DEFAULT_READINESS_TIMEOUT_MS);
1036
+ const request: SessionLifecycleLaunchRequest = {
1037
+ operation: "session.create",
1038
+ sessionId: childId,
1039
+ cwd,
1040
+ stateRoot,
1041
+ effectMarker,
1042
+ ...deadlines,
1043
+ ...(input.modelId === undefined ? {} : { modelId: input.modelId }),
1044
+ ...(input.modelPreset === undefined ? {} : { modelPreset: input.modelPreset }),
1045
+ };
1046
+ const cmd = command(broker);
1047
+ const inherited = "kind" in cmd ? cmd.env : process.env;
1048
+ const inheritedEnv = Object.fromEntries(
1049
+ Object.entries(inherited).filter(
1050
+ (entry): entry is [string, string] => entry[0] !== "GJC_MASTER_CAPABILITY" && typeof entry[1] === "string",
1051
+ ),
1052
+ );
1053
+ return {
1054
+ childId,
1055
+ cwd,
1056
+ stateRoot,
1057
+ argv: [cmd.file, ...cmd.args],
1058
+ inheritedEnv,
1059
+ env: {
1060
+ GJC_AGENT_DIR: broker.settings.agentDir,
1061
+ GJC_CODING_AGENT_DIR: broker.settings.agentDir,
1062
+ GJC_SESSION_ID: childId,
1063
+ GJC_STATE_ROOT: stateRoot,
1064
+ GJC_LIFECYCLE_REQUEST_ID: effectMarker,
1065
+ GJC_SDK_LIFECYCLE_REQUEST: JSON.stringify(request),
1066
+ },
1067
+ effectMarker,
1068
+ };
1069
+ }
1070
+
967
1071
  const lifecycleMarkerPath = (root: string, id: string) => path.join(root, "sdk", `${id}.lifecycle.json`);
968
1072
  const lifecycleReadyPath = (root: string, id: string) => path.join(root, "sdk", `${id}.lifecycle.ready.json`);
969
1073
  const lifecycleFailurePath = (root: string, id: string, effectMarker: string) =>
@@ -973,6 +1077,7 @@ type ReadyAuthority = {
973
1077
  endpoint: Record<string, unknown>;
974
1078
  endpointSource: string;
975
1079
  endpointMtimeMs: number;
1080
+ endpointFileId?: string;
976
1081
  endpointGeneration: number;
977
1082
  };
978
1083
  type ReadinessResult =
@@ -3351,7 +3456,7 @@ async function recordTerminalUncertain(
3351
3456
  await broker.index.append({
3352
3457
  type: "lifecycle_terminal",
3353
3458
  sessionId: id,
3354
- locator: { repo: "unknown", stateRoot: root },
3459
+ locator: { cwd: "unknown", worktreeRoot: null, stateRoot: root },
3355
3460
  endpointGeneration: 0,
3356
3461
  pid,
3357
3462
  terminalUncertain: true,
@@ -3667,7 +3772,10 @@ async function removeExactDeadSessionEndpoint(
3667
3772
  : finalEndpointPath;
3668
3773
  let handle: fs.FileHandle | undefined;
3669
3774
  try {
3670
- handle = await fs.open(endpointSource, fsSync.constants.O_RDONLY | fsSync.constants.O_NOFOLLOW);
3775
+ handle = await fs.open(
3776
+ endpointSource,
3777
+ fsSync.constants.O_RDONLY | (fsSync.constants.O_NOFOLLOW ?? 0) | (fsSync.constants.O_NONBLOCK ?? 0),
3778
+ );
3671
3779
  const metadata = await handle.stat({ bigint: true });
3672
3780
  if (!metadata.isFile() || metadata.nlink !== 1n || metadata.size > 4096n) return false;
3673
3781
  const bytes = Buffer.alloc(Number(metadata.size) + 1);
@@ -3847,10 +3955,9 @@ async function currentReadyAuthority(
3847
3955
  if (!(await hasOwnedReadinessEvidence(broker, root, id, expected))) return undefined;
3848
3956
  const endpointPath = path.join(root, "sdk", `${id}.json`);
3849
3957
  try {
3850
- const [endpointSource, endpointMetadata] = await Promise.all([
3851
- fs.readFile(endpointPath, "utf8"),
3852
- fs.stat(endpointPath),
3853
- ]);
3958
+ const endpointFile = await readEndpointFile(endpointPath);
3959
+ if (!endpointFile) return undefined;
3960
+ const endpointSource = endpointFile.source;
3854
3961
  const endpoint = JSON.parse(endpointSource) as {
3855
3962
  sessionId?: unknown;
3856
3963
  url?: unknown;
@@ -3863,14 +3970,24 @@ async function currentReadyAuthority(
3863
3970
  // native-dead child; do not refuse a native-alive ready host for a stale live bit.
3864
3971
  await broker.heartbeatSessions();
3865
3972
  await broker.index.refresh();
3866
- const record = broker.index.listSessions().sessions.find(session => session.sessionId === id);
3973
+ const record = broker.index
3974
+ .listSessions()
3975
+ .sessions.find(
3976
+ session =>
3977
+ session.sessionId === id &&
3978
+ session.pid === expected.pid &&
3979
+ resolveEquivalentPath(session.locator.stateRoot) === resolveEquivalentPath(root) &&
3980
+ (session.hostIncarnation ?? session.processIncarnation) === expected.incarnation &&
3981
+ session.lifecycleRequestId === expected.effectMarker,
3982
+ );
3867
3983
  if (
3868
3984
  !record ||
3869
3985
  record.terminal ||
3870
3986
  record.terminalUncertain ||
3871
3987
  record.pid !== expected.pid ||
3872
3988
  resolveEquivalentPath(record.locator.stateRoot) !== resolveEquivalentPath(root) ||
3873
- record.endpointMtimeMs !== endpointMetadata.mtimeMs ||
3989
+ (record.hostIncarnation ?? record.processIncarnation) !== expected.incarnation ||
3990
+ !matchesIndexedEndpointFile(endpointFile, record) ||
3874
3991
  endpoint.pid !== expected.pid ||
3875
3992
  endpoint.sessionId !== id ||
3876
3993
  typeof endpoint.url !== "string" ||
@@ -3885,7 +4002,8 @@ async function currentReadyAuthority(
3885
4002
  return {
3886
4003
  endpoint: endpoint as Record<string, unknown>,
3887
4004
  endpointSource,
3888
- endpointMtimeMs: endpointMetadata.mtimeMs,
4005
+ endpointMtimeMs: endpointFile.mtimeMs,
4006
+ ...(record.endpointFileId === undefined ? {} : { endpointFileId: record.endpointFileId }),
3889
4007
  endpointGeneration: record.endpointGeneration,
3890
4008
  };
3891
4009
  } catch {
@@ -3896,7 +4014,8 @@ async function currentReadyAuthority(
3896
4014
  function sameReadyAuthority(left: ReadyAuthority, right: ReadyAuthority): boolean {
3897
4015
  return (
3898
4016
  left.endpointSource === right.endpointSource &&
3899
- left.endpointMtimeMs === right.endpointMtimeMs &&
4017
+ Math.abs(left.endpointMtimeMs - right.endpointMtimeMs) <= 0.001 &&
4018
+ left.endpointFileId === right.endpointFileId &&
3900
4019
  left.endpointGeneration === right.endpointGeneration
3901
4020
  );
3902
4021
  }
@@ -4022,6 +4141,15 @@ function worktreeIntent(plan: GjcLaunchWorktreePlan | undefined): LifecycleWorkt
4022
4141
  };
4023
4142
  }
4024
4143
 
4144
+ /** Test seam for the worktree occupancy boundary. */
4145
+ export function worktreeOccupantForTest(
4146
+ sessions: IndexedSession[],
4147
+ worktreePath: string,
4148
+ observe: (pid: number, expectedIncarnation: string | undefined) => ProcessObservation = observeProcess,
4149
+ ): string | null {
4150
+ return worktreeOccupant(sessions, worktreePath, observe);
4151
+ }
4152
+
4025
4153
  function preparePlannedWorktree(plan: GjcLaunchWorktreePlan): SessionLifecycleWorktreeReceipt {
4026
4154
  const prepared = ensureLaunchWorktree(plan);
4027
4155
  if (!prepared.enabled || path.resolve(prepared.worktreePath) !== path.resolve(plan.worktreePath))
@@ -4453,7 +4581,7 @@ async function validateDeletePath(
4453
4581
  broker: Broker,
4454
4582
  input: Input,
4455
4583
  id: string,
4456
- record: { locator: { repo: string; stateRoot: string } } | undefined,
4584
+ record: { locator: SessionLocatorV2 } | undefined,
4457
4585
  cleanup?: CleanupEvidence,
4458
4586
  ): Promise<ValidatedDelete | BrokerResponse> {
4459
4587
  const sessionPath = text(input.sessionPath);
@@ -4467,7 +4595,7 @@ async function validateDeletePath(
4467
4595
  const canonicalRequestedRoot = canonicalExistingPath(requestedRoot);
4468
4596
  if (
4469
4597
  record &&
4470
- (canonicalExistingPath(record.locator.repo) !== cwd ||
4598
+ (canonicalExistingPath(record.locator.cwd) !== cwd ||
4471
4599
  canonicalExistingPath(record.locator.stateRoot) !== canonicalRequestedRoot)
4472
4600
  )
4473
4601
  return fail("invalid_input", "session.delete locator does not match the indexed session.");
@@ -4557,7 +4685,7 @@ async function validateDeletePath(
4557
4685
  }
4558
4686
  type CloseAuthority = { endpointGeneration: number; endpointIncarnation: string };
4559
4687
  type CloseRecord = {
4560
- locator: { repo: string; stateRoot: string };
4688
+ locator: SessionLocatorV2;
4561
4689
  endpointGeneration: number;
4562
4690
  pid: number;
4563
4691
  endpointMtimeMs?: number;
@@ -4621,7 +4749,7 @@ function sameCloseStoredProcessIdentity(expected: CloseRecord, current: CloseRec
4621
4749
  typeof expected.lifecycleRequestId === "string" &&
4622
4750
  expected.lifecycleRequestId.length > 0 &&
4623
4751
  current.lifecycleRequestId === expected.lifecycleRequestId &&
4624
- path.resolve(current.locator.repo) === path.resolve(expected.locator.repo) &&
4752
+ path.resolve(current.locator.cwd) === path.resolve(expected.locator.cwd) &&
4625
4753
  path.resolve(current.locator.stateRoot) === path.resolve(expected.locator.stateRoot)
4626
4754
  );
4627
4755
  }
@@ -4639,7 +4767,7 @@ function sameCloseEndpointIdentity(expected: CloseRecord, current: CloseRecord):
4639
4767
  current.endpointMtimeMs !== undefined &&
4640
4768
  expected.endpointMtimeMs !== undefined &&
4641
4769
  current.lifecycleRequestId === expected.lifecycleRequestId &&
4642
- path.resolve(current.locator.repo) === path.resolve(expected.locator.repo) &&
4770
+ path.resolve(current.locator.cwd) === path.resolve(expected.locator.cwd) &&
4643
4771
  path.resolve(current.locator.stateRoot) === path.resolve(expected.locator.stateRoot)
4644
4772
  );
4645
4773
  }
@@ -4656,7 +4784,7 @@ function sameCloseGeneration(expected: CloseRecord, current: CloseRecord & { liv
4656
4784
  current.endpointMtimeMs === expected.endpointMtimeMs &&
4657
4785
  current.lifecycleRequestId === expected.lifecycleRequestId &&
4658
4786
  current.processIncarnation === expected.processIncarnation &&
4659
- path.resolve(current.locator.repo) === path.resolve(expected.locator.repo) &&
4787
+ path.resolve(current.locator.cwd) === path.resolve(expected.locator.cwd) &&
4660
4788
  path.resolve(current.locator.stateRoot) === path.resolve(expected.locator.stateRoot)
4661
4789
  );
4662
4790
  }
@@ -4720,13 +4848,15 @@ async function executeLifecycleResponse(
4720
4848
  if (operation === "session.create" || operation === "session.fork" || operation === "session.resume") {
4721
4849
  await broker.index.refresh();
4722
4850
  await broker.heartbeatSessions();
4851
+ await broker.index.refresh();
4723
4852
  if (operation === "session.resume") {
4724
4853
  const requestedSessionId = sessionId(input);
4725
- const existing = requestedSessionId
4726
- ? broker.index.listSessions().sessions.find(session => session.sessionId === requestedSessionId)
4727
- : undefined;
4728
- if (existing && !isSessionAuthorityEligible(existing))
4854
+ const authority = requestedSessionId
4855
+ ? liveResumeAuthority(broker.index.listSessions().sessions, requestedSessionId)
4856
+ : { kind: "none" as const };
4857
+ if (authority.kind === "ambiguous")
4729
4858
  return fail("endpoint_stale", "Session authority is ambiguous and cannot be resumed safely.");
4859
+ const existing = authority.kind === "live" ? authority.record : undefined;
4730
4860
  if (existing?.live) {
4731
4861
  const initialScope = await validateLiveResumeScope(broker, input, requestedSessionId!, existing);
4732
4862
  if ("ok" in initialScope) return initialScope;
@@ -4743,16 +4873,17 @@ async function executeLifecycleResponse(
4743
4873
  "live_session",
4744
4874
  "Session is already live but its incarnation-bound endpoint is unavailable.",
4745
4875
  );
4746
- await broker.index.refresh();
4747
- const current = broker.index
4748
- .listSessions()
4749
- .sessions.find(session => session.sessionId === requestedSessionId);
4750
- if (!current || !sameLiveResumeRecord(existing, current))
4751
- return fail("endpoint_stale", "Live session changed while its resume authority was being verified.");
4752
- const finalScope = await validateLiveResumeScope(broker, input, requestedSessionId!, current);
4876
+ const finalScope = await validateLiveResumeScope(broker, input, requestedSessionId!, existing);
4753
4877
  if ("ok" in finalScope) return finalScope;
4754
4878
  if (!sameResumeSessionIdentity(initialScope, finalScope))
4755
4879
  return fail("endpoint_stale", "Saved session changed while its resume authority was being verified.");
4880
+ await broker.index.refresh();
4881
+ const finalAuthority = liveResumeAuthority(broker.index.listSessions().sessions, requestedSessionId!);
4882
+ if (finalAuthority.kind === "ambiguous")
4883
+ return fail("endpoint_stale", "Session authority became ambiguous while it was being verified.");
4884
+ const current = finalAuthority.kind === "live" ? finalAuthority.record : undefined;
4885
+ if (!current || !sameLiveResumeRecord(existing, current))
4886
+ return fail("endpoint_stale", "Live session changed while its resume authority was being verified.");
4756
4887
  if (current.endpointMtimeMs === undefined)
4757
4888
  return fail("endpoint_stale", "Live session endpoint authority is incomplete.");
4758
4889
  return {
@@ -4827,6 +4958,14 @@ async function executeLifecycleResponse(
4827
4958
  "incarnation_unavailable",
4828
4959
  "OS process incarnation authority is unavailable; refusing to spawn a lifecycle session.",
4829
4960
  );
4961
+ if (launch.worktreePlan) {
4962
+ const occupant = worktreeOccupant(broker.index.listSessions().sessions, launch.worktreePlan.worktreePath);
4963
+ if (occupant && occupant !== launch.id)
4964
+ return fail(
4965
+ "worktree_in_use",
4966
+ `The requested worktree is already held by session ${occupant}. Choose another worktree name or stop that session.`,
4967
+ );
4968
+ }
4830
4969
  const effectMarker = randomUUID();
4831
4970
  const plannedWorktreeIntent = worktreeIntent(launch.worktreePlan);
4832
4971
  const effectIntent: LifecycleEffectIntentWithDeadline = {
@@ -4933,7 +5072,13 @@ async function executeLifecycleResponse(
4933
5072
  // by the OS rather than captured (#4712 review).
4934
5073
  stdio: "ignore",
4935
5074
  env: {
4936
- ...("kind" in cmd ? cmd.env : process.env),
5075
+ // Master capability is process-local to direct Bash children and must
5076
+ // never cross a broker lifecycle launch boundary.
5077
+ ...Object.fromEntries(
5078
+ Object.entries("kind" in cmd ? cmd.env : process.env).filter(
5079
+ ([key]) => key !== "GJC_MASTER_CAPABILITY",
5080
+ ),
5081
+ ),
4937
5082
  GJC_AGENT_DIR: broker.settings.agentDir,
4938
5083
  GJC_CODING_AGENT_DIR: broker.settings.agentDir,
4939
5084
  GJC_SESSION_ID: launch.id,
@@ -5073,7 +5218,7 @@ async function executeLifecycleResponse(
5073
5218
  `Session ${launch.id} did not register an endpoint before the readiness timeout.`,
5074
5219
  );
5075
5220
  }
5076
- await reconcileReadyScope(broker, launch.id, launch.cwd);
5221
+ await reconcileReadyScope(broker, launch.id, launch.cwd, launch.root, spawnedAuthority);
5077
5222
  const verified = await currentReadyAuthority(broker, launch.id, launch.root, spawnedAuthority);
5078
5223
  if (!verified || !sameReadyAuthority(readiness.authority, verified)) {
5079
5224
  const exited =