@gajae-code/coding-agent 0.9.6 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/types/cli/classify-install-target.d.ts +0 -10
  3. package/dist/types/cli/daemon-cli.d.ts +2 -0
  4. package/dist/types/cli/initial-message.d.ts +1 -1
  5. package/dist/types/cli/malloc-env-guard.d.ts +65 -0
  6. package/dist/types/cli/malloc-env-guard.test.d.ts +1 -0
  7. package/dist/types/cli/notify-cli.d.ts +3 -1
  8. package/dist/types/cli/session-picker.d.ts +4 -3
  9. package/dist/types/cli/update-cli.d.ts +22 -3
  10. package/dist/types/cli.d.ts +5 -0
  11. package/dist/types/commands/daemon.d.ts +6 -2
  12. package/dist/types/commands/launch.d.ts +1 -0
  13. package/dist/types/commands/notify.d.ts +6 -0
  14. package/dist/types/commit/analysis/conventional.d.ts +1 -1
  15. package/dist/types/commit/analysis/summary.d.ts +1 -1
  16. package/dist/types/commit/changelog/generate.d.ts +1 -1
  17. package/dist/types/commit/changelog/index.d.ts +2 -2
  18. package/dist/types/commit/map-reduce/map-phase.d.ts +1 -1
  19. package/dist/types/commit/map-reduce/reduce-phase.d.ts +1 -1
  20. package/dist/types/config/keybindings.d.ts +5 -0
  21. package/dist/types/config/model-profile-activation.d.ts +1 -0
  22. package/dist/types/config/settings-schema.d.ts +47 -7
  23. package/dist/types/config/settings.d.ts +2 -0
  24. package/dist/types/coordinator/contract.d.ts +1 -1
  25. package/dist/types/coordinator-mcp/model-preset.d.ts +45 -0
  26. package/dist/types/coordinator-mcp/policy.d.ts +3 -0
  27. package/dist/types/coordinator-mcp/server.d.ts +79 -6
  28. package/dist/types/coordinator-mcp/session-reaper.d.ts +53 -0
  29. package/dist/types/daemon/control-types.d.ts +10 -0
  30. package/dist/types/daemon/operator-contract.d.ts +47 -0
  31. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +4 -0
  32. package/dist/types/discovery/helpers.d.ts +1 -1
  33. package/dist/types/discovery/substitute-plugin-root.d.ts +0 -4
  34. package/dist/types/extensibility/extensions/types.d.ts +3 -1
  35. package/dist/types/gjc-runtime/launch-tmux.d.ts +26 -7
  36. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +34 -3
  37. package/dist/types/gjc-runtime/tmux-common.d.ts +6 -0
  38. package/dist/types/gjc-runtime/tmux-owner-isolation-cli.d.ts +5 -0
  39. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +309 -0
  40. package/dist/types/gjc-runtime/tmux-sessions.d.ts +49 -17
  41. package/dist/types/internal-urls/local-protocol.d.ts +13 -9
  42. package/dist/types/main.d.ts +66 -2
  43. package/dist/types/modes/acp/acp-agent.d.ts +19 -2
  44. package/dist/types/modes/acp/acp-mode.d.ts +12 -0
  45. package/dist/types/modes/components/assistant-message.d.ts +1 -1
  46. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  47. package/dist/types/modes/components/gajae-pet-widget.d.ts +59 -0
  48. package/dist/types/modes/components/hook-selector.d.ts +5 -0
  49. package/dist/types/modes/components/irc-sidebar.d.ts +32 -0
  50. package/dist/types/modes/components/pet-selector.d.ts +11 -0
  51. package/dist/types/modes/components/session-selector.d.ts +22 -9
  52. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  53. package/dist/types/modes/components/status-line/types.d.ts +1 -1
  54. package/dist/types/modes/components/status-line.d.ts +0 -10
  55. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  56. package/dist/types/modes/components/user-message.d.ts +2 -1
  57. package/dist/types/modes/controllers/event-controller.d.ts +4 -0
  58. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  59. package/dist/types/modes/interactive-mode.d.ts +24 -2
  60. package/dist/types/modes/irc-observation-ledger.d.ts +22 -0
  61. package/dist/types/modes/print-mode.d.ts +3 -4
  62. package/dist/types/modes/rpc/rpc-client.d.ts +7 -1
  63. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -1
  64. package/dist/types/modes/rpc/rpc-types.d.ts +17 -1
  65. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +8 -0
  66. package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +5 -0
  67. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +45 -0
  68. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +42 -2
  69. package/dist/types/modes/theme/defaults/index.d.ts +568 -568
  70. package/dist/types/modes/types.d.ts +28 -2
  71. package/dist/types/modes/utils/abort-message.d.ts +1 -1
  72. package/dist/types/modes/utils/context-usage.d.ts +4 -19
  73. package/dist/types/modes/utils/irc-message.d.ts +38 -0
  74. package/dist/types/modes/utils/ui-helpers.d.ts +13 -4
  75. package/dist/types/notifications/config.d.ts +34 -0
  76. package/dist/types/notifications/daemon-paths.d.ts +7 -0
  77. package/dist/types/notifications/index.d.ts +27 -0
  78. package/dist/types/notifications/lifecycle-control-runtime.d.ts +12 -4
  79. package/dist/types/notifications/notification-service.d.ts +144 -0
  80. package/dist/types/notifications/rate-limit-pool.d.ts +27 -9
  81. package/dist/types/notifications/telegram-daemon-control.d.ts +6 -0
  82. package/dist/types/notifications/telegram-daemon.d.ts +130 -3
  83. package/dist/types/notifications/telegram-reference.d.ts +19 -2
  84. package/dist/types/notifications/topic-registry.d.ts +24 -4
  85. package/dist/types/registry/agent-registry.d.ts +2 -0
  86. package/dist/types/rlm/index.d.ts +1 -1
  87. package/dist/types/sdk.d.ts +2 -0
  88. package/dist/types/secrets/obfuscator.d.ts +1 -1
  89. package/dist/types/session/agent-session.d.ts +40 -3
  90. package/dist/types/session/cache-economics.d.ts +79 -0
  91. package/dist/types/session/context-estimation.d.ts +31 -0
  92. package/dist/types/session/provider-safety-stop.d.ts +5 -0
  93. package/dist/types/session/session-manager.d.ts +124 -4
  94. package/dist/types/session/session-storage.d.ts +155 -13
  95. package/dist/types/setup/credential-auto-import.d.ts +2 -2
  96. package/dist/types/task/executor.d.ts +3 -0
  97. package/dist/types/task/receipt.d.ts +3 -1
  98. package/dist/types/task/types.d.ts +8 -0
  99. package/dist/types/tools/ask-answer-registry.d.ts +5 -0
  100. package/dist/types/tools/ask.d.ts +25 -1
  101. package/dist/types/tools/bash.d.ts +1 -1
  102. package/dist/types/tools/browser/tab-supervisor.d.ts +6 -0
  103. package/dist/types/tools/index.d.ts +58 -6
  104. package/dist/types/tools/telegram-send.d.ts +7 -0
  105. package/dist/types/utils/external-editor.d.ts +2 -0
  106. package/package.json +9 -9
  107. package/scripts/g004-tmux-smoke.ts +196 -36
  108. package/src/cli/daemon-cli.ts +19 -29
  109. package/src/cli/malloc-env-guard.test.ts +104 -0
  110. package/src/cli/malloc-env-guard.ts +149 -0
  111. package/src/cli/notify-cli.ts +78 -14
  112. package/src/cli/session-picker.ts +23 -40
  113. package/src/cli/update-cli.ts +56 -37
  114. package/src/cli.ts +51 -10
  115. package/src/commands/daemon.ts +19 -5
  116. package/src/commands/harness.ts +515 -47
  117. package/src/commands/launch.ts +11 -3
  118. package/src/commands/notify.ts +5 -1
  119. package/src/commands/session.ts +6 -1
  120. package/src/config/keybindings.ts +5 -0
  121. package/src/config/model-profile-activation.ts +1 -1
  122. package/src/config/model-profiles.ts +14 -14
  123. package/src/config/settings-schema.ts +41 -1
  124. package/src/config/settings.ts +96 -45
  125. package/src/coordinator/contract.ts +1 -0
  126. package/src/coordinator-mcp/model-preset.ts +100 -0
  127. package/src/coordinator-mcp/policy.ts +30 -0
  128. package/src/coordinator-mcp/server.ts +2881 -425
  129. package/src/coordinator-mcp/session-reaper.ts +129 -0
  130. package/src/daemon/control-types.ts +11 -0
  131. package/src/daemon/operator-contract.ts +121 -0
  132. package/src/deep-interview/render-middleware.ts +67 -2
  133. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +57 -17
  134. package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +15 -2
  135. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +5 -1
  136. package/src/extensibility/extensions/runner.ts +1 -1
  137. package/src/extensibility/extensions/types.ts +3 -1
  138. package/src/gjc-runtime/launch-tmux.ts +702 -135
  139. package/src/gjc-runtime/session-state-sidecar.ts +756 -135
  140. package/src/gjc-runtime/team-runtime.ts +6 -0
  141. package/src/gjc-runtime/tmux-common.ts +26 -7
  142. package/src/gjc-runtime/tmux-gc.ts +52 -9
  143. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +185 -0
  144. package/src/gjc-runtime/tmux-owner-isolation.ts +2060 -0
  145. package/src/gjc-runtime/tmux-sessions.ts +871 -68
  146. package/src/internal-urls/docs-index.generated.ts +12 -11
  147. package/src/internal-urls/local-protocol.ts +29 -10
  148. package/src/main.ts +301 -80
  149. package/src/modes/acp/acp-agent.ts +730 -100
  150. package/src/modes/acp/acp-mode.ts +29 -2
  151. package/src/modes/components/assistant-message.ts +32 -3
  152. package/src/modes/components/bash-execution.ts +43 -13
  153. package/src/modes/components/custom-editor.ts +5 -0
  154. package/src/modes/components/footer.ts +3 -3
  155. package/src/modes/components/gajae-pet-widget.ts +360 -0
  156. package/src/modes/components/hook-selector.ts +12 -0
  157. package/src/modes/components/irc-sidebar.ts +206 -0
  158. package/src/modes/components/pet-selector.ts +55 -0
  159. package/src/modes/components/session-selector.ts +222 -221
  160. package/src/modes/components/settings-selector.ts +10 -0
  161. package/src/modes/components/status-line/segments.ts +13 -6
  162. package/src/modes/components/status-line/types.ts +1 -1
  163. package/src/modes/components/status-line.ts +4 -171
  164. package/src/modes/components/tool-execution.ts +47 -6
  165. package/src/modes/components/user-message.ts +26 -9
  166. package/src/modes/controllers/command-controller.ts +28 -2
  167. package/src/modes/controllers/event-controller.ts +159 -36
  168. package/src/modes/controllers/extension-ui-controller.ts +37 -18
  169. package/src/modes/controllers/input-controller.ts +8 -2
  170. package/src/modes/controllers/selector-controller.ts +62 -15
  171. package/src/modes/interactive-mode.ts +199 -7
  172. package/src/modes/irc-observation-ledger.ts +127 -0
  173. package/src/modes/print-mode.ts +228 -92
  174. package/src/modes/rpc/rpc-client.ts +58 -1
  175. package/src/modes/rpc/rpc-mode.ts +286 -85
  176. package/src/modes/rpc/rpc-types.ts +15 -1
  177. package/src/modes/shared/agent-wire/command-dispatch.ts +33 -1
  178. package/src/modes/shared/agent-wire/command-validation.ts +10 -0
  179. package/src/modes/shared/agent-wire/deep-interview-gate.ts +45 -2
  180. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  181. package/src/modes/shared/agent-wire/unattended-session.ts +288 -1
  182. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +130 -20
  183. package/src/modes/types.ts +34 -2
  184. package/src/modes/utils/context-usage.ts +56 -92
  185. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  186. package/src/modes/utils/irc-message.ts +138 -0
  187. package/src/modes/utils/ui-helpers.ts +216 -50
  188. package/src/notifications/config.ts +44 -0
  189. package/src/notifications/daemon-paths.ts +8 -0
  190. package/src/notifications/index.ts +535 -75
  191. package/src/notifications/lifecycle-control-runtime.ts +930 -77
  192. package/src/notifications/notification-service.ts +710 -0
  193. package/src/notifications/rate-limit-pool.ts +46 -12
  194. package/src/notifications/telegram-daemon-cli.ts +2 -0
  195. package/src/notifications/telegram-daemon-control.ts +27 -2
  196. package/src/notifications/telegram-daemon.ts +635 -94
  197. package/src/notifications/telegram-reference.ts +73 -13
  198. package/src/notifications/topic-registry.ts +86 -9
  199. package/src/prompts/system/irc-peer-roster.md +1 -0
  200. package/src/prompts/system/subagent-system-prompt.md +1 -0
  201. package/src/prompts/system/system-prompt.md +1 -0
  202. package/src/prompts/tools/irc.md +1 -1
  203. package/src/registry/agent-registry.ts +3 -0
  204. package/src/sdk.ts +52 -1
  205. package/src/secrets/obfuscator.ts +4 -2
  206. package/src/session/agent-session.ts +670 -217
  207. package/src/session/cache-economics.ts +275 -0
  208. package/src/session/context-estimation.ts +91 -0
  209. package/src/session/provider-safety-stop.ts +9 -0
  210. package/src/session/session-dump-format.ts +6 -0
  211. package/src/session/session-manager.ts +809 -39
  212. package/src/session/session-storage.ts +582 -38
  213. package/src/slash-commands/builtin-registry.ts +153 -9
  214. package/src/slash-commands/helpers/context-report.ts +26 -4
  215. package/src/task/executor.ts +12 -3
  216. package/src/task/index.ts +8 -0
  217. package/src/task/receipt.ts +7 -2
  218. package/src/task/types.ts +26 -0
  219. package/src/tools/ask-answer-registry.ts +25 -0
  220. package/src/tools/ask.ts +355 -33
  221. package/src/tools/browser/tab-supervisor.ts +63 -6
  222. package/src/tools/browser.ts +8 -3
  223. package/src/tools/index.ts +81 -6
  224. package/src/tools/sqlite-reader.ts +8 -4
  225. package/src/tui/output-block.ts +18 -6
  226. package/src/utils/external-editor.ts +6 -1
@@ -2,7 +2,9 @@ import { randomUUID } from "node:crypto";
2
2
  import * as nodeFs from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
5
+ import { isKnownSinkPeerClosedError } from "@gajae-code/utils";
5
6
  import { VERSION } from "@gajae-code/utils/dirs";
7
+
6
8
  import {
7
9
  COORDINATOR_MCP_PROTOCOL_VERSION,
8
10
  COORDINATOR_MCP_SERVER_NAME,
@@ -11,8 +13,31 @@ import {
11
13
  } from "../coordinator/contract";
12
14
  import {
13
15
  GJC_COORDINATOR_SESSION_ID_ENV,
16
+ GJC_COORDINATOR_SESSION_LAUNCH_ID_ENV,
17
+ GJC_COORDINATOR_SESSION_READINESS_FILE_ENV,
14
18
  GJC_COORDINATOR_SESSION_STATE_FILE_ENV,
19
+ GJC_TMUX_OWNER_GENERATION_ENV,
20
+ GJC_TMUX_OWNER_SERVER_KEY_ENV,
21
+ GJC_TMUX_OWNER_STATE_DIR_ENV,
15
22
  } from "../gjc-runtime/session-state-sidecar";
23
+
24
+ import {
25
+ captureOwnerGenerationBaselineSync,
26
+ classifyCgroup,
27
+ isExactScopedBootstrapSuccessReceipt,
28
+ type OwnerIsolationProbe,
29
+ ownerProcessStartTime,
30
+ planTmuxOwnerIsolation,
31
+ replaceOwnerGenerationSync,
32
+ type TmuxServerProof,
33
+ } from "../gjc-runtime/tmux-owner-isolation";
34
+ import { forceCloseGjcTmuxSession } from "../gjc-runtime/tmux-sessions";
35
+ import {
36
+ type CoordinatorModelProfileLoader,
37
+ loadCoordinatorModelProfiles,
38
+ resolveCoordinatorMpreset,
39
+ } from "./model-preset";
40
+
16
41
  import {
17
42
  assertCoordinatorArtifactPath,
18
43
  assertCoordinatorWorkdir,
@@ -21,6 +46,7 @@ import {
21
46
  coordinatorNamespacePath,
22
47
  requireCoordinatorMutation,
23
48
  } from "./policy";
49
+ import { createSessionReaper, type ReapableSession, type SessionReaper } from "./session-reaper";
24
50
 
25
51
  export type { CoordinatorToolName };
26
52
  export { COORDINATOR_MCP_PROTOCOL_VERSION, COORDINATOR_MCP_SERVER_NAME, COORDINATOR_MCP_TOOL_NAMES };
@@ -41,11 +67,28 @@ interface JsonRpcResponse {
41
67
  error?: { code: number; message: string; data?: unknown };
42
68
  }
43
69
 
70
+ function sinkErrorCode(error: unknown): string | undefined {
71
+ if (error === null || (typeof error !== "object" && typeof error !== "function")) return undefined;
72
+ try {
73
+ const code = Reflect.get(error, "code");
74
+ return typeof code === "string" ? code : undefined;
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ }
79
+
44
80
  interface SessionStartInput {
45
81
  cwd: string;
46
82
  prompt?: string;
83
+ mpreset?: string | null;
47
84
  namespace: { profile: string | null; repo: string | null };
48
85
  worktree: true;
86
+ sessionId: string;
87
+ launchId: string;
88
+ /** Canonical, coordinator-owned immutable readiness marker path. */
89
+ readinessMarkerFile: string;
90
+ /** @deprecated Compatibility alias for injected launch providers. */
91
+ readinessFile: string;
49
92
  }
50
93
 
51
94
  interface SessionRegisterInput {
@@ -79,10 +122,26 @@ interface RuntimeSessionStatePayload extends CoordinatorSessionState {
79
122
  error?: { code: string; message: string; recoverable: boolean } | null;
80
123
  }
81
124
 
125
+ interface CommandResult {
126
+ exitCode: number;
127
+ stdout: string;
128
+ stderr: string;
129
+ }
130
+
131
+ type CommandRunner = (command: string[], stdinLine?: string) => Promise<CommandResult>;
132
+
82
133
  interface CoordinatorServices {
83
134
  listSessions?: () => unknown[] | Promise<unknown[]>;
84
135
  startSession?: (input: SessionStartInput) => unknown | Promise<unknown>;
85
- commandRunner?: (command: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
136
+ commandRunner?: CommandRunner;
137
+ forceCloseSession?: (
138
+ sessionName: string,
139
+ env: NodeJS.ProcessEnv,
140
+ expectedSessionId?: string,
141
+ expectedStateFile?: string,
142
+ ) => Promise<unknown>;
143
+ ownerIsolationProbe?: OwnerIsolationProbe;
144
+ resolveModelProfiles?: CoordinatorModelProfileLoader;
86
145
  }
87
146
 
88
147
  interface CoordinatorMcpServerOptions {
@@ -92,7 +151,7 @@ interface CoordinatorMcpServerOptions {
92
151
 
93
152
  interface LegacyHandlerOptions {
94
153
  env?: NodeJS.ProcessEnv;
95
- createSession?: () => unknown;
154
+ createSession?: (input: SessionStartInput) => unknown;
96
155
  }
97
156
 
98
157
  type TurnStatus =
@@ -112,16 +171,28 @@ interface TurnRecord {
112
171
  session_id: string;
113
172
  namespace: { profile: string | null; repo: string | null };
114
173
  status: TurnStatus;
115
- prompt: { text: string; created_at: string; source: "mcp" | "question_answer" };
174
+ prompt: {
175
+ text: string;
176
+ created_at: string;
177
+ source: "mcp" | "question_answer";
178
+ };
116
179
  delivery: {
117
180
  delivered: boolean;
118
181
  queued: boolean;
119
182
  target: string | null;
120
183
  tmux_keys_sent?: boolean;
121
184
  prompt_acknowledged?: boolean;
122
- state?: "queued" | "tmux_keys_sent" | "acknowledged" | "unavailable" | "unacknowledged";
185
+ state?:
186
+ | "queued"
187
+ | "awaiting_runtime_readiness"
188
+ | "delivering"
189
+ | "tmux_keys_sent"
190
+ | "acknowledged"
191
+ | "unavailable"
192
+ | "unacknowledged";
123
193
  attempts: Array<{
124
194
  delivered: boolean;
195
+ attempt_id?: string;
125
196
  created_at: string;
126
197
  reason: string | null;
127
198
  channel?: "tmux_keys" | "runtime_ack";
@@ -138,7 +209,11 @@ interface TurnRecord {
138
209
  };
139
210
  evidence: Array<Record<string, unknown>>;
140
211
  error: { code: string; message: string; recoverable: boolean } | null;
141
- liveness: { checked_at: string | null; live: boolean | null; reason: string | null };
212
+ liveness: {
213
+ checked_at: string | null;
214
+ live: boolean | null;
215
+ reason: string | null;
216
+ };
142
217
  created_at: string;
143
218
  updated_at: string;
144
219
  started_at: string | null;
@@ -163,14 +238,28 @@ interface CoordinatorSessionState {
163
238
  current_turn_id: string | null;
164
239
  last_turn_id: string | null;
165
240
  updated_at: string;
166
- source: "coordinator" | "agent_session_event";
241
+ source: "coordinator" | "agent_session_event" | "process_postmortem";
242
+
167
243
  live: boolean | null;
168
244
  reason: string | null;
245
+ cwd?: string;
246
+ workdir?: string;
247
+ session_file?: string | null;
248
+ owner_generation?: string;
249
+ event?: string;
250
+ owner_terminal?: {
251
+ generation: string;
252
+ socket_key: string;
253
+ classification: string;
254
+ observer: string;
255
+ observed_at: string;
256
+ };
169
257
  }
170
258
 
171
259
  type CoordinatorEventKind =
172
260
  | "session.registered"
173
261
  | "session.started"
262
+ | "session.reaped"
174
263
  | "session.state_changed"
175
264
  | "turn.queued"
176
265
  | "turn.delivering"
@@ -218,6 +307,10 @@ const MISSING_FINAL_RESPONSE_ADVISORY = "completion_missing_final_response";
218
307
  const PROMPT_ACK_TIMEOUT_REASON = "runtime_prompt_ack_timeout";
219
308
  const DEFAULT_RUNTIME_PROMPT_ACK_TIMEOUT_MS = 10_000;
220
309
  const MAX_RUNTIME_PROMPT_ACK_TIMEOUT_MS = 5 * 60 * 1000;
310
+ const RUNTIME_READINESS_TIMEOUT_REASON = "runtime_readiness_timeout";
311
+ const DEFAULT_RUNTIME_READINESS_TIMEOUT_MS = 10_000;
312
+ const MAX_RUNTIME_READINESS_TIMEOUT_MS = 5 * 60 * 1000;
313
+
221
314
  const ACTIVE_TURN_STATUSES = new Set<TurnStatus>(["delivering", "active", "waiting_for_answer", "completing"]);
222
315
  const TERMINAL_TURN_STATUSES = new Set<TurnStatus>(["completed", "failed", "cancelled", "superseded"]);
223
316
  const TURN_ID_PATTERN = /^turn-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -233,7 +326,12 @@ function textResult(
233
326
  isError = false,
234
327
  ): { content: Array<{ type: "text"; text: string }>; isError: boolean } {
235
328
  return {
236
- content: [{ type: "text", text: typeof payload === "string" ? payload : JSON.stringify(payload) }],
329
+ content: [
330
+ {
331
+ type: "text",
332
+ text: typeof payload === "string" ? payload : JSON.stringify(payload),
333
+ },
334
+ ],
237
335
  isError,
238
336
  };
239
337
  }
@@ -243,13 +341,27 @@ function toolSchema(name: CoordinatorToolName): {
243
341
  description: string;
244
342
  inputSchema: Record<string, unknown>;
245
343
  } {
246
- const allowMutation = { type: "boolean", description: "Required and must be true for mutating tools." };
344
+ const allowMutation = {
345
+ type: "boolean",
346
+ description: "Required and must be true for mutating tools.",
347
+ };
247
348
  const cwd = {
248
349
  type: "string",
249
350
  description: "Canonicalized GJC worktree or project directory inside configured roots.",
250
351
  };
251
- const sessionId = { type: "string", description: "GJC coordinator bridge session id." };
252
- const pathField = { type: "string", description: "Artifact path inside configured safe roots." };
352
+ const sessionId = {
353
+ type: "string",
354
+ description: "GJC coordinator bridge session id.",
355
+ };
356
+ const pathField = {
357
+ type: "string",
358
+ description: "Artifact path inside configured safe roots.",
359
+ };
360
+ const mpreset = {
361
+ type: "string",
362
+ description:
363
+ "Optional GJC model profile (`gjc --mpreset <profile>`) to authoritatively activate for a fresh session; resolved through the merged built-in/custom profile registry and applied from the first turn. Unknown names are rejected with the available-profile listing.",
364
+ };
253
365
  const common = { type: "object", properties: {} as Record<string, unknown> };
254
366
  if (name === "gjc_coordinator_register_session") {
255
367
  return {
@@ -278,11 +390,36 @@ function toolSchema(name: CoordinatorToolName): {
278
390
  description: "Start a GJC worktree/tmux oriented session through the coordinator bridge.",
279
391
  inputSchema: {
280
392
  type: "object",
281
- properties: { cwd, prompt: { type: "string" }, allow_mutation: allowMutation },
393
+ properties: {
394
+ cwd,
395
+ prompt: { type: "string" },
396
+ mpreset,
397
+ allow_mutation: allowMutation,
398
+ },
282
399
  required: ["cwd", "allow_mutation"],
283
400
  },
284
401
  };
285
402
  }
403
+ if (name === "gjc_coordinator_stop_session") {
404
+ return {
405
+ name,
406
+ description:
407
+ "Reap a coordinator delegate-created (ephemeral) session: terminate its owner via the owner-proof forceCloseGjcTmuxSession, then purge its state files only on verified termination. Never touches non-ephemeral (user-registered) sessions unless force is set AND the force-stop capability is enabled.",
408
+ inputSchema: {
409
+ type: "object",
410
+ properties: {
411
+ session_id: sessionId,
412
+ force: {
413
+ type: "boolean",
414
+ description: "Reap a non-ephemeral session; requires the GJC_COORDINATOR_MCP_FORCE_STOP capability.",
415
+ },
416
+ reason: { type: "string", description: "Optional audit reason recorded on the session.reaped event." },
417
+ allow_mutation: allowMutation,
418
+ },
419
+ required: ["session_id", "allow_mutation"],
420
+ },
421
+ };
422
+ }
286
423
  if (name === "gjc_coordinator_send_prompt") {
287
424
  return {
288
425
  name,
@@ -307,7 +444,11 @@ function toolSchema(name: CoordinatorToolName): {
307
444
  description: "Read authoritative durable turn state plus bounded advisory tmux status.",
308
445
  inputSchema: {
309
446
  type: "object",
310
- properties: { session_id: sessionId, turn_id: { type: "string" }, lines: { type: "number" } },
447
+ properties: {
448
+ session_id: sessionId,
449
+ turn_id: { type: "string" },
450
+ lines: { type: "number" },
451
+ },
311
452
  required: ["turn_id"],
312
453
  },
313
454
  };
@@ -376,7 +517,11 @@ function toolSchema(name: CoordinatorToolName): {
376
517
  return {
377
518
  name,
378
519
  description: "Read one bounded artifact from configured safe roots.",
379
- inputSchema: { type: "object", properties: { path: pathField }, required: ["path"] },
520
+ inputSchema: {
521
+ type: "object",
522
+ properties: { path: pathField },
523
+ required: ["path"],
524
+ },
380
525
  };
381
526
  }
382
527
  if (name === "gjc_coordinator_read_status") {
@@ -390,21 +535,35 @@ function toolSchema(name: CoordinatorToolName): {
390
535
  return {
391
536
  name,
392
537
  description: "Read a bounded structured session tail, not tmux scrollback.",
393
- inputSchema: { type: "object", properties: { session_id: sessionId, lines: { type: "number" } } },
538
+ inputSchema: {
539
+ type: "object",
540
+ properties: { session_id: sessionId, lines: { type: "number" } },
541
+ },
394
542
  };
395
543
  }
396
544
  if (name === "gjc_coordinator_list_questions") {
397
545
  return {
398
546
  name,
399
547
  description: "List bounded structured questions for coordinator coordination.",
400
- inputSchema: { type: "object", properties: { session_id: sessionId, status: { type: "string" } } },
548
+ inputSchema: {
549
+ type: "object",
550
+ properties: { session_id: sessionId, status: { type: "string" } },
551
+ },
401
552
  };
402
553
  }
403
554
  if (name === "gjc_coordinator_list_artifacts") {
404
- return { name, description: "List known safe artifact roots for coordinator coordination.", inputSchema: common };
555
+ return {
556
+ name,
557
+ description: "List known safe artifact roots for coordinator coordination.",
558
+ inputSchema: common,
559
+ };
405
560
  }
406
561
  if (name === "gjc_coordinator_read_coordination_status") {
407
- return { name, description: "Read coordinator coordination reports.", inputSchema: common };
562
+ return {
563
+ name,
564
+ description: "Read coordinator coordination reports.",
565
+ inputSchema: common,
566
+ };
408
567
  }
409
568
  if (name === "gjc_coordinator_watch_events") {
410
569
  return {
@@ -438,7 +597,10 @@ function toolSchema(name: CoordinatorToolName): {
438
597
  type: "string",
439
598
  description: "Delegated task or objective to run through the selected GJC workflow.",
440
599
  },
441
- prompt: { type: "string", description: "Alias for task; accepted when task is absent." },
600
+ prompt: {
601
+ type: "string",
602
+ description: "Alias for task; accepted when task is absent.",
603
+ },
442
604
  allow_mutation: allowMutation,
443
605
  session_id: {
444
606
  type: "string",
@@ -453,24 +615,38 @@ function toolSchema(name: CoordinatorToolName): {
453
615
  type: "boolean",
454
616
  description: "When reusing a session with an active turn, supersede it before sending.",
455
617
  },
618
+ mpreset,
456
619
  model: {
457
620
  type: "string",
458
621
  description: "Optional model hint passed in prompt metadata; no provider default is implied.",
459
622
  },
460
- await_completion: { type: "boolean", description: "If true, poll the turn until terminal or timeout." },
623
+ await_completion: {
624
+ type: "boolean",
625
+ description: "If true, poll the turn until terminal or timeout.",
626
+ },
461
627
  timeout_ms: {
462
628
  type: "number",
463
629
  description:
464
630
  "Bounded await timeout in milliseconds, capped at 30 minutes like gjc_coordinator_await_turn.",
465
631
  },
466
- poll_interval_ms: { type: "number", description: "Bounded await polling interval." },
467
- lines: { type: "number", description: "Bounded advisory tail lines returned with await/read payloads." },
632
+ poll_interval_ms: {
633
+ type: "number",
634
+ description: "Bounded await polling interval.",
635
+ },
636
+ lines: {
637
+ type: "number",
638
+ description: "Bounded advisory tail lines returned with await/read payloads.",
639
+ },
468
640
  },
469
641
  required: ["cwd", "allow_mutation"],
470
642
  },
471
643
  };
472
644
  }
473
- return { name, description: "List known scoped GJC coordinator bridge sessions.", inputSchema: common };
645
+ return {
646
+ name,
647
+ description: "List known scoped GJC coordinator bridge sessions.",
648
+ inputSchema: common,
649
+ };
474
650
  }
475
651
 
476
652
  type DelegateWorkflow = "plan" | "execute" | "team";
@@ -536,13 +712,80 @@ function workflowPrompt(
536
712
  ].join("\n");
537
713
  }
538
714
 
715
+ const PRIVATE_SESSION_CONTROL_FIELDS = new Set([
716
+ "tmux_socket_key",
717
+ "tmuxSocketKey",
718
+ "tmux_owner_generation",
719
+ "tmuxOwnerGeneration",
720
+ "tmux_owner_state_dir",
721
+ "tmuxOwnerStateDir",
722
+ "tmux_owner_server_key",
723
+ "tmuxOwnerServerKey",
724
+ "tmux_owner_server_pid",
725
+ "tmuxOwnerServerPid",
726
+ "tmux_owner_server_start_time",
727
+ "tmuxOwnerServerStartTime",
728
+ "tmux_native_session_id",
729
+ "tmuxNativeSessionId",
730
+ "pane_id",
731
+ "paneId",
732
+ "socket_key",
733
+ "socketKey",
734
+ "owner_generation",
735
+ "ownerGeneration",
736
+ "state_dir",
737
+ "ownerStateDir",
738
+ "owner_server_key",
739
+ "ownerServerKey",
740
+ "owner_server_pid",
741
+ "ownerServerPid",
742
+ "owner_server_start_time",
743
+ "ownerServerStartTime",
744
+ "owner_terminal",
745
+ "ownerTerminal",
746
+ "generation",
747
+ "server_key",
748
+ "intent_id",
749
+ "dedupe_key",
750
+ "__coordinatorOwnerTransaction",
751
+ ]);
752
+
753
+ function publicSessionProjection(session: Record<string, unknown>): Record<string, unknown> {
754
+ return Object.fromEntries(Object.entries(session).filter(([key]) => !PRIVATE_SESSION_CONTROL_FIELDS.has(key)));
755
+ }
756
+
757
+ function publicCoordinatorResponse(value: unknown): unknown {
758
+ if (Array.isArray(value)) return value.map(publicCoordinatorResponse);
759
+ const record = asRecord(value);
760
+ if (!record) return value;
761
+ return Object.fromEntries(
762
+ Object.entries(publicSessionProjection(record)).map(([key, item]) => [key, publicCoordinatorResponse(item)]),
763
+ );
764
+ }
765
+
539
766
  function normalizeSession(session: Record<string, unknown>): Record<string, unknown> {
767
+ const suppliedIds = [session.session_id, session.sessionId, session.name].filter(
768
+ (value): value is string => typeof value === "string",
769
+ );
770
+ if (suppliedIds.length === 0) throw new Error("invalid_session_id");
771
+ const sessionId = safeExternalId("session", suppliedIds[0]);
772
+ if (suppliedIds.some(value => value !== sessionId)) throw new Error("session_id_conflict");
540
773
  return {
541
- session_id: session.sessionId ?? session.session_id ?? session.name ?? "unknown",
542
774
  ...(session.tmuxSession ? { tmux_session: session.tmuxSession } : {}),
775
+ ...(session.tmuxTarget ? { tmux_target: session.tmuxTarget } : {}),
776
+ ...(session.tmuxSocketKey ? { tmux_socket_key: session.tmuxSocketKey } : {}),
777
+ ...(session.tmuxOwnerGeneration ? { tmux_owner_generation: session.tmuxOwnerGeneration } : {}),
778
+ ...(session.tmuxOwnerStateDir ? { tmux_owner_state_dir: session.tmuxOwnerStateDir } : {}),
779
+ ...(session.tmuxOwnerServerKey ? { tmux_owner_server_key: session.tmuxOwnerServerKey } : {}),
780
+ ...(session.tmuxOwnerServerPid ? { tmux_owner_server_pid: session.tmuxOwnerServerPid } : {}),
781
+ ...(session.tmuxOwnerServerStartTime ? { tmux_owner_server_start_time: session.tmuxOwnerServerStartTime } : {}),
782
+ ...(session.tmuxNativeSessionId ? { tmux_native_session_id: session.tmuxNativeSessionId } : {}),
783
+ ...(session.paneId ? { pane_id: session.paneId } : {}),
543
784
  ...(session.cwd ? { cwd: session.cwd } : {}),
544
785
  ...(session.createdAt ? { created_at: session.createdAt } : {}),
545
786
  ...session,
787
+ session_id: sessionId,
788
+ sessionId,
546
789
  };
547
790
  }
548
791
 
@@ -554,15 +797,380 @@ async function canonicalizePath(value: string): Promise<string> {
554
797
  }
555
798
  }
556
799
 
800
+ interface RuntimeReadinessMarker {
801
+ schema_version: 1;
802
+ session_id: string;
803
+ launch_id: string;
804
+ state: "ready_for_input";
805
+ event: "interactive_input_ready";
806
+ source: "gjc_interactive_runtime";
807
+ ready_for_input: true;
808
+ created_at: string;
809
+ }
810
+
811
+ async function readRuntimeReadinessMarker(
812
+ markerFile: string,
813
+ sessionId: string,
814
+ launchId: string,
815
+ readinessRoot?: string,
816
+ ): Promise<RuntimeReadinessMarker | null> {
817
+ try {
818
+ const expectedFile = path.resolve(markerFile);
819
+ if (readinessRoot) {
820
+ const canonicalRoot = await fs.realpath(readinessRoot);
821
+ const canonicalParent = await fs.realpath(path.dirname(expectedFile));
822
+ if (
823
+ !expectedFile.startsWith(`${canonicalRoot}${path.sep}`) ||
824
+ !canonicalParent.startsWith(`${canonicalRoot}${path.sep}`)
825
+ )
826
+ return null;
827
+ }
828
+ const stat = await fs.lstat(expectedFile);
829
+ if (!stat.isFile() || stat.isSymbolicLink()) return null;
830
+ const marker = JSON.parse(await fs.readFile(expectedFile, "utf8")) as RuntimeReadinessMarker;
831
+ return marker.schema_version === 1 &&
832
+ marker.session_id === sessionId &&
833
+ marker.launch_id === launchId &&
834
+ marker.state === "ready_for_input" &&
835
+ marker.event === "interactive_input_ready" &&
836
+ marker.source === "gjc_interactive_runtime" &&
837
+ marker.ready_for_input === true &&
838
+ typeof marker.created_at === "string" &&
839
+ marker.created_at.length > 0 &&
840
+ Number.isFinite(Date.parse(marker.created_at))
841
+ ? marker
842
+ : null;
843
+ } catch {
844
+ return null;
845
+ }
846
+ }
847
+
848
+ async function waitForRuntimeReadinessMarker(
849
+ markerFile: string,
850
+ sessionId: string,
851
+ launchId: string,
852
+ readinessRoot: string,
853
+ timeoutMs: number,
854
+ ): Promise<boolean> {
855
+ const ready = async (): Promise<boolean> =>
856
+ (await readRuntimeReadinessMarker(markerFile, sessionId, launchId, readinessRoot)) !== null;
857
+ if (await ready()) return true;
858
+
859
+ const markerDir = path.dirname(markerFile);
860
+ await ensureDir(markerDir);
861
+ const deadline = Date.now() + Math.max(timeoutMs, 0);
862
+ let wake = Promise.withResolvers<void>();
863
+ let watcher: nodeFs.FSWatcher | null = null;
864
+ try {
865
+ watcher = nodeFs.watch(markerDir, (_eventType, filename) => {
866
+ if (filename === path.basename(markerFile)) wake.resolve();
867
+ });
868
+ // A marker may be installed between the first read and watch creation.
869
+ if (await ready()) return true;
870
+ while (Date.now() < deadline) {
871
+ const remainingMs = Math.max(1, deadline - Date.now());
872
+ let timer: NodeJS.Timeout | null = null;
873
+ const fallback = new Promise<void>(resolve => {
874
+ timer = setTimeout(resolve, Math.min(50, remainingMs));
875
+ timer.unref?.();
876
+ });
877
+ await Promise.race([wake.promise, fallback]);
878
+ if (timer) clearTimeout(timer);
879
+ wake = Promise.withResolvers<void>();
880
+ if (await ready()) return true;
881
+ }
882
+ return false;
883
+ } finally {
884
+ watcher?.close();
885
+ }
886
+ }
887
+
557
888
  async function ensureDir(dir: string): Promise<void> {
558
889
  await fs.mkdir(dir, { recursive: true });
559
890
  }
560
891
 
892
+ class CoordinatorStateError extends Error {
893
+ constructor(readonly reason: "invalid" | "unreadable") {
894
+ super(`coordinator_state_${reason}`);
895
+ }
896
+ }
897
+
898
+ const COORDINATOR_SESSION_STATES = new Set<CoordinatorSessionStateValue>([
899
+ "booting",
900
+ "ready_for_input",
901
+ "running",
902
+ "needs_user_input",
903
+ "completed",
904
+ "errored",
905
+ "stale",
906
+ "unknown",
907
+ ]);
908
+ const COORDINATOR_EVENT_KINDS = new Set<CoordinatorEventKind>([
909
+ "session.registered",
910
+ "session.started",
911
+ "session.reaped",
912
+ "session.state_changed",
913
+ "turn.queued",
914
+ "turn.delivering",
915
+ "turn.active",
916
+ "turn.acknowledged",
917
+ "turn.waiting_for_answer",
918
+ "turn.completed",
919
+ "turn.failed",
920
+ "turn.cancelled",
921
+ "turn.superseded",
922
+ "question.opened",
923
+ "question.answered",
924
+ "report.written",
925
+ "tmux.delivery_succeeded",
926
+ "tmux.delivery_failed",
927
+ "delegation.started",
928
+ ]);
929
+
930
+ function coordinatorStateError(reason: "invalid" | "unreadable"): CoordinatorStateError {
931
+ return new CoordinatorStateError(reason);
932
+ }
933
+
934
+ function isValidCoordinatorSessionState(value: unknown, sessionId: string): value is CoordinatorSessionState {
935
+ const record = asRecord(value);
936
+ return (
937
+ record?.schema_version === 1 &&
938
+ record.session_id === sessionId &&
939
+ typeof record.state === "string" &&
940
+ COORDINATOR_SESSION_STATES.has(record.state as CoordinatorSessionStateValue) &&
941
+ typeof record.ready_for_input === "boolean" &&
942
+ record.ready_for_input === (record.state === "ready_for_input" || record.state === "completed") &&
943
+ (record.current_turn_id === null ||
944
+ (typeof record.current_turn_id === "string" && TURN_ID_PATTERN.test(record.current_turn_id))) &&
945
+ (record.last_turn_id === null ||
946
+ (typeof record.last_turn_id === "string" && TURN_ID_PATTERN.test(record.last_turn_id))) &&
947
+ typeof record.updated_at === "string" &&
948
+ Number.isFinite(Date.parse(record.updated_at)) &&
949
+ (record.source === "coordinator" ||
950
+ record.source === "agent_session_event" ||
951
+ record.source === "process_postmortem") &&
952
+ (record.live === null || typeof record.live === "boolean") &&
953
+ (record.reason === null || typeof record.reason === "string")
954
+ );
955
+ }
956
+
957
+ const MAX_RUNTIME_FINAL_RESPONSE_TEXT_LENGTH = 16_384;
958
+ const MAX_RUNTIME_FINAL_RESPONSE_SOURCE_LENGTH = 256;
959
+ const MAX_RUNTIME_FINAL_RESPONSE_ARTIFACT_PATH_LENGTH = 1_024;
960
+ const MAX_RUNTIME_ERROR_CODE_LENGTH = 128;
961
+ const MAX_RUNTIME_ERROR_MESSAGE_LENGTH = 4_096;
962
+
963
+ function isBoundedNullableString(value: unknown, maxLength: number): value is string | null {
964
+ return value === null || (typeof value === "string" && value.length <= maxLength);
965
+ }
966
+
967
+ function isValidRuntimeFinalResponse(value: unknown): value is CoordinatorFinalResponse {
968
+ const record = asRecord(value);
969
+ return (
970
+ record !== null &&
971
+ Object.keys(record).every(key => ["text", "format", "source", "artifact_path", "truncated"].includes(key)) &&
972
+ isBoundedNullableString(record.text, MAX_RUNTIME_FINAL_RESPONSE_TEXT_LENGTH) &&
973
+ record.format === "markdown" &&
974
+ (record.source === null ||
975
+ (typeof record.source === "string" &&
976
+ record.source.length > 0 &&
977
+ record.source.length <= MAX_RUNTIME_FINAL_RESPONSE_SOURCE_LENGTH)) &&
978
+ (record.artifact_path === null ||
979
+ (typeof record.artifact_path === "string" &&
980
+ record.artifact_path.length > 0 &&
981
+ record.artifact_path.length <= MAX_RUNTIME_FINAL_RESPONSE_ARTIFACT_PATH_LENGTH &&
982
+ !path.isAbsolute(record.artifact_path) &&
983
+ !record.artifact_path.split(/[\\/]/).includes(".."))) &&
984
+ typeof record.truncated === "boolean"
985
+ );
986
+ }
987
+
988
+ function isValidRuntimeError(value: unknown): value is NonNullable<RuntimeSessionStatePayload["error"]> {
989
+ const record = asRecord(value);
990
+ return (
991
+ record !== null &&
992
+ Object.keys(record).every(key => ["code", "message", "recoverable"].includes(key)) &&
993
+ typeof record.code === "string" &&
994
+ record.code.length > 0 &&
995
+ record.code.length <= MAX_RUNTIME_ERROR_CODE_LENGTH &&
996
+ typeof record.message === "string" &&
997
+ record.message.length > 0 &&
998
+ record.message.length <= MAX_RUNTIME_ERROR_MESSAGE_LENGTH &&
999
+ typeof record.recoverable === "boolean"
1000
+ );
1001
+ }
1002
+
1003
+ function isValidOwnerTerminalRuntimeReceipt(value: unknown, sessionId: string): boolean {
1004
+ const record = asRecord(value);
1005
+ if (!record) return false;
1006
+ const baseKeys = [
1007
+ "generation",
1008
+ "socket_key",
1009
+ "signal",
1010
+ "result",
1011
+ "classification",
1012
+ "observer",
1013
+ "observed_at",
1014
+ "dedupe_key",
1015
+ ];
1016
+ const keys = Object.keys(record);
1017
+ if (!keys.every(key => [...baseKeys, "intent_id"].includes(key)) || baseKeys.some(key => !keys.includes(key)))
1018
+ return false;
1019
+ if (
1020
+ !validRuntimeOwnerGeneration(record.generation) ||
1021
+ !validRuntimeOwnerGeneration(record.socket_key) ||
1022
+ typeof record.signal !== "string" ||
1023
+ !["SIGTERM", "SIGHUP", "SIGINT", "SIGKILL", "EXIT", "MANUAL", "UNKNOWN"].includes(record.signal) ||
1024
+ typeof record.result !== "string" ||
1025
+ !record.result ||
1026
+ record.result.length > 64 ||
1027
+ !["expected_operator_shutdown", "unexpected_owner_loss", "non_operator_cleanup"].includes(
1028
+ String(record.classification),
1029
+ ) ||
1030
+ !["sidecar", "raw_monitor"].includes(String(record.observer)) ||
1031
+ typeof record.observed_at !== "string" ||
1032
+ !Number.isFinite(Date.parse(record.observed_at)) ||
1033
+ record.dedupe_key !== `owner-loss:${sessionId}:${record.generation}`
1034
+ )
1035
+ return false;
1036
+ if (record.classification === "expected_operator_shutdown")
1037
+ return (
1038
+ record.signal === "SIGTERM" &&
1039
+ record.result === "owner_term_then_session_cleanup" &&
1040
+ typeof record.intent_id === "string" &&
1041
+ record.intent_id.length > 0
1042
+ );
1043
+ if (record.intent_id !== undefined) return false;
1044
+ if (record.classification === "non_operator_cleanup") return record.result === "cleanup";
1045
+ return ["owner_lost", "cleanup", "process_postmortem", "exit", "unknown_terminal"].includes(record.result);
1046
+ }
1047
+
1048
+ function isValidRuntimeSessionState(value: unknown, sessionId: string): value is RuntimeSessionStatePayload {
1049
+ if (!isValidCoordinatorSessionState(value, sessionId)) return false;
1050
+ const record = asRecord(value);
1051
+ if (!record) return false;
1052
+ const finalResponse = record.final_response;
1053
+ const error = record.error;
1054
+ if (finalResponse !== undefined && !isValidRuntimeFinalResponse(finalResponse)) return false;
1055
+ if (record.source !== "coordinator" && record.live !== (record.state === "running")) return false;
1056
+ if (error !== undefined && error !== null && !isValidRuntimeError(error)) return false;
1057
+ if (record.owner_generation !== undefined && !validRuntimeOwnerGeneration(record.owner_generation)) return false;
1058
+ if (record.source === "coordinator") return finalResponse === undefined && (error === undefined || error === null);
1059
+ if (record.source === "process_postmortem") {
1060
+ if (record.state !== "completed" && record.state !== "errored") return false;
1061
+ if (record.event === "owner_terminal") {
1062
+ if (!isValidOwnerTerminalRuntimeReceipt(record.owner_terminal, sessionId)) return false;
1063
+ const ownerTerminal = asRecord(record.owner_terminal)!;
1064
+ const expected = ownerTerminal.classification === "expected_operator_shutdown";
1065
+ if (
1066
+ record.reason !== ownerTerminal.classification ||
1067
+ record.state !== (expected ? "completed" : "errored") ||
1068
+ (expected
1069
+ ? error !== undefined && error !== null
1070
+ : error === undefined ||
1071
+ error === null ||
1072
+ error.code !== ownerTerminal.classification ||
1073
+ error.recoverable !== true)
1074
+ )
1075
+ return false;
1076
+ } else if (record.event === "process_exit") {
1077
+ if (
1078
+ record.owner_terminal !== undefined ||
1079
+ typeof record.exit_kind !== "string" ||
1080
+ record.exit_kind.length === 0 ||
1081
+ record.exit_kind.length > 128 ||
1082
+ (record.exit_code !== null &&
1083
+ (typeof record.exit_code !== "number" || !Number.isSafeInteger(record.exit_code))) ||
1084
+ (record.signal !== null &&
1085
+ record.signal !== "SIGINT" &&
1086
+ record.signal !== "SIGTERM" &&
1087
+ record.signal !== "SIGHUP") ||
1088
+ typeof record.prompt_accepted !== "boolean" ||
1089
+ typeof record.observed_recoverable_worktree_changes !== "boolean" ||
1090
+ (record.worktree_baseline_dirty !== null && typeof record.worktree_baseline_dirty !== "boolean") ||
1091
+ typeof record.worktree_changed_since_baseline !== "boolean"
1092
+ )
1093
+ return false;
1094
+ } else {
1095
+ return false;
1096
+ }
1097
+ }
1098
+ if (record.state === "completed") return error === undefined || error === null;
1099
+ if (record.state === "errored") return error !== undefined && error !== null;
1100
+ return finalResponse === undefined && (error === undefined || error === null);
1101
+ }
1102
+
1103
+ const PUBLIC_COORDINATOR_ERRORS = new Set([
1104
+ "coordinator_workdir_required",
1105
+ "coordinator_workdir_roots_required",
1106
+ "coordinator_workdir_outside_allowed_roots",
1107
+ "coordinator_artifact_path_required",
1108
+ "coordinator_artifact_roots_required",
1109
+ "coordinator_artifact_outside_allowed_roots",
1110
+ "coordinator_mutation_class_disabled",
1111
+ "coordinator_mutation_call_not_allowed",
1112
+ "invalid_session_id",
1113
+ "invalid_tmux_session",
1114
+ "invalid_tmux_target",
1115
+ "invalid_turn_id",
1116
+ "invalid_question_id",
1117
+ "session_id_conflict",
1118
+ "tmux_session_unavailable",
1119
+ "tmux_target_unavailable",
1120
+ "coordinator_session_command_required",
1121
+ "coordinator_launch_attestation_missing",
1122
+ "coordinator_launch_attestation_mismatch",
1123
+ "coordinator_launch_metadata_missing",
1124
+ "coordinator_readiness_path_invalid",
1125
+ "session_origin_unverified",
1126
+ "coordinator_prompt_buffer_privacy_unverified",
1127
+ "coordinator_tmux_start_failed",
1128
+ "coordinator_tmux_owner_server_unsafe",
1129
+ "coordinator_tmux_owner_server_unverifiable",
1130
+ "coordinator_tmux_owner_server_race",
1131
+ "coordinator_evidence_paths_must_be_array",
1132
+ ]);
1133
+
1134
+ class CoordinatorStartError extends Error {
1135
+ constructor(
1136
+ message: string,
1137
+ readonly cleanupStatus: "cleaned" | "failed" | "unverifiable",
1138
+ ) {
1139
+ super(message);
1140
+ }
1141
+ }
1142
+
1143
+ function publicCleanupStatus(error: unknown): "cleaned" | "failed" | "unverifiable" | undefined {
1144
+ return error instanceof CoordinatorStartError ? error.cleanupStatus : undefined;
1145
+ }
1146
+
1147
+ function publicCoordinatorError(error: unknown): string {
1148
+ if (error instanceof CoordinatorStateError) return `coordinator_state_${error.reason}`;
1149
+ const message = error instanceof Error ? error.message : "";
1150
+ const normalized = message
1151
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
1152
+ .replace(/\s+/g, " ")
1153
+ .trim();
1154
+ const code = normalized.split(":", 1)[0];
1155
+ return PUBLIC_COORDINATOR_ERRORS.has(code) ? code : "coordinator_request_failed";
1156
+ }
1157
+
1158
+ function isExpectedStateAbsence(error: unknown): boolean {
1159
+ return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
1160
+ }
1161
+
561
1162
  async function readJsonFile(file: string): Promise<unknown | null> {
1163
+ let content: string;
1164
+ try {
1165
+ content = await fs.readFile(file, "utf8");
1166
+ } catch (error) {
1167
+ if (isExpectedStateAbsence(error)) return null;
1168
+ throw coordinatorStateError("unreadable");
1169
+ }
562
1170
  try {
563
- return JSON.parse(await fs.readFile(file, "utf8"));
1171
+ return JSON.parse(content);
564
1172
  } catch {
565
- return null;
1173
+ throw coordinatorStateError("invalid");
566
1174
  }
567
1175
  }
568
1176
 
@@ -572,15 +1180,16 @@ async function writeJsonFile(file: string, value: unknown): Promise<void> {
572
1180
  }
573
1181
 
574
1182
  async function listJsonFiles(dir: string): Promise<unknown[]> {
1183
+ let entries: string[];
575
1184
  try {
576
- const entries = await fs.readdir(dir);
577
- const values = await Promise.all(
578
- entries.filter(entry => entry.endsWith(".json")).map(entry => readJsonFile(path.join(dir, entry))),
579
- );
580
- return values.filter(value => value !== null);
581
- } catch {
582
- return [];
1185
+ entries = await fs.readdir(dir);
1186
+ } catch (error) {
1187
+ if (isExpectedStateAbsence(error)) return [];
1188
+ throw coordinatorStateError("unreadable");
583
1189
  }
1190
+ return await Promise.all(
1191
+ entries.filter(entry => entry.endsWith(".json")).map(entry => readJsonFile(path.join(dir, entry))),
1192
+ );
584
1193
  }
585
1194
 
586
1195
  const COORDINATOR_STATUS_EVENT_LIMIT = 100;
@@ -668,22 +1277,39 @@ function boundSummary(value: string): string {
668
1277
  }
669
1278
 
670
1279
  async function readLatestEventSeq(namespaceDir: string): Promise<number> {
671
- const sequence = asRecord(await readJsonFile(eventSequenceFile(namespaceDir)));
672
- const seq = sequence?.seq;
673
- if (typeof seq === "number" && Number.isInteger(seq) && seq >= 0) return seq;
674
- let latestSeq = 0;
675
- for (const event of await readCoordinatorEvents(namespaceDir)) latestSeq = Math.max(latestSeq, event.seq);
676
- return latestSeq;
1280
+ const rawSequence = await readJsonFile(eventSequenceFile(namespaceDir));
1281
+ const events = await readCoordinatorEvents(namespaceDir);
1282
+ const journalLatestSeq = events.at(-1)?.seq ?? 0;
1283
+ if (rawSequence === null) return journalLatestSeq;
1284
+ const sequence = asRecord(rawSequence);
1285
+ if (
1286
+ !sequence ||
1287
+ Object.keys(sequence).some(key => key !== "seq" && key !== "updated_at") ||
1288
+ typeof sequence.seq !== "number" ||
1289
+ !Number.isSafeInteger(sequence.seq) ||
1290
+ sequence.seq < 0 ||
1291
+ typeof sequence.updated_at !== "string" ||
1292
+ !Number.isFinite(Date.parse(sequence.updated_at)) ||
1293
+ sequence.seq > journalLatestSeq
1294
+ )
1295
+ throw coordinatorStateError("invalid");
1296
+ return journalLatestSeq;
677
1297
  }
678
1298
 
679
1299
  const eventAppendQueues = new Map<string, Promise<unknown>>();
680
1300
 
1301
+ /** Lock order: namespace transaction lock, then event journal lock. */
1302
+ function coordinatorTransactionLockFile(namespaceDir: string): string {
1303
+ return path.join(namespaceDir, "locks", "mutation.lock");
1304
+ }
1305
+
1306
+ async function withCoordinatorTransaction<T>(namespaceDir: string, operation: () => Promise<T>): Promise<T> {
1307
+ return await withSessionStateLock(coordinatorTransactionLockFile(namespaceDir), operation);
1308
+ }
1309
+
681
1310
  async function appendCoordinatorEvent(namespaceDir: string, input: CoordinatorEventInput): Promise<CoordinatorEvent> {
682
1311
  const previous = eventAppendQueues.get(namespaceDir) ?? Promise.resolve();
683
- let release!: () => void;
684
- const current = new Promise<void>(resolve => {
685
- release = resolve;
686
- });
1312
+ const { promise: current, resolve: release } = Promise.withResolvers<void>();
687
1313
  const queued = previous.then(
688
1314
  () => current,
689
1315
  () => current,
@@ -692,57 +1318,148 @@ async function appendCoordinatorEvent(namespaceDir: string, input: CoordinatorEv
692
1318
 
693
1319
  await previous.catch(() => undefined);
694
1320
  try {
695
- const latestSeq = await readLatestEventSeq(namespaceDir);
696
- const seq = latestSeq + 1;
697
- const timestamp = new Date().toISOString();
698
- const event: CoordinatorEvent = {
699
- schema_version: 1,
700
- seq,
701
- id: `event-${seq.toString().padStart(12, "0")}`,
702
- timestamp,
703
- kind: input.kind,
704
- summary: boundSummary(input.summary),
705
- ...(input.sessionId ? { session_id: input.sessionId } : {}),
706
- ...(input.turnId ? { turn_id: input.turnId } : {}),
707
- ...(input.questionId ? { question_id: input.questionId } : {}),
708
- ...(input.reportId ? { report_id: input.reportId } : {}),
709
- ...(input.payloadRef ? { payload_ref: input.payloadRef } : {}),
710
- ...(input.metadata ? { metadata: input.metadata } : {}),
711
- };
712
- await ensureDir(eventsDir(namespaceDir));
713
- await fs.appendFile(eventJournalFile(namespaceDir), `${JSON.stringify(event)}\n`);
714
- await writeJsonFile(eventSequenceFile(namespaceDir), { seq, updated_at: timestamp });
715
- return event;
1321
+ return await withSessionStateLock(eventJournalFile(namespaceDir), async () => {
1322
+ const latestSeq = await readLatestEventSeq(namespaceDir);
1323
+ const seq = latestSeq + 1;
1324
+ const timestamp = new Date().toISOString();
1325
+ const event: CoordinatorEvent = {
1326
+ schema_version: 1,
1327
+ seq,
1328
+ id: `event-${seq.toString().padStart(12, "0")}`,
1329
+ timestamp,
1330
+ kind: input.kind,
1331
+ summary: boundSummary(input.summary),
1332
+ ...(input.sessionId ? { session_id: input.sessionId } : {}),
1333
+ ...(input.turnId ? { turn_id: input.turnId } : {}),
1334
+ ...(input.questionId ? { question_id: input.questionId } : {}),
1335
+ ...(input.reportId ? { report_id: input.reportId } : {}),
1336
+ ...(input.payloadRef ? { payload_ref: input.payloadRef } : {}),
1337
+ ...(input.metadata ? { metadata: input.metadata } : {}),
1338
+ };
1339
+ await ensureDir(eventsDir(namespaceDir));
1340
+ await fs.appendFile(eventJournalFile(namespaceDir), `${JSON.stringify(event)}\n`);
1341
+ await writeJsonFile(eventSequenceFile(namespaceDir), {
1342
+ seq,
1343
+ updated_at: timestamp,
1344
+ });
1345
+ return event;
1346
+ });
716
1347
  } finally {
717
1348
  release();
718
1349
  if (eventAppendQueues.get(namespaceDir) === queued) eventAppendQueues.delete(namespaceDir);
719
1350
  }
720
1351
  }
721
1352
 
722
- function parseCoordinatorEvent(line: string): CoordinatorEvent | null {
1353
+ // Per-session mutation lock. Concurrent stdio dispatch (see pumpCoordinatorMcpStream) makes
1354
+ // same-session read-modify-write tool calls (notably send_prompt: read active turn → decide →
1355
+ // write new turn) interleave, which could persist two "active" turns while only one active-turn
1356
+ // pointer survives. Serializing per session — same promise-chain shape as eventAppendQueues —
1357
+ // restores the atomicity the former serial read loop provided, without serializing across
1358
+ // sessions or blocking long read-only polls (await_turn/watch_events). This is an in-process
1359
+ // lock distinct from the file-based withSessionStateLock/withCoordinatorTransaction, so it can
1360
+ // never deadlock by nesting inside them.
1361
+ const sessionMutationQueues = new Map<string, Promise<unknown>>();
1362
+
1363
+ async function withSessionMutation<T>(key: string, fn: () => Promise<T>): Promise<T> {
1364
+ const previous = sessionMutationQueues.get(key) ?? Promise.resolve();
1365
+ const { promise: current, resolve: release } = Promise.withResolvers<void>();
1366
+ const queued = previous.then(
1367
+ () => current,
1368
+ () => current,
1369
+ );
1370
+ sessionMutationQueues.set(key, queued);
1371
+ await previous.catch(() => undefined);
1372
+ try {
1373
+ return await fn();
1374
+ } finally {
1375
+ release();
1376
+ if (sessionMutationQueues.get(key) === queued) sessionMutationQueues.delete(key);
1377
+ }
1378
+ }
1379
+
1380
+ function parseCoordinatorEvent(line: string): CoordinatorEvent {
1381
+ let event: unknown;
723
1382
  try {
724
- const event = JSON.parse(line) as CoordinatorEvent;
725
- if (typeof event.seq !== "number" || typeof event.kind !== "string") return null;
726
- return event;
1383
+ event = JSON.parse(line);
727
1384
  } catch {
728
- return null;
1385
+ throw coordinatorStateError("invalid");
1386
+ }
1387
+ const record = asRecord(event);
1388
+ const allowed = new Set([
1389
+ "schema_version",
1390
+ "seq",
1391
+ "id",
1392
+ "timestamp",
1393
+ "kind",
1394
+ "session_id",
1395
+ "turn_id",
1396
+ "question_id",
1397
+ "report_id",
1398
+ "summary",
1399
+ "payload_ref",
1400
+ "metadata",
1401
+ ]);
1402
+ const metadata = asRecord(record?.metadata);
1403
+ const validMetadata =
1404
+ record?.metadata === undefined && metadata === null
1405
+ ? true
1406
+ : metadata !== null &&
1407
+ Object.values(metadata).every(
1408
+ value => value === null || ["string", "number", "boolean"].includes(typeof value),
1409
+ );
1410
+ const validOptionalExternalId = (value: unknown) =>
1411
+ value === undefined || (typeof value === "string" && SAFE_EXTERNAL_ID_PATTERN.test(value));
1412
+ const validPayloadRef =
1413
+ record?.payload_ref === undefined ||
1414
+ (typeof record.payload_ref === "string" &&
1415
+ record.payload_ref.length > 0 &&
1416
+ !path.isAbsolute(record.payload_ref) &&
1417
+ !record.payload_ref.split(/[\\/]/).includes(".."));
1418
+ if (
1419
+ !record ||
1420
+ Object.keys(record).some(key => !allowed.has(key)) ||
1421
+ record.schema_version !== 1 ||
1422
+ typeof record.seq !== "number" ||
1423
+ !Number.isSafeInteger(record.seq) ||
1424
+ record.seq <= 0 ||
1425
+ record.id !== `event-${record.seq.toString().padStart(12, "0")}` ||
1426
+ typeof record.timestamp !== "string" ||
1427
+ !Number.isFinite(Date.parse(record.timestamp)) ||
1428
+ typeof record.kind !== "string" ||
1429
+ !COORDINATOR_EVENT_KINDS.has(record.kind as CoordinatorEventKind) ||
1430
+ typeof record.summary !== "string" ||
1431
+ record.summary.length > 240 ||
1432
+ !validOptionalExternalId(record.session_id) ||
1433
+ (record.turn_id !== undefined && (typeof record.turn_id !== "string" || !TURN_ID_PATTERN.test(record.turn_id))) ||
1434
+ !validOptionalExternalId(record.question_id) ||
1435
+ !validOptionalExternalId(record.report_id) ||
1436
+ !validPayloadRef ||
1437
+ !validMetadata
1438
+ ) {
1439
+ throw coordinatorStateError("invalid");
729
1440
  }
1441
+ return record as unknown as CoordinatorEvent;
730
1442
  }
731
1443
 
732
1444
  async function readCoordinatorEvents(namespaceDir: string): Promise<CoordinatorEvent[]> {
1445
+ const journalFile = eventJournalFile(namespaceDir);
1446
+ let content: string;
733
1447
  try {
734
- const content = await fs.readFile(eventJournalFile(namespaceDir), "utf8");
735
- return content
736
- .split("\n")
737
- .map(line => line.trim())
738
- .filter(Boolean)
739
- .map(parseCoordinatorEvent)
740
- .filter((event): event is CoordinatorEvent => event !== null)
741
- .sort((left, right) => left.seq - right.seq);
1448
+ content = await fs.readFile(journalFile, "utf8");
742
1449
  } catch (error) {
743
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
744
- throw error;
1450
+ if (isExpectedStateAbsence(error)) return [];
1451
+ throw coordinatorStateError("unreadable");
745
1452
  }
1453
+ const events = content
1454
+ .split("\n")
1455
+ .map(line => line.trim())
1456
+ .filter(Boolean)
1457
+ .map(parseCoordinatorEvent);
1458
+ for (let index = 1; index < events.length; index++) {
1459
+ if (events[index].seq !== events[index - 1].seq + 1) throw coordinatorStateError("invalid");
1460
+ }
1461
+
1462
+ return events;
746
1463
  }
747
1464
 
748
1465
  function boundedEventLimit(value: unknown): number {
@@ -819,6 +1536,33 @@ function safeTmuxTarget(value: unknown): string {
819
1536
  return value;
820
1537
  }
821
1538
 
1539
+ function safeTmuxRuntimeIdentity(value: string): boolean {
1540
+ return value.length > 0 && value.length <= 160 && !/[\s\u0000-\u001f\u007f]/.test(value);
1541
+ }
1542
+
1543
+ function coordinatorCleanupPredicate(expectedPid: number, nativeSessionId: string, attemptSession: string): string {
1544
+ return `#{&&:#{==:#{pid},${expectedPid}},#{&&:#{==:#{session_id},${nativeSessionId}},#{==:#{session_name},${attemptSession}}}}`;
1545
+ }
1546
+
1547
+ function parseTmuxNewSessionRecord(
1548
+ stdout: string,
1549
+ sessionName: string,
1550
+ ): { tmuxTarget: string; paneId: string; nativeSessionId: string } | undefined {
1551
+ const match = /^(\S+) (%\d+) (\$\d+)\n?$/.exec(stdout);
1552
+ if (!match) return undefined;
1553
+ const [tmuxTarget, paneId, nativeSessionId] = match.slice(1);
1554
+ const targetSuffix = tmuxTarget!.slice(`${sessionName}:`.length);
1555
+ if (
1556
+ !tmuxTarget!.startsWith(`${sessionName}:`) ||
1557
+ !/^\d+\.\d+$/.test(targetSuffix) ||
1558
+ !safeTmuxRuntimeIdentity(tmuxTarget!) ||
1559
+ !safeTmuxRuntimeIdentity(paneId!) ||
1560
+ !safeTmuxRuntimeIdentity(nativeSessionId!)
1561
+ )
1562
+ return undefined;
1563
+ return { tmuxTarget: tmuxTarget!, paneId: paneId!, nativeSessionId: nativeSessionId! };
1564
+ }
1565
+
822
1566
  function optionalString(value: unknown): string | null {
823
1567
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
824
1568
  }
@@ -847,8 +1591,150 @@ function sessionStateFile(namespaceDir: string, sessionId: string): string {
847
1591
  return path.join(namespaceDir, "session-states", `${safeExternalId("session", sessionId)}.json`);
848
1592
  }
849
1593
 
1594
+ function isTimestamp(value: unknown): value is string {
1595
+ return typeof value === "string" && Number.isFinite(Date.parse(value));
1596
+ }
1597
+
1598
+ function isValidTurnRecord(value: unknown): value is TurnRecord {
1599
+ const turn = asRecord(value);
1600
+ const delivery = asRecord(turn?.delivery);
1601
+ const prompt = asRecord(turn?.prompt);
1602
+ const response = asRecord(turn?.final_response);
1603
+ const liveness = asRecord(turn?.liveness);
1604
+ const namespace = asRecord(turn?.namespace);
1605
+ const statuses: readonly TurnStatus[] = [
1606
+ "queued",
1607
+ "delivering",
1608
+ "active",
1609
+ "waiting_for_answer",
1610
+ "completing",
1611
+ "completed",
1612
+ "failed",
1613
+ "cancelled",
1614
+ "superseded",
1615
+ ];
1616
+ if (
1617
+ turn?.schema_version !== 1 ||
1618
+ typeof turn.turn_id !== "string" ||
1619
+ !TURN_ID_PATTERN.test(turn.turn_id) ||
1620
+ typeof turn.session_id !== "string" ||
1621
+ !SAFE_EXTERNAL_ID_PATTERN.test(turn.session_id) ||
1622
+ !namespace ||
1623
+ (namespace.profile !== null && typeof namespace.profile !== "string") ||
1624
+ (namespace.repo !== null && typeof namespace.repo !== "string") ||
1625
+ !statuses.includes(turn.status as TurnStatus)
1626
+ )
1627
+ return false;
1628
+ if (
1629
+ !prompt ||
1630
+ typeof prompt.text !== "string" ||
1631
+ !isTimestamp(prompt.created_at) ||
1632
+ (prompt.source !== "mcp" && prompt.source !== "question_answer")
1633
+ )
1634
+ return false;
1635
+ if (
1636
+ !delivery ||
1637
+ typeof delivery.delivered !== "boolean" ||
1638
+ typeof delivery.queued !== "boolean" ||
1639
+ (delivery.target !== null && typeof delivery.target !== "string") ||
1640
+ (delivery.tmux_keys_sent !== undefined && typeof delivery.tmux_keys_sent !== "boolean") ||
1641
+ (delivery.prompt_acknowledged !== undefined && typeof delivery.prompt_acknowledged !== "boolean") ||
1642
+ (delivery.state !== undefined &&
1643
+ ![
1644
+ "queued",
1645
+ "awaiting_runtime_readiness",
1646
+ "delivering",
1647
+ "tmux_keys_sent",
1648
+ "acknowledged",
1649
+ "unavailable",
1650
+ "unacknowledged",
1651
+ ].includes(delivery.state as string)) ||
1652
+ !Array.isArray(delivery.attempts) ||
1653
+ !delivery.attempts.every(attempt => {
1654
+ const item = asRecord(attempt);
1655
+ return (
1656
+ item !== null &&
1657
+ typeof item.delivered === "boolean" &&
1658
+ isTimestamp(item.created_at) &&
1659
+ (item.reason === null || typeof item.reason === "string") &&
1660
+ (item.channel === undefined || item.channel === "tmux_keys" || item.channel === "runtime_ack") &&
1661
+ (item.tmux_keys_sent === undefined || typeof item.tmux_keys_sent === "boolean")
1662
+ );
1663
+ })
1664
+ )
1665
+ return false;
1666
+ if (
1667
+ !Array.isArray(turn.question_ids) ||
1668
+ !turn.question_ids.every(id => typeof id === "string" && SAFE_EXTERNAL_ID_PATTERN.test(id)) ||
1669
+ !isValidRuntimeFinalResponse(response) ||
1670
+ !Array.isArray(turn.evidence) ||
1671
+ !turn.evidence.every(item => asRecord(item) !== null) ||
1672
+ (turn.error !== null && !isValidRuntimeError(turn.error))
1673
+ )
1674
+ return false;
1675
+ if (
1676
+ !liveness ||
1677
+ (liveness.checked_at !== null && !isTimestamp(liveness.checked_at)) ||
1678
+ (liveness.live !== null && typeof liveness.live !== "boolean") ||
1679
+ (liveness.reason !== null && typeof liveness.reason !== "string") ||
1680
+ !isTimestamp(turn.created_at) ||
1681
+ !isTimestamp(turn.updated_at) ||
1682
+ (turn.started_at !== null && !isTimestamp(turn.started_at)) ||
1683
+ (turn.completed_at !== null && !isTimestamp(turn.completed_at))
1684
+ )
1685
+ return false;
1686
+ return TERMINAL_TURN_STATUSES.has(turn.status as TurnStatus)
1687
+ ? turn.completed_at !== null && (turn.status === "failed" ? turn.error !== null : turn.error === null)
1688
+ : turn.completed_at === null && turn.error === null;
1689
+ }
1690
+
1691
+ function isValidActiveTurnRecord(
1692
+ value: unknown,
1693
+ sessionId: string,
1694
+ ): value is {
1695
+ session_id: string;
1696
+ turn_id: string;
1697
+ status: TurnStatus;
1698
+ updated_at: string;
1699
+ } {
1700
+ const active = asRecord(value);
1701
+ return (
1702
+ active !== null &&
1703
+ Object.keys(active).every(key => ["session_id", "turn_id", "status", "updated_at"].includes(key)) &&
1704
+ active.session_id === sessionId &&
1705
+ typeof active.turn_id === "string" &&
1706
+ TURN_ID_PATTERN.test(active.turn_id) &&
1707
+ ACTIVE_TURN_STATUSES.has(active.status as TurnStatus) &&
1708
+ isTimestamp(active.updated_at)
1709
+ );
1710
+ }
1711
+
850
1712
  async function readTurnRecord(namespaceDir: string, turnId: unknown): Promise<TurnRecord | null> {
851
- return (await readJsonFile(turnFile(namespaceDir, safeTurnId(turnId)))) as TurnRecord | null;
1713
+ const requestedTurnId = safeTurnId(turnId);
1714
+ const value = await readJsonFile(turnFile(namespaceDir, requestedTurnId));
1715
+ if (value === null) return null;
1716
+ if (!isValidTurnRecord(value) || value.turn_id !== requestedTurnId) throw coordinatorStateError("invalid");
1717
+ return value;
1718
+ }
1719
+
1720
+ async function listTurnRecords(namespaceDir: string): Promise<TurnRecord[]> {
1721
+ let entries: string[];
1722
+ try {
1723
+ entries = await fs.readdir(turnsDir(namespaceDir));
1724
+ } catch (error) {
1725
+ if (isExpectedStateAbsence(error)) return [];
1726
+ throw coordinatorStateError("unreadable");
1727
+ }
1728
+ const records = await Promise.all(
1729
+ entries
1730
+ .filter(entry => entry.endsWith(".json"))
1731
+ .map(async entry => {
1732
+ const turnId = entry.slice(0, -".json".length);
1733
+ if (!TURN_ID_PATTERN.test(turnId)) throw coordinatorStateError("invalid");
1734
+ return await readTurnRecord(namespaceDir, turnId);
1735
+ }),
1736
+ );
1737
+ return records.filter((record): record is TurnRecord => record !== null);
852
1738
  }
853
1739
 
854
1740
  function turnEventKind(status: TurnStatus): CoordinatorEventKind | null {
@@ -864,7 +1750,8 @@ function turnEventKind(status: TurnStatus): CoordinatorEventKind | null {
864
1750
  }
865
1751
 
866
1752
  async function writeTurnRecord(namespaceDir: string, turn: TurnRecord): Promise<void> {
867
- const previous = (await readJsonFile(turnFile(namespaceDir, turn.turn_id))) as TurnRecord | null;
1753
+ if (!isValidTurnRecord(turn)) throw coordinatorStateError("invalid");
1754
+ const previous = await readTurnRecord(namespaceDir, turn.turn_id);
868
1755
  await writeJsonFile(turnFile(namespaceDir, turn.turn_id), turn);
869
1756
  const kind = previous?.status === turn.status ? null : turnEventKind(turn.status);
870
1757
  if (kind) {
@@ -884,14 +1771,17 @@ async function writeTurnRecord(namespaceDir: string, turn: TurnRecord): Promise<
884
1771
  }
885
1772
 
886
1773
  async function readActiveTurn(namespaceDir: string, sessionId: string): Promise<TurnRecord | null> {
887
- const active = asRecord(await readJsonFile(activeTurnFile(namespaceDir, sessionId)));
888
- if (!active || typeof active.turn_id !== "string") return null;
889
- const turn = await readTurnRecord(namespaceDir, active.turn_id);
890
- if (!turn || turn.session_id !== sessionId || !ACTIVE_TURN_STATUSES.has(turn.status)) return null;
1774
+ const value = await readJsonFile(activeTurnFile(namespaceDir, sessionId));
1775
+ if (value === null) return null;
1776
+ if (!isValidActiveTurnRecord(value, sessionId)) throw coordinatorStateError("invalid");
1777
+ const turn = await readTurnRecord(namespaceDir, value.turn_id);
1778
+ if (!turn || turn.session_id !== sessionId || turn.status !== value.status || !ACTIVE_TURN_STATUSES.has(turn.status))
1779
+ throw coordinatorStateError("invalid");
891
1780
  return turn;
892
1781
  }
893
1782
 
894
1783
  async function writeActiveTurn(namespaceDir: string, turn: TurnRecord): Promise<void> {
1784
+ if (!ACTIVE_TURN_STATUSES.has(turn.status)) throw coordinatorStateError("invalid");
895
1785
  await writeJsonFile(activeTurnFile(namespaceDir, turn.session_id), {
896
1786
  session_id: turn.session_id,
897
1787
  turn_id: turn.turn_id,
@@ -901,15 +1791,84 @@ async function writeActiveTurn(namespaceDir: string, turn: TurnRecord): Promise<
901
1791
  }
902
1792
 
903
1793
  async function clearActiveTurn(namespaceDir: string, turn: TurnRecord): Promise<void> {
904
- const active = asRecord(await readJsonFile(activeTurnFile(namespaceDir, turn.session_id)));
905
- if (active?.turn_id === turn.turn_id) await fs.rm(activeTurnFile(namespaceDir, turn.session_id), { force: true });
1794
+ const value = await readJsonFile(activeTurnFile(namespaceDir, turn.session_id));
1795
+ if (value === null) return;
1796
+ if (!isValidActiveTurnRecord(value, turn.session_id)) throw coordinatorStateError("invalid");
1797
+ if (value.turn_id === turn.turn_id) await fs.rm(activeTurnFile(namespaceDir, turn.session_id), { force: true });
906
1798
  }
907
1799
 
908
1800
  async function readSessionState(namespaceDir: string, sessionId: string): Promise<CoordinatorSessionState | null> {
909
- return (await readJsonFile(sessionStateFile(namespaceDir, sessionId))) as CoordinatorSessionState | null;
1801
+ const value = await readJsonFile(sessionStateFile(namespaceDir, sessionId));
1802
+ if (value === null) return null;
1803
+ if (!isValidRuntimeSessionState(value, sessionId)) throw coordinatorStateError("invalid");
1804
+ return value;
910
1805
  }
911
1806
 
912
- async function writeSessionState(
1807
+ function validRuntimeOwnerGeneration(value: unknown): value is string {
1808
+ return (
1809
+ typeof value === "string" && value.length > 0 && value.length <= 256 && !/[\s\u0000-\u001f\u007f]/.test(value)
1810
+ );
1811
+ }
1812
+
1813
+ function runtimeStateMatchesPrivateOwnerGeneration(
1814
+ sessionState: CoordinatorSessionState,
1815
+ session: Record<string, unknown> | null,
1816
+ ): boolean {
1817
+ const expectedGeneration = session?.tmux_owner_generation ?? session?.tmuxOwnerGeneration;
1818
+ if (expectedGeneration === undefined)
1819
+ return !(sessionState.source === "process_postmortem" && sessionState.event === "owner_terminal");
1820
+ if (!validRuntimeOwnerGeneration(expectedGeneration) || sessionState.owner_generation !== expectedGeneration)
1821
+ return false;
1822
+ if (sessionState.source !== "process_postmortem") return true;
1823
+ if (sessionState.event !== "owner_terminal") return false;
1824
+ const expectedSocketKey = session?.tmux_owner_server_key ?? session?.tmuxOwnerServerKey;
1825
+ return (
1826
+ typeof expectedSocketKey === "string" &&
1827
+ sessionState.owner_terminal?.generation === expectedGeneration &&
1828
+ sessionState.owner_terminal.socket_key === expectedSocketKey
1829
+ );
1830
+ }
1831
+
1832
+ async function reconcileTerminalRuntimeEvidence(namespaceDir: string, sessionId: string): Promise<boolean> {
1833
+ const sessionState = await readSessionState(namespaceDir, sessionId);
1834
+ if (
1835
+ !sessionState ||
1836
+ sessionState.source === "coordinator" ||
1837
+ (sessionState.state !== "completed" && sessionState.state !== "errored")
1838
+ )
1839
+ return false;
1840
+ const session = asRecord(await readJsonFile(path.join(namespaceDir, "sessions", `${sessionId}.json`)));
1841
+ if (!runtimeStateMatchesPrivateOwnerGeneration(sessionState, session)) return false;
1842
+
1843
+ const activeTurn = await readActiveTurn(namespaceDir, sessionId);
1844
+ if (activeTurn && (sessionState.current_turn_id === null || sessionState.current_turn_id === activeTurn.turn_id)) {
1845
+ await markTurnTerminalFromSessionState(namespaceDir, activeTurn, sessionState);
1846
+ }
1847
+ return true;
1848
+ }
1849
+
1850
+ async function preflightCoordinatorMutation(
1851
+ namespaceDir: string,
1852
+ sessionId?: string,
1853
+ options: { rejectTerminalRuntimeEvidence?: boolean } = {},
1854
+ ): Promise<void> {
1855
+ await readLatestEventSeq(namespaceDir);
1856
+ if (!sessionId) return;
1857
+ const [sessionState, session] = await Promise.all([
1858
+ readSessionState(namespaceDir, sessionId),
1859
+ readJsonFile(path.join(namespaceDir, "sessions", `${sessionId}.json`)),
1860
+ ]);
1861
+ const sessionRecord = asRecord(session);
1862
+ if (sessionRecord && normalizeSession(sessionRecord).session_id !== sessionId)
1863
+ throw coordinatorStateError("invalid");
1864
+ if (sessionState && sessionState.session_id !== sessionId) throw coordinatorStateError("invalid");
1865
+ await readActiveTurn(namespaceDir, sessionId);
1866
+ const terminalRuntimeEvidence = await reconcileTerminalRuntimeEvidence(namespaceDir, sessionId);
1867
+ if (terminalRuntimeEvidence && options.rejectTerminalRuntimeEvidence !== false)
1868
+ throw coordinatorStateError("invalid");
1869
+ }
1870
+
1871
+ async function writeSessionStateUnlocked(
913
1872
  namespaceDir: string,
914
1873
  sessionId: string,
915
1874
  state: CoordinatorSessionStateValue,
@@ -922,6 +1881,20 @@ async function writeSessionState(
922
1881
  } = {},
923
1882
  ): Promise<CoordinatorSessionState> {
924
1883
  const previous = await readSessionState(namespaceDir, sessionId);
1884
+ if (previous && previous.source !== "coordinator") return previous;
1885
+ const session = asRecord(await readJsonFile(path.join(namespaceDir, "sessions", `${sessionId}.json`)));
1886
+ const previousCwd = typeof previous?.cwd === "string" && previous.cwd.trim() ? previous.cwd : null;
1887
+ const cwd =
1888
+ typeof session?.cwd === "string" && session.cwd.trim()
1889
+ ? path.resolve(session.cwd)
1890
+ : previousCwd
1891
+ ? path.resolve(previousCwd)
1892
+ : null;
1893
+ if (!cwd) throw coordinatorStateError("invalid");
1894
+ const sessionFile =
1895
+ typeof session?.session_file === "string" && session.session_file.trim()
1896
+ ? path.resolve(session.session_file)
1897
+ : null;
925
1898
  const payload: CoordinatorSessionState = {
926
1899
  schema_version: 1,
927
1900
  session_id: sessionId,
@@ -933,6 +1906,9 @@ async function writeSessionState(
933
1906
  source: options.source ?? "coordinator",
934
1907
  live: options.live ?? previous?.live ?? null,
935
1908
  reason: options.reason ?? null,
1909
+ cwd,
1910
+ workdir: cwd,
1911
+ session_file: sessionFile,
936
1912
  };
937
1913
  await writeJsonFile(sessionStateFile(namespaceDir, sessionId), payload);
938
1914
  if (
@@ -960,6 +1936,138 @@ async function writeSessionState(
960
1936
  return payload;
961
1937
  }
962
1938
 
1939
+ interface SessionStateLockOwner {
1940
+ pid: number;
1941
+ start_time: string;
1942
+ token: string;
1943
+ }
1944
+
1945
+ function processStartTime(pid: number): string | null {
1946
+ try {
1947
+ const stat = nodeFs.readFileSync(`/proc/${pid}/stat`, "utf8");
1948
+ const close = stat.lastIndexOf(")");
1949
+ const fields = stat
1950
+ .slice(close + 1)
1951
+ .trim()
1952
+ .split(/\s+/);
1953
+ return fields[19] ?? null;
1954
+ } catch {
1955
+ return null;
1956
+ }
1957
+ }
1958
+
1959
+ function validLockOwner(value: unknown): value is SessionStateLockOwner {
1960
+ if (!value || typeof value !== "object") return false;
1961
+ const owner = value as Partial<SessionStateLockOwner>;
1962
+ return (
1963
+ typeof owner.pid === "number" &&
1964
+ Number.isSafeInteger(owner.pid) &&
1965
+ owner.pid > 0 &&
1966
+ typeof owner.start_time === "string" &&
1967
+ typeof owner.token === "string" &&
1968
+ owner.token.length > 0
1969
+ );
1970
+ }
1971
+
1972
+ function lockOwnerIsAlive(value: unknown): boolean {
1973
+ if (!validLockOwner(value)) return false;
1974
+ const owner = value;
1975
+ try {
1976
+ process.kill(owner.pid, 0);
1977
+ } catch (error) {
1978
+ if ((error as NodeJS.ErrnoException).code === "ESRCH") return false;
1979
+ return true;
1980
+ }
1981
+ const currentStartTime = processStartTime(owner.pid);
1982
+ return currentStartTime === null || currentStartTime === owner.start_time;
1983
+ }
1984
+
1985
+ async function reclaimStaleSessionStateLock(lockFile: string): Promise<void> {
1986
+ let raw: string;
1987
+ try {
1988
+ raw = await fs.readFile(lockFile, "utf8");
1989
+ } catch (error) {
1990
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
1991
+ throw error;
1992
+ }
1993
+ let owner: unknown;
1994
+ try {
1995
+ owner = JSON.parse(raw);
1996
+ } catch {
1997
+ owner = null;
1998
+ }
1999
+ if (!validLockOwner(owner)) {
2000
+ const stat = await fs.stat(lockFile);
2001
+ if (Date.now() - stat.mtimeMs < 30_000) return;
2002
+ } else if (lockOwnerIsAlive(owner)) return;
2003
+ try {
2004
+ if ((await fs.readFile(lockFile, "utf8")) === raw) await fs.rm(lockFile);
2005
+ } catch (error) {
2006
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
2007
+ }
2008
+ }
2009
+
2010
+ async function withSessionStateLock<T>(stateFile: string, operation: () => Promise<T>): Promise<T> {
2011
+ const lockFile = `${stateFile}.lock`;
2012
+ const owner: SessionStateLockOwner = {
2013
+ pid: process.pid,
2014
+ start_time: processStartTime(process.pid) ?? "unknown",
2015
+ token: randomUUID(),
2016
+ };
2017
+ await ensureDir(path.dirname(stateFile));
2018
+ for (let attempt = 0; attempt < 12_000; attempt++) {
2019
+ let handle: fs.FileHandle | undefined;
2020
+ try {
2021
+ handle = await fs.open(lockFile, "wx");
2022
+ try {
2023
+ await handle.writeFile(JSON.stringify(owner));
2024
+ } catch (error) {
2025
+ await handle.close().catch(() => undefined);
2026
+ handle = undefined;
2027
+ await fs.rm(lockFile, { force: true }).catch(() => undefined);
2028
+ throw error;
2029
+ }
2030
+ const outcome = await operation().then(
2031
+ value => ({ ok: true as const, value }),
2032
+ error => ({ ok: false as const, error }),
2033
+ );
2034
+ await handle.close();
2035
+ try {
2036
+ if ((await fs.readFile(lockFile, "utf8")) === JSON.stringify(owner)) await fs.rm(lockFile);
2037
+ } catch (error) {
2038
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
2039
+ }
2040
+ if (!outcome.ok) throw outcome.error;
2041
+ return outcome.value;
2042
+ } catch (error) {
2043
+ if (handle) throw error;
2044
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw coordinatorStateError("unreadable");
2045
+ await reclaimStaleSessionStateLock(lockFile);
2046
+ await Bun.sleep(5);
2047
+ }
2048
+ }
2049
+ throw coordinatorStateError("unreadable");
2050
+ }
2051
+
2052
+ async function writeSessionState(
2053
+ namespaceDir: string,
2054
+ sessionId: string,
2055
+ state: CoordinatorSessionStateValue,
2056
+ options: {
2057
+ currentTurnId?: string | null;
2058
+ lastTurnId?: string | null;
2059
+ live?: boolean | null;
2060
+ reason?: string | null;
2061
+ source?: CoordinatorSessionState["source"];
2062
+ } = {},
2063
+ ): Promise<CoordinatorSessionState> {
2064
+ const file = sessionStateFile(namespaceDir, sessionId);
2065
+ return await withSessionStateLock(
2066
+ file,
2067
+ async () => await writeSessionStateUnlocked(namespaceDir, sessionId, state, options),
2068
+ );
2069
+ }
2070
+
963
2071
  function hasTmuxIdentity(session: Record<string, unknown>): boolean {
964
2072
  return (
965
2073
  (typeof session.tmux_session === "string" && session.tmux_session.length > 0) ||
@@ -1012,7 +2120,11 @@ async function markTurnFailedForUnavailableSession(
1012
2120
  truncated: false,
1013
2121
  },
1014
2122
  evidence: unavailableSessionEvidence(turn, durableReason, timestamp),
1015
- error: { code: "session_unavailable", message: durableReason, recoverable: true },
2123
+ error: {
2124
+ code: "session_unavailable",
2125
+ message: durableReason,
2126
+ recoverable: true,
2127
+ },
1016
2128
  liveness: { checked_at: timestamp, live: false, reason: durableReason },
1017
2129
  updated_at: timestamp,
1018
2130
  completed_at: timestamp,
@@ -1193,7 +2305,11 @@ async function markTurnFailedForUnacknowledgedDelivery(
1193
2305
  created_at: timestamp,
1194
2306
  },
1195
2307
  ],
1196
- liveness: { checked_at: timestamp, live: turn.liveness.live, reason: PROMPT_ACK_TIMEOUT_REASON },
2308
+ liveness: {
2309
+ checked_at: timestamp,
2310
+ live: turn.liveness.live,
2311
+ reason: PROMPT_ACK_TIMEOUT_REASON,
2312
+ },
1197
2313
  updated_at: timestamp,
1198
2314
  completed_at: timestamp,
1199
2315
  };
@@ -1214,6 +2330,16 @@ async function reconcileRuntimeAcknowledgement(
1214
2330
  ackTimeoutMs: number,
1215
2331
  options: { failOnTimeout: boolean } = { failOnTimeout: true },
1216
2332
  ): Promise<TurnRecord> {
2333
+ const session = asRecord(await readJsonFile(path.join(namespaceDir, "sessions", `${turn.session_id}.json`)));
2334
+ if (
2335
+ sessionState &&
2336
+ runtimeStateMatchesPrivateOwnerGeneration(sessionState, session) &&
2337
+ sessionState.source !== "coordinator" &&
2338
+ (sessionState.state === "completed" || sessionState.state === "errored") &&
2339
+ (sessionState.current_turn_id === null || sessionState.current_turn_id === turn.turn_id)
2340
+ ) {
2341
+ return await markTurnTerminalFromSessionState(namespaceDir, turn, sessionState);
2342
+ }
1217
2343
  if (sessionState && runtimeStateAcknowledgesTurn(turn, sessionState)) {
1218
2344
  return await markTurnAcknowledgedFromRuntimeState(namespaceDir, turn, sessionState);
1219
2345
  }
@@ -1226,6 +2352,27 @@ async function reconcileRuntimeAcknowledgement(
1226
2352
  function shellQuote(value: string): string {
1227
2353
  return `'${value.replaceAll("'", "'\\''")}'`;
1228
2354
  }
2355
+
2356
+ /**
2357
+ * Append an authoritative `--mpreset <profile>` to the coordinator child launch
2358
+ * command so the spawned GJC session activates the selected profile from its
2359
+ * first turn. The profile name is validated against the merged registry before
2360
+ * reaching here; it is still shell-quoted as defense in depth.
2361
+ *
2362
+ * Precedence note: this matches `gjc --mpreset <profile>` semantics exactly, so
2363
+ * an explicit model-affecting flag already present in the operator-configured
2364
+ * `sessionCommand` (e.g. a `--model`/`--thinking` baked into the launch command)
2365
+ * follows the same CLI precedence as a direct `gjc --mpreset ... --model ...`
2366
+ * invocation. The effective profile recorded on the session is the
2367
+ * coordinator-resolved request, not a child-attested activation receipt; a
2368
+ * cross-process activation receipt is a possible future enhancement and is out
2369
+ * of scope for the per-session selection contract here.
2370
+ */
2371
+ export function buildCoordinatorSessionCommand(sessionCommand: string, mpreset: string | null | undefined): string {
2372
+ if (!mpreset) return sessionCommand;
2373
+ return `${sessionCommand} --mpreset ${shellQuote(mpreset)}`;
2374
+ }
2375
+
1229
2376
  function makeTurnRecord(
1230
2377
  config: CoordinatorMcpConfig,
1231
2378
  sessionId: string,
@@ -1250,7 +2397,13 @@ function makeTurnRecord(
1250
2397
  attempts: [],
1251
2398
  },
1252
2399
  question_ids: [],
1253
- final_response: { text: null, format: "markdown", source: null, artifact_path: null, truncated: false },
2400
+ final_response: {
2401
+ text: null,
2402
+ format: "markdown",
2403
+ source: null,
2404
+ artifact_path: null,
2405
+ truncated: false,
2406
+ },
1254
2407
  evidence: [],
1255
2408
  error: null,
1256
2409
  liveness: { checked_at: null, live: null, reason: null },
@@ -1272,6 +2425,8 @@ function asTerminalTurnStatus(status: unknown): TurnStatus | null {
1272
2425
 
1273
2426
  export const COORDINATOR_AWAIT_TURN_TIMEOUT_MAX_MS = 30 * 60 * 1000;
1274
2427
  export const COORDINATOR_RUNTIME_PROMPT_ACK_TIMEOUT_MAX_MS = MAX_RUNTIME_PROMPT_ACK_TIMEOUT_MS;
2428
+ export const COORDINATOR_RUNTIME_READINESS_TIMEOUT_MAX_MS = MAX_RUNTIME_READINESS_TIMEOUT_MS;
2429
+
1275
2430
  export const COORDINATOR_EVENT_WATCH_TIMEOUT_MAX_MS = 30_000;
1276
2431
  export const COORDINATOR_POLL_INTERVAL_MAX_MS = 10_000;
1277
2432
 
@@ -1290,6 +2445,12 @@ export function boundedRuntimePromptAckTimeoutMs(value: unknown): number {
1290
2445
  COORDINATOR_RUNTIME_PROMPT_ACK_TIMEOUT_MAX_MS,
1291
2446
  );
1292
2447
  }
2448
+ export function boundedRuntimeReadinessTimeoutMs(value: unknown): number {
2449
+ return Math.min(
2450
+ parsePositiveIntegerMs(value, DEFAULT_RUNTIME_READINESS_TIMEOUT_MS),
2451
+ COORDINATOR_RUNTIME_READINESS_TIMEOUT_MAX_MS,
2452
+ );
2453
+ }
1293
2454
 
1294
2455
  export function boundedEventWatchTimeoutMs(value: unknown): number {
1295
2456
  return Math.min(parsePositiveIntegerMs(value, 1000), COORDINATOR_EVENT_WATCH_TIMEOUT_MAX_MS);
@@ -1298,8 +2459,21 @@ export function boundedEventWatchTimeoutMs(value: unknown): number {
1298
2459
  export function boundedPollIntervalMs(value: unknown): number {
1299
2460
  return Math.min(Math.max(parsePositiveIntegerMs(value, 100), 10), COORDINATOR_POLL_INTERVAL_MAX_MS);
1300
2461
  }
1301
- async function runCommand(command: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
1302
- const proc = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" });
2462
+ async function runCommand(
2463
+ command: string[],
2464
+ stdinLine?: string,
2465
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
2466
+ const proc = Bun.spawn(command, {
2467
+ stdin: stdinLine === undefined ? "ignore" : "pipe",
2468
+ stdout: "pipe",
2469
+ stderr: "pipe",
2470
+ });
2471
+ if (stdinLine !== undefined) {
2472
+ const stdin = proc.stdin;
2473
+ if (!stdin) throw new Error("coordinator_command_stdin_unavailable");
2474
+ stdin.write(`${stdinLine}\n`);
2475
+ stdin.end();
2476
+ }
1303
2477
  const [stdout, stderr, exitCode] = await Promise.all([
1304
2478
  new Response(proc.stdout).text(),
1305
2479
  new Response(proc.stderr).text(),
@@ -1308,29 +2482,39 @@ async function runCommand(command: string[]): Promise<{ exitCode: number; stdout
1308
2482
  return { exitCode, stdout, stderr };
1309
2483
  }
1310
2484
 
1311
- type CommandRunner = (command: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
1312
-
1313
2485
  async function sendTmuxPromptKeys(
1314
2486
  target: string,
1315
2487
  prompt: string,
1316
- runner: CommandRunner = runCommand,
2488
+ runner: CommandRunner,
2489
+ socketKey: string,
2490
+ proveCurrentServer: () => Promise<boolean>,
1317
2491
  ): Promise<boolean> {
2492
+ const runMutation = async (argv: string[]): Promise<CommandResult | null> => {
2493
+ if (!(await proveCurrentServer())) return null;
2494
+ return await runner(argv);
2495
+ };
1318
2496
  const bufferName = `gjc-coordinator-prompt-${randomUUID()}`;
1319
- const buffered = await runner(["tmux", "set-buffer", "-b", bufferName, "--", prompt]);
1320
- if (buffered.exitCode !== 0) return false;
1321
- const pasted = await runner(["tmux", "paste-buffer", "-d", "-b", bufferName, "-t", target]);
1322
- if (pasted.exitCode !== 0) {
1323
- await runner(["tmux", "delete-buffer", "-b", bufferName]);
2497
+ const tmux = ["tmux", "-L", socketKey];
2498
+ const zeroize = async (): Promise<boolean> => {
2499
+ for (let attempt = 0; attempt < 2; attempt++) {
2500
+ const cleared = await runMutation([...tmux, "set-buffer", "-b", bufferName, "--", ""]);
2501
+ const deleted =
2502
+ cleared?.exitCode === 0 ? await runMutation([...tmux, "delete-buffer", "-b", bufferName]) : null;
2503
+ if (cleared?.exitCode === 0 && deleted?.exitCode === 0) return true;
2504
+ }
2505
+ return false;
2506
+ };
2507
+ const buffered = await runMutation([...tmux, "set-buffer", "-b", bufferName, "--", prompt]);
2508
+ if (buffered?.exitCode !== 0) return false;
2509
+ const pasted = await runMutation([...tmux, "paste-buffer", "-d", "-b", bufferName, "-t", target]);
2510
+ if (pasted?.exitCode !== 0) {
2511
+ if (!(await zeroize())) throw new Error("coordinator_prompt_buffer_privacy_unverified");
1324
2512
  return false;
1325
2513
  }
1326
-
1327
- // Multiline slash-command prompts can leave the editor autocomplete menu focused
1328
- // after paste. Escape clears that UI-only state so Enter submits the buffered
1329
- // prompt instead of selecting the highlighted completion.
1330
- const dismissedAutocomplete = await runner(["tmux", "send-keys", "-t", target, "Escape"]);
1331
- if (dismissedAutocomplete.exitCode !== 0) return false;
1332
- const submitted = await runner(["tmux", "send-keys", "-t", target, "Enter"]);
1333
- return submitted.exitCode === 0;
2514
+ const dismissedAutocomplete = await runMutation([...tmux, "send-keys", "-t", target, "Escape"]);
2515
+ if (dismissedAutocomplete?.exitCode !== 0) return false;
2516
+ const submitted = await runMutation([...tmux, "send-keys", "-t", target, "Enter"]);
2517
+ return submitted?.exitCode === 0;
1334
2518
  }
1335
2519
 
1336
2520
  function boundedLineCount(value: unknown): number {
@@ -1355,7 +2539,10 @@ async function registerExistingTmuxSession(
1355
2539
  namespaceDir: string,
1356
2540
  sessionFilePath: string,
1357
2541
  runner: CommandRunner = runCommand,
1358
- ): Promise<{ session: Record<string, unknown>; sessionState: CoordinatorSessionState }> {
2542
+ ): Promise<{
2543
+ session: Record<string, unknown>;
2544
+ sessionState: CoordinatorSessionState;
2545
+ }> {
1359
2546
  await assertTmuxTargetAvailable(input.tmuxSession, input.tmuxTarget, runner);
1360
2547
  const existing = asRecord(await readJsonFile(sessionFilePath));
1361
2548
  if (existing) {
@@ -1381,6 +2568,7 @@ async function registerExistingTmuxSession(
1381
2568
  authoritative: true,
1382
2569
  warp_attached: input.warpAttached,
1383
2570
  source: input.source,
2571
+ origin: "registered",
1384
2572
  model: input.model,
1385
2573
  };
1386
2574
  await writeJsonFile(sessionFilePath, session);
@@ -1391,52 +2579,406 @@ async function registerExistingTmuxSession(
1391
2579
  return { session, sessionState: state };
1392
2580
  }
1393
2581
 
2582
+ async function portableProcessStartTime(pid: number, runner: CommandRunner): Promise<string | null> {
2583
+ const result = await runner(["ps", "-o", "lstart=", "-p", String(pid)]);
2584
+ const startTime = result.stdout.trim();
2585
+ return result.exitCode === 0 &&
2586
+ startTime.length > 0 &&
2587
+ startTime.length <= 160 &&
2588
+ !/[\r\n\u0000-\u001f\u007f]/.test(startTime)
2589
+ ? startTime
2590
+ : null;
2591
+ }
2592
+
2593
+ export async function coordinatorOwnerIsolationProbe(runner: CommandRunner): Promise<OwnerIsolationProbe> {
2594
+ return {
2595
+ readCallerCgroup: async () => await fs.readFile("/proc/self/cgroup", "utf8").catch(() => null),
2596
+ probeServer: async (socketKey): Promise<TmuxServerProof> => {
2597
+ try {
2598
+ const result = await runner(["tmux", "-L", socketKey, "list-sessions", "-F", "#{pid} #{session_name}"]);
2599
+ if (result.exitCode !== 0) {
2600
+ const diagnostic = `${result.stdout}\n${result.stderr}`.slice(0, 512);
2601
+ return /(?:no server running|failed to connect to server|no sessions|error connecting to)/i.test(
2602
+ diagnostic,
2603
+ )
2604
+ ? { state: "absent" }
2605
+ : { state: "unverifiable" };
2606
+ }
2607
+ const rows = result.stdout
2608
+ .split("\n")
2609
+ .map(line => line.trim().split(/\s+/, 2))
2610
+ .filter(([pid, name]) => Boolean(pid && name));
2611
+ const pid = Number(rows[0]?.[0]);
2612
+ const sessionNames = rows.map(([, name]) => name as string);
2613
+ if (!Number.isSafeInteger(pid) || pid <= 0 || rows.some(([rowPid]) => Number(rowPid) !== pid))
2614
+ return { state: "unverifiable" };
2615
+ const stat =
2616
+ process.platform === "linux" ? await fs.readFile(`/proc/${pid}/stat`, "utf8").catch(() => null) : null;
2617
+ const startTime =
2618
+ process.platform === "linux"
2619
+ ? ownerProcessStartTime(process.platform, stat)
2620
+ : await portableProcessStartTime(pid, runner);
2621
+ if (!startTime) return { state: "unverifiable", pid, sessionNames };
2622
+ if (process.platform !== "linux")
2623
+ return {
2624
+ state: "safe",
2625
+ pid,
2626
+ startTime,
2627
+ sessionNames,
2628
+ cgroup: { classification: "not_applicable" },
2629
+ };
2630
+ const cgroupText = await fs.readFile(`/proc/${pid}/cgroup`, "utf8").catch(() => null);
2631
+ const cgroup = classifyCgroup({ platform: process.platform, cgroupText });
2632
+ return {
2633
+ state:
2634
+ cgroup.classification === "safe"
2635
+ ? "safe"
2636
+ : cgroup.classification === "unsafe_service"
2637
+ ? "unsafe"
2638
+ : "unverifiable",
2639
+ pid,
2640
+ startTime,
2641
+ sessionNames,
2642
+ cgroup,
2643
+ };
2644
+ } catch {
2645
+ // A transient spawn failure (e.g. EAGAIN under load, or a momentary tmux/ps hiccup)
2646
+ // must not fatally abort the owner-server proof. Surfacing it as an unverifiable state
2647
+ // lets proveCoordinatorOwnerServer's bounded retry loop re-probe once the transient
2648
+ // clears, instead of propagating the throw and failing the entire delegate outright.
2649
+ return { state: "unverifiable" };
2650
+ }
2651
+ },
2652
+ };
2653
+ }
2654
+
2655
+ async function proveCoordinatorOwnerServer(probe: OwnerIsolationProbe, socketKey: string): Promise<TmuxServerProof> {
2656
+ for (let attempt = 0; attempt < 50; attempt++) {
2657
+ const proof = await probe.probeServer(socketKey);
2658
+ if (proof.state === "unsafe" || (proof.state === "safe" && proof.pid && proof.startTime)) return proof;
2659
+ if (attempt < 49) await Bun.sleep(10);
2660
+ }
2661
+ return { state: "unverifiable" };
2662
+ }
2663
+
1394
2664
  async function startTmuxSession(
1395
2665
  config: CoordinatorMcpConfig,
1396
2666
  input: SessionStartInput,
1397
2667
  namespaceDir: string,
1398
2668
  runner: CommandRunner = runCommand,
2669
+ ownerIsolationProbe?: OwnerIsolationProbe,
1399
2670
  ): Promise<Record<string, unknown>> {
1400
2671
  if (!config.sessionCommand) throw new Error("coordinator_session_command_required");
1401
- const sessionName = `gjc-coordinator-${randomUUID().slice(0, 8)}`;
2672
+ const sessionName = input.sessionId;
2673
+ const socketKey = `gjc-coordinator-${randomUUID().slice(0, 8)}`;
2674
+ const ownerStateDir = namespaceDir;
2675
+ const ownerGeneration = randomUUID();
2676
+ const generationBaseline = captureOwnerGenerationBaselineSync(ownerStateDir, sessionName);
1402
2677
  const runtimeStateFile = sessionStateFile(namespaceDir, sessionName);
1403
2678
  const sessionCommand = [
1404
2679
  "exec env",
1405
2680
  `${GJC_COORDINATOR_SESSION_STATE_FILE_ENV}=${shellQuote(runtimeStateFile)}`,
1406
2681
  `${GJC_COORDINATOR_SESSION_ID_ENV}=${shellQuote(sessionName)}`,
1407
- config.sessionCommand,
2682
+ `${GJC_TMUX_OWNER_GENERATION_ENV}=${shellQuote(ownerGeneration)}`,
2683
+ `${GJC_TMUX_OWNER_STATE_DIR_ENV}=${shellQuote(ownerStateDir)}`,
2684
+ `${GJC_TMUX_OWNER_SERVER_KEY_ENV}=${shellQuote(socketKey)}`,
2685
+ `${GJC_COORDINATOR_SESSION_LAUNCH_ID_ENV}=${shellQuote(input.launchId)}`,
2686
+ `${GJC_COORDINATOR_SESSION_READINESS_FILE_ENV}=${shellQuote(input.readinessMarkerFile)}`,
2687
+ buildCoordinatorSessionCommand(config.sessionCommand, input.mpreset),
1408
2688
  ].join(" ");
1409
- const started = await runner([
2689
+ const tmuxArgv = [
1410
2690
  "tmux",
2691
+ "-L",
2692
+ socketKey,
1411
2693
  "new-session",
1412
2694
  "-d",
1413
2695
  "-P",
1414
2696
  "-F",
1415
- "#{session_name}:#{window_index}.#{pane_index} #{pane_id}",
2697
+ "#{session_name}:#{window_index}.#{pane_index} #{pane_id} #{session_id}",
1416
2698
  "-s",
1417
2699
  sessionName,
1418
2700
  "-c",
1419
2701
  input.cwd,
1420
2702
  sessionCommand,
1421
- ]);
1422
- if (started.exitCode !== 0) throw new Error(`coordinator_tmux_start_failed:${started.stderr || started.stdout}`);
1423
- const [tmuxTarget, paneId] = started.stdout.trim().split(/\s+/, 2);
2703
+ ];
2704
+ const probe = ownerIsolationProbe ?? (await coordinatorOwnerIsolationProbe(runner));
2705
+ let plan = await planTmuxOwnerIsolation(
2706
+ {
2707
+ schema_version: 1,
2708
+ op: "plan",
2709
+ platform: process.platform,
2710
+ session_id: sessionName,
2711
+ owner_generation: ownerGeneration,
2712
+ baseline: generationBaseline,
2713
+ cwd: input.cwd,
2714
+ state_dir: ownerStateDir,
2715
+ socket_key: socketKey,
2716
+ tmux_argv: tmuxArgv,
2717
+ },
2718
+ probe,
2719
+ );
2720
+ if (!plan.ok) throw new Error(`coordinator_tmux_owner_${plan.code}`);
2721
+ if (plan.execution.mode === "direct" && plan.execution.server_absent_before) {
2722
+ plan = await planTmuxOwnerIsolation(
2723
+ {
2724
+ schema_version: 1,
2725
+ op: "plan",
2726
+ platform: process.platform,
2727
+ session_id: sessionName,
2728
+ owner_generation: ownerGeneration,
2729
+ baseline: generationBaseline,
2730
+ cwd: input.cwd,
2731
+ state_dir: ownerStateDir,
2732
+ socket_key: socketKey,
2733
+ tmux_argv: tmuxArgv,
2734
+ },
2735
+ {
2736
+ ...probe,
2737
+ readCallerCgroup: async () => "0::/gjc-coordinator-bootstrap.service",
2738
+ },
2739
+ );
2740
+ if (!plan.ok) throw new Error(`coordinator_tmux_owner_${plan.code}`);
2741
+ }
2742
+ const preSpawnProof =
2743
+ plan.execution.mode === "direct" && plan.server_state === "safe"
2744
+ ? await proveCoordinatorOwnerServer(probe, socketKey)
2745
+ : null;
2746
+ if (
2747
+ preSpawnProof &&
2748
+ (preSpawnProof.state !== "safe" ||
2749
+ !preSpawnProof.pid ||
2750
+ !preSpawnProof.startTime ||
2751
+ (process.platform === "linux" && preSpawnProof.cgroup?.classification !== "safe"))
2752
+ )
2753
+ throw new Error("coordinator_tmux_owner_server_unverifiable");
2754
+ const started = await runner(
2755
+ plan.execution.argv,
2756
+ plan.execution.mode === "scoped" ? plan.execution.stdin_line : undefined,
2757
+ );
2758
+
2759
+ const rawScopedReceipt =
2760
+ plan.execution.mode === "scoped" && isExactScopedBootstrapSuccessReceipt(started.stdout)
2761
+ ? (JSON.parse(started.stdout.trim()) as {
2762
+ native_session_id: string;
2763
+ server_pid: number;
2764
+ server_start_time: string;
2765
+ session_name: string;
2766
+ })
2767
+ : undefined;
2768
+ const scopedReceipt =
2769
+ rawScopedReceipt?.session_name === plan.execution.attempt_session ? rawScopedReceipt : undefined;
2770
+ const directReceipt =
2771
+ plan.execution.mode === "direct"
2772
+ ? parseTmuxNewSessionRecord(started.stdout, plan.execution.attempt_session)
2773
+ : undefined;
2774
+ const directNativeSessionId = directReceipt?.nativeSessionId;
2775
+ const spawnedNativeSessionId = scopedReceipt?.native_session_id ?? directNativeSessionId;
2776
+ let expectedServer = scopedReceipt
2777
+ ? {
2778
+ pid: scopedReceipt.server_pid,
2779
+ startTime: scopedReceipt.server_start_time,
2780
+ }
2781
+ : preSpawnProof && preSpawnProof.state === "safe" && preSpawnProof.pid && preSpawnProof.startTime
2782
+ ? { pid: preSpawnProof.pid, startTime: preSpawnProof.startTime }
2783
+ : undefined;
2784
+ const cleanupFailedAttempt = async (): Promise<"cleaned" | "failed" | "unverifiable"> => {
2785
+ if (!spawnedNativeSessionId || !expectedServer) return "unverifiable";
2786
+ try {
2787
+ const result = await runner([
2788
+ "tmux",
2789
+ "-L",
2790
+ socketKey,
2791
+ "if-shell",
2792
+ "-t",
2793
+ spawnedNativeSessionId,
2794
+ "-F",
2795
+ coordinatorCleanupPredicate(expectedServer.pid, spawnedNativeSessionId, plan.execution.attempt_session),
2796
+ `kill-session -t '${spawnedNativeSessionId}' ; display-message -p __gjc_coordinator_cleanup_ok__`,
2797
+ "display-message -p __gjc_coordinator_cleanup_refused__",
2798
+ ]);
2799
+ if (result.exitCode !== 0) return "failed";
2800
+ return result.stdout.trim() === "__gjc_coordinator_cleanup_ok__" ? "cleaned" : "unverifiable";
2801
+ } catch {
2802
+ return "unverifiable";
2803
+ }
2804
+ };
2805
+ const failAfterCleanup = async (reason: string): Promise<never> => {
2806
+ throw new CoordinatorStartError(reason, await cleanupFailedAttempt());
2807
+ };
2808
+ if (started.exitCode !== 0) return await failAfterCleanup("coordinator_tmux_start_failed");
2809
+ const bootstrapReceiptValid = plan.execution.mode !== "scoped" || scopedReceipt !== undefined;
2810
+ const initialProof = await proveCoordinatorOwnerServer(probe, socketKey).catch(error =>
2811
+ failAfterCleanup(error instanceof Error ? error.message : "coordinator_tmux_owner_server_unverifiable"),
2812
+ );
2813
+ if (
2814
+ initialProof.state !== "safe" ||
2815
+ !initialProof.pid ||
2816
+ !initialProof.startTime ||
2817
+ (process.platform === "linux" && initialProof.cgroup?.classification !== "safe")
2818
+ ) {
2819
+ return await failAfterCleanup("coordinator_tmux_owner_server_unverifiable");
2820
+ }
2821
+ if (
2822
+ scopedReceipt &&
2823
+ (initialProof.pid !== scopedReceipt.server_pid || initialProof.startTime !== scopedReceipt.server_start_time)
2824
+ ) {
2825
+ return await failAfterCleanup("coordinator_tmux_owner_server_race");
2826
+ }
2827
+ if (
2828
+ preSpawnProof &&
2829
+ (initialProof.pid !== preSpawnProof.pid || initialProof.startTime !== preSpawnProof.startTime)
2830
+ ) {
2831
+ return await failAfterCleanup("coordinator_tmux_owner_server_race");
2832
+ }
2833
+ if (!expectedServer)
2834
+ expectedServer = {
2835
+ pid: initialProof.pid,
2836
+ startTime: initialProof.startTime,
2837
+ };
2838
+ if (!bootstrapReceiptValid) {
2839
+ return await failAfterCleanup("coordinator_tmux_start_failed");
2840
+ }
2841
+
2842
+ const startRecord = await (async () => {
2843
+ const queried = await runner([
2844
+ "tmux",
2845
+ "-L",
2846
+ socketKey,
2847
+ "display-message",
2848
+ "-p",
2849
+ "-t",
2850
+ sessionName,
2851
+ "#{session_name}:#{window_index}.#{pane_index} #{pane_id} #{session_id}",
2852
+ ]);
2853
+ if (queried.exitCode !== 0) throw new Error("coordinator_tmux_start_failed");
2854
+ return queried.stdout.trim();
2855
+ })().catch(error => failAfterCleanup(error instanceof Error ? error.message : "coordinator_tmux_start_failed"));
2856
+ const postLaunchProof = await probe.probeServer(socketKey);
2857
+ if (
2858
+ postLaunchProof.state !== "safe" ||
2859
+ postLaunchProof.pid !== initialProof.pid ||
2860
+ postLaunchProof.startTime !== initialProof.startTime
2861
+ ) {
2862
+ return await failAfterCleanup("coordinator_tmux_owner_server_race");
2863
+ }
2864
+ const parsedStartRecord = parseTmuxNewSessionRecord(startRecord, plan.execution.attempt_session);
2865
+ if (!parsedStartRecord) {
2866
+ return await failAfterCleanup("coordinator_tmux_start_failed");
2867
+ }
2868
+ const { tmuxTarget, paneId, nativeSessionId } = parsedStartRecord;
2869
+ if (
2870
+ (scopedReceipt && nativeSessionId !== scopedReceipt.native_session_id) ||
2871
+ (plan.execution.mode === "direct" && nativeSessionId !== directNativeSessionId)
2872
+ ) {
2873
+ return await failAfterCleanup("coordinator_tmux_owner_server_race");
2874
+ }
2875
+ const ownerTransaction = {
2876
+ commit: () => replaceOwnerGenerationSync(ownerStateDir, sessionName, ownerGeneration, generationBaseline),
2877
+ rollback: cleanupFailedAttempt,
2878
+ };
1424
2879
  return {
1425
2880
  sessionId: sessionName,
1426
2881
  tmuxSession: sessionName,
1427
- tmuxTarget: tmuxTarget || sessionName,
2882
+ tmuxTarget,
1428
2883
  paneId,
2884
+ tmuxNativeSessionId: nativeSessionId,
2885
+ tmuxSocketKey: socketKey,
2886
+ tmuxOwnerGeneration: ownerGeneration,
2887
+ tmuxOwnerStateDir: ownerStateDir,
2888
+ tmuxOwnerServerKey: socketKey,
2889
+ tmuxOwnerServerPid: initialProof.pid,
2890
+ tmuxOwnerServerStartTime: initialProof.startTime,
2891
+
1429
2892
  cwd: input.cwd,
1430
2893
  createdAt: new Date().toISOString(),
1431
2894
  sessionCommand: config.sessionCommand,
2895
+ ...(input.mpreset ? { mpreset: input.mpreset } : {}),
1432
2896
  runtimeStateFile,
2897
+ __coordinatorOwnerTransaction: ownerTransaction,
2898
+ launchId: input.launchId,
2899
+ readinessMarkerFile: input.readinessMarkerFile,
2900
+ readinessFile: input.readinessMarkerFile,
2901
+ origin: "coordinator_created",
2902
+ runtime_readiness_latched: false,
1433
2903
  };
1434
2904
  }
1435
2905
 
1436
- async function captureTmuxTail(session: Record<string, unknown>, lines: number): Promise<string[]> {
2906
+ function hasPrivateTmuxOwnerIdentity(session: Record<string, unknown>): boolean {
2907
+ const socketKey = typeof session.tmux_socket_key === "string" ? session.tmux_socket_key : session.tmuxSocketKey;
2908
+ const serverKey =
2909
+ typeof session.tmux_owner_server_key === "string" ? session.tmux_owner_server_key : session.tmuxOwnerServerKey;
2910
+ const pid = session.tmux_owner_server_pid ?? session.tmuxOwnerServerPid;
2911
+ const startTime = session.tmux_owner_server_start_time ?? session.tmuxOwnerServerStartTime;
2912
+ const nativeSessionId = session.tmux_native_session_id ?? session.tmuxNativeSessionId;
2913
+ const paneId = session.pane_id ?? session.paneId;
2914
+ return (
2915
+ typeof socketKey === "string" &&
2916
+ socketKey.length > 0 &&
2917
+ socketKey === serverKey &&
2918
+ Number.isSafeInteger(pid) &&
2919
+ typeof startTime === "string" &&
2920
+ startTime.length > 0 &&
2921
+ typeof nativeSessionId === "string" &&
2922
+ nativeSessionId.length > 0 &&
2923
+ typeof paneId === "string" &&
2924
+ paneId.length > 0
2925
+ );
2926
+ }
2927
+
2928
+ async function proveImmutableTmuxTarget(session: Record<string, unknown>, runner: CommandRunner): Promise<boolean> {
2929
+ const target = typeof session.tmux_target === "string" ? session.tmux_target : session.tmuxTarget;
2930
+ const socketKey = typeof session.tmux_socket_key === "string" ? session.tmux_socket_key : session.tmuxSocketKey;
2931
+ const expectedSessionId = session.tmux_native_session_id ?? session.tmuxNativeSessionId;
2932
+ const expectedPaneId = session.pane_id ?? session.paneId;
2933
+ if (
2934
+ typeof target !== "string" ||
2935
+ typeof socketKey !== "string" ||
2936
+ typeof expectedSessionId !== "string" ||
2937
+ typeof expectedPaneId !== "string"
2938
+ )
2939
+ return false;
2940
+ const identity = await runner([
2941
+ "tmux",
2942
+ "-L",
2943
+ socketKey,
2944
+ "display-message",
2945
+ "-p",
2946
+ "-t",
2947
+ target,
2948
+ "#{session_id} #{pane_id}",
2949
+ ]);
2950
+ const parts = identity.stdout.trim().split(/\s+/);
2951
+ if (identity.exitCode !== 0) return false;
2952
+ return parts.length === 2 && parts[0] === expectedSessionId && parts[1] === expectedPaneId;
2953
+ }
2954
+ async function captureTmuxTail(
2955
+ session: Record<string, unknown>,
2956
+ lines: number,
2957
+ runner: CommandRunner = runCommand,
2958
+ ): Promise<string[]> {
1437
2959
  const target = typeof session.tmux_target === "string" ? session.tmux_target : session.tmuxTarget;
1438
- if (typeof target !== "string" || target.length === 0) return [];
1439
- const captured = await runCommand(["tmux", "capture-pane", "-t", target, "-p", "-S", `-${lines}`]);
2960
+ const socketKey = typeof session.tmux_socket_key === "string" ? session.tmux_socket_key : session.tmuxSocketKey;
2961
+ if (
2962
+ !hasPrivateTmuxOwnerIdentity(session) ||
2963
+ typeof socketKey !== "string" ||
2964
+ socketKey.length === 0 ||
2965
+ typeof target !== "string" ||
2966
+ target.length === 0
2967
+ )
2968
+ return [];
2969
+ const pid = session.tmux_owner_server_pid ?? session.tmuxOwnerServerPid;
2970
+ const startTime = session.tmux_owner_server_start_time ?? session.tmuxOwnerServerStartTime;
2971
+ const probe = await coordinatorOwnerIsolationProbe(runner);
2972
+ const proof = await probe.probeServer(socketKey);
2973
+ if (
2974
+ proof.state !== "safe" ||
2975
+ proof.pid !== pid ||
2976
+ proof.startTime !== startTime ||
2977
+ (process.platform === "linux" && proof.cgroup?.classification !== "safe") ||
2978
+ !(await proveImmutableTmuxTarget(session, runner))
2979
+ )
2980
+ return [];
2981
+ const captured = await runner(["tmux", "-L", socketKey, "capture-pane", "-t", target, "-p", "-S", `-${lines}`]);
1440
2982
  if (captured.exitCode !== 0) return [];
1441
2983
  return captured.stdout.split("\n").slice(-lines);
1442
2984
  }
@@ -1445,10 +2987,38 @@ async function sendTmuxPrompt(
1445
2987
  session: Record<string, unknown>,
1446
2988
  prompt: string,
1447
2989
  runner: CommandRunner = runCommand,
2990
+ ownerIsolationProbe?: OwnerIsolationProbe,
1448
2991
  ): Promise<boolean> {
1449
2992
  const target = typeof session.tmux_target === "string" ? session.tmux_target : session.tmuxTarget;
1450
- if (typeof target !== "string" || target.length === 0) return false;
1451
- return await sendTmuxPromptKeys(target, prompt, runner);
2993
+ const socketKey = typeof session.tmux_socket_key === "string" ? session.tmux_socket_key : session.tmuxSocketKey;
2994
+ const serverKey =
2995
+ typeof session.tmux_owner_server_key === "string" ? session.tmux_owner_server_key : session.tmuxOwnerServerKey;
2996
+ const pid = session.tmux_owner_server_pid ?? session.tmuxOwnerServerPid;
2997
+ const startTime = session.tmux_owner_server_start_time ?? session.tmuxOwnerServerStartTime;
2998
+ if (
2999
+ typeof target !== "string" ||
3000
+ target.length === 0 ||
3001
+ typeof socketKey !== "string" ||
3002
+ socketKey.length === 0 ||
3003
+ socketKey !== serverKey ||
3004
+ !Number.isSafeInteger(pid) ||
3005
+ typeof startTime !== "string" ||
3006
+ startTime.length === 0
3007
+ )
3008
+ return false;
3009
+ const paneId = session.pane_id ?? session.paneId;
3010
+ if (typeof paneId !== "string" || !(await proveImmutableTmuxTarget(session, runner))) return false;
3011
+ const probe = ownerIsolationProbe ?? (await coordinatorOwnerIsolationProbe(runner));
3012
+ const proveCurrentServer = async (): Promise<boolean> => {
3013
+ const proof = await probe.probeServer(socketKey);
3014
+ return (
3015
+ proof.state === "safe" &&
3016
+ proof.pid === pid &&
3017
+ proof.startTime === startTime &&
3018
+ (process.platform !== "linux" || proof.cgroup?.classification === "safe")
3019
+ );
3020
+ };
3021
+ return await sendTmuxPromptKeys(paneId, prompt, runner, socketKey, proveCurrentServer);
1452
3022
  }
1453
3023
 
1454
3024
  async function hasTmuxSession(
@@ -1456,8 +3026,16 @@ async function hasTmuxSession(
1456
3026
  runner: CommandRunner = runCommand,
1457
3027
  ): Promise<boolean | null> {
1458
3028
  const tmuxSession = typeof session.tmux_session === "string" ? session.tmux_session : session.tmuxSession;
1459
- if (typeof tmuxSession !== "string" || tmuxSession.length === 0) return null;
1460
- const checked = await runner(["tmux", "has-session", "-t", tmuxSession]);
3029
+ const socketKey = typeof session.tmux_socket_key === "string" ? session.tmux_socket_key : session.tmuxSocketKey;
3030
+ if (
3031
+ !hasPrivateTmuxOwnerIdentity(session) ||
3032
+ typeof socketKey !== "string" ||
3033
+ socketKey.length === 0 ||
3034
+ typeof tmuxSession !== "string" ||
3035
+ tmuxSession.length === 0
3036
+ )
3037
+ return null;
3038
+ const checked = await runner(["tmux", "-L", socketKey, "has-session", "-t", tmuxSession]);
1461
3039
  return checked.exitCode === 0;
1462
3040
  }
1463
3041
 
@@ -1492,7 +3070,8 @@ async function inspectTmuxSession(
1492
3070
  runner: CommandRunner = runCommand,
1493
3071
  ): Promise<Record<string, unknown>> {
1494
3072
  const live = await hasTmuxSession(session, runner);
1495
- const tail = live ? await captureTmuxTail(session, lines) : [];
3073
+ const tail = live ? await captureTmuxTail(session, lines, runner) : [];
3074
+
1496
3075
  return {
1497
3076
  live,
1498
3077
  ...summarizePaneTail(tail),
@@ -1607,7 +3186,7 @@ export async function readCoordinatorArtifact(
1607
3186
  } catch (error) {
1608
3187
  return {
1609
3188
  ok: false,
1610
- reason: (error instanceof Error ? error.message.split(":")[0] : String(error)).replace(/^coordinator_/, ""),
3189
+ reason: publicCoordinatorError(error),
1611
3190
  };
1612
3191
  } finally {
1613
3192
  await handle?.close();
@@ -1621,6 +3200,122 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1621
3200
  const services = options.services ?? {};
1622
3201
  const namespaceDir = coordinatorNamespacePath(config);
1623
3202
  const commandRunner = services.commandRunner ?? runCommand;
3203
+ const loadModelProfiles = services.resolveModelProfiles ?? loadCoordinatorModelProfiles;
3204
+ const readinessTimeoutMs = boundedRuntimeReadinessTimeoutMs(env.GJC_COORDINATOR_MCP_RUNTIME_READINESS_TIMEOUT_MS);
3205
+ async function withSessionTransition<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
3206
+ return await withSessionMutation(`${namespaceDir}::${sessionId}`, operation);
3207
+ }
3208
+
3209
+ async function startCoordinatorSession(
3210
+ cwd: string,
3211
+ prompt?: string,
3212
+ mpreset?: string | null,
3213
+ ): Promise<Record<string, unknown>> {
3214
+ const sessionId = `gjc-coordinator-${randomUUID().slice(0, 8)}`;
3215
+ const launchId = randomUUID();
3216
+ const readinessRoot = path.join(namespaceDir, "launch-readiness");
3217
+ const readinessDir = path.join(readinessRoot, sessionId);
3218
+ await ensureDir(readinessDir);
3219
+ const canonicalRoot = await fs.realpath(readinessRoot);
3220
+ const canonicalDir = await fs.realpath(readinessDir);
3221
+ if (!canonicalDir.startsWith(`${canonicalRoot}${path.sep}`))
3222
+ throw new Error("coordinator_readiness_path_invalid");
3223
+ const readinessMarkerFile = path.join(canonicalDir, `${launchId}.json`);
3224
+ const input: SessionStartInput = {
3225
+ cwd,
3226
+ prompt,
3227
+ namespace: config.namespace,
3228
+ mpreset,
3229
+ worktree: true,
3230
+ sessionId,
3231
+ launchId,
3232
+ readinessMarkerFile,
3233
+ readinessFile: readinessMarkerFile,
3234
+ };
3235
+ const started = services.startSession
3236
+ ? await services.startSession(input)
3237
+ : await startTmuxSession(config, input, namespaceDir, commandRunner, services.ownerIsolationProbe);
3238
+ const record = asRecord(started);
3239
+ if (!record) throw new Error("coordinator_session_command_required");
3240
+ const attestedSessionId = optionalString(record.sessionId ?? record.session_id);
3241
+ const attestedLaunchId = optionalString(record.launchId ?? record.launch_id);
3242
+ const attestedMarkerFile = optionalString(
3243
+ record.readinessMarkerFile ?? record.readiness_marker_file ?? record.readinessFile ?? record.readiness_file,
3244
+ );
3245
+ if (!attestedSessionId || !attestedLaunchId || !attestedMarkerFile)
3246
+ throw new Error("coordinator_launch_attestation_missing");
3247
+ if (
3248
+ attestedSessionId !== sessionId ||
3249
+ attestedLaunchId !== launchId ||
3250
+ (await canonicalizePath(attestedMarkerFile)) !== readinessMarkerFile
3251
+ )
3252
+ throw new Error("coordinator_launch_attestation_mismatch");
3253
+ return normalizeSession({
3254
+ ...record,
3255
+ session_id: sessionId,
3256
+ launch_id: launchId,
3257
+ readiness_marker_file: readinessMarkerFile,
3258
+ readiness_marker_path: readinessMarkerFile,
3259
+ origin: "coordinator_created",
3260
+ runtime_ready_launch_id: null,
3261
+ runtime_ready_at: null,
3262
+ runtime_readiness_latched: false,
3263
+ });
3264
+ }
3265
+
3266
+ function sessionReadinessAuthorityError(
3267
+ reason: "coordinator_launch_metadata_missing" | "session_origin_unverified",
3268
+ session: Record<string, unknown>,
3269
+ ): Error {
3270
+ const error = new Error(reason);
3271
+ Object.assign(error, {
3272
+ sessionId: optionalString(session.session_id),
3273
+ action: "Start a new coordinator session or explicitly register the live external tmux session.",
3274
+ });
3275
+ return error;
3276
+ }
3277
+
3278
+ function assertSessionReadinessAuthority(session: Record<string, unknown>): void {
3279
+ if (session.origin === "registered") return;
3280
+ const tmuxTarget = session.tmux_target ?? session.tmuxTarget;
3281
+ if (
3282
+ session.origin == null &&
3283
+ typeof session.registered_at === "string" &&
3284
+ session.registered_at.length > 0 &&
3285
+ session.authoritative === true &&
3286
+ hasTmuxIdentity(session) &&
3287
+ typeof tmuxTarget === "string" &&
3288
+ tmuxTarget.length > 0
3289
+ )
3290
+ return;
3291
+ if (session.origin === "coordinator_created") {
3292
+ const sessionId = optionalString(session.session_id);
3293
+ const launchId = optionalString(session.launch_id);
3294
+ const markerFile = optionalString(session.readiness_marker_file ?? session.readiness_marker_path);
3295
+ if (!sessionId || !launchId || !markerFile) {
3296
+ throw sessionReadinessAuthorityError("coordinator_launch_metadata_missing", session);
3297
+ }
3298
+ return;
3299
+ }
3300
+ throw sessionReadinessAuthorityError("session_origin_unverified", session);
3301
+ }
3302
+
3303
+ async function awaitRuntimeReadiness(session: Record<string, unknown>): Promise<boolean> {
3304
+ assertSessionReadinessAuthority(session);
3305
+ if (session.origin === "registered" || session.origin == null) return true;
3306
+ const sessionId = optionalString(session.session_id);
3307
+ const launchId = optionalString(session.launch_id);
3308
+ const markerFile = optionalString(session.readiness_marker_file ?? session.readiness_marker_path);
3309
+ if (!sessionId || !launchId || !markerFile) throw new Error("coordinator_launch_metadata_missing");
3310
+ if (session.runtime_ready_launch_id === launchId) return true;
3311
+ return await waitForRuntimeReadinessMarker(
3312
+ markerFile,
3313
+ sessionId,
3314
+ launchId,
3315
+ path.join(namespaceDir, "launch-readiness"),
3316
+ readinessTimeoutMs,
3317
+ );
3318
+ }
1624
3319
 
1625
3320
  async function listSessions(): Promise<unknown[]> {
1626
3321
  if (!config.namespace.profile || !config.namespace.repo) return [];
@@ -1630,6 +3325,154 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1630
3325
  function sessionFile(sessionId: unknown): string {
1631
3326
  return path.join(namespaceDir, "sessions", `${safeExternalId("session", sessionId)}.json`);
1632
3327
  }
3328
+ const forceCloseSession =
3329
+ services.forceCloseSession ??
3330
+ ((name: string, targetEnv: NodeJS.ProcessEnv, expectedSessionId?: string, expectedStateFile?: string) =>
3331
+ forceCloseGjcTmuxSession(name, targetEnv, expectedSessionId, expectedStateFile));
3332
+
3333
+ // Reap a coordinator delegate-created (ephemeral) session. Addresses the #2034 review:
3334
+ // - terminates via the owner-proof forceCloseGjcTmuxSession (pid + native session_id +
3335
+ // generation + server_key + start_time verified atomically) — never a raw process.kill,
3336
+ // so a recycled PID can never signal a foreign process.
3337
+ // - re-validates ephemeral + no-active-turn AT kill time under a per-session lock, closing
3338
+ // the select→kill TOCTOU and races with concurrent send_prompt / delegate / stop.
3339
+ // - purges state files ONLY on verified termination; on failure the record is kept for retry
3340
+ // and no session.reaped event is emitted (honest audit, no worse leak).
3341
+ async function reapSession(
3342
+ rawId: unknown,
3343
+ opts: { force?: boolean; reason?: string } = {},
3344
+ ): Promise<{ ok: boolean; reason?: string; killed: boolean; active_turn_id?: string; detail?: string }> {
3345
+ const id = safeExternalId("session", rawId);
3346
+ return await withSessionMutation(`${namespaceDir}::${id}`, async () => {
3347
+ const record = asRecord(await readJsonFile(sessionFile(id)));
3348
+ if (!record) return { ok: false, reason: "unknown_session", killed: false };
3349
+ if (record.ephemeral !== true && opts.force !== true) {
3350
+ return { ok: false, reason: "not_ephemeral", killed: false };
3351
+ }
3352
+ const activeTurn = await readActiveTurn(namespaceDir, id);
3353
+ if (activeTurn) {
3354
+ return { ok: false, reason: "active_turn", killed: false, active_turn_id: activeTurn.turn_id };
3355
+ }
3356
+ const tmuxSession = optionalString(record.tmux_session) ?? optionalString(record.tmuxSession);
3357
+ let killed = false;
3358
+ if (tmuxSession) {
3359
+ // Production delegate records persist the owner state-file path as `runtimeStateFile`
3360
+ // (see startTmuxSession). Read that so the owner-proof close is actually bound to the
3361
+ // recorded state-file identity; `sessionStateFile` is only a compatibility fallback for
3362
+ // any externally-registered record that used the older key.
3363
+ const expectedStateFile =
3364
+ optionalString(record.runtimeStateFile) ?? optionalString(record.sessionStateFile) ?? undefined;
3365
+ try {
3366
+ await forceCloseSession(tmuxSession, env, undefined, expectedStateFile);
3367
+ killed = true;
3368
+ } catch (err) {
3369
+ return {
3370
+ ok: false,
3371
+ reason: "terminate_failed",
3372
+ detail: err instanceof Error ? err.message : String(err),
3373
+ killed: false,
3374
+ };
3375
+ }
3376
+ }
3377
+ // Purge the session record FIRST so a partial rm failure can never re-list → re-reap →
3378
+ // emit a duplicate session.reaped. `killed` is honest: false when nothing was signalled.
3379
+ await fs.rm(sessionFile(id), { force: true });
3380
+ await appendCoordinatorEvent(namespaceDir, {
3381
+ kind: "session.reaped",
3382
+ sessionId: id,
3383
+ summary: `Session ${id} reaped${opts.reason ? ` (${opts.reason})` : ""}`,
3384
+ metadata: { reason: opts.reason ?? null, force: opts.force === true, killed },
3385
+ });
3386
+ await fs.rm(sessionStateFile(namespaceDir, id), { force: true });
3387
+ await fs.rm(activeTurnFile(namespaceDir, id), { force: true });
3388
+ return { ok: true, killed };
3389
+ });
3390
+ }
3391
+
3392
+ const sessionReaper: SessionReaper = createSessionReaper(
3393
+ {
3394
+ listSessions: async (): Promise<ReapableSession[]> => {
3395
+ const out: ReapableSession[] = [];
3396
+ for (const raw of await listSessions()) {
3397
+ try {
3398
+ const rec = asRecord(raw);
3399
+ if (rec?.ephemeral !== true) continue;
3400
+ const sid = optionalString(rec.session_id);
3401
+ if (!sid) continue;
3402
+ const activeTurn = await readActiveTurn(namespaceDir, sid);
3403
+ const state = await readSessionState(namespaceDir, sid);
3404
+ const stamp = optionalString(state?.updated_at) ?? optionalString(rec.created_at);
3405
+ const lastActivityMs = stamp ? Date.parse(stamp) : 0;
3406
+ out.push({
3407
+ sessionId: sid,
3408
+ ephemeral: true,
3409
+ hasActiveTurn: activeTurn !== null,
3410
+ lastActivityMs: Number.isFinite(lastActivityMs) ? lastActivityMs : 0,
3411
+ });
3412
+ } catch {
3413
+ // A single corrupt session/state file must not abort the whole sweep — skip it.
3414
+ }
3415
+ }
3416
+ return out;
3417
+ },
3418
+ reapSession: async (sid: string): Promise<void> => {
3419
+ await reapSession(sid, { reason: "idle_reaper" });
3420
+ },
3421
+ now: () => Date.now(),
3422
+ },
3423
+ { idleTtlMs: config.sessionIdleTtlMs, sweepIntervalMs: config.sessionSweepIntervalMs },
3424
+ );
3425
+ async function compensateFailedOwnerStart(
3426
+ session: Record<string, unknown>,
3427
+ ownerTransaction: {
3428
+ rollback?: () => Promise<"cleaned" | "failed" | "unverifiable">;
3429
+ } | null,
3430
+ ): Promise<"cleaned" | "failed" | "unverifiable"> {
3431
+ let cleanupStatus: "cleaned" | "failed" | "unverifiable" = "unverifiable";
3432
+ try {
3433
+ cleanupStatus = (await ownerTransaction?.rollback?.()) ?? "unverifiable";
3434
+ } catch {
3435
+ cleanupStatus = "unverifiable";
3436
+ }
3437
+ try {
3438
+ const sessionId = String(session.session_id);
3439
+ const cwd = typeof session.cwd === "string" ? path.resolve(session.cwd) : null;
3440
+ if (!cwd) throw coordinatorStateError("invalid");
3441
+ const state: CoordinatorSessionState = {
3442
+ schema_version: 1,
3443
+ session_id: sessionId,
3444
+ state: "errored",
3445
+ ready_for_input: false,
3446
+ current_turn_id: null,
3447
+ last_turn_id: null,
3448
+ updated_at: new Date().toISOString(),
3449
+ source: "coordinator",
3450
+ live: false,
3451
+ reason: "coordinator_start_rolled_back",
3452
+ cwd,
3453
+ workdir: cwd,
3454
+ session_file: null,
3455
+ };
3456
+ await writeJsonFile(sessionStateFile(namespaceDir, sessionId), state);
3457
+ await fs.rm(sessionFile(sessionId), { force: true });
3458
+ await appendCoordinatorEvent(namespaceDir, {
3459
+ kind: "session.state_changed",
3460
+ sessionId,
3461
+ summary: `Session ${sessionId} start rolled back`,
3462
+ payloadRef: path.relative(namespaceDir, sessionStateFile(namespaceDir, sessionId)),
3463
+ metadata: {
3464
+ state: "errored",
3465
+ ready_for_input: false,
3466
+ live: false,
3467
+ reason: "coordinator_start_rolled_back",
3468
+ },
3469
+ });
3470
+ } catch {
3471
+ return "failed";
3472
+ }
3473
+ return cleanupStatus;
3474
+ }
3475
+
1633
3476
  async function listQuestions(args: Record<string, unknown>): Promise<unknown[]> {
1634
3477
  const sessionId = args.session_id == null ? null : safeExternalId("session", args.session_id);
1635
3478
  const status = typeof args.status === "string" && args.status.length > 0 ? args.status : null;
@@ -1653,11 +3496,12 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1653
3496
  return evidence;
1654
3497
  }
1655
3498
 
1656
- async function activateTurn(session: Record<string, unknown>, turn: TurnRecord): Promise<TurnRecord> {
3499
+ async function prepareActiveTurnUnlocked(session: Record<string, unknown>, turn: TurnRecord): Promise<TurnRecord> {
3500
+ const active = await readActiveTurn(namespaceDir, turn.session_id);
3501
+ if (active && active.turn_id !== turn.turn_id) return active;
1657
3502
  const timestamp = new Date().toISOString();
1658
3503
  const target = typeof session.tmux_target === "string" ? session.tmux_target : session.tmuxTarget;
1659
- const live = hasTmuxIdentity(session) ? await hasTmuxSession(session, commandRunner) : null;
1660
- const pendingTurn: TurnRecord = {
3504
+ const pending: TurnRecord = {
1661
3505
  ...turn,
1662
3506
  status: "active",
1663
3507
  delivery: {
@@ -1666,90 +3510,312 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1666
3510
  target: typeof target === "string" ? target : null,
1667
3511
  tmux_keys_sent: false,
1668
3512
  prompt_acknowledged: false,
1669
- state: "queued",
3513
+ state: "awaiting_runtime_readiness",
1670
3514
  attempts: [
1671
3515
  {
1672
3516
  delivered: false,
1673
3517
  tmux_keys_sent: false,
1674
3518
  channel: "tmux_keys",
1675
3519
  created_at: timestamp,
1676
- reason: "awaiting_tmux_delivery",
3520
+ reason: "awaiting_runtime_readiness",
1677
3521
  },
1678
3522
  ],
1679
3523
  },
1680
- liveness: { checked_at: timestamp, live, reason: live === false ? "tmux_session_missing" : null },
3524
+ liveness: { checked_at: null, live: null, reason: null },
1681
3525
  started_at: turn.started_at ?? timestamp,
1682
3526
  updated_at: timestamp,
1683
3527
  };
1684
- await writeTurnRecord(namespaceDir, pendingTurn);
1685
- await writeActiveTurn(namespaceDir, pendingTurn);
1686
- await writeSessionState(namespaceDir, pendingTurn.session_id, "running", {
1687
- currentTurnId: pendingTurn.turn_id,
1688
- live,
1689
- reason: null,
3528
+ await writeTurnRecord(namespaceDir, pending);
3529
+ await writeActiveTurn(namespaceDir, pending);
3530
+ await writeSessionState(namespaceDir, pending.session_id, "booting", {
3531
+ currentTurnId: pending.turn_id,
3532
+ live: null,
3533
+ reason: "awaiting_runtime_readiness",
1690
3534
  });
3535
+ return pending;
3536
+ }
1691
3537
 
1692
- const tmuxKeysSent = await sendTmuxPrompt(session, turn.prompt.text, commandRunner);
1693
- const deliveredAt = new Date().toISOString();
1694
- const activeTurn: TurnRecord = {
1695
- ...pendingTurn,
1696
- delivery: {
1697
- delivered: false,
1698
- queued: !tmuxKeysSent,
1699
- target: typeof target === "string" ? target : null,
1700
- tmux_keys_sent: tmuxKeysSent,
1701
- prompt_acknowledged: false,
1702
- state: tmuxKeysSent ? "tmux_keys_sent" : "unavailable",
1703
- attempts: [
1704
- {
3538
+ async function completePreparedTurn(session: Record<string, unknown>, pendingTurn: TurnRecord): Promise<TurnRecord> {
3539
+ const ready = await awaitRuntimeReadiness(session);
3540
+ const observedLive = hasTmuxIdentity(session) ? await hasTmuxSession(session, commandRunner) : null;
3541
+ return await withSessionTransition(pendingTurn.session_id, async () => {
3542
+ const current = await readTurnRecord(namespaceDir, pendingTurn.turn_id);
3543
+ const active = await readActiveTurn(namespaceDir, pendingTurn.session_id);
3544
+ if (
3545
+ !current ||
3546
+ !active ||
3547
+ active.turn_id !== pendingTurn.turn_id ||
3548
+ TERMINAL_TURN_STATUSES.has(current.status) ||
3549
+ current.delivery.state !== "awaiting_runtime_readiness" ||
3550
+ current.delivery.tmux_keys_sent === true
3551
+ )
3552
+ return current ?? pendingTurn;
3553
+ if (!ready) {
3554
+ const completedAt = new Date().toISOString();
3555
+ const message = `GJC runtime did not attest interactive input readiness within ${readinessTimeoutMs}ms.`;
3556
+ const failed: TurnRecord = {
3557
+ ...current,
3558
+ status: "failed",
3559
+ delivery: {
3560
+ ...current.delivery,
1705
3561
  delivered: false,
1706
- tmux_keys_sent: tmuxKeysSent,
1707
- channel: "tmux_keys",
1708
- created_at: deliveredAt,
1709
- reason: tmuxKeysSent ? "awaiting_runtime_ack" : "tmux_delivery_unavailable",
3562
+ queued: false,
3563
+ tmux_keys_sent: false,
3564
+ prompt_acknowledged: false,
3565
+ state: "awaiting_runtime_readiness",
1710
3566
  },
1711
- ],
1712
- },
1713
- updated_at: deliveredAt,
1714
- };
1715
- await writeTurnRecord(namespaceDir, activeTurn);
1716
- await writeActiveTurn(namespaceDir, activeTurn);
1717
- const sessionState = await readSessionState(namespaceDir, activeTurn.session_id);
1718
- const runtimeStateAlreadyAcknowledged =
1719
- sessionState !== null && runtimeStateAcknowledgesTurn(activeTurn, sessionState);
1720
- const resolvedTurn =
1721
- runtimeStateAlreadyAcknowledged && sessionState
1722
- ? await markTurnAcknowledgedFromRuntimeState(namespaceDir, activeTurn, sessionState)
1723
- : activeTurn;
1724
- if (!runtimeStateAlreadyAcknowledged && !tmuxKeysSent) {
1725
- await writeSessionState(namespaceDir, activeTurn.session_id, "stale", {
1726
- currentTurnId: activeTurn.turn_id,
1727
- live,
1728
- reason: "tmux_delivery_unavailable",
3567
+ final_response: {
3568
+ text: message,
3569
+ format: "markdown",
3570
+ source: "coordinator_runtime_readiness",
3571
+ artifact_path: null,
3572
+ truncated: false,
3573
+ },
3574
+ error: { code: RUNTIME_READINESS_TIMEOUT_REASON, message, recoverable: true },
3575
+ evidence: [
3576
+ ...current.evidence,
3577
+ {
3578
+ type: RUNTIME_READINESS_TIMEOUT_REASON,
3579
+ created_at: completedAt,
3580
+ tmux_keys_sent: false,
3581
+ prompt_acknowledged: false,
3582
+ },
3583
+ ],
3584
+ updated_at: completedAt,
3585
+ completed_at: completedAt,
3586
+ };
3587
+ await writeTurnRecord(namespaceDir, failed);
3588
+ await clearActiveTurn(namespaceDir, failed);
3589
+ await writeSessionState(namespaceDir, failed.session_id, "booting", {
3590
+ lastTurnId: failed.turn_id,
3591
+ live: null,
3592
+ reason: RUNTIME_READINESS_TIMEOUT_REASON,
3593
+ });
3594
+ return failed;
3595
+ }
3596
+ const currentSession = asRecord(await readJsonFile(sessionFile(pendingTurn.session_id))) ?? session;
3597
+ if (
3598
+ currentSession.origin === "coordinator_created" &&
3599
+ (currentSession.session_id !== session.session_id ||
3600
+ currentSession.launch_id !== session.launch_id ||
3601
+ currentSession.readiness_marker_file !== session.readiness_marker_file)
3602
+ )
3603
+ return current;
3604
+ const latched =
3605
+ currentSession.origin === "coordinator_created"
3606
+ ? {
3607
+ ...currentSession,
3608
+ runtime_ready_launch_id: currentSession.launch_id,
3609
+ runtime_ready_at: new Date().toISOString(),
3610
+ runtime_readiness_latched: true,
3611
+ }
3612
+ : currentSession;
3613
+ if (latched !== currentSession) await writeJsonFile(sessionFile(pendingTurn.session_id), latched);
3614
+ const target = typeof latched.tmux_target === "string" ? latched.tmux_target : latched.tmuxTarget;
3615
+ const attemptId = `delivery-${randomUUID()}`;
3616
+ const attemptedAt = new Date().toISOString();
3617
+ const delivering: TurnRecord = {
3618
+ ...current,
3619
+ status: "delivering",
3620
+ updated_at: attemptedAt,
3621
+ delivery: {
3622
+ ...current.delivery,
3623
+ queued: false,
3624
+ state: "delivering",
3625
+ attempts: [
3626
+ ...current.delivery.attempts,
3627
+ {
3628
+ attempt_id: attemptId,
3629
+ delivered: false,
3630
+ tmux_keys_sent: false,
3631
+ channel: "tmux_keys",
3632
+ created_at: attemptedAt,
3633
+ reason: "delivery_attempt",
3634
+ },
3635
+ ],
3636
+ },
3637
+ };
3638
+ await writeTurnRecord(namespaceDir, delivering);
3639
+ await writeActiveTurn(namespaceDir, delivering);
3640
+ await writeSessionState(namespaceDir, delivering.session_id, "running", {
3641
+ currentTurnId: delivering.turn_id,
3642
+ live: observedLive,
3643
+ reason: null,
1729
3644
  });
1730
- }
1731
- await appendCoordinatorEvent(namespaceDir, {
1732
- kind: tmuxKeysSent ? "tmux.delivery_succeeded" : "tmux.delivery_failed",
1733
- sessionId: activeTurn.session_id,
1734
- turnId: activeTurn.turn_id,
1735
- summary: tmuxKeysSent
1736
- ? `Tmux delivery succeeded for turn ${activeTurn.turn_id}`
1737
- : `Tmux delivery failed for turn ${activeTurn.turn_id}`,
1738
- payloadRef: path.relative(namespaceDir, turnFile(namespaceDir, activeTurn.turn_id)),
1739
- metadata: { target: typeof target === "string" ? target : null, live },
3645
+ const tmuxKeysSent = await sendTmuxPrompt(
3646
+ latched,
3647
+ delivering.prompt.text,
3648
+ commandRunner,
3649
+ services.ownerIsolationProbe,
3650
+ );
3651
+ const deliveredAt = new Date().toISOString();
3652
+ const delivered: TurnRecord = {
3653
+ ...delivering,
3654
+ status: "active",
3655
+ updated_at: deliveredAt,
3656
+ delivery: {
3657
+ ...delivering.delivery,
3658
+ delivered: false,
3659
+ queued: !tmuxKeysSent,
3660
+ tmux_keys_sent: tmuxKeysSent,
3661
+ prompt_acknowledged: false,
3662
+ state: tmuxKeysSent ? "tmux_keys_sent" : "unavailable",
3663
+ attempts: [
3664
+ ...delivering.delivery.attempts.slice(0, -1),
3665
+ {
3666
+ attempt_id: attemptId,
3667
+ delivered: false,
3668
+ tmux_keys_sent: tmuxKeysSent,
3669
+ channel: "tmux_keys",
3670
+ created_at: deliveredAt,
3671
+ reason: tmuxKeysSent ? "awaiting_runtime_ack" : "tmux_delivery_unavailable",
3672
+ },
3673
+ ],
3674
+ },
3675
+ };
3676
+ await writeTurnRecord(namespaceDir, delivered);
3677
+ await writeActiveTurn(namespaceDir, delivered);
3678
+ await appendCoordinatorEvent(namespaceDir, {
3679
+ kind: tmuxKeysSent ? "tmux.delivery_succeeded" : "tmux.delivery_failed",
3680
+ sessionId: delivered.session_id,
3681
+ turnId: delivered.turn_id,
3682
+ summary: tmuxKeysSent
3683
+ ? `Tmux delivery succeeded for turn ${delivered.turn_id}`
3684
+ : `Tmux delivery failed for turn ${delivered.turn_id}`,
3685
+ payloadRef: path.relative(namespaceDir, turnFile(namespaceDir, delivered.turn_id)),
3686
+ metadata: {
3687
+ target: typeof target === "string" ? target : null,
3688
+ live: observedLive,
3689
+ attempt_id: attemptId,
3690
+ },
3691
+ });
3692
+ return delivered;
1740
3693
  });
1741
- return resolvedTurn;
1742
3694
  }
1743
3695
 
1744
- async function promoteNextQueuedTurn(sessionId: string): Promise<TurnRecord | null> {
3696
+ async function activateTurn(session: Record<string, unknown>, turn: TurnRecord): Promise<TurnRecord> {
3697
+ const pendingTurn = await withSessionTransition(
3698
+ turn.session_id,
3699
+ async () => await prepareActiveTurnUnlocked(session, turn),
3700
+ );
3701
+ if (pendingTurn.turn_id !== turn.turn_id) return pendingTurn;
3702
+ return await completePreparedTurn(session, pendingTurn);
3703
+ }
3704
+
3705
+ async function prepareNextQueuedTurnUnlocked(sessionId: string): Promise<{
3706
+ session: Record<string, unknown>;
3707
+ turn: TurnRecord;
3708
+ } | null> {
1745
3709
  const session = asRecord(await readJsonFile(sessionFile(sessionId)));
1746
- if (!session) return null;
1747
- const queuedTurns = (await listJsonFiles(turnsDir(namespaceDir)))
1748
- .map(turn => asRecord(turn) as TurnRecord | null)
1749
- .filter((turn): turn is TurnRecord => turn?.session_id === sessionId && turn.status === "queued")
3710
+ if (!session || (await readActiveTurn(namespaceDir, sessionId))) return null;
3711
+ try {
3712
+ assertSessionReadinessAuthority(session);
3713
+ } catch {
3714
+ return null;
3715
+ }
3716
+ const queuedTurns = (await listTurnRecords(namespaceDir))
3717
+ .filter(turn => turn.session_id === sessionId && turn.status === "queued")
1750
3718
  .sort((left, right) => left.created_at.localeCompare(right.created_at));
1751
3719
  const nextTurn = queuedTurns[0];
1752
- return nextTurn ? await activateTurn(session, nextTurn) : null;
3720
+ if (!nextTurn) return null;
3721
+ return { session, turn: await prepareActiveTurnUnlocked(session, nextTurn) };
3722
+ }
3723
+
3724
+ async function reconcileRuntimeAcknowledgementFenced(
3725
+ turnId: string,
3726
+ sessionState: CoordinatorSessionState | null,
3727
+ failOnTimeout: boolean,
3728
+ ): Promise<TurnRecord | null> {
3729
+ const observed = await readTurnRecord(namespaceDir, turnId);
3730
+ if (!observed) return null;
3731
+ const observedTurnId = sessionState?.current_turn_id;
3732
+ return await withSessionTransition(observed.session_id, async () => {
3733
+ const current = await readTurnRecord(namespaceDir, turnId);
3734
+ if (!current || !ACTIVE_TURN_STATUSES.has(current.status)) return current;
3735
+ const currentState = await readSessionState(namespaceDir, current.session_id);
3736
+ const state =
3737
+ sessionState &&
3738
+ sessionState.updated_at === currentState?.updated_at &&
3739
+ observedTurnId === currentState.current_turn_id
3740
+ ? sessionState
3741
+ : currentState;
3742
+ return await reconcileRuntimeAcknowledgement(namespaceDir, current, state, promptAckTimeoutMs, {
3743
+ failOnTimeout,
3744
+ });
3745
+ });
3746
+ }
3747
+
3748
+ async function markTurnTerminalFromSessionStateFenced(
3749
+ turnId: string,
3750
+ observedState: CoordinatorSessionState,
3751
+ ): Promise<TurnRecord | null> {
3752
+ const observed = await readTurnRecord(namespaceDir, turnId);
3753
+ if (!observed) return null;
3754
+ return await withSessionTransition(observed.session_id, async () => {
3755
+ const current = await readTurnRecord(namespaceDir, turnId);
3756
+ if (!current || !ACTIVE_TURN_STATUSES.has(current.status)) return current;
3757
+ const currentState = await readSessionState(namespaceDir, current.session_id);
3758
+ if (!currentState || currentState.updated_at !== observedState.updated_at) return current;
3759
+ const matchesTurn =
3760
+ currentState.current_turn_id === current.turn_id ||
3761
+ (currentState.state === "errored" &&
3762
+ currentState.source === "agent_session_event" &&
3763
+ currentState.current_turn_id == null);
3764
+ if (!matchesTurn || (currentState.state !== "completed" && currentState.state !== "errored")) return current;
3765
+ return await markTurnTerminalFromSessionState(namespaceDir, current, currentState);
3766
+ });
3767
+ }
3768
+
3769
+ async function markTurnFailedForUnavailableSessionFenced(
3770
+ turnId: string,
3771
+ reason: string,
3772
+ ): Promise<TurnRecord | null> {
3773
+ const observed = await readTurnRecord(namespaceDir, turnId);
3774
+ if (!observed) return null;
3775
+ return await withSessionTransition(observed.session_id, async () => {
3776
+ const current = await readTurnRecord(namespaceDir, turnId);
3777
+ if (!current || !ACTIVE_TURN_STATUSES.has(current.status)) return current;
3778
+ return await markTurnFailedForUnavailableSession(namespaceDir, current, reason);
3779
+ });
3780
+ }
3781
+
3782
+ async function preparePromptTurn(
3783
+ session: Record<string, unknown>,
3784
+ prompt: string,
3785
+ options: { force: boolean; queue: boolean; queueWithoutActive?: boolean },
3786
+ ): Promise<
3787
+ | { ok: false; activeTurn: TurnRecord }
3788
+ | { ok: true; turn: TurnRecord; priorActiveTurn: TurnRecord | null; queued: boolean }
3789
+ > {
3790
+ const sessionId = safeExternalId("session", session.session_id);
3791
+ const prepared = await withSessionTransition(sessionId, async () => {
3792
+ const activeTurn = await readActiveTurn(namespaceDir, sessionId);
3793
+ if (activeTurn && !options.force && !options.queue) {
3794
+ return { ok: false as const, activeTurn };
3795
+ }
3796
+ if (activeTurn && options.force) {
3797
+ const timestamp = new Date().toISOString();
3798
+ const superseded: TurnRecord = {
3799
+ ...activeTurn,
3800
+ status: "superseded",
3801
+ updated_at: timestamp,
3802
+ completed_at: timestamp,
3803
+ };
3804
+ await writeTurnRecord(namespaceDir, superseded);
3805
+ await clearActiveTurn(namespaceDir, superseded);
3806
+ }
3807
+ const shouldQueue =
3808
+ options.queue && !options.force && (activeTurn !== null || options.queueWithoutActive === true);
3809
+ const turn = makeTurnRecord(config, sessionId, prompt, shouldQueue ? "queued" : "active");
3810
+ if (shouldQueue) {
3811
+ await writeTurnRecord(namespaceDir, turn);
3812
+ return { ok: true as const, turn, priorActiveTurn: activeTurn, queued: true };
3813
+ }
3814
+ const pending = await prepareActiveTurnUnlocked(session, turn);
3815
+ return { ok: true as const, turn: pending, priorActiveTurn: activeTurn, queued: false };
3816
+ });
3817
+ if (!prepared.ok || prepared.queued) return prepared;
3818
+ return { ...prepared, turn: await completePreparedTurn(session, prepared.turn) };
1753
3819
  }
1754
3820
 
1755
3821
  async function readTurnPayload(
@@ -1766,24 +3832,21 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1766
3832
  let resolvedTurn = turn;
1767
3833
  let advisoryStatus: Record<string, unknown> = { live: false };
1768
3834
  let sessionState = await readSessionState(namespaceDir, turn.session_id);
1769
- resolvedTurn = await reconcileRuntimeAcknowledgement(
1770
- namespaceDir,
1771
- resolvedTurn,
1772
- sessionState,
1773
- promptAckTimeoutMs,
1774
- { failOnTimeout: false },
1775
- );
3835
+ resolvedTurn =
3836
+ (await reconcileRuntimeAcknowledgementFenced(resolvedTurn.turn_id, sessionState, false)) ?? resolvedTurn;
1776
3837
  if (resolvedTurn !== turn) sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
1777
3838
  if (
1778
3839
  sessionState &&
1779
3840
  ACTIVE_TURN_STATUSES.has(resolvedTurn.status) &&
3841
+ runtimeStateMatchesPrivateOwnerGeneration(sessionState, session) &&
1780
3842
  (sessionState.current_turn_id === resolvedTurn.turn_id ||
1781
3843
  (sessionState.state === "errored" &&
1782
3844
  sessionState.source === "agent_session_event" &&
1783
3845
  sessionState.current_turn_id == null)) &&
1784
3846
  (sessionState.state === "completed" || sessionState.state === "errored")
1785
3847
  ) {
1786
- resolvedTurn = await markTurnTerminalFromSessionState(namespaceDir, resolvedTurn, sessionState);
3848
+ resolvedTurn =
3849
+ (await markTurnTerminalFromSessionStateFenced(resolvedTurn.turn_id, sessionState)) ?? resolvedTurn;
1787
3850
  sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
1788
3851
  } else if (
1789
3852
  sessionState &&
@@ -1795,14 +3858,14 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1795
3858
  session &&
1796
3859
  hasTmuxIdentity(session)
1797
3860
  ) {
1798
- resolvedTurn = await markTurnFailedForUnavailableSession(
1799
- namespaceDir,
1800
- resolvedTurn,
1801
- "tmux_delivery_unavailable",
1802
- );
3861
+ resolvedTurn =
3862
+ (await markTurnFailedForUnavailableSessionFenced(resolvedTurn.turn_id, "tmux_delivery_unavailable")) ??
3863
+ resolvedTurn;
1803
3864
  sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
1804
3865
  } else if (!session && ACTIVE_TURN_STATUSES.has(resolvedTurn.status)) {
1805
- resolvedTurn = await markTurnFailedForUnavailableSession(namespaceDir, resolvedTurn, "session_record_missing");
3866
+ resolvedTurn =
3867
+ (await markTurnFailedForUnavailableSessionFenced(resolvedTurn.turn_id, "session_record_missing")) ??
3868
+ resolvedTurn;
1806
3869
  sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
1807
3870
  } else if (session) {
1808
3871
  advisoryStatus = await inspectTmuxSession(session, boundedLineCount(lines), commandRunner);
@@ -1811,21 +3874,15 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1811
3874
  hasTmuxIdentity(session) &&
1812
3875
  advisoryStatus.live === false
1813
3876
  ) {
1814
- resolvedTurn = await markTurnFailedForUnavailableSession(
1815
- namespaceDir,
1816
- resolvedTurn,
1817
- "tmux_session_missing",
1818
- );
3877
+ resolvedTurn =
3878
+ (await markTurnFailedForUnavailableSessionFenced(resolvedTurn.turn_id, "tmux_session_missing")) ??
3879
+ resolvedTurn;
1819
3880
  sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
1820
3881
  }
1821
3882
  }
1822
3883
  if (ACTIVE_TURN_STATUSES.has(resolvedTurn.status)) {
1823
- resolvedTurn = await reconcileRuntimeAcknowledgement(
1824
- namespaceDir,
1825
- resolvedTurn,
1826
- sessionState,
1827
- promptAckTimeoutMs,
1828
- );
3884
+ resolvedTurn =
3885
+ (await reconcileRuntimeAcknowledgementFenced(resolvedTurn.turn_id, sessionState, true)) ?? resolvedTurn;
1829
3886
  if (!ACTIVE_TURN_STATUSES.has(resolvedTurn.status)) {
1830
3887
  sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
1831
3888
  }
@@ -1847,51 +3904,59 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1847
3904
  }
1848
3905
 
1849
3906
  async function reconcileActiveTurnAcknowledgements(): Promise<void> {
1850
- const turns = (await listJsonFiles(turnsDir(namespaceDir)))
1851
- .map(turn => asRecord(turn) as TurnRecord | null)
1852
- .filter((turn): turn is TurnRecord => turn !== null && ACTIVE_TURN_STATUSES.has(turn.status));
3907
+ await preflightCoordinatorMutation(namespaceDir, undefined, {
3908
+ rejectTerminalRuntimeEvidence: false,
3909
+ });
3910
+
3911
+ const turns = (await listTurnRecords(namespaceDir)).filter(turn => ACTIVE_TURN_STATUSES.has(turn.status));
1853
3912
  for (const turn of turns) {
1854
3913
  let sessionState = await readSessionState(namespaceDir, turn.session_id);
1855
- const resolvedTurn = await reconcileRuntimeAcknowledgement(
1856
- namespaceDir,
1857
- turn,
1858
- sessionState,
1859
- promptAckTimeoutMs,
1860
- { failOnTimeout: false },
1861
- );
1862
- if (!ACTIVE_TURN_STATUSES.has(resolvedTurn.status)) continue;
1863
- if (resolvedTurn !== turn) sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
1864
- const session = asRecord(await readJsonFile(sessionFile(resolvedTurn.session_id)));
1865
3914
  if (
1866
3915
  sessionState &&
1867
- sessionState.current_turn_id === resolvedTurn.turn_id &&
3916
+ (sessionState.state === "completed" || sessionState.state === "errored") &&
3917
+ (sessionState.current_turn_id === turn.turn_id ||
3918
+ (sessionState.state === "errored" &&
3919
+ sessionState.source === "agent_session_event" &&
3920
+ sessionState.current_turn_id == null))
3921
+ ) {
3922
+ const terminal = await markTurnTerminalFromSessionStateFenced(turn.turn_id, sessionState);
3923
+ if (!terminal || !ACTIVE_TURN_STATUSES.has(terminal.status)) continue;
3924
+ }
3925
+ const acknowledged = (await reconcileRuntimeAcknowledgementFenced(turn.turn_id, sessionState, false)) ?? turn;
3926
+ if (!ACTIVE_TURN_STATUSES.has(acknowledged.status)) continue;
3927
+ if (acknowledged !== turn) sessionState = await readSessionState(namespaceDir, acknowledged.session_id);
3928
+ const session = asRecord(await readJsonFile(sessionFile(acknowledged.session_id)));
3929
+ if (
3930
+ sessionState &&
3931
+ sessionState.current_turn_id === acknowledged.turn_id &&
1868
3932
  sessionState.state === "stale" &&
1869
3933
  sessionState.reason === "tmux_delivery_unavailable" &&
1870
- resolvedTurn.delivery.state === "unavailable" &&
3934
+ acknowledged.delivery.state === "unavailable" &&
1871
3935
  session &&
1872
3936
  hasTmuxIdentity(session)
1873
3937
  ) {
1874
- await markTurnFailedForUnavailableSession(namespaceDir, resolvedTurn, "tmux_delivery_unavailable");
3938
+ await markTurnFailedForUnavailableSessionFenced(acknowledged.turn_id, "tmux_delivery_unavailable");
1875
3939
  continue;
1876
3940
  }
1877
3941
  if (!session) {
1878
- await markTurnFailedForUnavailableSession(namespaceDir, resolvedTurn, "session_record_missing");
3942
+ await markTurnFailedForUnavailableSessionFenced(acknowledged.turn_id, "session_record_missing");
1879
3943
  continue;
1880
3944
  }
1881
3945
  if (hasTmuxIdentity(session) && (await hasTmuxSession(session, commandRunner)) === false) {
1882
- await markTurnFailedForUnavailableSession(namespaceDir, resolvedTurn, "tmux_session_missing");
3946
+ await markTurnFailedForUnavailableSessionFenced(acknowledged.turn_id, "tmux_session_missing");
1883
3947
  continue;
1884
3948
  }
1885
- await reconcileRuntimeAcknowledgement(namespaceDir, resolvedTurn, sessionState, promptAckTimeoutMs);
3949
+ await reconcileRuntimeAcknowledgementFenced(acknowledged.turn_id, sessionState, true);
1886
3950
  }
1887
3951
  }
1888
3952
 
1889
- async function callTool(name: string, args: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
3953
+ async function callToolUnlocked(name: string, args: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
1890
3954
  try {
1891
3955
  if (name === "gjc_coordinator_list_sessions") return { ok: true, sessions: await listSessions() };
1892
3956
  if (name === "gjc_coordinator_register_session") {
1893
3957
  requireCoordinatorMutation(config, "sessions", args);
1894
3958
  const sessionId = safeExternalId("session", args.session_id);
3959
+ await preflightCoordinatorMutation(namespaceDir, sessionId);
1895
3960
  const cwd = await assertCoordinatorWorkdir(config, args.cwd);
1896
3961
  const tmuxSession = safeTmuxSessionName(args.tmux_session);
1897
3962
  const tmuxTarget = safeTmuxTarget(args.tmux_target);
@@ -1915,7 +3980,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1915
3980
  sessionId,
1916
3981
  summary: `Session ${sessionId} registered for coordinator control`,
1917
3982
  payloadRef: path.relative(namespaceDir, sessionFile(sessionId)),
1918
- metadata: { source: optionalString(args.source) ?? "register_session", visible: args.visible !== false },
3983
+ metadata: {
3984
+ source: optionalString(args.source) ?? "register_session",
3985
+ visible: args.visible !== false,
3986
+ },
1919
3987
  });
1920
3988
 
1921
3989
  return {
@@ -1952,7 +4020,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1952
4020
  }
1953
4021
  if (name === "gjc_coordinator_read_tail") {
1954
4022
  const session = asRecord(await readJsonFile(sessionFile(args.session_id)));
1955
- return { ok: true, lines: session ? await captureTmuxTail(session, boundedLineCount(args.lines)) : [] };
4023
+ return {
4024
+ ok: true,
4025
+ lines: session ? await captureTmuxTail(session, boundedLineCount(args.lines), commandRunner) : [],
4026
+ };
1956
4027
  }
1957
4028
  if (name === "gjc_coordinator_list_questions") return { ok: true, questions: await listQuestions(args) };
1958
4029
  if (name === "gjc_coordinator_list_artifacts") return { ok: true, roots: config.allowedRoots };
@@ -1987,13 +4058,18 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1987
4058
  turns,
1988
4059
  questions,
1989
4060
  reports,
1990
- events: buildCanonicalCoordinatorEvents({ sessionStates, turns, questions, reports }),
4061
+ events: buildCanonicalCoordinatorEvents({
4062
+ sessionStates,
4063
+ turns,
4064
+ questions,
4065
+ reports,
4066
+ }),
1991
4067
  latest_event_seq: await readLatestEventSeq(namespaceDir),
1992
4068
  recent_events: eventSummaries(events.slice(-10)),
1993
4069
  };
1994
4070
  }
1995
4071
  if (name === "gjc_coordinator_watch_events") {
1996
- await reconcileActiveTurnAcknowledgements();
4072
+ await withCoordinatorTransaction(namespaceDir, reconcileActiveTurnAcknowledgements);
1997
4073
  const limit = boundedEventLimit(args.limit);
1998
4074
  const timeoutMs = boundedEventWatchTimeoutMs(args.timeout_ms);
1999
4075
  let events = await readCoordinatorEvents(namespaceDir);
@@ -2003,7 +4079,7 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2003
4079
  const deadline = Date.now() + timeoutMs;
2004
4080
  while (matched.length === 0 && Date.now() < deadline) {
2005
4081
  await waitForCoordinatorEvents(namespaceDir, Math.min(50, Math.max(1, deadline - Date.now())));
2006
- await reconcileActiveTurnAcknowledgements();
4082
+ await withCoordinatorTransaction(namespaceDir, reconcileActiveTurnAcknowledgements);
2007
4083
  events = await readCoordinatorEvents(namespaceDir);
2008
4084
  matched = filterCoordinatorEvents(events, args, limit);
2009
4085
  }
@@ -2025,6 +4101,17 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2025
4101
  const hasPrompt = typeof args.prompt === "string" && args.prompt.trim().length > 0;
2026
4102
  const task = hasTask ? String(args.task) : hasPrompt ? String(args.prompt) : null;
2027
4103
  if (!task) return { ok: false, reason: "task_required" };
4104
+ await preflightCoordinatorMutation(namespaceDir);
4105
+ const mpresetResolution = await resolveCoordinatorMpreset(args.mpreset, loadModelProfiles);
4106
+ if (!mpresetResolution.ok) {
4107
+ return {
4108
+ ok: false,
4109
+ reason: mpresetResolution.reason,
4110
+ mpreset: mpresetResolution.mpreset,
4111
+ available_profiles: mpresetResolution.available_profiles,
4112
+ };
4113
+ }
4114
+
2028
4115
  const promptAliasIgnored = hasTask && hasPrompt;
2029
4116
  const mutationRequested = args.allow_mutation === true;
2030
4117
  const taggedPrompt = workflowPrompt(delegateWorkflow, name, canonicalCwd, task, {
@@ -2036,70 +4123,88 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2036
4123
  let reusedSession = false;
2037
4124
  if (args.session_id != null) {
2038
4125
  const sessionId = safeExternalId("session", args.session_id);
4126
+ await preflightCoordinatorMutation(namespaceDir, sessionId);
2039
4127
  const existing = asRecord(await readJsonFile(sessionFile(sessionId)));
2040
- if (!existing) return { ok: false, reason: "unknown_session", session_id: sessionId };
4128
+ if (!existing)
4129
+ return {
4130
+ ok: false,
4131
+ reason: "unknown_session",
4132
+ session_id: sessionId,
4133
+ };
2041
4134
  const storedCwd = typeof existing.cwd === "string" ? existing.cwd : null;
2042
4135
  const canonicalStored = storedCwd ? await canonicalizePath(storedCwd) : null;
2043
4136
  const canonicalRequested = await canonicalizePath(canonicalCwd);
2044
4137
  if (!canonicalStored || canonicalStored !== canonicalRequested) {
2045
- return { ok: false, reason: "session_cwd_mismatch", session_id: sessionId };
4138
+ return {
4139
+ ok: false,
4140
+ reason: "session_cwd_mismatch",
4141
+ session_id: sessionId,
4142
+ };
4143
+ }
4144
+ if (mpresetResolution.mpreset !== null) {
4145
+ const sessionMpreset = typeof existing.mpreset === "string" ? existing.mpreset : null;
4146
+ if (sessionMpreset !== mpresetResolution.mpreset) {
4147
+ return {
4148
+ ok: false,
4149
+ reason: "mpreset_conflict",
4150
+ session_id: sessionId,
4151
+ session_mpreset: sessionMpreset,
4152
+ requested_mpreset: mpresetResolution.mpreset,
4153
+ };
4154
+ }
2046
4155
  }
4156
+
2047
4157
  session = existing;
2048
4158
  reusedSession = true;
2049
4159
  } else {
2050
- const input = {
2051
- cwd: canonicalCwd,
2052
- prompt: undefined,
2053
- namespace: config.namespace,
2054
- worktree: true as const,
2055
- };
2056
- const started = services.startSession
2057
- ? await services.startSession(input)
2058
- : await startTmuxSession(config, input, namespaceDir, commandRunner);
2059
- const startedRecord = asRecord(started);
2060
- if (!startedRecord) throw new Error("coordinator_session_command_required");
2061
- session = normalizeSession(startedRecord);
2062
- await writeJsonFile(sessionFile(session.session_id), session);
2063
- await appendCoordinatorEvent(namespaceDir, {
2064
- kind: "session.started",
2065
- sessionId: String(session.session_id),
2066
- summary: `Session ${String(session.session_id)} started by coordinator delegate`,
2067
- payloadRef: path.relative(namespaceDir, sessionFile(session.session_id)),
2068
- metadata: { delegate: true, workflow: delegateWorkflow },
2069
- });
2070
- const live = hasTmuxIdentity(session) ? await hasTmuxSession(session, commandRunner) : null;
2071
- await writeSessionState(namespaceDir, String(session.session_id), "ready_for_input", {
2072
- live,
2073
- reason: null,
2074
- });
4160
+ session = await startCoordinatorSession(canonicalCwd, undefined, mpresetResolution.mpreset);
4161
+ const ownerTransaction = asRecord(session.__coordinatorOwnerTransaction) as {
4162
+ commit?: () => void;
4163
+ rollback?: () => Promise<"cleaned" | "failed" | "unverifiable">;
4164
+ } | null;
4165
+ // Delegate-created sessions are ephemeral: the idle reaper may reclaim them once
4166
+ // idle past the TTL with no active turn. User-registered sessions are never flagged.
4167
+ session.ephemeral = true;
4168
+ try {
4169
+ if (mpresetResolution.mpreset) session.mpreset = mpresetResolution.mpreset;
4170
+ await writeJsonFile(sessionFile(session.session_id), session);
4171
+ await appendCoordinatorEvent(namespaceDir, {
4172
+ kind: "session.started",
4173
+ sessionId: String(session.session_id),
4174
+ summary: `Session ${String(session.session_id)} started by coordinator delegate`,
4175
+ payloadRef: path.relative(namespaceDir, sessionFile(session.session_id)),
4176
+ metadata: { delegate: true, workflow: delegateWorkflow },
4177
+ });
4178
+ await writeSessionState(namespaceDir, String(session.session_id), "booting", {
4179
+ live: null,
4180
+ reason: "awaiting_runtime_readiness",
4181
+ });
4182
+ ownerTransaction?.commit?.();
4183
+ } catch (error) {
4184
+ if (ownerTransaction)
4185
+ throw new CoordinatorStartError(
4186
+ "coordinator_tmux_start_failed",
4187
+ await compensateFailedOwnerStart(session, ownerTransaction),
4188
+ );
4189
+ throw error;
4190
+ }
2075
4191
  }
2076
4192
 
2077
4193
  const sessionId = String(session.session_id);
2078
- const activeTurn = reusedSession ? await readActiveTurn(namespaceDir, sessionId) : null;
2079
- if (activeTurn && args.force !== true && args.queue !== true) {
4194
+ assertSessionReadinessAuthority(session);
4195
+ const prepared = await preparePromptTurn(session, taggedPrompt, {
4196
+ force: args.force === true,
4197
+ queue: args.queue === true,
4198
+ });
4199
+ if (!prepared.ok) {
2080
4200
  return {
2081
4201
  ok: false,
2082
4202
  reason: "active_turn_exists",
2083
4203
  session_id: sessionId,
2084
- active_turn_id: activeTurn.turn_id,
2085
- };
2086
- }
2087
- if (activeTurn && args.force === true) {
2088
- const timestamp = new Date().toISOString();
2089
- const superseded = {
2090
- ...activeTurn,
2091
- status: "superseded" as const,
2092
- updated_at: timestamp,
2093
- completed_at: timestamp,
4204
+ active_turn_id: prepared.activeTurn.turn_id,
2094
4205
  };
2095
- await writeTurnRecord(namespaceDir, superseded);
2096
- await clearActiveTurn(namespaceDir, superseded);
2097
4206
  }
2098
- const shouldQueue = args.queue === true && args.force !== true && !!activeTurn;
2099
- const turn = shouldQueue
2100
- ? makeTurnRecord(config, sessionId, taggedPrompt, "queued")
2101
- : await activateTurn(session, makeTurnRecord(config, sessionId, taggedPrompt, "active"));
2102
- if (shouldQueue) await writeTurnRecord(namespaceDir, turn);
4207
+ const { turn, priorActiveTurn: activeTurn, queued: shouldQueue } = prepared;
2103
4208
  await appendCoordinatorEvent(namespaceDir, {
2104
4209
  kind: "delegation.started",
2105
4210
  sessionId,
@@ -2170,34 +4275,75 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2170
4275
  }
2171
4276
  return base;
2172
4277
  }
4278
+ if (name === "gjc_coordinator_stop_session") {
4279
+ requireCoordinatorMutation(config, "sessions", args);
4280
+ const sessionId = safeExternalId("session", args.session_id);
4281
+ const forceRequested = args.force === true;
4282
+ // force is a capability distinct from allow_mutation: reaping a non-ephemeral
4283
+ // (user-registered) session requires GJC_COORDINATOR_MCP_FORCE_STOP to be enabled.
4284
+ if (forceRequested && !config.forceStopEnabled) {
4285
+ return { ok: false, reason: "force_not_authorized", session_id: sessionId, killed: false };
4286
+ }
4287
+ const result = await reapSession(sessionId, {
4288
+ force: forceRequested,
4289
+ reason: optionalString(args.reason) ?? "stop_session",
4290
+ });
4291
+ return {
4292
+ ok: result.ok,
4293
+ session_id: sessionId,
4294
+ killed: result.killed,
4295
+ ...(result.reason ? { reason: result.reason } : {}),
4296
+ ...(result.active_turn_id ? { active_turn_id: result.active_turn_id } : {}),
4297
+ ...(result.detail ? { detail: result.detail } : {}),
4298
+ };
4299
+ }
2173
4300
  if (name === "gjc_coordinator_start_session") {
2174
4301
  requireCoordinatorMutation(config, "sessions", args);
2175
4302
  const cwd = await assertCoordinatorWorkdir(config, args.cwd);
2176
- const input = {
4303
+ await preflightCoordinatorMutation(namespaceDir);
4304
+ const mpresetResolution = await resolveCoordinatorMpreset(args.mpreset, loadModelProfiles);
4305
+ if (!mpresetResolution.ok) {
4306
+ return {
4307
+ ok: false,
4308
+ reason: mpresetResolution.reason,
4309
+ mpreset: mpresetResolution.mpreset,
4310
+ available_profiles: mpresetResolution.available_profiles,
4311
+ };
4312
+ }
4313
+
4314
+ const session = await startCoordinatorSession(
2177
4315
  cwd,
2178
- prompt: typeof args.prompt === "string" ? args.prompt : undefined,
2179
- namespace: config.namespace,
2180
- worktree: true as const,
2181
- };
2182
- const started = services.startSession
2183
- ? await services.startSession(input)
2184
- : await startTmuxSession(config, input, namespaceDir, commandRunner);
2185
- const startedRecord = asRecord(started);
2186
- if (!startedRecord) throw new Error("coordinator_session_command_required");
2187
- const session = normalizeSession(startedRecord);
2188
- await writeJsonFile(sessionFile(session.session_id), session);
2189
- await appendCoordinatorEvent(namespaceDir, {
2190
- kind: "session.started",
2191
- sessionId: String(session.session_id),
2192
- summary: `Session ${String(session.session_id)} started by coordinator`,
2193
- payloadRef: path.relative(namespaceDir, sessionFile(session.session_id)),
2194
- metadata: { prompted: typeof args.prompt === "string" && args.prompt.length > 0 },
2195
- });
2196
- const live = hasTmuxIdentity(session) ? await hasTmuxSession(session, commandRunner) : null;
2197
- let sessionState = await writeSessionState(namespaceDir, String(session.session_id), "ready_for_input", {
2198
- live,
2199
- reason: null,
2200
- });
4316
+ typeof args.prompt === "string" ? args.prompt : undefined,
4317
+ mpresetResolution.mpreset,
4318
+ );
4319
+ const ownerTransaction = asRecord(session.__coordinatorOwnerTransaction) as {
4320
+ commit?: () => void;
4321
+ rollback?: () => Promise<"cleaned" | "failed" | "unverifiable">;
4322
+ } | null;
4323
+ let sessionState: CoordinatorSessionState;
4324
+ try {
4325
+ if (mpresetResolution.mpreset) session.mpreset = mpresetResolution.mpreset;
4326
+ await writeJsonFile(sessionFile(session.session_id), session);
4327
+ await appendCoordinatorEvent(namespaceDir, {
4328
+ kind: "session.started",
4329
+ sessionId: String(session.session_id),
4330
+ summary: `Session ${String(session.session_id)} started by coordinator`,
4331
+ payloadRef: path.relative(namespaceDir, sessionFile(session.session_id)),
4332
+ metadata: { prompted: typeof args.prompt === "string" && args.prompt.length > 0 },
4333
+ });
4334
+ sessionState = await writeSessionState(namespaceDir, String(session.session_id), "booting", {
4335
+ live: null,
4336
+ reason: "awaiting_runtime_readiness",
4337
+ });
4338
+ ownerTransaction?.commit?.();
4339
+ } catch (error) {
4340
+ if (ownerTransaction)
4341
+ throw new CoordinatorStartError(
4342
+ "coordinator_tmux_start_failed",
4343
+ await compensateFailedOwnerStart(session, ownerTransaction),
4344
+ );
4345
+ throw error;
4346
+ }
2201
4347
  if (typeof args.prompt === "string" && args.prompt.length > 0) {
2202
4348
  const turn = await activateTurn(
2203
4349
  session,
@@ -2233,36 +4379,28 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2233
4379
  if (name === "gjc_coordinator_send_prompt") {
2234
4380
  requireCoordinatorMutation(config, "sessions", args);
2235
4381
  const sessionId = safeExternalId("session", args.session_id);
4382
+ await preflightCoordinatorMutation(namespaceDir, sessionId);
2236
4383
  const session = asRecord(await readJsonFile(sessionFile(sessionId)));
2237
4384
  if (!session) return { ok: false, reason: "unknown_session", session_id: sessionId };
2238
4385
  if (typeof args.prompt !== "string" || args.prompt.length === 0)
2239
4386
  return { ok: false, reason: "prompt_required" };
2240
- const activeTurn = await readActiveTurn(namespaceDir, sessionId);
2241
- if (activeTurn && args.force !== true && args.queue !== true) {
4387
+ await readSessionState(namespaceDir, sessionId);
4388
+ await readLatestEventSeq(namespaceDir);
4389
+ assertSessionReadinessAuthority(session);
4390
+ const prepared = await preparePromptTurn(session, args.prompt, {
4391
+ force: args.force === true,
4392
+ queue: args.queue === true,
4393
+ queueWithoutActive: true,
4394
+ });
4395
+ if (!prepared.ok) {
2242
4396
  return {
2243
4397
  ok: false,
2244
4398
  reason: "active_turn_exists",
2245
4399
  session_id: sessionId,
2246
- active_turn_id: activeTurn.turn_id,
2247
- };
2248
- }
2249
- if (activeTurn && args.force === true) {
2250
- const timestamp = new Date().toISOString();
2251
- const superseded = {
2252
- ...activeTurn,
2253
- status: "superseded" as const,
2254
- updated_at: timestamp,
2255
- completed_at: timestamp,
4400
+ active_turn_id: prepared.activeTurn.turn_id,
2256
4401
  };
2257
- await writeTurnRecord(namespaceDir, superseded);
2258
- await clearActiveTurn(namespaceDir, superseded);
2259
4402
  }
2260
- const shouldQueue = args.queue === true && args.force !== true;
2261
- const turn = shouldQueue
2262
- ? makeTurnRecord(config, sessionId, args.prompt, "queued")
2263
- : await activateTurn(session, makeTurnRecord(config, sessionId, args.prompt, "active"));
2264
- if (shouldQueue) await writeTurnRecord(namespaceDir, turn);
2265
- const recordedTurn = turn;
4403
+ const { turn: recordedTurn, priorActiveTurn: activeTurn, queued: shouldQueue } = prepared;
2266
4404
  const prompt = {
2267
4405
  session_id: sessionId,
2268
4406
  turn_id: recordedTurn.turn_id,
@@ -2296,7 +4434,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2296
4434
  const timeoutMs = boundedAwaitTurnTimeoutMs(args.timeout_ms);
2297
4435
  const pollIntervalMs = boundedPollIntervalMs(args.poll_interval_ms);
2298
4436
  const deadline = Date.now() + timeoutMs;
2299
- let payload = await readTurnPayload(args.turn_id, args.session_id, args.lines);
4437
+ let payload = await withCoordinatorTransaction(
4438
+ namespaceDir,
4439
+ async () => await readTurnPayload(args.turn_id, args.session_id, args.lines),
4440
+ );
2300
4441
  while (
2301
4442
  payload.ok === true &&
2302
4443
  !TERMINAL_TURN_STATUSES.has((payload.turn as TurnRecord).status) &&
@@ -2308,7 +4449,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2308
4449
  payload.turn as TurnRecord,
2309
4450
  Math.min(pollIntervalMs, remainingMs),
2310
4451
  );
2311
- payload = await readTurnPayload(args.turn_id, args.session_id, args.lines);
4452
+ payload = await withCoordinatorTransaction(
4453
+ namespaceDir,
4454
+ async () => await readTurnPayload(args.turn_id, args.session_id, args.lines),
4455
+ );
2312
4456
  }
2313
4457
  if (payload.ok === true && !TERMINAL_TURN_STATUSES.has((payload.turn as TurnRecord).status)) {
2314
4458
  return {
@@ -2333,6 +4477,16 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2333
4477
  if (args.turn_id != null && question.turn_id !== safeTurnId(args.turn_id)) {
2334
4478
  return { ok: false, reason: "question_turn_mismatch" };
2335
4479
  }
4480
+ await preflightCoordinatorMutation(namespaceDir);
4481
+ const questionSessionId =
4482
+ typeof question.session_id === "string" ? safeExternalId("session", question.session_id) : null;
4483
+ if (!questionSessionId) throw coordinatorStateError("invalid");
4484
+ await preflightCoordinatorMutation(namespaceDir, questionSessionId);
4485
+ if (typeof question.turn_id === "string") {
4486
+ const referencedTurn = await readTurnRecord(namespaceDir, question.turn_id);
4487
+ if (!referencedTurn || referencedTurn.session_id !== questionSessionId)
4488
+ throw coordinatorStateError("invalid");
4489
+ }
2336
4490
  const answeredTurnId = typeof question.turn_id === "string" ? question.turn_id : null;
2337
4491
  const answered = {
2338
4492
  ...question,
@@ -2362,33 +4516,43 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2362
4516
 
2363
4517
  let turn: TurnRecord | null = null;
2364
4518
  if (answeredTurnId) {
2365
- turn = await readTurnRecord(namespaceDir, answeredTurnId);
2366
- if (turn) {
2367
- const timestamp = new Date().toISOString();
2368
- turn = {
2369
- ...turn,
2370
- status: "active",
2371
- question_ids: [...new Set([...turn.question_ids, questionId])],
2372
- updated_at: timestamp,
2373
- };
2374
- await writeTurnRecord(namespaceDir, turn);
2375
- await writeActiveTurn(namespaceDir, turn);
2376
- await writeSessionState(namespaceDir, turn.session_id, "running", {
2377
- currentTurnId: turn.turn_id,
2378
- live: null,
2379
- reason: null,
4519
+ const observedTurn = await readTurnRecord(namespaceDir, answeredTurnId);
4520
+ if (observedTurn) {
4521
+ turn = await withSessionTransition(observedTurn.session_id, async () => {
4522
+ const current = await readTurnRecord(namespaceDir, answeredTurnId);
4523
+ if (!current || !ACTIVE_TURN_STATUSES.has(current.status)) return current;
4524
+ const timestamp = new Date().toISOString();
4525
+ const answeredTurn: TurnRecord = {
4526
+ ...current,
4527
+ status: "active",
4528
+ question_ids: [...new Set([...current.question_ids, questionId])],
4529
+ updated_at: timestamp,
4530
+ };
4531
+ await writeTurnRecord(namespaceDir, answeredTurn);
4532
+ await writeActiveTurn(namespaceDir, answeredTurn);
4533
+ await writeSessionState(namespaceDir, answeredTurn.session_id, "running", {
4534
+ currentTurnId: answeredTurn.turn_id,
4535
+ live: null,
4536
+ reason: null,
4537
+ });
4538
+ const session = asRecord(await readJsonFile(sessionFile(answeredTurn.session_id)));
4539
+ if (session && typeof args.answer === "string") {
4540
+ await sendTmuxPrompt(session, args.answer, commandRunner, services.ownerIsolationProbe);
4541
+ }
4542
+ return answeredTurn;
2380
4543
  });
2381
- const session = asRecord(await readJsonFile(sessionFile(turn.session_id)));
2382
- if (session && typeof args.answer === "string")
2383
- await sendTmuxPrompt(session, args.answer, commandRunner);
2384
4544
  }
2385
4545
  }
2386
4546
  return { ok: true, question: answered, ...(turn ? { turn } : {}) };
2387
4547
  }
2388
4548
  if (name === "gjc_coordinator_report_status") {
2389
4549
  requireCoordinatorMutation(config, "reports", args);
4550
+ await preflightCoordinatorMutation(namespaceDir);
4551
+
2390
4552
  const evidence = await validateEvidencePaths(args.evidence_paths);
2391
4553
  const sessionId = args.session_id == null ? null : safeExternalId("session", args.session_id);
4554
+ if (sessionId) await preflightCoordinatorMutation(namespaceDir, sessionId);
4555
+
2392
4556
  const report = {
2393
4557
  session_id: sessionId,
2394
4558
  turn_id: args.turn_id,
@@ -2401,61 +4565,79 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2401
4565
  };
2402
4566
  let turn: TurnRecord | null = null;
2403
4567
  let promotedTurn: TurnRecord | null = null;
4568
+ let promotedPreparation: { session: Record<string, unknown>; turn: TurnRecord } | null = null;
2404
4569
  if (args.turn_id != null) {
2405
4570
  turn = await readTurnRecord(namespaceDir, args.turn_id);
2406
4571
  if (!turn) return { ok: false, reason: "unknown_turn" };
2407
4572
  if (sessionId != null && turn.session_id !== sessionId) {
2408
4573
  return { ok: false, reason: "turn_session_mismatch" };
2409
4574
  }
4575
+ await preflightCoordinatorMutation(namespaceDir, turn.session_id);
2410
4576
  const terminalStatus = asTerminalTurnStatus(args.status);
2411
4577
  if (terminalStatus) {
2412
- const timestamp = new Date().toISOString();
2413
- turn = {
2414
- ...turn,
2415
- status: terminalStatus,
2416
- delivery: {
2417
- ...turn.delivery,
2418
- prompt_acknowledged: true,
2419
- state: "acknowledged",
2420
- },
2421
- final_response: {
2422
- text:
2423
- typeof args.summary === "string"
2424
- ? args.summary
2425
- : typeof args.blocker === "string"
2426
- ? args.blocker
2427
- : null,
2428
- format: "markdown",
2429
- source: "report_status",
2430
- artifact_path: null,
2431
- truncated: false,
2432
- },
2433
- evidence,
2434
- error:
2435
- terminalStatus === "failed"
2436
- ? {
2437
- code: "reported_failure",
2438
- message:
2439
- typeof args.blocker === "string" ? args.blocker : String(args.summary ?? "failed"),
2440
- recoverable: true,
2441
- }
2442
- : null,
2443
- updated_at: timestamp,
2444
- completed_at: timestamp,
2445
- };
2446
- await writeTurnRecord(namespaceDir, turn);
2447
- await clearActiveTurn(namespaceDir, turn);
2448
- await writeSessionState(
2449
- namespaceDir,
2450
- turn.session_id,
2451
- terminalStatus === "failed" ? "errored" : "completed",
2452
- {
2453
- lastTurnId: turn.turn_id,
2454
- live: null,
2455
- reason: terminalStatus === "failed" ? "reported_failure" : null,
2456
- },
2457
- );
2458
- promotedTurn = await promoteNextQueuedTurn(turn.session_id);
4578
+ const transition = await withSessionTransition(turn.session_id, async () => {
4579
+ const current = await readTurnRecord(namespaceDir, turn?.turn_id);
4580
+ if (!current || TERMINAL_TURN_STATUSES.has(current.status)) {
4581
+ return { turn: current, promotion: null };
4582
+ }
4583
+ const timestamp = new Date().toISOString();
4584
+ const terminalTurn: TurnRecord = {
4585
+ ...current,
4586
+ status: terminalStatus,
4587
+ delivery: {
4588
+ ...current.delivery,
4589
+ prompt_acknowledged: true,
4590
+ state: "acknowledged",
4591
+ },
4592
+ final_response: {
4593
+ text:
4594
+ typeof args.summary === "string"
4595
+ ? args.summary
4596
+ : typeof args.blocker === "string"
4597
+ ? args.blocker
4598
+ : null,
4599
+ format: "markdown",
4600
+ source: "report_status",
4601
+ artifact_path: null,
4602
+ truncated: false,
4603
+ },
4604
+ evidence,
4605
+ error:
4606
+ terminalStatus === "failed"
4607
+ ? {
4608
+ code: "reported_failure",
4609
+ message:
4610
+ typeof args.blocker === "string"
4611
+ ? args.blocker
4612
+ : String(args.summary ?? "failed"),
4613
+ recoverable: true,
4614
+ }
4615
+ : null,
4616
+ updated_at: timestamp,
4617
+ completed_at: timestamp,
4618
+ };
4619
+ await writeTurnRecord(namespaceDir, terminalTurn);
4620
+ await clearActiveTurn(namespaceDir, terminalTurn);
4621
+ await writeSessionState(
4622
+ namespaceDir,
4623
+ terminalTurn.session_id,
4624
+ terminalStatus === "failed" ? "errored" : "completed",
4625
+ {
4626
+ lastTurnId: terminalTurn.turn_id,
4627
+ live: null,
4628
+ reason: terminalStatus === "failed" ? "reported_failure" : null,
4629
+ },
4630
+ );
4631
+ return {
4632
+ turn: terminalTurn,
4633
+ promotion: await prepareNextQueuedTurnUnlocked(terminalTurn.session_id),
4634
+ };
4635
+ });
4636
+ turn = transition.turn;
4637
+ promotedPreparation = transition.promotion;
4638
+ if (promotedPreparation) {
4639
+ promotedTurn = await completePreparedTurn(promotedPreparation.session, promotedPreparation.turn);
4640
+ }
2459
4641
  }
2460
4642
  }
2461
4643
  const reportId = `report-${Date.now()}`;
@@ -2471,21 +4653,60 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2471
4653
  ? args.summary
2472
4654
  : `Report ${String(args.status ?? "unknown")} written`,
2473
4655
  payloadRef: path.relative(namespaceDir, reportPath),
2474
- metadata: { status: typeof args.status === "string" ? args.status : null },
4656
+ metadata: {
4657
+ status: typeof args.status === "string" ? args.status : null,
4658
+ },
2475
4659
  });
2476
4660
  return {
2477
4661
  ok: true,
2478
4662
  report,
2479
- ...(turn ? { turn, session_state: await readSessionState(namespaceDir, turn.session_id) } : {}),
4663
+ ...(turn
4664
+ ? {
4665
+ turn,
4666
+ session_state: await readSessionState(namespaceDir, turn.session_id),
4667
+ }
4668
+ : {}),
2480
4669
  ...(promotedTurn ? { promoted_turn: promotedTurn } : {}),
2481
4670
  };
2482
4671
  }
2483
4672
  return { ok: false, reason: "unknown_tool", tool: name };
2484
4673
  } catch (error) {
2485
- return { ok: false, reason: error instanceof Error ? error.message : String(error) };
4674
+ const failure = error as { sessionId?: unknown; action?: unknown };
4675
+ return {
4676
+ ok: false,
4677
+ reason: publicCoordinatorError(error),
4678
+ ...(publicCleanupStatus(error) ? { cleanup_status: publicCleanupStatus(error) } : {}),
4679
+ ...(typeof failure.sessionId === "string" ? { session_id: failure.sessionId } : {}),
4680
+ ...(typeof failure.action === "string" ? { action: failure.action } : {}),
4681
+ };
4682
+ }
4683
+ }
4684
+
4685
+ async function callToolInternal(name: string, args: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
4686
+ try {
4687
+ if (
4688
+ name === "gjc_coordinator_await_turn" ||
4689
+ name === "gjc_coordinator_watch_events" ||
4690
+ (name === "gjc_coordinator_start_session" && typeof args.prompt === "string" && args.prompt.length > 0) ||
4691
+ name === "gjc_coordinator_send_prompt" ||
4692
+ workflowForDelegateTool(name) !== null
4693
+ ) {
4694
+ return await callToolUnlocked(name, args);
4695
+ }
4696
+ return await withCoordinatorTransaction(namespaceDir, async () => await callToolUnlocked(name, args));
4697
+ } catch (error) {
4698
+ return {
4699
+ ok: false,
4700
+ reason: publicCoordinatorError(error),
4701
+ ...(publicCleanupStatus(error) ? { cleanup_status: publicCleanupStatus(error) } : {}),
4702
+ };
2486
4703
  }
2487
4704
  }
2488
4705
 
4706
+ async function callTool(name: string, args: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
4707
+ return publicCoordinatorResponse(await callToolInternal(name, args)) as Record<string, unknown>;
4708
+ }
4709
+
2489
4710
  async function handleJsonRpc(request: JsonRpcRequest): Promise<JsonRpcResponse> {
2490
4711
  const id = request.id ?? null;
2491
4712
  if (request.method === "initialize") {
@@ -2499,8 +4720,15 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2499
4720
  },
2500
4721
  };
2501
4722
  }
4723
+ if (request.method === "ping") {
4724
+ return { jsonrpc: "2.0", id, result: {} };
4725
+ }
2502
4726
  if (request.method === "tools/list") {
2503
- return { jsonrpc: "2.0", id, result: { tools: COORDINATOR_MCP_TOOL_NAMES.map(toolSchema) } };
4727
+ return {
4728
+ jsonrpc: "2.0",
4729
+ id,
4730
+ result: { tools: COORDINATOR_MCP_TOOL_NAMES.map(toolSchema) },
4731
+ };
2504
4732
  }
2505
4733
  if (request.method === "prompts/list") {
2506
4734
  return { jsonrpc: "2.0", id, result: { prompts: [] } };
@@ -2509,17 +4737,31 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2509
4737
  return { jsonrpc: "2.0", id, result: { resources: [] } };
2510
4738
  }
2511
4739
  if (request.method === "tools/call") {
2512
- const params = (request.params ?? {}) as { name?: string; arguments?: Record<string, unknown> };
4740
+ const params = (request.params ?? {}) as {
4741
+ name?: string;
4742
+ arguments?: Record<string, unknown>;
4743
+ };
2513
4744
  const payload = await callTool(params.name ?? "", params.arguments ?? {});
2514
- return { jsonrpc: "2.0", id, result: textResult(payload, payload.ok === false) };
4745
+ return {
4746
+ jsonrpc: "2.0",
4747
+ id,
4748
+ result: textResult(payload, payload.ok === false),
4749
+ };
2515
4750
  }
2516
- return { jsonrpc: "2.0", id, error: { code: -32601, message: `unknown_method:${request.method}` } };
4751
+ return {
4752
+ jsonrpc: "2.0",
4753
+ id,
4754
+ error: { code: -32601, message: `unknown_method:${request.method}` },
4755
+ };
2517
4756
  }
2518
4757
 
2519
- return { config, callTool, handleJsonRpc, handle: handleJsonRpc };
4758
+ return { config, callTool, handleJsonRpc, handle: handleJsonRpc, reapSession, sessionReaper };
2520
4759
  }
2521
4760
 
2522
- function legacyToolResult(payload: unknown): { content: Array<{ type: "text"; text: string }>; isError: boolean } {
4761
+ function legacyToolResult(payload: unknown): {
4762
+ content: Array<{ type: "text"; text: string }>;
4763
+ isError: boolean;
4764
+ } {
2523
4765
  const failed = typeof payload === "object" && payload !== null && (payload as { ok?: unknown }).ok === false;
2524
4766
  return textResult(payload, failed);
2525
4767
  }
@@ -2540,13 +4782,21 @@ export async function handleCoordinatorMcpRequest(
2540
4782
  };
2541
4783
  }
2542
4784
  if (request.method === "tools/list") {
2543
- return { jsonrpc: "2.0", id: request.id ?? null, result: { tools: COORDINATOR_MCP_TOOL_NAMES.map(toolSchema) } };
4785
+ return {
4786
+ jsonrpc: "2.0",
4787
+ id: request.id ?? null,
4788
+ result: { tools: COORDINATOR_MCP_TOOL_NAMES.map(toolSchema) },
4789
+ };
2544
4790
  }
2545
4791
  if (request.method === "prompts/list") {
2546
4792
  return { jsonrpc: "2.0", id: request.id ?? null, result: { prompts: [] } };
2547
4793
  }
2548
4794
  if (request.method === "resources/list") {
2549
- return { jsonrpc: "2.0", id: request.id ?? null, result: { resources: [] } };
4795
+ return {
4796
+ jsonrpc: "2.0",
4797
+ id: request.id ?? null,
4798
+ result: { resources: [] },
4799
+ };
2550
4800
  }
2551
4801
  if (request.method !== "tools/call")
2552
4802
  return {
@@ -2554,11 +4804,14 @@ export async function handleCoordinatorMcpRequest(
2554
4804
  id: request.id ?? null,
2555
4805
  error: { code: -32601, message: `unknown_method:${request.method}` },
2556
4806
  };
2557
- const params = (request.params ?? {}) as { name?: string; arguments?: Record<string, unknown> };
4807
+ const params = (request.params ?? {}) as {
4808
+ name?: string;
4809
+ arguments?: Record<string, unknown>;
4810
+ };
2558
4811
  const args = params.arguments ?? {};
2559
4812
  const server = createCoordinatorMcpServer({
2560
4813
  env: options.env ?? process.env,
2561
- services: options.createSession ? { startSession: () => options.createSession?.() } : undefined,
4814
+ services: options.createSession ? { startSession: input => options.createSession?.(input) } : undefined,
2562
4815
  });
2563
4816
  return {
2564
4817
  jsonrpc: "2.0",
@@ -2567,23 +4820,226 @@ export async function handleCoordinatorMcpRequest(
2567
4820
  };
2568
4821
  }
2569
4822
 
2570
- export async function runCoordinatorMcpStdio(options: CoordinatorMcpServerOptions = {}): Promise<void> {
2571
- const server = createCoordinatorMcpServer(options);
4823
+ export interface PumpCoordinatorOptions {
4824
+ /** Max concurrent in-flight *data* (non-control) handlers. Control frames (ping) bypass this. */
4825
+ maxDataConcurrency?: number;
4826
+ /** Max data requests queued waiting for a slot before overflow is rejected as server_busy. */
4827
+ maxQueueDepth?: number;
4828
+ /** Bounded wait for in-flight handlers/writes to settle after input ends. */
4829
+ drainTimeoutMs?: number;
4830
+ }
4831
+
4832
+ /**
4833
+ * Pump a newline-delimited JSON-RPC stream with BOUNDED concurrent dispatch.
4834
+ *
4835
+ * A long-running tool call (e.g. gjc_coordinator_await_turn, which polls for
4836
+ * minutes) must not block the read loop from answering keepalive pings on the
4837
+ * same stdio channel. But naive unbounded concurrency reintroduces its own
4838
+ * hazards, so this pump enforces the safety envelope the coordinator needs:
4839
+ *
4840
+ * - Control frames (ping) bypass the data-concurrency cap → keepalive is always
4841
+ * answerable even while data handlers saturate.
4842
+ * - Data handlers are capped at `maxDataConcurrency`; excess is queued up to
4843
+ * `maxQueueDepth`, then rejected as `server_busy` (bounded memory / fanout).
4844
+ * - A coded local `EPIPE` terminalizes writer and dispatch together; other
4845
+ * writer faults reject the pump without poisoning the serialized write chain.
4846
+ * - On EOF or a closed peer the pump drains already-running handlers (bounded
4847
+ * by `drainTimeoutMs`) and never promotes queued work.
4848
+ * - Byte chunks are decoded with a streaming decoder so multibyte characters
4849
+ * split across chunks are not corrupted.
4850
+ */
4851
+ export async function pumpCoordinatorMcpStream(
4852
+ handleJsonRpc: (request: JsonRpcRequest) => Promise<JsonRpcResponse>,
4853
+ input: AsyncIterable<string | Uint8Array>,
4854
+ writeLine: (line: string) => void | Promise<void>,
4855
+ options: PumpCoordinatorOptions = {},
4856
+ ): Promise<void> {
4857
+ const maxDataConcurrency = Math.max(1, options.maxDataConcurrency ?? 32);
4858
+ const maxQueueDepth = Math.max(0, options.maxQueueDepth ?? 256);
4859
+ const drainTimeoutMs = Math.max(1, options.drainTimeoutMs ?? 30_000);
4860
+
4861
+ let writerState: "open" | "terminalizing" | "closed" = "open";
4862
+ let draining = false;
4863
+ let writeChain: Promise<void> = Promise.resolve();
4864
+ const inFlight = new Set<Promise<void>>();
4865
+ let activeData = 0;
4866
+ const dataQueue: JsonRpcRequest[] = [];
4867
+ const peerClosed = Promise.withResolvers<void>();
4868
+ const writerFailure = Promise.withResolvers<never>();
4869
+ void writerFailure.promise.catch(() => {});
4870
+ const inputIterator = input[Symbol.asyncIterator]();
4871
+ let inputDetached = false;
4872
+ let fatalWriterFailure: { value: unknown } | undefined;
4873
+
4874
+ const detachInput = (): void => {
4875
+ if (inputDetached) return;
4876
+ inputDetached = true;
4877
+ const detached = inputIterator.return?.();
4878
+ if (detached) void detached.catch(() => {});
4879
+ };
4880
+ const terminalizePeer = (): void => {
4881
+ if (writerState !== "open") return;
4882
+ writerState = "terminalizing";
4883
+ draining = true;
4884
+ dataQueue.length = 0;
4885
+ detachInput();
4886
+ peerClosed.resolve();
4887
+ };
4888
+ const failWriter = (failure: unknown): void => {
4889
+ if (writerState === "closed") return;
4890
+ writerState = "closed";
4891
+ draining = true;
4892
+ dataQueue.length = 0;
4893
+ detachInput();
4894
+ fatalWriterFailure = { value: failure };
4895
+
4896
+ writerFailure.reject(failure);
4897
+ };
4898
+ const isExpectedPeerClosure = (failure: unknown): boolean =>
4899
+ isKnownSinkPeerClosedError(failure) && sinkErrorCode(failure) === "EPIPE";
4900
+
4901
+ const emit = (response: JsonRpcResponse): Promise<void> => {
4902
+ writeChain = writeChain.then(async () => {
4903
+ if (writerState !== "open") return;
4904
+ try {
4905
+ await writeLine(`${JSON.stringify(response)}\n`);
4906
+ } catch (failure) {
4907
+ if (isExpectedPeerClosure(failure)) terminalizePeer();
4908
+ else failWriter(failure);
4909
+ }
4910
+ });
4911
+ return writeChain;
4912
+ };
4913
+
4914
+ const launch = (request: JsonRpcRequest, control: boolean): void => {
4915
+ const task = (async () => {
4916
+ let response: JsonRpcResponse;
4917
+ try {
4918
+ response = await handleJsonRpc(request);
4919
+ } catch (failure) {
4920
+ response = {
4921
+ jsonrpc: "2.0",
4922
+ id: request.id ?? null,
4923
+ error: { code: -32603, message: publicCoordinatorError(failure) },
4924
+ };
4925
+ }
4926
+ await emit(response);
4927
+ if (!control) {
4928
+ activeData -= 1;
4929
+ if (!draining && writerState === "open") {
4930
+ const next = dataQueue.shift();
4931
+ if (next) {
4932
+ activeData += 1;
4933
+ launch(next, false);
4934
+ }
4935
+ }
4936
+ }
4937
+ })();
4938
+ inFlight.add(task);
4939
+ void task.finally(() => inFlight.delete(task));
4940
+ };
4941
+
4942
+ const dispatch = (request: JsonRpcRequest): void => {
4943
+ if (writerState !== "open") return;
4944
+ // Notifications (no id) get no response; the coordinator has no side-effecting ones.
4945
+ if (request.id === undefined || request.id === null) return;
4946
+ if (request.method === "ping") {
4947
+ launch(request, true); // control frame: bypass the data cap
4948
+ return;
4949
+ }
4950
+ if (activeData < maxDataConcurrency) {
4951
+ activeData += 1;
4952
+ launch(request, false);
4953
+ return;
4954
+ }
4955
+ if (dataQueue.length < maxQueueDepth) {
4956
+ dataQueue.push(request);
4957
+ return;
4958
+ }
4959
+ void emit({
4960
+ jsonrpc: "2.0",
4961
+ id: request.id,
4962
+ error: { code: -32000, message: "server_busy: coordinator request queue is full" },
4963
+ });
4964
+ };
4965
+
4966
+ const drainInFlightAndWrites = async (): Promise<void> => {
4967
+ let timer: NodeJS.Timeout | undefined;
4968
+ const timeout = new Promise<"timed_out">(resolve => {
4969
+ timer = setTimeout(() => resolve("timed_out"), drainTimeoutMs);
4970
+ (timer as { unref?: () => void }).unref?.();
4971
+ });
4972
+ const drain = async (): Promise<"drained"> => {
4973
+ while (true) {
4974
+ if (inFlight.size > 0) await Promise.allSettled([...inFlight]);
4975
+ const writes = writeChain;
4976
+ await writes;
4977
+ if (inFlight.size === 0 && writes === writeChain) return "drained";
4978
+ }
4979
+ };
4980
+ try {
4981
+ if ((await Promise.race([drain(), timeout])) === "timed_out") {
4982
+ writerState = "closed";
4983
+ draining = true;
4984
+ dataQueue.length = 0;
4985
+ detachInput();
4986
+ }
4987
+ } finally {
4988
+ if (timer) clearTimeout(timer);
4989
+ }
4990
+ };
4991
+
4992
+ const decoder = new TextDecoder();
2572
4993
  let buffer = "";
2573
- for await (const chunk of process.stdin) {
2574
- buffer += chunk.toString();
2575
- let newline = buffer.indexOf("\n");
2576
- while (newline >= 0) {
2577
- const line = buffer.slice(0, newline).trim();
2578
- buffer = buffer.slice(newline + 1);
2579
- if (line.length > 0) {
2580
- const request = JSON.parse(line) as JsonRpcRequest;
2581
- if (request.id !== undefined && request.id !== null) {
2582
- const response = await server.handleJsonRpc(request);
2583
- process.stdout.write(`${JSON.stringify(response)}\n`);
4994
+ let inputFailure: unknown;
4995
+ try {
4996
+ while (writerState === "open") {
4997
+ const next = await Promise.race([inputIterator.next(), peerClosed.promise, writerFailure.promise]);
4998
+ if (!next || next.done) break;
4999
+ buffer += typeof next.value === "string" ? next.value : decoder.decode(next.value, { stream: true });
5000
+ let newline = buffer.indexOf("\n");
5001
+ while (newline >= 0 && writerState === "open") {
5002
+ const line = buffer.slice(0, newline).trim();
5003
+ buffer = buffer.slice(newline + 1);
5004
+ if (line.length > 0) {
5005
+ let request: JsonRpcRequest | null = null;
5006
+ try {
5007
+ request = JSON.parse(line) as JsonRpcRequest;
5008
+ } catch {
5009
+ request = null; // ignore malformed frames rather than crashing the loop
5010
+ }
5011
+ if (request) dispatch(request);
2584
5012
  }
5013
+ newline = buffer.indexOf("\n");
2585
5014
  }
2586
- newline = buffer.indexOf("\n");
2587
5015
  }
5016
+ } catch (failure) {
5017
+ inputFailure = failure;
5018
+ }
5019
+
5020
+ // Input EOF or a closed peer stops promotion. One deadline bounds both the
5021
+ // active handlers and the serialized writes they have already queued.
5022
+ draining = true;
5023
+ dataQueue.length = 0;
5024
+ await drainInFlightAndWrites();
5025
+ if (fatalWriterFailure) throw fatalWriterFailure.value;
5026
+ if (inputFailure !== undefined) throw inputFailure;
5027
+ writerState = "closed";
5028
+ }
5029
+
5030
+ export async function runCoordinatorMcpStdio(options: CoordinatorMcpServerOptions = {}): Promise<void> {
5031
+ const server = createCoordinatorMcpServer(options);
5032
+ server.sessionReaper.start(); // background idle-reap of ephemeral delegate sessions
5033
+ try {
5034
+ await pumpCoordinatorMcpStream(
5035
+ request => server.handleJsonRpc(request),
5036
+ process.stdin,
5037
+ line =>
5038
+ new Promise<void>((resolve, reject) => {
5039
+ process.stdout.write(line, err => (err ? reject(err) : resolve()));
5040
+ }),
5041
+ );
5042
+ } finally {
5043
+ server.sessionReaper.stop();
2588
5044
  }
2589
5045
  }