@gajae-code/coding-agent 0.9.6 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/CHANGELOG.md +45 -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 +4 -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/coordinator-mcp/model-preset.d.ts +45 -0
  24. package/dist/types/coordinator-mcp/server.d.ts +67 -6
  25. package/dist/types/daemon/control-types.d.ts +10 -0
  26. package/dist/types/daemon/operator-contract.d.ts +47 -0
  27. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +4 -0
  28. package/dist/types/discovery/helpers.d.ts +1 -1
  29. package/dist/types/discovery/substitute-plugin-root.d.ts +0 -4
  30. package/dist/types/gjc-runtime/launch-tmux.d.ts +26 -7
  31. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +34 -3
  32. package/dist/types/gjc-runtime/tmux-common.d.ts +6 -0
  33. package/dist/types/gjc-runtime/tmux-owner-isolation-cli.d.ts +5 -0
  34. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +309 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +49 -17
  36. package/dist/types/internal-urls/local-protocol.d.ts +13 -9
  37. package/dist/types/main.d.ts +66 -2
  38. package/dist/types/modes/acp/acp-agent.d.ts +19 -2
  39. package/dist/types/modes/acp/acp-mode.d.ts +12 -0
  40. package/dist/types/modes/components/assistant-message.d.ts +1 -1
  41. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  42. package/dist/types/modes/components/gajae-pet-widget.d.ts +59 -0
  43. package/dist/types/modes/components/hook-selector.d.ts +5 -0
  44. package/dist/types/modes/components/irc-sidebar.d.ts +32 -0
  45. package/dist/types/modes/components/pet-selector.d.ts +11 -0
  46. package/dist/types/modes/components/session-selector.d.ts +22 -9
  47. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  48. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  49. package/dist/types/modes/components/user-message.d.ts +2 -1
  50. package/dist/types/modes/controllers/event-controller.d.ts +4 -0
  51. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  52. package/dist/types/modes/interactive-mode.d.ts +24 -2
  53. package/dist/types/modes/irc-observation-ledger.d.ts +22 -0
  54. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +8 -0
  55. package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +5 -0
  56. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +45 -0
  57. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +42 -2
  58. package/dist/types/modes/theme/defaults/index.d.ts +568 -568
  59. package/dist/types/modes/types.d.ts +28 -2
  60. package/dist/types/modes/utils/abort-message.d.ts +1 -1
  61. package/dist/types/modes/utils/irc-message.d.ts +38 -0
  62. package/dist/types/modes/utils/ui-helpers.d.ts +13 -4
  63. package/dist/types/notifications/config.d.ts +34 -0
  64. package/dist/types/notifications/daemon-paths.d.ts +7 -0
  65. package/dist/types/notifications/index.d.ts +27 -0
  66. package/dist/types/notifications/lifecycle-control-runtime.d.ts +12 -4
  67. package/dist/types/notifications/notification-service.d.ts +144 -0
  68. package/dist/types/notifications/rate-limit-pool.d.ts +27 -9
  69. package/dist/types/notifications/telegram-daemon-control.d.ts +6 -0
  70. package/dist/types/notifications/telegram-daemon.d.ts +130 -3
  71. package/dist/types/notifications/telegram-reference.d.ts +19 -2
  72. package/dist/types/notifications/topic-registry.d.ts +24 -4
  73. package/dist/types/registry/agent-registry.d.ts +2 -0
  74. package/dist/types/rlm/index.d.ts +1 -1
  75. package/dist/types/sdk.d.ts +2 -0
  76. package/dist/types/secrets/obfuscator.d.ts +1 -1
  77. package/dist/types/session/agent-session.d.ts +26 -2
  78. package/dist/types/session/cache-economics.d.ts +79 -0
  79. package/dist/types/session/provider-safety-stop.d.ts +5 -0
  80. package/dist/types/session/session-manager.d.ts +124 -4
  81. package/dist/types/session/session-storage.d.ts +155 -13
  82. package/dist/types/setup/credential-auto-import.d.ts +2 -2
  83. package/dist/types/task/executor.d.ts +3 -0
  84. package/dist/types/task/receipt.d.ts +3 -1
  85. package/dist/types/task/types.d.ts +8 -0
  86. package/dist/types/tools/ask-answer-registry.d.ts +5 -0
  87. package/dist/types/tools/ask.d.ts +25 -1
  88. package/dist/types/tools/bash.d.ts +1 -1
  89. package/dist/types/tools/browser/tab-supervisor.d.ts +6 -0
  90. package/dist/types/tools/index.d.ts +58 -6
  91. package/dist/types/tools/telegram-send.d.ts +7 -0
  92. package/dist/types/utils/external-editor.d.ts +2 -0
  93. package/package.json +9 -9
  94. package/scripts/g004-tmux-smoke.ts +196 -36
  95. package/src/cli/daemon-cli.ts +19 -29
  96. package/src/cli/malloc-env-guard.test.ts +104 -0
  97. package/src/cli/malloc-env-guard.ts +149 -0
  98. package/src/cli/notify-cli.ts +78 -14
  99. package/src/cli/session-picker.ts +23 -40
  100. package/src/cli/update-cli.ts +56 -37
  101. package/src/cli.ts +50 -10
  102. package/src/commands/daemon.ts +19 -5
  103. package/src/commands/harness.ts +515 -47
  104. package/src/commands/launch.ts +11 -3
  105. package/src/commands/notify.ts +5 -1
  106. package/src/commands/session.ts +6 -1
  107. package/src/config/keybindings.ts +5 -0
  108. package/src/config/model-profile-activation.ts +1 -1
  109. package/src/config/model-profiles.ts +14 -14
  110. package/src/config/settings-schema.ts +41 -1
  111. package/src/coordinator-mcp/model-preset.ts +100 -0
  112. package/src/coordinator-mcp/server.ts +2653 -425
  113. package/src/daemon/control-types.ts +11 -0
  114. package/src/daemon/operator-contract.ts +121 -0
  115. package/src/deep-interview/render-middleware.ts +67 -2
  116. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +57 -17
  117. package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +15 -2
  118. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +5 -1
  119. package/src/gjc-runtime/launch-tmux.ts +702 -135
  120. package/src/gjc-runtime/session-state-sidecar.ts +756 -135
  121. package/src/gjc-runtime/team-runtime.ts +6 -0
  122. package/src/gjc-runtime/tmux-common.ts +26 -7
  123. package/src/gjc-runtime/tmux-gc.ts +52 -9
  124. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +185 -0
  125. package/src/gjc-runtime/tmux-owner-isolation.ts +2060 -0
  126. package/src/gjc-runtime/tmux-sessions.ts +871 -68
  127. package/src/internal-urls/docs-index.generated.ts +9 -8
  128. package/src/internal-urls/local-protocol.ts +29 -10
  129. package/src/main.ts +298 -75
  130. package/src/modes/acp/acp-agent.ts +728 -98
  131. package/src/modes/acp/acp-mode.ts +29 -2
  132. package/src/modes/components/assistant-message.ts +32 -3
  133. package/src/modes/components/bash-execution.ts +43 -13
  134. package/src/modes/components/custom-editor.ts +5 -0
  135. package/src/modes/components/gajae-pet-widget.ts +360 -0
  136. package/src/modes/components/hook-selector.ts +12 -0
  137. package/src/modes/components/irc-sidebar.ts +206 -0
  138. package/src/modes/components/pet-selector.ts +55 -0
  139. package/src/modes/components/session-selector.ts +222 -221
  140. package/src/modes/components/settings-selector.ts +10 -0
  141. package/src/modes/components/tool-execution.ts +47 -6
  142. package/src/modes/components/user-message.ts +26 -9
  143. package/src/modes/controllers/command-controller.ts +28 -2
  144. package/src/modes/controllers/event-controller.ts +159 -36
  145. package/src/modes/controllers/extension-ui-controller.ts +36 -17
  146. package/src/modes/controllers/input-controller.ts +8 -2
  147. package/src/modes/controllers/selector-controller.ts +62 -15
  148. package/src/modes/interactive-mode.ts +183 -7
  149. package/src/modes/irc-observation-ledger.ts +127 -0
  150. package/src/modes/shared/agent-wire/deep-interview-gate.ts +45 -2
  151. package/src/modes/shared/agent-wire/unattended-session.ts +288 -1
  152. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +130 -20
  153. package/src/modes/types.ts +34 -2
  154. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  155. package/src/modes/utils/irc-message.ts +138 -0
  156. package/src/modes/utils/ui-helpers.ts +216 -50
  157. package/src/notifications/config.ts +44 -0
  158. package/src/notifications/daemon-paths.ts +8 -0
  159. package/src/notifications/index.ts +535 -75
  160. package/src/notifications/lifecycle-control-runtime.ts +930 -77
  161. package/src/notifications/notification-service.ts +710 -0
  162. package/src/notifications/rate-limit-pool.ts +46 -12
  163. package/src/notifications/telegram-daemon-cli.ts +2 -0
  164. package/src/notifications/telegram-daemon-control.ts +27 -2
  165. package/src/notifications/telegram-daemon.ts +635 -94
  166. package/src/notifications/telegram-reference.ts +73 -13
  167. package/src/notifications/topic-registry.ts +86 -9
  168. package/src/prompts/system/irc-peer-roster.md +1 -0
  169. package/src/prompts/system/subagent-system-prompt.md +1 -0
  170. package/src/prompts/system/system-prompt.md +1 -0
  171. package/src/prompts/tools/irc.md +1 -1
  172. package/src/registry/agent-registry.ts +3 -0
  173. package/src/sdk.ts +52 -1
  174. package/src/secrets/obfuscator.ts +4 -2
  175. package/src/session/agent-session.ts +396 -145
  176. package/src/session/cache-economics.ts +275 -0
  177. package/src/session/provider-safety-stop.ts +9 -0
  178. package/src/session/session-dump-format.ts +6 -0
  179. package/src/session/session-manager.ts +809 -39
  180. package/src/session/session-storage.ts +582 -38
  181. package/src/slash-commands/builtin-registry.ts +143 -9
  182. package/src/task/executor.ts +12 -3
  183. package/src/task/index.ts +8 -0
  184. package/src/task/receipt.ts +7 -2
  185. package/src/task/types.ts +26 -0
  186. package/src/tools/ask-answer-registry.ts +25 -0
  187. package/src/tools/ask.ts +355 -33
  188. package/src/tools/browser/tab-supervisor.ts +63 -6
  189. package/src/tools/browser.ts +8 -3
  190. package/src/tools/index.ts +81 -6
  191. package/src/tools/sqlite-reader.ts +8 -4
  192. package/src/tui/output-block.ts +18 -6
  193. package/src/utils/external-editor.ts +6 -1
@@ -11,8 +11,31 @@ import {
11
11
  } from "../coordinator/contract";
12
12
  import {
13
13
  GJC_COORDINATOR_SESSION_ID_ENV,
14
+ GJC_COORDINATOR_SESSION_LAUNCH_ID_ENV,
15
+ GJC_COORDINATOR_SESSION_READINESS_FILE_ENV,
14
16
  GJC_COORDINATOR_SESSION_STATE_FILE_ENV,
17
+ GJC_TMUX_OWNER_GENERATION_ENV,
18
+ GJC_TMUX_OWNER_SERVER_KEY_ENV,
19
+ GJC_TMUX_OWNER_STATE_DIR_ENV,
15
20
  } from "../gjc-runtime/session-state-sidecar";
21
+
22
+ import {
23
+ captureOwnerGenerationBaselineSync,
24
+ classifyCgroup,
25
+ isExactScopedBootstrapSuccessReceipt,
26
+ type OwnerIsolationProbe,
27
+ ownerProcessStartTime,
28
+ planTmuxOwnerIsolation,
29
+ replaceOwnerGenerationSync,
30
+ type TmuxServerProof,
31
+ } from "../gjc-runtime/tmux-owner-isolation";
32
+
33
+ import {
34
+ type CoordinatorModelProfileLoader,
35
+ loadCoordinatorModelProfiles,
36
+ resolveCoordinatorMpreset,
37
+ } from "./model-preset";
38
+
16
39
  import {
17
40
  assertCoordinatorArtifactPath,
18
41
  assertCoordinatorWorkdir,
@@ -44,8 +67,15 @@ interface JsonRpcResponse {
44
67
  interface SessionStartInput {
45
68
  cwd: string;
46
69
  prompt?: string;
70
+ mpreset?: string | null;
47
71
  namespace: { profile: string | null; repo: string | null };
48
72
  worktree: true;
73
+ sessionId: string;
74
+ launchId: string;
75
+ /** Canonical, coordinator-owned immutable readiness marker path. */
76
+ readinessMarkerFile: string;
77
+ /** @deprecated Compatibility alias for injected launch providers. */
78
+ readinessFile: string;
49
79
  }
50
80
 
51
81
  interface SessionRegisterInput {
@@ -79,10 +109,20 @@ interface RuntimeSessionStatePayload extends CoordinatorSessionState {
79
109
  error?: { code: string; message: string; recoverable: boolean } | null;
80
110
  }
81
111
 
112
+ interface CommandResult {
113
+ exitCode: number;
114
+ stdout: string;
115
+ stderr: string;
116
+ }
117
+
118
+ type CommandRunner = (command: string[], stdinLine?: string) => Promise<CommandResult>;
119
+
82
120
  interface CoordinatorServices {
83
121
  listSessions?: () => unknown[] | Promise<unknown[]>;
84
122
  startSession?: (input: SessionStartInput) => unknown | Promise<unknown>;
85
- commandRunner?: (command: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
123
+ commandRunner?: CommandRunner;
124
+ ownerIsolationProbe?: OwnerIsolationProbe;
125
+ resolveModelProfiles?: CoordinatorModelProfileLoader;
86
126
  }
87
127
 
88
128
  interface CoordinatorMcpServerOptions {
@@ -92,7 +132,7 @@ interface CoordinatorMcpServerOptions {
92
132
 
93
133
  interface LegacyHandlerOptions {
94
134
  env?: NodeJS.ProcessEnv;
95
- createSession?: () => unknown;
135
+ createSession?: (input: SessionStartInput) => unknown;
96
136
  }
97
137
 
98
138
  type TurnStatus =
@@ -112,16 +152,28 @@ interface TurnRecord {
112
152
  session_id: string;
113
153
  namespace: { profile: string | null; repo: string | null };
114
154
  status: TurnStatus;
115
- prompt: { text: string; created_at: string; source: "mcp" | "question_answer" };
155
+ prompt: {
156
+ text: string;
157
+ created_at: string;
158
+ source: "mcp" | "question_answer";
159
+ };
116
160
  delivery: {
117
161
  delivered: boolean;
118
162
  queued: boolean;
119
163
  target: string | null;
120
164
  tmux_keys_sent?: boolean;
121
165
  prompt_acknowledged?: boolean;
122
- state?: "queued" | "tmux_keys_sent" | "acknowledged" | "unavailable" | "unacknowledged";
166
+ state?:
167
+ | "queued"
168
+ | "awaiting_runtime_readiness"
169
+ | "delivering"
170
+ | "tmux_keys_sent"
171
+ | "acknowledged"
172
+ | "unavailable"
173
+ | "unacknowledged";
123
174
  attempts: Array<{
124
175
  delivered: boolean;
176
+ attempt_id?: string;
125
177
  created_at: string;
126
178
  reason: string | null;
127
179
  channel?: "tmux_keys" | "runtime_ack";
@@ -138,7 +190,11 @@ interface TurnRecord {
138
190
  };
139
191
  evidence: Array<Record<string, unknown>>;
140
192
  error: { code: string; message: string; recoverable: boolean } | null;
141
- liveness: { checked_at: string | null; live: boolean | null; reason: string | null };
193
+ liveness: {
194
+ checked_at: string | null;
195
+ live: boolean | null;
196
+ reason: string | null;
197
+ };
142
198
  created_at: string;
143
199
  updated_at: string;
144
200
  started_at: string | null;
@@ -163,9 +219,22 @@ interface CoordinatorSessionState {
163
219
  current_turn_id: string | null;
164
220
  last_turn_id: string | null;
165
221
  updated_at: string;
166
- source: "coordinator" | "agent_session_event";
222
+ source: "coordinator" | "agent_session_event" | "process_postmortem";
223
+
167
224
  live: boolean | null;
168
225
  reason: string | null;
226
+ cwd?: string;
227
+ workdir?: string;
228
+ session_file?: string | null;
229
+ owner_generation?: string;
230
+ event?: string;
231
+ owner_terminal?: {
232
+ generation: string;
233
+ socket_key: string;
234
+ classification: string;
235
+ observer: string;
236
+ observed_at: string;
237
+ };
169
238
  }
170
239
 
171
240
  type CoordinatorEventKind =
@@ -218,6 +287,10 @@ const MISSING_FINAL_RESPONSE_ADVISORY = "completion_missing_final_response";
218
287
  const PROMPT_ACK_TIMEOUT_REASON = "runtime_prompt_ack_timeout";
219
288
  const DEFAULT_RUNTIME_PROMPT_ACK_TIMEOUT_MS = 10_000;
220
289
  const MAX_RUNTIME_PROMPT_ACK_TIMEOUT_MS = 5 * 60 * 1000;
290
+ const RUNTIME_READINESS_TIMEOUT_REASON = "runtime_readiness_timeout";
291
+ const DEFAULT_RUNTIME_READINESS_TIMEOUT_MS = 10_000;
292
+ const MAX_RUNTIME_READINESS_TIMEOUT_MS = 5 * 60 * 1000;
293
+
221
294
  const ACTIVE_TURN_STATUSES = new Set<TurnStatus>(["delivering", "active", "waiting_for_answer", "completing"]);
222
295
  const TERMINAL_TURN_STATUSES = new Set<TurnStatus>(["completed", "failed", "cancelled", "superseded"]);
223
296
  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 +306,12 @@ function textResult(
233
306
  isError = false,
234
307
  ): { content: Array<{ type: "text"; text: string }>; isError: boolean } {
235
308
  return {
236
- content: [{ type: "text", text: typeof payload === "string" ? payload : JSON.stringify(payload) }],
309
+ content: [
310
+ {
311
+ type: "text",
312
+ text: typeof payload === "string" ? payload : JSON.stringify(payload),
313
+ },
314
+ ],
237
315
  isError,
238
316
  };
239
317
  }
@@ -243,13 +321,27 @@ function toolSchema(name: CoordinatorToolName): {
243
321
  description: string;
244
322
  inputSchema: Record<string, unknown>;
245
323
  } {
246
- const allowMutation = { type: "boolean", description: "Required and must be true for mutating tools." };
324
+ const allowMutation = {
325
+ type: "boolean",
326
+ description: "Required and must be true for mutating tools.",
327
+ };
247
328
  const cwd = {
248
329
  type: "string",
249
330
  description: "Canonicalized GJC worktree or project directory inside configured roots.",
250
331
  };
251
- const sessionId = { type: "string", description: "GJC coordinator bridge session id." };
252
- const pathField = { type: "string", description: "Artifact path inside configured safe roots." };
332
+ const sessionId = {
333
+ type: "string",
334
+ description: "GJC coordinator bridge session id.",
335
+ };
336
+ const pathField = {
337
+ type: "string",
338
+ description: "Artifact path inside configured safe roots.",
339
+ };
340
+ const mpreset = {
341
+ type: "string",
342
+ description:
343
+ "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.",
344
+ };
253
345
  const common = { type: "object", properties: {} as Record<string, unknown> };
254
346
  if (name === "gjc_coordinator_register_session") {
255
347
  return {
@@ -278,7 +370,12 @@ function toolSchema(name: CoordinatorToolName): {
278
370
  description: "Start a GJC worktree/tmux oriented session through the coordinator bridge.",
279
371
  inputSchema: {
280
372
  type: "object",
281
- properties: { cwd, prompt: { type: "string" }, allow_mutation: allowMutation },
373
+ properties: {
374
+ cwd,
375
+ prompt: { type: "string" },
376
+ mpreset,
377
+ allow_mutation: allowMutation,
378
+ },
282
379
  required: ["cwd", "allow_mutation"],
283
380
  },
284
381
  };
@@ -307,7 +404,11 @@ function toolSchema(name: CoordinatorToolName): {
307
404
  description: "Read authoritative durable turn state plus bounded advisory tmux status.",
308
405
  inputSchema: {
309
406
  type: "object",
310
- properties: { session_id: sessionId, turn_id: { type: "string" }, lines: { type: "number" } },
407
+ properties: {
408
+ session_id: sessionId,
409
+ turn_id: { type: "string" },
410
+ lines: { type: "number" },
411
+ },
311
412
  required: ["turn_id"],
312
413
  },
313
414
  };
@@ -376,7 +477,11 @@ function toolSchema(name: CoordinatorToolName): {
376
477
  return {
377
478
  name,
378
479
  description: "Read one bounded artifact from configured safe roots.",
379
- inputSchema: { type: "object", properties: { path: pathField }, required: ["path"] },
480
+ inputSchema: {
481
+ type: "object",
482
+ properties: { path: pathField },
483
+ required: ["path"],
484
+ },
380
485
  };
381
486
  }
382
487
  if (name === "gjc_coordinator_read_status") {
@@ -390,21 +495,35 @@ function toolSchema(name: CoordinatorToolName): {
390
495
  return {
391
496
  name,
392
497
  description: "Read a bounded structured session tail, not tmux scrollback.",
393
- inputSchema: { type: "object", properties: { session_id: sessionId, lines: { type: "number" } } },
498
+ inputSchema: {
499
+ type: "object",
500
+ properties: { session_id: sessionId, lines: { type: "number" } },
501
+ },
394
502
  };
395
503
  }
396
504
  if (name === "gjc_coordinator_list_questions") {
397
505
  return {
398
506
  name,
399
507
  description: "List bounded structured questions for coordinator coordination.",
400
- inputSchema: { type: "object", properties: { session_id: sessionId, status: { type: "string" } } },
508
+ inputSchema: {
509
+ type: "object",
510
+ properties: { session_id: sessionId, status: { type: "string" } },
511
+ },
401
512
  };
402
513
  }
403
514
  if (name === "gjc_coordinator_list_artifacts") {
404
- return { name, description: "List known safe artifact roots for coordinator coordination.", inputSchema: common };
515
+ return {
516
+ name,
517
+ description: "List known safe artifact roots for coordinator coordination.",
518
+ inputSchema: common,
519
+ };
405
520
  }
406
521
  if (name === "gjc_coordinator_read_coordination_status") {
407
- return { name, description: "Read coordinator coordination reports.", inputSchema: common };
522
+ return {
523
+ name,
524
+ description: "Read coordinator coordination reports.",
525
+ inputSchema: common,
526
+ };
408
527
  }
409
528
  if (name === "gjc_coordinator_watch_events") {
410
529
  return {
@@ -438,7 +557,10 @@ function toolSchema(name: CoordinatorToolName): {
438
557
  type: "string",
439
558
  description: "Delegated task or objective to run through the selected GJC workflow.",
440
559
  },
441
- prompt: { type: "string", description: "Alias for task; accepted when task is absent." },
560
+ prompt: {
561
+ type: "string",
562
+ description: "Alias for task; accepted when task is absent.",
563
+ },
442
564
  allow_mutation: allowMutation,
443
565
  session_id: {
444
566
  type: "string",
@@ -453,24 +575,38 @@ function toolSchema(name: CoordinatorToolName): {
453
575
  type: "boolean",
454
576
  description: "When reusing a session with an active turn, supersede it before sending.",
455
577
  },
578
+ mpreset,
456
579
  model: {
457
580
  type: "string",
458
581
  description: "Optional model hint passed in prompt metadata; no provider default is implied.",
459
582
  },
460
- await_completion: { type: "boolean", description: "If true, poll the turn until terminal or timeout." },
583
+ await_completion: {
584
+ type: "boolean",
585
+ description: "If true, poll the turn until terminal or timeout.",
586
+ },
461
587
  timeout_ms: {
462
588
  type: "number",
463
589
  description:
464
590
  "Bounded await timeout in milliseconds, capped at 30 minutes like gjc_coordinator_await_turn.",
465
591
  },
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." },
592
+ poll_interval_ms: {
593
+ type: "number",
594
+ description: "Bounded await polling interval.",
595
+ },
596
+ lines: {
597
+ type: "number",
598
+ description: "Bounded advisory tail lines returned with await/read payloads.",
599
+ },
468
600
  },
469
601
  required: ["cwd", "allow_mutation"],
470
602
  },
471
603
  };
472
604
  }
473
- return { name, description: "List known scoped GJC coordinator bridge sessions.", inputSchema: common };
605
+ return {
606
+ name,
607
+ description: "List known scoped GJC coordinator bridge sessions.",
608
+ inputSchema: common,
609
+ };
474
610
  }
475
611
 
476
612
  type DelegateWorkflow = "plan" | "execute" | "team";
@@ -536,13 +672,80 @@ function workflowPrompt(
536
672
  ].join("\n");
537
673
  }
538
674
 
675
+ const PRIVATE_SESSION_CONTROL_FIELDS = new Set([
676
+ "tmux_socket_key",
677
+ "tmuxSocketKey",
678
+ "tmux_owner_generation",
679
+ "tmuxOwnerGeneration",
680
+ "tmux_owner_state_dir",
681
+ "tmuxOwnerStateDir",
682
+ "tmux_owner_server_key",
683
+ "tmuxOwnerServerKey",
684
+ "tmux_owner_server_pid",
685
+ "tmuxOwnerServerPid",
686
+ "tmux_owner_server_start_time",
687
+ "tmuxOwnerServerStartTime",
688
+ "tmux_native_session_id",
689
+ "tmuxNativeSessionId",
690
+ "pane_id",
691
+ "paneId",
692
+ "socket_key",
693
+ "socketKey",
694
+ "owner_generation",
695
+ "ownerGeneration",
696
+ "state_dir",
697
+ "ownerStateDir",
698
+ "owner_server_key",
699
+ "ownerServerKey",
700
+ "owner_server_pid",
701
+ "ownerServerPid",
702
+ "owner_server_start_time",
703
+ "ownerServerStartTime",
704
+ "owner_terminal",
705
+ "ownerTerminal",
706
+ "generation",
707
+ "server_key",
708
+ "intent_id",
709
+ "dedupe_key",
710
+ "__coordinatorOwnerTransaction",
711
+ ]);
712
+
713
+ function publicSessionProjection(session: Record<string, unknown>): Record<string, unknown> {
714
+ return Object.fromEntries(Object.entries(session).filter(([key]) => !PRIVATE_SESSION_CONTROL_FIELDS.has(key)));
715
+ }
716
+
717
+ function publicCoordinatorResponse(value: unknown): unknown {
718
+ if (Array.isArray(value)) return value.map(publicCoordinatorResponse);
719
+ const record = asRecord(value);
720
+ if (!record) return value;
721
+ return Object.fromEntries(
722
+ Object.entries(publicSessionProjection(record)).map(([key, item]) => [key, publicCoordinatorResponse(item)]),
723
+ );
724
+ }
725
+
539
726
  function normalizeSession(session: Record<string, unknown>): Record<string, unknown> {
727
+ const suppliedIds = [session.session_id, session.sessionId, session.name].filter(
728
+ (value): value is string => typeof value === "string",
729
+ );
730
+ if (suppliedIds.length === 0) throw new Error("invalid_session_id");
731
+ const sessionId = safeExternalId("session", suppliedIds[0]);
732
+ if (suppliedIds.some(value => value !== sessionId)) throw new Error("session_id_conflict");
540
733
  return {
541
- session_id: session.sessionId ?? session.session_id ?? session.name ?? "unknown",
542
734
  ...(session.tmuxSession ? { tmux_session: session.tmuxSession } : {}),
735
+ ...(session.tmuxTarget ? { tmux_target: session.tmuxTarget } : {}),
736
+ ...(session.tmuxSocketKey ? { tmux_socket_key: session.tmuxSocketKey } : {}),
737
+ ...(session.tmuxOwnerGeneration ? { tmux_owner_generation: session.tmuxOwnerGeneration } : {}),
738
+ ...(session.tmuxOwnerStateDir ? { tmux_owner_state_dir: session.tmuxOwnerStateDir } : {}),
739
+ ...(session.tmuxOwnerServerKey ? { tmux_owner_server_key: session.tmuxOwnerServerKey } : {}),
740
+ ...(session.tmuxOwnerServerPid ? { tmux_owner_server_pid: session.tmuxOwnerServerPid } : {}),
741
+ ...(session.tmuxOwnerServerStartTime ? { tmux_owner_server_start_time: session.tmuxOwnerServerStartTime } : {}),
742
+ ...(session.tmuxNativeSessionId ? { tmux_native_session_id: session.tmuxNativeSessionId } : {}),
743
+ ...(session.paneId ? { pane_id: session.paneId } : {}),
543
744
  ...(session.cwd ? { cwd: session.cwd } : {}),
544
745
  ...(session.createdAt ? { created_at: session.createdAt } : {}),
545
746
  ...session,
747
+ session_id: sessionId,
748
+ sessionId,
546
749
  };
547
750
  }
548
751
 
@@ -554,15 +757,379 @@ async function canonicalizePath(value: string): Promise<string> {
554
757
  }
555
758
  }
556
759
 
760
+ interface RuntimeReadinessMarker {
761
+ schema_version: 1;
762
+ session_id: string;
763
+ launch_id: string;
764
+ state: "ready_for_input";
765
+ event: "interactive_input_ready";
766
+ source: "gjc_interactive_runtime";
767
+ ready_for_input: true;
768
+ created_at: string;
769
+ }
770
+
771
+ async function readRuntimeReadinessMarker(
772
+ markerFile: string,
773
+ sessionId: string,
774
+ launchId: string,
775
+ readinessRoot?: string,
776
+ ): Promise<RuntimeReadinessMarker | null> {
777
+ try {
778
+ const expectedFile = path.resolve(markerFile);
779
+ if (readinessRoot) {
780
+ const canonicalRoot = await fs.realpath(readinessRoot);
781
+ const canonicalParent = await fs.realpath(path.dirname(expectedFile));
782
+ if (
783
+ !expectedFile.startsWith(`${canonicalRoot}${path.sep}`) ||
784
+ !canonicalParent.startsWith(`${canonicalRoot}${path.sep}`)
785
+ )
786
+ return null;
787
+ }
788
+ const stat = await fs.lstat(expectedFile);
789
+ if (!stat.isFile() || stat.isSymbolicLink()) return null;
790
+ const marker = JSON.parse(await fs.readFile(expectedFile, "utf8")) as RuntimeReadinessMarker;
791
+ return marker.schema_version === 1 &&
792
+ marker.session_id === sessionId &&
793
+ marker.launch_id === launchId &&
794
+ marker.state === "ready_for_input" &&
795
+ marker.event === "interactive_input_ready" &&
796
+ marker.source === "gjc_interactive_runtime" &&
797
+ marker.ready_for_input === true &&
798
+ typeof marker.created_at === "string" &&
799
+ marker.created_at.length > 0 &&
800
+ Number.isFinite(Date.parse(marker.created_at))
801
+ ? marker
802
+ : null;
803
+ } catch {
804
+ return null;
805
+ }
806
+ }
807
+
808
+ async function waitForRuntimeReadinessMarker(
809
+ markerFile: string,
810
+ sessionId: string,
811
+ launchId: string,
812
+ readinessRoot: string,
813
+ timeoutMs: number,
814
+ ): Promise<boolean> {
815
+ const ready = async (): Promise<boolean> =>
816
+ (await readRuntimeReadinessMarker(markerFile, sessionId, launchId, readinessRoot)) !== null;
817
+ if (await ready()) return true;
818
+
819
+ const markerDir = path.dirname(markerFile);
820
+ await ensureDir(markerDir);
821
+ const deadline = Date.now() + Math.max(timeoutMs, 0);
822
+ let wake = Promise.withResolvers<void>();
823
+ let watcher: nodeFs.FSWatcher | null = null;
824
+ try {
825
+ watcher = nodeFs.watch(markerDir, (_eventType, filename) => {
826
+ if (filename === path.basename(markerFile)) wake.resolve();
827
+ });
828
+ // A marker may be installed between the first read and watch creation.
829
+ if (await ready()) return true;
830
+ while (Date.now() < deadline) {
831
+ const remainingMs = Math.max(1, deadline - Date.now());
832
+ let timer: NodeJS.Timeout | null = null;
833
+ const fallback = new Promise<void>(resolve => {
834
+ timer = setTimeout(resolve, Math.min(50, remainingMs));
835
+ timer.unref?.();
836
+ });
837
+ await Promise.race([wake.promise, fallback]);
838
+ if (timer) clearTimeout(timer);
839
+ wake = Promise.withResolvers<void>();
840
+ if (await ready()) return true;
841
+ }
842
+ return false;
843
+ } finally {
844
+ watcher?.close();
845
+ }
846
+ }
847
+
557
848
  async function ensureDir(dir: string): Promise<void> {
558
849
  await fs.mkdir(dir, { recursive: true });
559
850
  }
560
851
 
852
+ class CoordinatorStateError extends Error {
853
+ constructor(readonly reason: "invalid" | "unreadable") {
854
+ super(`coordinator_state_${reason}`);
855
+ }
856
+ }
857
+
858
+ const COORDINATOR_SESSION_STATES = new Set<CoordinatorSessionStateValue>([
859
+ "booting",
860
+ "ready_for_input",
861
+ "running",
862
+ "needs_user_input",
863
+ "completed",
864
+ "errored",
865
+ "stale",
866
+ "unknown",
867
+ ]);
868
+ const COORDINATOR_EVENT_KINDS = new Set<CoordinatorEventKind>([
869
+ "session.registered",
870
+ "session.started",
871
+ "session.state_changed",
872
+ "turn.queued",
873
+ "turn.delivering",
874
+ "turn.active",
875
+ "turn.acknowledged",
876
+ "turn.waiting_for_answer",
877
+ "turn.completed",
878
+ "turn.failed",
879
+ "turn.cancelled",
880
+ "turn.superseded",
881
+ "question.opened",
882
+ "question.answered",
883
+ "report.written",
884
+ "tmux.delivery_succeeded",
885
+ "tmux.delivery_failed",
886
+ "delegation.started",
887
+ ]);
888
+
889
+ function coordinatorStateError(reason: "invalid" | "unreadable"): CoordinatorStateError {
890
+ return new CoordinatorStateError(reason);
891
+ }
892
+
893
+ function isValidCoordinatorSessionState(value: unknown, sessionId: string): value is CoordinatorSessionState {
894
+ const record = asRecord(value);
895
+ return (
896
+ record?.schema_version === 1 &&
897
+ record.session_id === sessionId &&
898
+ typeof record.state === "string" &&
899
+ COORDINATOR_SESSION_STATES.has(record.state as CoordinatorSessionStateValue) &&
900
+ typeof record.ready_for_input === "boolean" &&
901
+ record.ready_for_input === (record.state === "ready_for_input" || record.state === "completed") &&
902
+ (record.current_turn_id === null ||
903
+ (typeof record.current_turn_id === "string" && TURN_ID_PATTERN.test(record.current_turn_id))) &&
904
+ (record.last_turn_id === null ||
905
+ (typeof record.last_turn_id === "string" && TURN_ID_PATTERN.test(record.last_turn_id))) &&
906
+ typeof record.updated_at === "string" &&
907
+ Number.isFinite(Date.parse(record.updated_at)) &&
908
+ (record.source === "coordinator" ||
909
+ record.source === "agent_session_event" ||
910
+ record.source === "process_postmortem") &&
911
+ (record.live === null || typeof record.live === "boolean") &&
912
+ (record.reason === null || typeof record.reason === "string")
913
+ );
914
+ }
915
+
916
+ const MAX_RUNTIME_FINAL_RESPONSE_TEXT_LENGTH = 16_384;
917
+ const MAX_RUNTIME_FINAL_RESPONSE_SOURCE_LENGTH = 256;
918
+ const MAX_RUNTIME_FINAL_RESPONSE_ARTIFACT_PATH_LENGTH = 1_024;
919
+ const MAX_RUNTIME_ERROR_CODE_LENGTH = 128;
920
+ const MAX_RUNTIME_ERROR_MESSAGE_LENGTH = 4_096;
921
+
922
+ function isBoundedNullableString(value: unknown, maxLength: number): value is string | null {
923
+ return value === null || (typeof value === "string" && value.length <= maxLength);
924
+ }
925
+
926
+ function isValidRuntimeFinalResponse(value: unknown): value is CoordinatorFinalResponse {
927
+ const record = asRecord(value);
928
+ return (
929
+ record !== null &&
930
+ Object.keys(record).every(key => ["text", "format", "source", "artifact_path", "truncated"].includes(key)) &&
931
+ isBoundedNullableString(record.text, MAX_RUNTIME_FINAL_RESPONSE_TEXT_LENGTH) &&
932
+ record.format === "markdown" &&
933
+ (record.source === null ||
934
+ (typeof record.source === "string" &&
935
+ record.source.length > 0 &&
936
+ record.source.length <= MAX_RUNTIME_FINAL_RESPONSE_SOURCE_LENGTH)) &&
937
+ (record.artifact_path === null ||
938
+ (typeof record.artifact_path === "string" &&
939
+ record.artifact_path.length > 0 &&
940
+ record.artifact_path.length <= MAX_RUNTIME_FINAL_RESPONSE_ARTIFACT_PATH_LENGTH &&
941
+ !path.isAbsolute(record.artifact_path) &&
942
+ !record.artifact_path.split(/[\\/]/).includes(".."))) &&
943
+ typeof record.truncated === "boolean"
944
+ );
945
+ }
946
+
947
+ function isValidRuntimeError(value: unknown): value is NonNullable<RuntimeSessionStatePayload["error"]> {
948
+ const record = asRecord(value);
949
+ return (
950
+ record !== null &&
951
+ Object.keys(record).every(key => ["code", "message", "recoverable"].includes(key)) &&
952
+ typeof record.code === "string" &&
953
+ record.code.length > 0 &&
954
+ record.code.length <= MAX_RUNTIME_ERROR_CODE_LENGTH &&
955
+ typeof record.message === "string" &&
956
+ record.message.length > 0 &&
957
+ record.message.length <= MAX_RUNTIME_ERROR_MESSAGE_LENGTH &&
958
+ typeof record.recoverable === "boolean"
959
+ );
960
+ }
961
+
962
+ function isValidOwnerTerminalRuntimeReceipt(value: unknown, sessionId: string): boolean {
963
+ const record = asRecord(value);
964
+ if (!record) return false;
965
+ const baseKeys = [
966
+ "generation",
967
+ "socket_key",
968
+ "signal",
969
+ "result",
970
+ "classification",
971
+ "observer",
972
+ "observed_at",
973
+ "dedupe_key",
974
+ ];
975
+ const keys = Object.keys(record);
976
+ if (!keys.every(key => [...baseKeys, "intent_id"].includes(key)) || baseKeys.some(key => !keys.includes(key)))
977
+ return false;
978
+ if (
979
+ !validRuntimeOwnerGeneration(record.generation) ||
980
+ !validRuntimeOwnerGeneration(record.socket_key) ||
981
+ typeof record.signal !== "string" ||
982
+ !["SIGTERM", "SIGHUP", "SIGINT", "SIGKILL", "EXIT", "MANUAL", "UNKNOWN"].includes(record.signal) ||
983
+ typeof record.result !== "string" ||
984
+ !record.result ||
985
+ record.result.length > 64 ||
986
+ !["expected_operator_shutdown", "unexpected_owner_loss", "non_operator_cleanup"].includes(
987
+ String(record.classification),
988
+ ) ||
989
+ !["sidecar", "raw_monitor"].includes(String(record.observer)) ||
990
+ typeof record.observed_at !== "string" ||
991
+ !Number.isFinite(Date.parse(record.observed_at)) ||
992
+ record.dedupe_key !== `owner-loss:${sessionId}:${record.generation}`
993
+ )
994
+ return false;
995
+ if (record.classification === "expected_operator_shutdown")
996
+ return (
997
+ record.signal === "SIGTERM" &&
998
+ record.result === "owner_term_then_session_cleanup" &&
999
+ typeof record.intent_id === "string" &&
1000
+ record.intent_id.length > 0
1001
+ );
1002
+ if (record.intent_id !== undefined) return false;
1003
+ if (record.classification === "non_operator_cleanup") return record.result === "cleanup";
1004
+ return ["owner_lost", "cleanup", "process_postmortem", "exit", "unknown_terminal"].includes(record.result);
1005
+ }
1006
+
1007
+ function isValidRuntimeSessionState(value: unknown, sessionId: string): value is RuntimeSessionStatePayload {
1008
+ if (!isValidCoordinatorSessionState(value, sessionId)) return false;
1009
+ const record = asRecord(value);
1010
+ if (!record) return false;
1011
+ const finalResponse = record.final_response;
1012
+ const error = record.error;
1013
+ if (finalResponse !== undefined && !isValidRuntimeFinalResponse(finalResponse)) return false;
1014
+ if (record.source !== "coordinator" && record.live !== (record.state === "running")) return false;
1015
+ if (error !== undefined && error !== null && !isValidRuntimeError(error)) return false;
1016
+ if (record.owner_generation !== undefined && !validRuntimeOwnerGeneration(record.owner_generation)) return false;
1017
+ if (record.source === "coordinator") return finalResponse === undefined && (error === undefined || error === null);
1018
+ if (record.source === "process_postmortem") {
1019
+ if (record.state !== "completed" && record.state !== "errored") return false;
1020
+ if (record.event === "owner_terminal") {
1021
+ if (!isValidOwnerTerminalRuntimeReceipt(record.owner_terminal, sessionId)) return false;
1022
+ const ownerTerminal = asRecord(record.owner_terminal)!;
1023
+ const expected = ownerTerminal.classification === "expected_operator_shutdown";
1024
+ if (
1025
+ record.reason !== ownerTerminal.classification ||
1026
+ record.state !== (expected ? "completed" : "errored") ||
1027
+ (expected
1028
+ ? error !== undefined && error !== null
1029
+ : error === undefined ||
1030
+ error === null ||
1031
+ error.code !== ownerTerminal.classification ||
1032
+ error.recoverable !== true)
1033
+ )
1034
+ return false;
1035
+ } else if (record.event === "process_exit") {
1036
+ if (
1037
+ record.owner_terminal !== undefined ||
1038
+ typeof record.exit_kind !== "string" ||
1039
+ record.exit_kind.length === 0 ||
1040
+ record.exit_kind.length > 128 ||
1041
+ (record.exit_code !== null &&
1042
+ (typeof record.exit_code !== "number" || !Number.isSafeInteger(record.exit_code))) ||
1043
+ (record.signal !== null &&
1044
+ record.signal !== "SIGINT" &&
1045
+ record.signal !== "SIGTERM" &&
1046
+ record.signal !== "SIGHUP") ||
1047
+ typeof record.prompt_accepted !== "boolean" ||
1048
+ typeof record.observed_recoverable_worktree_changes !== "boolean" ||
1049
+ (record.worktree_baseline_dirty !== null && typeof record.worktree_baseline_dirty !== "boolean") ||
1050
+ typeof record.worktree_changed_since_baseline !== "boolean"
1051
+ )
1052
+ return false;
1053
+ } else {
1054
+ return false;
1055
+ }
1056
+ }
1057
+ if (record.state === "completed") return error === undefined || error === null;
1058
+ if (record.state === "errored") return error !== undefined && error !== null;
1059
+ return finalResponse === undefined && (error === undefined || error === null);
1060
+ }
1061
+
1062
+ const PUBLIC_COORDINATOR_ERRORS = new Set([
1063
+ "coordinator_workdir_required",
1064
+ "coordinator_workdir_roots_required",
1065
+ "coordinator_workdir_outside_allowed_roots",
1066
+ "coordinator_artifact_path_required",
1067
+ "coordinator_artifact_roots_required",
1068
+ "coordinator_artifact_outside_allowed_roots",
1069
+ "coordinator_mutation_class_disabled",
1070
+ "coordinator_mutation_call_not_allowed",
1071
+ "invalid_session_id",
1072
+ "invalid_tmux_session",
1073
+ "invalid_tmux_target",
1074
+ "invalid_turn_id",
1075
+ "invalid_question_id",
1076
+ "session_id_conflict",
1077
+ "tmux_session_unavailable",
1078
+ "tmux_target_unavailable",
1079
+ "coordinator_session_command_required",
1080
+ "coordinator_launch_attestation_missing",
1081
+ "coordinator_launch_attestation_mismatch",
1082
+ "coordinator_launch_metadata_missing",
1083
+ "coordinator_readiness_path_invalid",
1084
+ "session_origin_unverified",
1085
+ "coordinator_prompt_buffer_privacy_unverified",
1086
+ "coordinator_tmux_start_failed",
1087
+ "coordinator_tmux_owner_server_unsafe",
1088
+ "coordinator_tmux_owner_server_unverifiable",
1089
+ "coordinator_tmux_owner_server_race",
1090
+ "coordinator_evidence_paths_must_be_array",
1091
+ ]);
1092
+
1093
+ class CoordinatorStartError extends Error {
1094
+ constructor(
1095
+ message: string,
1096
+ readonly cleanupStatus: "cleaned" | "failed" | "unverifiable",
1097
+ ) {
1098
+ super(message);
1099
+ }
1100
+ }
1101
+
1102
+ function publicCleanupStatus(error: unknown): "cleaned" | "failed" | "unverifiable" | undefined {
1103
+ return error instanceof CoordinatorStartError ? error.cleanupStatus : undefined;
1104
+ }
1105
+
1106
+ function publicCoordinatorError(error: unknown): string {
1107
+ if (error instanceof CoordinatorStateError) return `coordinator_state_${error.reason}`;
1108
+ const message = error instanceof Error ? error.message : "";
1109
+ const normalized = message
1110
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
1111
+ .replace(/\s+/g, " ")
1112
+ .trim();
1113
+ const code = normalized.split(":", 1)[0];
1114
+ return PUBLIC_COORDINATOR_ERRORS.has(code) ? code : "coordinator_request_failed";
1115
+ }
1116
+
1117
+ function isExpectedStateAbsence(error: unknown): boolean {
1118
+ return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
1119
+ }
1120
+
561
1121
  async function readJsonFile(file: string): Promise<unknown | null> {
1122
+ let content: string;
1123
+ try {
1124
+ content = await fs.readFile(file, "utf8");
1125
+ } catch (error) {
1126
+ if (isExpectedStateAbsence(error)) return null;
1127
+ throw coordinatorStateError("unreadable");
1128
+ }
562
1129
  try {
563
- return JSON.parse(await fs.readFile(file, "utf8"));
1130
+ return JSON.parse(content);
564
1131
  } catch {
565
- return null;
1132
+ throw coordinatorStateError("invalid");
566
1133
  }
567
1134
  }
568
1135
 
@@ -572,15 +1139,16 @@ async function writeJsonFile(file: string, value: unknown): Promise<void> {
572
1139
  }
573
1140
 
574
1141
  async function listJsonFiles(dir: string): Promise<unknown[]> {
1142
+ let entries: string[];
575
1143
  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 [];
1144
+ entries = await fs.readdir(dir);
1145
+ } catch (error) {
1146
+ if (isExpectedStateAbsence(error)) return [];
1147
+ throw coordinatorStateError("unreadable");
583
1148
  }
1149
+ return await Promise.all(
1150
+ entries.filter(entry => entry.endsWith(".json")).map(entry => readJsonFile(path.join(dir, entry))),
1151
+ );
584
1152
  }
585
1153
 
586
1154
  const COORDINATOR_STATUS_EVENT_LIMIT = 100;
@@ -668,22 +1236,39 @@ function boundSummary(value: string): string {
668
1236
  }
669
1237
 
670
1238
  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;
1239
+ const rawSequence = await readJsonFile(eventSequenceFile(namespaceDir));
1240
+ const events = await readCoordinatorEvents(namespaceDir);
1241
+ const journalLatestSeq = events.at(-1)?.seq ?? 0;
1242
+ if (rawSequence === null) return journalLatestSeq;
1243
+ const sequence = asRecord(rawSequence);
1244
+ if (
1245
+ !sequence ||
1246
+ Object.keys(sequence).some(key => key !== "seq" && key !== "updated_at") ||
1247
+ typeof sequence.seq !== "number" ||
1248
+ !Number.isSafeInteger(sequence.seq) ||
1249
+ sequence.seq < 0 ||
1250
+ typeof sequence.updated_at !== "string" ||
1251
+ !Number.isFinite(Date.parse(sequence.updated_at)) ||
1252
+ sequence.seq > journalLatestSeq
1253
+ )
1254
+ throw coordinatorStateError("invalid");
1255
+ return journalLatestSeq;
677
1256
  }
678
1257
 
679
1258
  const eventAppendQueues = new Map<string, Promise<unknown>>();
680
1259
 
1260
+ /** Lock order: namespace transaction lock, then event journal lock. */
1261
+ function coordinatorTransactionLockFile(namespaceDir: string): string {
1262
+ return path.join(namespaceDir, "locks", "mutation.lock");
1263
+ }
1264
+
1265
+ async function withCoordinatorTransaction<T>(namespaceDir: string, operation: () => Promise<T>): Promise<T> {
1266
+ return await withSessionStateLock(coordinatorTransactionLockFile(namespaceDir), operation);
1267
+ }
1268
+
681
1269
  async function appendCoordinatorEvent(namespaceDir: string, input: CoordinatorEventInput): Promise<CoordinatorEvent> {
682
1270
  const previous = eventAppendQueues.get(namespaceDir) ?? Promise.resolve();
683
- let release!: () => void;
684
- const current = new Promise<void>(resolve => {
685
- release = resolve;
686
- });
1271
+ const { promise: current, resolve: release } = Promise.withResolvers<void>();
687
1272
  const queued = previous.then(
688
1273
  () => current,
689
1274
  () => current,
@@ -692,57 +1277,148 @@ async function appendCoordinatorEvent(namespaceDir: string, input: CoordinatorEv
692
1277
 
693
1278
  await previous.catch(() => undefined);
694
1279
  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;
1280
+ return await withSessionStateLock(eventJournalFile(namespaceDir), async () => {
1281
+ const latestSeq = await readLatestEventSeq(namespaceDir);
1282
+ const seq = latestSeq + 1;
1283
+ const timestamp = new Date().toISOString();
1284
+ const event: CoordinatorEvent = {
1285
+ schema_version: 1,
1286
+ seq,
1287
+ id: `event-${seq.toString().padStart(12, "0")}`,
1288
+ timestamp,
1289
+ kind: input.kind,
1290
+ summary: boundSummary(input.summary),
1291
+ ...(input.sessionId ? { session_id: input.sessionId } : {}),
1292
+ ...(input.turnId ? { turn_id: input.turnId } : {}),
1293
+ ...(input.questionId ? { question_id: input.questionId } : {}),
1294
+ ...(input.reportId ? { report_id: input.reportId } : {}),
1295
+ ...(input.payloadRef ? { payload_ref: input.payloadRef } : {}),
1296
+ ...(input.metadata ? { metadata: input.metadata } : {}),
1297
+ };
1298
+ await ensureDir(eventsDir(namespaceDir));
1299
+ await fs.appendFile(eventJournalFile(namespaceDir), `${JSON.stringify(event)}\n`);
1300
+ await writeJsonFile(eventSequenceFile(namespaceDir), {
1301
+ seq,
1302
+ updated_at: timestamp,
1303
+ });
1304
+ return event;
1305
+ });
716
1306
  } finally {
717
1307
  release();
718
1308
  if (eventAppendQueues.get(namespaceDir) === queued) eventAppendQueues.delete(namespaceDir);
719
1309
  }
720
1310
  }
721
1311
 
722
- function parseCoordinatorEvent(line: string): CoordinatorEvent | null {
1312
+ // Per-session mutation lock. Concurrent stdio dispatch (see pumpCoordinatorMcpStream) makes
1313
+ // same-session read-modify-write tool calls (notably send_prompt: read active turn → decide →
1314
+ // write new turn) interleave, which could persist two "active" turns while only one active-turn
1315
+ // pointer survives. Serializing per session — same promise-chain shape as eventAppendQueues —
1316
+ // restores the atomicity the former serial read loop provided, without serializing across
1317
+ // sessions or blocking long read-only polls (await_turn/watch_events). This is an in-process
1318
+ // lock distinct from the file-based withSessionStateLock/withCoordinatorTransaction, so it can
1319
+ // never deadlock by nesting inside them.
1320
+ const sessionMutationQueues = new Map<string, Promise<unknown>>();
1321
+
1322
+ async function withSessionMutation<T>(key: string, fn: () => Promise<T>): Promise<T> {
1323
+ const previous = sessionMutationQueues.get(key) ?? Promise.resolve();
1324
+ const { promise: current, resolve: release } = Promise.withResolvers<void>();
1325
+ const queued = previous.then(
1326
+ () => current,
1327
+ () => current,
1328
+ );
1329
+ sessionMutationQueues.set(key, queued);
1330
+ await previous.catch(() => undefined);
1331
+ try {
1332
+ return await fn();
1333
+ } finally {
1334
+ release();
1335
+ if (sessionMutationQueues.get(key) === queued) sessionMutationQueues.delete(key);
1336
+ }
1337
+ }
1338
+
1339
+ function parseCoordinatorEvent(line: string): CoordinatorEvent {
1340
+ let event: unknown;
723
1341
  try {
724
- const event = JSON.parse(line) as CoordinatorEvent;
725
- if (typeof event.seq !== "number" || typeof event.kind !== "string") return null;
726
- return event;
1342
+ event = JSON.parse(line);
727
1343
  } catch {
728
- return null;
1344
+ throw coordinatorStateError("invalid");
1345
+ }
1346
+ const record = asRecord(event);
1347
+ const allowed = new Set([
1348
+ "schema_version",
1349
+ "seq",
1350
+ "id",
1351
+ "timestamp",
1352
+ "kind",
1353
+ "session_id",
1354
+ "turn_id",
1355
+ "question_id",
1356
+ "report_id",
1357
+ "summary",
1358
+ "payload_ref",
1359
+ "metadata",
1360
+ ]);
1361
+ const metadata = asRecord(record?.metadata);
1362
+ const validMetadata =
1363
+ record?.metadata === undefined && metadata === null
1364
+ ? true
1365
+ : metadata !== null &&
1366
+ Object.values(metadata).every(
1367
+ value => value === null || ["string", "number", "boolean"].includes(typeof value),
1368
+ );
1369
+ const validOptionalExternalId = (value: unknown) =>
1370
+ value === undefined || (typeof value === "string" && SAFE_EXTERNAL_ID_PATTERN.test(value));
1371
+ const validPayloadRef =
1372
+ record?.payload_ref === undefined ||
1373
+ (typeof record.payload_ref === "string" &&
1374
+ record.payload_ref.length > 0 &&
1375
+ !path.isAbsolute(record.payload_ref) &&
1376
+ !record.payload_ref.split(/[\\/]/).includes(".."));
1377
+ if (
1378
+ !record ||
1379
+ Object.keys(record).some(key => !allowed.has(key)) ||
1380
+ record.schema_version !== 1 ||
1381
+ typeof record.seq !== "number" ||
1382
+ !Number.isSafeInteger(record.seq) ||
1383
+ record.seq <= 0 ||
1384
+ record.id !== `event-${record.seq.toString().padStart(12, "0")}` ||
1385
+ typeof record.timestamp !== "string" ||
1386
+ !Number.isFinite(Date.parse(record.timestamp)) ||
1387
+ typeof record.kind !== "string" ||
1388
+ !COORDINATOR_EVENT_KINDS.has(record.kind as CoordinatorEventKind) ||
1389
+ typeof record.summary !== "string" ||
1390
+ record.summary.length > 240 ||
1391
+ !validOptionalExternalId(record.session_id) ||
1392
+ (record.turn_id !== undefined && (typeof record.turn_id !== "string" || !TURN_ID_PATTERN.test(record.turn_id))) ||
1393
+ !validOptionalExternalId(record.question_id) ||
1394
+ !validOptionalExternalId(record.report_id) ||
1395
+ !validPayloadRef ||
1396
+ !validMetadata
1397
+ ) {
1398
+ throw coordinatorStateError("invalid");
729
1399
  }
1400
+ return record as unknown as CoordinatorEvent;
730
1401
  }
731
1402
 
732
1403
  async function readCoordinatorEvents(namespaceDir: string): Promise<CoordinatorEvent[]> {
1404
+ const journalFile = eventJournalFile(namespaceDir);
1405
+ let content: string;
733
1406
  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);
1407
+ content = await fs.readFile(journalFile, "utf8");
742
1408
  } catch (error) {
743
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
744
- throw error;
1409
+ if (isExpectedStateAbsence(error)) return [];
1410
+ throw coordinatorStateError("unreadable");
745
1411
  }
1412
+ const events = content
1413
+ .split("\n")
1414
+ .map(line => line.trim())
1415
+ .filter(Boolean)
1416
+ .map(parseCoordinatorEvent);
1417
+ for (let index = 1; index < events.length; index++) {
1418
+ if (events[index].seq !== events[index - 1].seq + 1) throw coordinatorStateError("invalid");
1419
+ }
1420
+
1421
+ return events;
746
1422
  }
747
1423
 
748
1424
  function boundedEventLimit(value: unknown): number {
@@ -819,6 +1495,33 @@ function safeTmuxTarget(value: unknown): string {
819
1495
  return value;
820
1496
  }
821
1497
 
1498
+ function safeTmuxRuntimeIdentity(value: string): boolean {
1499
+ return value.length > 0 && value.length <= 160 && !/[\s\u0000-\u001f\u007f]/.test(value);
1500
+ }
1501
+
1502
+ function coordinatorCleanupPredicate(expectedPid: number, nativeSessionId: string, attemptSession: string): string {
1503
+ return `#{&&:#{==:#{pid},${expectedPid}},#{&&:#{==:#{session_id},${nativeSessionId}},#{==:#{session_name},${attemptSession}}}}`;
1504
+ }
1505
+
1506
+ function parseTmuxNewSessionRecord(
1507
+ stdout: string,
1508
+ sessionName: string,
1509
+ ): { tmuxTarget: string; paneId: string; nativeSessionId: string } | undefined {
1510
+ const match = /^(\S+) (%\d+) (\$\d+)\n?$/.exec(stdout);
1511
+ if (!match) return undefined;
1512
+ const [tmuxTarget, paneId, nativeSessionId] = match.slice(1);
1513
+ const targetSuffix = tmuxTarget!.slice(`${sessionName}:`.length);
1514
+ if (
1515
+ !tmuxTarget!.startsWith(`${sessionName}:`) ||
1516
+ !/^\d+\.\d+$/.test(targetSuffix) ||
1517
+ !safeTmuxRuntimeIdentity(tmuxTarget!) ||
1518
+ !safeTmuxRuntimeIdentity(paneId!) ||
1519
+ !safeTmuxRuntimeIdentity(nativeSessionId!)
1520
+ )
1521
+ return undefined;
1522
+ return { tmuxTarget: tmuxTarget!, paneId: paneId!, nativeSessionId: nativeSessionId! };
1523
+ }
1524
+
822
1525
  function optionalString(value: unknown): string | null {
823
1526
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
824
1527
  }
@@ -847,8 +1550,150 @@ function sessionStateFile(namespaceDir: string, sessionId: string): string {
847
1550
  return path.join(namespaceDir, "session-states", `${safeExternalId("session", sessionId)}.json`);
848
1551
  }
849
1552
 
1553
+ function isTimestamp(value: unknown): value is string {
1554
+ return typeof value === "string" && Number.isFinite(Date.parse(value));
1555
+ }
1556
+
1557
+ function isValidTurnRecord(value: unknown): value is TurnRecord {
1558
+ const turn = asRecord(value);
1559
+ const delivery = asRecord(turn?.delivery);
1560
+ const prompt = asRecord(turn?.prompt);
1561
+ const response = asRecord(turn?.final_response);
1562
+ const liveness = asRecord(turn?.liveness);
1563
+ const namespace = asRecord(turn?.namespace);
1564
+ const statuses: readonly TurnStatus[] = [
1565
+ "queued",
1566
+ "delivering",
1567
+ "active",
1568
+ "waiting_for_answer",
1569
+ "completing",
1570
+ "completed",
1571
+ "failed",
1572
+ "cancelled",
1573
+ "superseded",
1574
+ ];
1575
+ if (
1576
+ turn?.schema_version !== 1 ||
1577
+ typeof turn.turn_id !== "string" ||
1578
+ !TURN_ID_PATTERN.test(turn.turn_id) ||
1579
+ typeof turn.session_id !== "string" ||
1580
+ !SAFE_EXTERNAL_ID_PATTERN.test(turn.session_id) ||
1581
+ !namespace ||
1582
+ (namespace.profile !== null && typeof namespace.profile !== "string") ||
1583
+ (namespace.repo !== null && typeof namespace.repo !== "string") ||
1584
+ !statuses.includes(turn.status as TurnStatus)
1585
+ )
1586
+ return false;
1587
+ if (
1588
+ !prompt ||
1589
+ typeof prompt.text !== "string" ||
1590
+ !isTimestamp(prompt.created_at) ||
1591
+ (prompt.source !== "mcp" && prompt.source !== "question_answer")
1592
+ )
1593
+ return false;
1594
+ if (
1595
+ !delivery ||
1596
+ typeof delivery.delivered !== "boolean" ||
1597
+ typeof delivery.queued !== "boolean" ||
1598
+ (delivery.target !== null && typeof delivery.target !== "string") ||
1599
+ (delivery.tmux_keys_sent !== undefined && typeof delivery.tmux_keys_sent !== "boolean") ||
1600
+ (delivery.prompt_acknowledged !== undefined && typeof delivery.prompt_acknowledged !== "boolean") ||
1601
+ (delivery.state !== undefined &&
1602
+ ![
1603
+ "queued",
1604
+ "awaiting_runtime_readiness",
1605
+ "delivering",
1606
+ "tmux_keys_sent",
1607
+ "acknowledged",
1608
+ "unavailable",
1609
+ "unacknowledged",
1610
+ ].includes(delivery.state as string)) ||
1611
+ !Array.isArray(delivery.attempts) ||
1612
+ !delivery.attempts.every(attempt => {
1613
+ const item = asRecord(attempt);
1614
+ return (
1615
+ item !== null &&
1616
+ typeof item.delivered === "boolean" &&
1617
+ isTimestamp(item.created_at) &&
1618
+ (item.reason === null || typeof item.reason === "string") &&
1619
+ (item.channel === undefined || item.channel === "tmux_keys" || item.channel === "runtime_ack") &&
1620
+ (item.tmux_keys_sent === undefined || typeof item.tmux_keys_sent === "boolean")
1621
+ );
1622
+ })
1623
+ )
1624
+ return false;
1625
+ if (
1626
+ !Array.isArray(turn.question_ids) ||
1627
+ !turn.question_ids.every(id => typeof id === "string" && SAFE_EXTERNAL_ID_PATTERN.test(id)) ||
1628
+ !isValidRuntimeFinalResponse(response) ||
1629
+ !Array.isArray(turn.evidence) ||
1630
+ !turn.evidence.every(item => asRecord(item) !== null) ||
1631
+ (turn.error !== null && !isValidRuntimeError(turn.error))
1632
+ )
1633
+ return false;
1634
+ if (
1635
+ !liveness ||
1636
+ (liveness.checked_at !== null && !isTimestamp(liveness.checked_at)) ||
1637
+ (liveness.live !== null && typeof liveness.live !== "boolean") ||
1638
+ (liveness.reason !== null && typeof liveness.reason !== "string") ||
1639
+ !isTimestamp(turn.created_at) ||
1640
+ !isTimestamp(turn.updated_at) ||
1641
+ (turn.started_at !== null && !isTimestamp(turn.started_at)) ||
1642
+ (turn.completed_at !== null && !isTimestamp(turn.completed_at))
1643
+ )
1644
+ return false;
1645
+ return TERMINAL_TURN_STATUSES.has(turn.status as TurnStatus)
1646
+ ? turn.completed_at !== null && (turn.status === "failed" ? turn.error !== null : turn.error === null)
1647
+ : turn.completed_at === null && turn.error === null;
1648
+ }
1649
+
1650
+ function isValidActiveTurnRecord(
1651
+ value: unknown,
1652
+ sessionId: string,
1653
+ ): value is {
1654
+ session_id: string;
1655
+ turn_id: string;
1656
+ status: TurnStatus;
1657
+ updated_at: string;
1658
+ } {
1659
+ const active = asRecord(value);
1660
+ return (
1661
+ active !== null &&
1662
+ Object.keys(active).every(key => ["session_id", "turn_id", "status", "updated_at"].includes(key)) &&
1663
+ active.session_id === sessionId &&
1664
+ typeof active.turn_id === "string" &&
1665
+ TURN_ID_PATTERN.test(active.turn_id) &&
1666
+ ACTIVE_TURN_STATUSES.has(active.status as TurnStatus) &&
1667
+ isTimestamp(active.updated_at)
1668
+ );
1669
+ }
1670
+
850
1671
  async function readTurnRecord(namespaceDir: string, turnId: unknown): Promise<TurnRecord | null> {
851
- return (await readJsonFile(turnFile(namespaceDir, safeTurnId(turnId)))) as TurnRecord | null;
1672
+ const requestedTurnId = safeTurnId(turnId);
1673
+ const value = await readJsonFile(turnFile(namespaceDir, requestedTurnId));
1674
+ if (value === null) return null;
1675
+ if (!isValidTurnRecord(value) || value.turn_id !== requestedTurnId) throw coordinatorStateError("invalid");
1676
+ return value;
1677
+ }
1678
+
1679
+ async function listTurnRecords(namespaceDir: string): Promise<TurnRecord[]> {
1680
+ let entries: string[];
1681
+ try {
1682
+ entries = await fs.readdir(turnsDir(namespaceDir));
1683
+ } catch (error) {
1684
+ if (isExpectedStateAbsence(error)) return [];
1685
+ throw coordinatorStateError("unreadable");
1686
+ }
1687
+ const records = await Promise.all(
1688
+ entries
1689
+ .filter(entry => entry.endsWith(".json"))
1690
+ .map(async entry => {
1691
+ const turnId = entry.slice(0, -".json".length);
1692
+ if (!TURN_ID_PATTERN.test(turnId)) throw coordinatorStateError("invalid");
1693
+ return await readTurnRecord(namespaceDir, turnId);
1694
+ }),
1695
+ );
1696
+ return records.filter((record): record is TurnRecord => record !== null);
852
1697
  }
853
1698
 
854
1699
  function turnEventKind(status: TurnStatus): CoordinatorEventKind | null {
@@ -864,7 +1709,8 @@ function turnEventKind(status: TurnStatus): CoordinatorEventKind | null {
864
1709
  }
865
1710
 
866
1711
  async function writeTurnRecord(namespaceDir: string, turn: TurnRecord): Promise<void> {
867
- const previous = (await readJsonFile(turnFile(namespaceDir, turn.turn_id))) as TurnRecord | null;
1712
+ if (!isValidTurnRecord(turn)) throw coordinatorStateError("invalid");
1713
+ const previous = await readTurnRecord(namespaceDir, turn.turn_id);
868
1714
  await writeJsonFile(turnFile(namespaceDir, turn.turn_id), turn);
869
1715
  const kind = previous?.status === turn.status ? null : turnEventKind(turn.status);
870
1716
  if (kind) {
@@ -884,14 +1730,17 @@ async function writeTurnRecord(namespaceDir: string, turn: TurnRecord): Promise<
884
1730
  }
885
1731
 
886
1732
  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;
1733
+ const value = await readJsonFile(activeTurnFile(namespaceDir, sessionId));
1734
+ if (value === null) return null;
1735
+ if (!isValidActiveTurnRecord(value, sessionId)) throw coordinatorStateError("invalid");
1736
+ const turn = await readTurnRecord(namespaceDir, value.turn_id);
1737
+ if (!turn || turn.session_id !== sessionId || turn.status !== value.status || !ACTIVE_TURN_STATUSES.has(turn.status))
1738
+ throw coordinatorStateError("invalid");
891
1739
  return turn;
892
1740
  }
893
1741
 
894
1742
  async function writeActiveTurn(namespaceDir: string, turn: TurnRecord): Promise<void> {
1743
+ if (!ACTIVE_TURN_STATUSES.has(turn.status)) throw coordinatorStateError("invalid");
895
1744
  await writeJsonFile(activeTurnFile(namespaceDir, turn.session_id), {
896
1745
  session_id: turn.session_id,
897
1746
  turn_id: turn.turn_id,
@@ -901,15 +1750,84 @@ async function writeActiveTurn(namespaceDir: string, turn: TurnRecord): Promise<
901
1750
  }
902
1751
 
903
1752
  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 });
1753
+ const value = await readJsonFile(activeTurnFile(namespaceDir, turn.session_id));
1754
+ if (value === null) return;
1755
+ if (!isValidActiveTurnRecord(value, turn.session_id)) throw coordinatorStateError("invalid");
1756
+ if (value.turn_id === turn.turn_id) await fs.rm(activeTurnFile(namespaceDir, turn.session_id), { force: true });
906
1757
  }
907
1758
 
908
1759
  async function readSessionState(namespaceDir: string, sessionId: string): Promise<CoordinatorSessionState | null> {
909
- return (await readJsonFile(sessionStateFile(namespaceDir, sessionId))) as CoordinatorSessionState | null;
1760
+ const value = await readJsonFile(sessionStateFile(namespaceDir, sessionId));
1761
+ if (value === null) return null;
1762
+ if (!isValidRuntimeSessionState(value, sessionId)) throw coordinatorStateError("invalid");
1763
+ return value;
910
1764
  }
911
1765
 
912
- async function writeSessionState(
1766
+ function validRuntimeOwnerGeneration(value: unknown): value is string {
1767
+ return (
1768
+ typeof value === "string" && value.length > 0 && value.length <= 256 && !/[\s\u0000-\u001f\u007f]/.test(value)
1769
+ );
1770
+ }
1771
+
1772
+ function runtimeStateMatchesPrivateOwnerGeneration(
1773
+ sessionState: CoordinatorSessionState,
1774
+ session: Record<string, unknown> | null,
1775
+ ): boolean {
1776
+ const expectedGeneration = session?.tmux_owner_generation ?? session?.tmuxOwnerGeneration;
1777
+ if (expectedGeneration === undefined)
1778
+ return !(sessionState.source === "process_postmortem" && sessionState.event === "owner_terminal");
1779
+ if (!validRuntimeOwnerGeneration(expectedGeneration) || sessionState.owner_generation !== expectedGeneration)
1780
+ return false;
1781
+ if (sessionState.source !== "process_postmortem") return true;
1782
+ if (sessionState.event !== "owner_terminal") return false;
1783
+ const expectedSocketKey = session?.tmux_owner_server_key ?? session?.tmuxOwnerServerKey;
1784
+ return (
1785
+ typeof expectedSocketKey === "string" &&
1786
+ sessionState.owner_terminal?.generation === expectedGeneration &&
1787
+ sessionState.owner_terminal.socket_key === expectedSocketKey
1788
+ );
1789
+ }
1790
+
1791
+ async function reconcileTerminalRuntimeEvidence(namespaceDir: string, sessionId: string): Promise<boolean> {
1792
+ const sessionState = await readSessionState(namespaceDir, sessionId);
1793
+ if (
1794
+ !sessionState ||
1795
+ sessionState.source === "coordinator" ||
1796
+ (sessionState.state !== "completed" && sessionState.state !== "errored")
1797
+ )
1798
+ return false;
1799
+ const session = asRecord(await readJsonFile(path.join(namespaceDir, "sessions", `${sessionId}.json`)));
1800
+ if (!runtimeStateMatchesPrivateOwnerGeneration(sessionState, session)) return false;
1801
+
1802
+ const activeTurn = await readActiveTurn(namespaceDir, sessionId);
1803
+ if (activeTurn && (sessionState.current_turn_id === null || sessionState.current_turn_id === activeTurn.turn_id)) {
1804
+ await markTurnTerminalFromSessionState(namespaceDir, activeTurn, sessionState);
1805
+ }
1806
+ return true;
1807
+ }
1808
+
1809
+ async function preflightCoordinatorMutation(
1810
+ namespaceDir: string,
1811
+ sessionId?: string,
1812
+ options: { rejectTerminalRuntimeEvidence?: boolean } = {},
1813
+ ): Promise<void> {
1814
+ await readLatestEventSeq(namespaceDir);
1815
+ if (!sessionId) return;
1816
+ const [sessionState, session] = await Promise.all([
1817
+ readSessionState(namespaceDir, sessionId),
1818
+ readJsonFile(path.join(namespaceDir, "sessions", `${sessionId}.json`)),
1819
+ ]);
1820
+ const sessionRecord = asRecord(session);
1821
+ if (sessionRecord && normalizeSession(sessionRecord).session_id !== sessionId)
1822
+ throw coordinatorStateError("invalid");
1823
+ if (sessionState && sessionState.session_id !== sessionId) throw coordinatorStateError("invalid");
1824
+ await readActiveTurn(namespaceDir, sessionId);
1825
+ const terminalRuntimeEvidence = await reconcileTerminalRuntimeEvidence(namespaceDir, sessionId);
1826
+ if (terminalRuntimeEvidence && options.rejectTerminalRuntimeEvidence !== false)
1827
+ throw coordinatorStateError("invalid");
1828
+ }
1829
+
1830
+ async function writeSessionStateUnlocked(
913
1831
  namespaceDir: string,
914
1832
  sessionId: string,
915
1833
  state: CoordinatorSessionStateValue,
@@ -922,6 +1840,20 @@ async function writeSessionState(
922
1840
  } = {},
923
1841
  ): Promise<CoordinatorSessionState> {
924
1842
  const previous = await readSessionState(namespaceDir, sessionId);
1843
+ if (previous && previous.source !== "coordinator") return previous;
1844
+ const session = asRecord(await readJsonFile(path.join(namespaceDir, "sessions", `${sessionId}.json`)));
1845
+ const previousCwd = typeof previous?.cwd === "string" && previous.cwd.trim() ? previous.cwd : null;
1846
+ const cwd =
1847
+ typeof session?.cwd === "string" && session.cwd.trim()
1848
+ ? path.resolve(session.cwd)
1849
+ : previousCwd
1850
+ ? path.resolve(previousCwd)
1851
+ : null;
1852
+ if (!cwd) throw coordinatorStateError("invalid");
1853
+ const sessionFile =
1854
+ typeof session?.session_file === "string" && session.session_file.trim()
1855
+ ? path.resolve(session.session_file)
1856
+ : null;
925
1857
  const payload: CoordinatorSessionState = {
926
1858
  schema_version: 1,
927
1859
  session_id: sessionId,
@@ -933,6 +1865,9 @@ async function writeSessionState(
933
1865
  source: options.source ?? "coordinator",
934
1866
  live: options.live ?? previous?.live ?? null,
935
1867
  reason: options.reason ?? null,
1868
+ cwd,
1869
+ workdir: cwd,
1870
+ session_file: sessionFile,
936
1871
  };
937
1872
  await writeJsonFile(sessionStateFile(namespaceDir, sessionId), payload);
938
1873
  if (
@@ -960,17 +1895,149 @@ async function writeSessionState(
960
1895
  return payload;
961
1896
  }
962
1897
 
963
- function hasTmuxIdentity(session: Record<string, unknown>): boolean {
1898
+ interface SessionStateLockOwner {
1899
+ pid: number;
1900
+ start_time: string;
1901
+ token: string;
1902
+ }
1903
+
1904
+ function processStartTime(pid: number): string | null {
1905
+ try {
1906
+ const stat = nodeFs.readFileSync(`/proc/${pid}/stat`, "utf8");
1907
+ const close = stat.lastIndexOf(")");
1908
+ const fields = stat
1909
+ .slice(close + 1)
1910
+ .trim()
1911
+ .split(/\s+/);
1912
+ return fields[19] ?? null;
1913
+ } catch {
1914
+ return null;
1915
+ }
1916
+ }
1917
+
1918
+ function validLockOwner(value: unknown): value is SessionStateLockOwner {
1919
+ if (!value || typeof value !== "object") return false;
1920
+ const owner = value as Partial<SessionStateLockOwner>;
964
1921
  return (
965
- (typeof session.tmux_session === "string" && session.tmux_session.length > 0) ||
966
- (typeof session.tmuxSession === "string" && session.tmuxSession.length > 0)
1922
+ typeof owner.pid === "number" &&
1923
+ Number.isSafeInteger(owner.pid) &&
1924
+ owner.pid > 0 &&
1925
+ typeof owner.start_time === "string" &&
1926
+ typeof owner.token === "string" &&
1927
+ owner.token.length > 0
967
1928
  );
968
1929
  }
969
1930
 
970
- function unavailableSessionReason(turn: TurnRecord, reason: string): string {
971
- if (
972
- reason === "tmux_session_missing" &&
973
- turn.delivery.tmux_keys_sent === true &&
1931
+ function lockOwnerIsAlive(value: unknown): boolean {
1932
+ if (!validLockOwner(value)) return false;
1933
+ const owner = value;
1934
+ try {
1935
+ process.kill(owner.pid, 0);
1936
+ } catch (error) {
1937
+ if ((error as NodeJS.ErrnoException).code === "ESRCH") return false;
1938
+ return true;
1939
+ }
1940
+ const currentStartTime = processStartTime(owner.pid);
1941
+ return currentStartTime === null || currentStartTime === owner.start_time;
1942
+ }
1943
+
1944
+ async function reclaimStaleSessionStateLock(lockFile: string): Promise<void> {
1945
+ let raw: string;
1946
+ try {
1947
+ raw = await fs.readFile(lockFile, "utf8");
1948
+ } catch (error) {
1949
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
1950
+ throw error;
1951
+ }
1952
+ let owner: unknown;
1953
+ try {
1954
+ owner = JSON.parse(raw);
1955
+ } catch {
1956
+ owner = null;
1957
+ }
1958
+ if (!validLockOwner(owner)) {
1959
+ const stat = await fs.stat(lockFile);
1960
+ if (Date.now() - stat.mtimeMs < 30_000) return;
1961
+ } else if (lockOwnerIsAlive(owner)) return;
1962
+ try {
1963
+ if ((await fs.readFile(lockFile, "utf8")) === raw) await fs.rm(lockFile);
1964
+ } catch (error) {
1965
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
1966
+ }
1967
+ }
1968
+
1969
+ async function withSessionStateLock<T>(stateFile: string, operation: () => Promise<T>): Promise<T> {
1970
+ const lockFile = `${stateFile}.lock`;
1971
+ const owner: SessionStateLockOwner = {
1972
+ pid: process.pid,
1973
+ start_time: processStartTime(process.pid) ?? "unknown",
1974
+ token: randomUUID(),
1975
+ };
1976
+ await ensureDir(path.dirname(stateFile));
1977
+ for (let attempt = 0; attempt < 12_000; attempt++) {
1978
+ let handle: fs.FileHandle | undefined;
1979
+ try {
1980
+ handle = await fs.open(lockFile, "wx");
1981
+ try {
1982
+ await handle.writeFile(JSON.stringify(owner));
1983
+ } catch (error) {
1984
+ await handle.close().catch(() => undefined);
1985
+ handle = undefined;
1986
+ await fs.rm(lockFile, { force: true }).catch(() => undefined);
1987
+ throw error;
1988
+ }
1989
+ const outcome = await operation().then(
1990
+ value => ({ ok: true as const, value }),
1991
+ error => ({ ok: false as const, error }),
1992
+ );
1993
+ await handle.close();
1994
+ try {
1995
+ if ((await fs.readFile(lockFile, "utf8")) === JSON.stringify(owner)) await fs.rm(lockFile);
1996
+ } catch (error) {
1997
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
1998
+ }
1999
+ if (!outcome.ok) throw outcome.error;
2000
+ return outcome.value;
2001
+ } catch (error) {
2002
+ if (handle) throw error;
2003
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw coordinatorStateError("unreadable");
2004
+ await reclaimStaleSessionStateLock(lockFile);
2005
+ await Bun.sleep(5);
2006
+ }
2007
+ }
2008
+ throw coordinatorStateError("unreadable");
2009
+ }
2010
+
2011
+ async function writeSessionState(
2012
+ namespaceDir: string,
2013
+ sessionId: string,
2014
+ state: CoordinatorSessionStateValue,
2015
+ options: {
2016
+ currentTurnId?: string | null;
2017
+ lastTurnId?: string | null;
2018
+ live?: boolean | null;
2019
+ reason?: string | null;
2020
+ source?: CoordinatorSessionState["source"];
2021
+ } = {},
2022
+ ): Promise<CoordinatorSessionState> {
2023
+ const file = sessionStateFile(namespaceDir, sessionId);
2024
+ return await withSessionStateLock(
2025
+ file,
2026
+ async () => await writeSessionStateUnlocked(namespaceDir, sessionId, state, options),
2027
+ );
2028
+ }
2029
+
2030
+ function hasTmuxIdentity(session: Record<string, unknown>): boolean {
2031
+ return (
2032
+ (typeof session.tmux_session === "string" && session.tmux_session.length > 0) ||
2033
+ (typeof session.tmuxSession === "string" && session.tmuxSession.length > 0)
2034
+ );
2035
+ }
2036
+
2037
+ function unavailableSessionReason(turn: TurnRecord, reason: string): string {
2038
+ if (
2039
+ reason === "tmux_session_missing" &&
2040
+ turn.delivery.tmux_keys_sent === true &&
974
2041
  turn.delivery.prompt_acknowledged === true
975
2042
  ) {
976
2043
  return "tmux_session_missing_after_prompt_acknowledgement";
@@ -1012,7 +2079,11 @@ async function markTurnFailedForUnavailableSession(
1012
2079
  truncated: false,
1013
2080
  },
1014
2081
  evidence: unavailableSessionEvidence(turn, durableReason, timestamp),
1015
- error: { code: "session_unavailable", message: durableReason, recoverable: true },
2082
+ error: {
2083
+ code: "session_unavailable",
2084
+ message: durableReason,
2085
+ recoverable: true,
2086
+ },
1016
2087
  liveness: { checked_at: timestamp, live: false, reason: durableReason },
1017
2088
  updated_at: timestamp,
1018
2089
  completed_at: timestamp,
@@ -1193,7 +2264,11 @@ async function markTurnFailedForUnacknowledgedDelivery(
1193
2264
  created_at: timestamp,
1194
2265
  },
1195
2266
  ],
1196
- liveness: { checked_at: timestamp, live: turn.liveness.live, reason: PROMPT_ACK_TIMEOUT_REASON },
2267
+ liveness: {
2268
+ checked_at: timestamp,
2269
+ live: turn.liveness.live,
2270
+ reason: PROMPT_ACK_TIMEOUT_REASON,
2271
+ },
1197
2272
  updated_at: timestamp,
1198
2273
  completed_at: timestamp,
1199
2274
  };
@@ -1214,6 +2289,16 @@ async function reconcileRuntimeAcknowledgement(
1214
2289
  ackTimeoutMs: number,
1215
2290
  options: { failOnTimeout: boolean } = { failOnTimeout: true },
1216
2291
  ): Promise<TurnRecord> {
2292
+ const session = asRecord(await readJsonFile(path.join(namespaceDir, "sessions", `${turn.session_id}.json`)));
2293
+ if (
2294
+ sessionState &&
2295
+ runtimeStateMatchesPrivateOwnerGeneration(sessionState, session) &&
2296
+ sessionState.source !== "coordinator" &&
2297
+ (sessionState.state === "completed" || sessionState.state === "errored") &&
2298
+ (sessionState.current_turn_id === null || sessionState.current_turn_id === turn.turn_id)
2299
+ ) {
2300
+ return await markTurnTerminalFromSessionState(namespaceDir, turn, sessionState);
2301
+ }
1217
2302
  if (sessionState && runtimeStateAcknowledgesTurn(turn, sessionState)) {
1218
2303
  return await markTurnAcknowledgedFromRuntimeState(namespaceDir, turn, sessionState);
1219
2304
  }
@@ -1226,6 +2311,27 @@ async function reconcileRuntimeAcknowledgement(
1226
2311
  function shellQuote(value: string): string {
1227
2312
  return `'${value.replaceAll("'", "'\\''")}'`;
1228
2313
  }
2314
+
2315
+ /**
2316
+ * Append an authoritative `--mpreset <profile>` to the coordinator child launch
2317
+ * command so the spawned GJC session activates the selected profile from its
2318
+ * first turn. The profile name is validated against the merged registry before
2319
+ * reaching here; it is still shell-quoted as defense in depth.
2320
+ *
2321
+ * Precedence note: this matches `gjc --mpreset <profile>` semantics exactly, so
2322
+ * an explicit model-affecting flag already present in the operator-configured
2323
+ * `sessionCommand` (e.g. a `--model`/`--thinking` baked into the launch command)
2324
+ * follows the same CLI precedence as a direct `gjc --mpreset ... --model ...`
2325
+ * invocation. The effective profile recorded on the session is the
2326
+ * coordinator-resolved request, not a child-attested activation receipt; a
2327
+ * cross-process activation receipt is a possible future enhancement and is out
2328
+ * of scope for the per-session selection contract here.
2329
+ */
2330
+ export function buildCoordinatorSessionCommand(sessionCommand: string, mpreset: string | null | undefined): string {
2331
+ if (!mpreset) return sessionCommand;
2332
+ return `${sessionCommand} --mpreset ${shellQuote(mpreset)}`;
2333
+ }
2334
+
1229
2335
  function makeTurnRecord(
1230
2336
  config: CoordinatorMcpConfig,
1231
2337
  sessionId: string,
@@ -1250,7 +2356,13 @@ function makeTurnRecord(
1250
2356
  attempts: [],
1251
2357
  },
1252
2358
  question_ids: [],
1253
- final_response: { text: null, format: "markdown", source: null, artifact_path: null, truncated: false },
2359
+ final_response: {
2360
+ text: null,
2361
+ format: "markdown",
2362
+ source: null,
2363
+ artifact_path: null,
2364
+ truncated: false,
2365
+ },
1254
2366
  evidence: [],
1255
2367
  error: null,
1256
2368
  liveness: { checked_at: null, live: null, reason: null },
@@ -1272,6 +2384,8 @@ function asTerminalTurnStatus(status: unknown): TurnStatus | null {
1272
2384
 
1273
2385
  export const COORDINATOR_AWAIT_TURN_TIMEOUT_MAX_MS = 30 * 60 * 1000;
1274
2386
  export const COORDINATOR_RUNTIME_PROMPT_ACK_TIMEOUT_MAX_MS = MAX_RUNTIME_PROMPT_ACK_TIMEOUT_MS;
2387
+ export const COORDINATOR_RUNTIME_READINESS_TIMEOUT_MAX_MS = MAX_RUNTIME_READINESS_TIMEOUT_MS;
2388
+
1275
2389
  export const COORDINATOR_EVENT_WATCH_TIMEOUT_MAX_MS = 30_000;
1276
2390
  export const COORDINATOR_POLL_INTERVAL_MAX_MS = 10_000;
1277
2391
 
@@ -1290,6 +2404,12 @@ export function boundedRuntimePromptAckTimeoutMs(value: unknown): number {
1290
2404
  COORDINATOR_RUNTIME_PROMPT_ACK_TIMEOUT_MAX_MS,
1291
2405
  );
1292
2406
  }
2407
+ export function boundedRuntimeReadinessTimeoutMs(value: unknown): number {
2408
+ return Math.min(
2409
+ parsePositiveIntegerMs(value, DEFAULT_RUNTIME_READINESS_TIMEOUT_MS),
2410
+ COORDINATOR_RUNTIME_READINESS_TIMEOUT_MAX_MS,
2411
+ );
2412
+ }
1293
2413
 
1294
2414
  export function boundedEventWatchTimeoutMs(value: unknown): number {
1295
2415
  return Math.min(parsePositiveIntegerMs(value, 1000), COORDINATOR_EVENT_WATCH_TIMEOUT_MAX_MS);
@@ -1298,8 +2418,21 @@ export function boundedEventWatchTimeoutMs(value: unknown): number {
1298
2418
  export function boundedPollIntervalMs(value: unknown): number {
1299
2419
  return Math.min(Math.max(parsePositiveIntegerMs(value, 100), 10), COORDINATOR_POLL_INTERVAL_MAX_MS);
1300
2420
  }
1301
- async function runCommand(command: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
1302
- const proc = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" });
2421
+ async function runCommand(
2422
+ command: string[],
2423
+ stdinLine?: string,
2424
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
2425
+ const proc = Bun.spawn(command, {
2426
+ stdin: stdinLine === undefined ? "ignore" : "pipe",
2427
+ stdout: "pipe",
2428
+ stderr: "pipe",
2429
+ });
2430
+ if (stdinLine !== undefined) {
2431
+ const stdin = proc.stdin;
2432
+ if (!stdin) throw new Error("coordinator_command_stdin_unavailable");
2433
+ stdin.write(`${stdinLine}\n`);
2434
+ stdin.end();
2435
+ }
1303
2436
  const [stdout, stderr, exitCode] = await Promise.all([
1304
2437
  new Response(proc.stdout).text(),
1305
2438
  new Response(proc.stderr).text(),
@@ -1308,29 +2441,39 @@ async function runCommand(command: string[]): Promise<{ exitCode: number; stdout
1308
2441
  return { exitCode, stdout, stderr };
1309
2442
  }
1310
2443
 
1311
- type CommandRunner = (command: string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
1312
-
1313
2444
  async function sendTmuxPromptKeys(
1314
2445
  target: string,
1315
2446
  prompt: string,
1316
- runner: CommandRunner = runCommand,
2447
+ runner: CommandRunner,
2448
+ socketKey: string,
2449
+ proveCurrentServer: () => Promise<boolean>,
1317
2450
  ): Promise<boolean> {
2451
+ const runMutation = async (argv: string[]): Promise<CommandResult | null> => {
2452
+ if (!(await proveCurrentServer())) return null;
2453
+ return await runner(argv);
2454
+ };
1318
2455
  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]);
2456
+ const tmux = ["tmux", "-L", socketKey];
2457
+ const zeroize = async (): Promise<boolean> => {
2458
+ for (let attempt = 0; attempt < 2; attempt++) {
2459
+ const cleared = await runMutation([...tmux, "set-buffer", "-b", bufferName, "--", ""]);
2460
+ const deleted =
2461
+ cleared?.exitCode === 0 ? await runMutation([...tmux, "delete-buffer", "-b", bufferName]) : null;
2462
+ if (cleared?.exitCode === 0 && deleted?.exitCode === 0) return true;
2463
+ }
2464
+ return false;
2465
+ };
2466
+ const buffered = await runMutation([...tmux, "set-buffer", "-b", bufferName, "--", prompt]);
2467
+ if (buffered?.exitCode !== 0) return false;
2468
+ const pasted = await runMutation([...tmux, "paste-buffer", "-d", "-b", bufferName, "-t", target]);
2469
+ if (pasted?.exitCode !== 0) {
2470
+ if (!(await zeroize())) throw new Error("coordinator_prompt_buffer_privacy_unverified");
1324
2471
  return false;
1325
2472
  }
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;
2473
+ const dismissedAutocomplete = await runMutation([...tmux, "send-keys", "-t", target, "Escape"]);
2474
+ if (dismissedAutocomplete?.exitCode !== 0) return false;
2475
+ const submitted = await runMutation([...tmux, "send-keys", "-t", target, "Enter"]);
2476
+ return submitted?.exitCode === 0;
1334
2477
  }
1335
2478
 
1336
2479
  function boundedLineCount(value: unknown): number {
@@ -1355,7 +2498,10 @@ async function registerExistingTmuxSession(
1355
2498
  namespaceDir: string,
1356
2499
  sessionFilePath: string,
1357
2500
  runner: CommandRunner = runCommand,
1358
- ): Promise<{ session: Record<string, unknown>; sessionState: CoordinatorSessionState }> {
2501
+ ): Promise<{
2502
+ session: Record<string, unknown>;
2503
+ sessionState: CoordinatorSessionState;
2504
+ }> {
1359
2505
  await assertTmuxTargetAvailable(input.tmuxSession, input.tmuxTarget, runner);
1360
2506
  const existing = asRecord(await readJsonFile(sessionFilePath));
1361
2507
  if (existing) {
@@ -1381,6 +2527,7 @@ async function registerExistingTmuxSession(
1381
2527
  authoritative: true,
1382
2528
  warp_attached: input.warpAttached,
1383
2529
  source: input.source,
2530
+ origin: "registered",
1384
2531
  model: input.model,
1385
2532
  };
1386
2533
  await writeJsonFile(sessionFilePath, session);
@@ -1391,52 +2538,406 @@ async function registerExistingTmuxSession(
1391
2538
  return { session, sessionState: state };
1392
2539
  }
1393
2540
 
2541
+ async function portableProcessStartTime(pid: number, runner: CommandRunner): Promise<string | null> {
2542
+ const result = await runner(["ps", "-o", "lstart=", "-p", String(pid)]);
2543
+ const startTime = result.stdout.trim();
2544
+ return result.exitCode === 0 &&
2545
+ startTime.length > 0 &&
2546
+ startTime.length <= 160 &&
2547
+ !/[\r\n\u0000-\u001f\u007f]/.test(startTime)
2548
+ ? startTime
2549
+ : null;
2550
+ }
2551
+
2552
+ export async function coordinatorOwnerIsolationProbe(runner: CommandRunner): Promise<OwnerIsolationProbe> {
2553
+ return {
2554
+ readCallerCgroup: async () => await fs.readFile("/proc/self/cgroup", "utf8").catch(() => null),
2555
+ probeServer: async (socketKey): Promise<TmuxServerProof> => {
2556
+ try {
2557
+ const result = await runner(["tmux", "-L", socketKey, "list-sessions", "-F", "#{pid} #{session_name}"]);
2558
+ if (result.exitCode !== 0) {
2559
+ const diagnostic = `${result.stdout}\n${result.stderr}`.slice(0, 512);
2560
+ return /(?:no server running|failed to connect to server|no sessions|error connecting to)/i.test(
2561
+ diagnostic,
2562
+ )
2563
+ ? { state: "absent" }
2564
+ : { state: "unverifiable" };
2565
+ }
2566
+ const rows = result.stdout
2567
+ .split("\n")
2568
+ .map(line => line.trim().split(/\s+/, 2))
2569
+ .filter(([pid, name]) => Boolean(pid && name));
2570
+ const pid = Number(rows[0]?.[0]);
2571
+ const sessionNames = rows.map(([, name]) => name as string);
2572
+ if (!Number.isSafeInteger(pid) || pid <= 0 || rows.some(([rowPid]) => Number(rowPid) !== pid))
2573
+ return { state: "unverifiable" };
2574
+ const stat =
2575
+ process.platform === "linux" ? await fs.readFile(`/proc/${pid}/stat`, "utf8").catch(() => null) : null;
2576
+ const startTime =
2577
+ process.platform === "linux"
2578
+ ? ownerProcessStartTime(process.platform, stat)
2579
+ : await portableProcessStartTime(pid, runner);
2580
+ if (!startTime) return { state: "unverifiable", pid, sessionNames };
2581
+ if (process.platform !== "linux")
2582
+ return {
2583
+ state: "safe",
2584
+ pid,
2585
+ startTime,
2586
+ sessionNames,
2587
+ cgroup: { classification: "not_applicable" },
2588
+ };
2589
+ const cgroupText = await fs.readFile(`/proc/${pid}/cgroup`, "utf8").catch(() => null);
2590
+ const cgroup = classifyCgroup({ platform: process.platform, cgroupText });
2591
+ return {
2592
+ state:
2593
+ cgroup.classification === "safe"
2594
+ ? "safe"
2595
+ : cgroup.classification === "unsafe_service"
2596
+ ? "unsafe"
2597
+ : "unverifiable",
2598
+ pid,
2599
+ startTime,
2600
+ sessionNames,
2601
+ cgroup,
2602
+ };
2603
+ } catch {
2604
+ // A transient spawn failure (e.g. EAGAIN under load, or a momentary tmux/ps hiccup)
2605
+ // must not fatally abort the owner-server proof. Surfacing it as an unverifiable state
2606
+ // lets proveCoordinatorOwnerServer's bounded retry loop re-probe once the transient
2607
+ // clears, instead of propagating the throw and failing the entire delegate outright.
2608
+ return { state: "unverifiable" };
2609
+ }
2610
+ },
2611
+ };
2612
+ }
2613
+
2614
+ async function proveCoordinatorOwnerServer(probe: OwnerIsolationProbe, socketKey: string): Promise<TmuxServerProof> {
2615
+ for (let attempt = 0; attempt < 50; attempt++) {
2616
+ const proof = await probe.probeServer(socketKey);
2617
+ if (proof.state === "unsafe" || (proof.state === "safe" && proof.pid && proof.startTime)) return proof;
2618
+ if (attempt < 49) await Bun.sleep(10);
2619
+ }
2620
+ return { state: "unverifiable" };
2621
+ }
2622
+
1394
2623
  async function startTmuxSession(
1395
2624
  config: CoordinatorMcpConfig,
1396
2625
  input: SessionStartInput,
1397
2626
  namespaceDir: string,
1398
2627
  runner: CommandRunner = runCommand,
2628
+ ownerIsolationProbe?: OwnerIsolationProbe,
1399
2629
  ): Promise<Record<string, unknown>> {
1400
2630
  if (!config.sessionCommand) throw new Error("coordinator_session_command_required");
1401
- const sessionName = `gjc-coordinator-${randomUUID().slice(0, 8)}`;
2631
+ const sessionName = input.sessionId;
2632
+ const socketKey = `gjc-coordinator-${randomUUID().slice(0, 8)}`;
2633
+ const ownerStateDir = namespaceDir;
2634
+ const ownerGeneration = randomUUID();
2635
+ const generationBaseline = captureOwnerGenerationBaselineSync(ownerStateDir, sessionName);
1402
2636
  const runtimeStateFile = sessionStateFile(namespaceDir, sessionName);
1403
2637
  const sessionCommand = [
1404
2638
  "exec env",
1405
2639
  `${GJC_COORDINATOR_SESSION_STATE_FILE_ENV}=${shellQuote(runtimeStateFile)}`,
1406
2640
  `${GJC_COORDINATOR_SESSION_ID_ENV}=${shellQuote(sessionName)}`,
1407
- config.sessionCommand,
2641
+ `${GJC_TMUX_OWNER_GENERATION_ENV}=${shellQuote(ownerGeneration)}`,
2642
+ `${GJC_TMUX_OWNER_STATE_DIR_ENV}=${shellQuote(ownerStateDir)}`,
2643
+ `${GJC_TMUX_OWNER_SERVER_KEY_ENV}=${shellQuote(socketKey)}`,
2644
+ `${GJC_COORDINATOR_SESSION_LAUNCH_ID_ENV}=${shellQuote(input.launchId)}`,
2645
+ `${GJC_COORDINATOR_SESSION_READINESS_FILE_ENV}=${shellQuote(input.readinessMarkerFile)}`,
2646
+ buildCoordinatorSessionCommand(config.sessionCommand, input.mpreset),
1408
2647
  ].join(" ");
1409
- const started = await runner([
2648
+ const tmuxArgv = [
1410
2649
  "tmux",
2650
+ "-L",
2651
+ socketKey,
1411
2652
  "new-session",
1412
2653
  "-d",
1413
2654
  "-P",
1414
2655
  "-F",
1415
- "#{session_name}:#{window_index}.#{pane_index} #{pane_id}",
2656
+ "#{session_name}:#{window_index}.#{pane_index} #{pane_id} #{session_id}",
1416
2657
  "-s",
1417
2658
  sessionName,
1418
2659
  "-c",
1419
2660
  input.cwd,
1420
2661
  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);
2662
+ ];
2663
+ const probe = ownerIsolationProbe ?? (await coordinatorOwnerIsolationProbe(runner));
2664
+ let plan = await planTmuxOwnerIsolation(
2665
+ {
2666
+ schema_version: 1,
2667
+ op: "plan",
2668
+ platform: process.platform,
2669
+ session_id: sessionName,
2670
+ owner_generation: ownerGeneration,
2671
+ baseline: generationBaseline,
2672
+ cwd: input.cwd,
2673
+ state_dir: ownerStateDir,
2674
+ socket_key: socketKey,
2675
+ tmux_argv: tmuxArgv,
2676
+ },
2677
+ probe,
2678
+ );
2679
+ if (!plan.ok) throw new Error(`coordinator_tmux_owner_${plan.code}`);
2680
+ if (plan.execution.mode === "direct" && plan.execution.server_absent_before) {
2681
+ plan = await planTmuxOwnerIsolation(
2682
+ {
2683
+ schema_version: 1,
2684
+ op: "plan",
2685
+ platform: process.platform,
2686
+ session_id: sessionName,
2687
+ owner_generation: ownerGeneration,
2688
+ baseline: generationBaseline,
2689
+ cwd: input.cwd,
2690
+ state_dir: ownerStateDir,
2691
+ socket_key: socketKey,
2692
+ tmux_argv: tmuxArgv,
2693
+ },
2694
+ {
2695
+ ...probe,
2696
+ readCallerCgroup: async () => "0::/gjc-coordinator-bootstrap.service",
2697
+ },
2698
+ );
2699
+ if (!plan.ok) throw new Error(`coordinator_tmux_owner_${plan.code}`);
2700
+ }
2701
+ const preSpawnProof =
2702
+ plan.execution.mode === "direct" && plan.server_state === "safe"
2703
+ ? await proveCoordinatorOwnerServer(probe, socketKey)
2704
+ : null;
2705
+ if (
2706
+ preSpawnProof &&
2707
+ (preSpawnProof.state !== "safe" ||
2708
+ !preSpawnProof.pid ||
2709
+ !preSpawnProof.startTime ||
2710
+ (process.platform === "linux" && preSpawnProof.cgroup?.classification !== "safe"))
2711
+ )
2712
+ throw new Error("coordinator_tmux_owner_server_unverifiable");
2713
+ const started = await runner(
2714
+ plan.execution.argv,
2715
+ plan.execution.mode === "scoped" ? plan.execution.stdin_line : undefined,
2716
+ );
2717
+
2718
+ const rawScopedReceipt =
2719
+ plan.execution.mode === "scoped" && isExactScopedBootstrapSuccessReceipt(started.stdout)
2720
+ ? (JSON.parse(started.stdout.trim()) as {
2721
+ native_session_id: string;
2722
+ server_pid: number;
2723
+ server_start_time: string;
2724
+ session_name: string;
2725
+ })
2726
+ : undefined;
2727
+ const scopedReceipt =
2728
+ rawScopedReceipt?.session_name === plan.execution.attempt_session ? rawScopedReceipt : undefined;
2729
+ const directReceipt =
2730
+ plan.execution.mode === "direct"
2731
+ ? parseTmuxNewSessionRecord(started.stdout, plan.execution.attempt_session)
2732
+ : undefined;
2733
+ const directNativeSessionId = directReceipt?.nativeSessionId;
2734
+ const spawnedNativeSessionId = scopedReceipt?.native_session_id ?? directNativeSessionId;
2735
+ let expectedServer = scopedReceipt
2736
+ ? {
2737
+ pid: scopedReceipt.server_pid,
2738
+ startTime: scopedReceipt.server_start_time,
2739
+ }
2740
+ : preSpawnProof && preSpawnProof.state === "safe" && preSpawnProof.pid && preSpawnProof.startTime
2741
+ ? { pid: preSpawnProof.pid, startTime: preSpawnProof.startTime }
2742
+ : undefined;
2743
+ const cleanupFailedAttempt = async (): Promise<"cleaned" | "failed" | "unverifiable"> => {
2744
+ if (!spawnedNativeSessionId || !expectedServer) return "unverifiable";
2745
+ try {
2746
+ const result = await runner([
2747
+ "tmux",
2748
+ "-L",
2749
+ socketKey,
2750
+ "if-shell",
2751
+ "-t",
2752
+ spawnedNativeSessionId,
2753
+ "-F",
2754
+ coordinatorCleanupPredicate(expectedServer.pid, spawnedNativeSessionId, plan.execution.attempt_session),
2755
+ `kill-session -t '${spawnedNativeSessionId}' ; display-message -p __gjc_coordinator_cleanup_ok__`,
2756
+ "display-message -p __gjc_coordinator_cleanup_refused__",
2757
+ ]);
2758
+ if (result.exitCode !== 0) return "failed";
2759
+ return result.stdout.trim() === "__gjc_coordinator_cleanup_ok__" ? "cleaned" : "unverifiable";
2760
+ } catch {
2761
+ return "unverifiable";
2762
+ }
2763
+ };
2764
+ const failAfterCleanup = async (reason: string): Promise<never> => {
2765
+ throw new CoordinatorStartError(reason, await cleanupFailedAttempt());
2766
+ };
2767
+ if (started.exitCode !== 0) return await failAfterCleanup("coordinator_tmux_start_failed");
2768
+ const bootstrapReceiptValid = plan.execution.mode !== "scoped" || scopedReceipt !== undefined;
2769
+ const initialProof = await proveCoordinatorOwnerServer(probe, socketKey).catch(error =>
2770
+ failAfterCleanup(error instanceof Error ? error.message : "coordinator_tmux_owner_server_unverifiable"),
2771
+ );
2772
+ if (
2773
+ initialProof.state !== "safe" ||
2774
+ !initialProof.pid ||
2775
+ !initialProof.startTime ||
2776
+ (process.platform === "linux" && initialProof.cgroup?.classification !== "safe")
2777
+ ) {
2778
+ return await failAfterCleanup("coordinator_tmux_owner_server_unverifiable");
2779
+ }
2780
+ if (
2781
+ scopedReceipt &&
2782
+ (initialProof.pid !== scopedReceipt.server_pid || initialProof.startTime !== scopedReceipt.server_start_time)
2783
+ ) {
2784
+ return await failAfterCleanup("coordinator_tmux_owner_server_race");
2785
+ }
2786
+ if (
2787
+ preSpawnProof &&
2788
+ (initialProof.pid !== preSpawnProof.pid || initialProof.startTime !== preSpawnProof.startTime)
2789
+ ) {
2790
+ return await failAfterCleanup("coordinator_tmux_owner_server_race");
2791
+ }
2792
+ if (!expectedServer)
2793
+ expectedServer = {
2794
+ pid: initialProof.pid,
2795
+ startTime: initialProof.startTime,
2796
+ };
2797
+ if (!bootstrapReceiptValid) {
2798
+ return await failAfterCleanup("coordinator_tmux_start_failed");
2799
+ }
2800
+
2801
+ const startRecord = await (async () => {
2802
+ const queried = await runner([
2803
+ "tmux",
2804
+ "-L",
2805
+ socketKey,
2806
+ "display-message",
2807
+ "-p",
2808
+ "-t",
2809
+ sessionName,
2810
+ "#{session_name}:#{window_index}.#{pane_index} #{pane_id} #{session_id}",
2811
+ ]);
2812
+ if (queried.exitCode !== 0) throw new Error("coordinator_tmux_start_failed");
2813
+ return queried.stdout.trim();
2814
+ })().catch(error => failAfterCleanup(error instanceof Error ? error.message : "coordinator_tmux_start_failed"));
2815
+ const postLaunchProof = await probe.probeServer(socketKey);
2816
+ if (
2817
+ postLaunchProof.state !== "safe" ||
2818
+ postLaunchProof.pid !== initialProof.pid ||
2819
+ postLaunchProof.startTime !== initialProof.startTime
2820
+ ) {
2821
+ return await failAfterCleanup("coordinator_tmux_owner_server_race");
2822
+ }
2823
+ const parsedStartRecord = parseTmuxNewSessionRecord(startRecord, plan.execution.attempt_session);
2824
+ if (!parsedStartRecord) {
2825
+ return await failAfterCleanup("coordinator_tmux_start_failed");
2826
+ }
2827
+ const { tmuxTarget, paneId, nativeSessionId } = parsedStartRecord;
2828
+ if (
2829
+ (scopedReceipt && nativeSessionId !== scopedReceipt.native_session_id) ||
2830
+ (plan.execution.mode === "direct" && nativeSessionId !== directNativeSessionId)
2831
+ ) {
2832
+ return await failAfterCleanup("coordinator_tmux_owner_server_race");
2833
+ }
2834
+ const ownerTransaction = {
2835
+ commit: () => replaceOwnerGenerationSync(ownerStateDir, sessionName, ownerGeneration, generationBaseline),
2836
+ rollback: cleanupFailedAttempt,
2837
+ };
1424
2838
  return {
1425
2839
  sessionId: sessionName,
1426
2840
  tmuxSession: sessionName,
1427
- tmuxTarget: tmuxTarget || sessionName,
2841
+ tmuxTarget,
1428
2842
  paneId,
2843
+ tmuxNativeSessionId: nativeSessionId,
2844
+ tmuxSocketKey: socketKey,
2845
+ tmuxOwnerGeneration: ownerGeneration,
2846
+ tmuxOwnerStateDir: ownerStateDir,
2847
+ tmuxOwnerServerKey: socketKey,
2848
+ tmuxOwnerServerPid: initialProof.pid,
2849
+ tmuxOwnerServerStartTime: initialProof.startTime,
2850
+
1429
2851
  cwd: input.cwd,
1430
2852
  createdAt: new Date().toISOString(),
1431
2853
  sessionCommand: config.sessionCommand,
2854
+ ...(input.mpreset ? { mpreset: input.mpreset } : {}),
1432
2855
  runtimeStateFile,
2856
+ __coordinatorOwnerTransaction: ownerTransaction,
2857
+ launchId: input.launchId,
2858
+ readinessMarkerFile: input.readinessMarkerFile,
2859
+ readinessFile: input.readinessMarkerFile,
2860
+ origin: "coordinator_created",
2861
+ runtime_readiness_latched: false,
1433
2862
  };
1434
2863
  }
1435
2864
 
1436
- async function captureTmuxTail(session: Record<string, unknown>, lines: number): Promise<string[]> {
2865
+ function hasPrivateTmuxOwnerIdentity(session: Record<string, unknown>): boolean {
2866
+ const socketKey = typeof session.tmux_socket_key === "string" ? session.tmux_socket_key : session.tmuxSocketKey;
2867
+ const serverKey =
2868
+ typeof session.tmux_owner_server_key === "string" ? session.tmux_owner_server_key : session.tmuxOwnerServerKey;
2869
+ const pid = session.tmux_owner_server_pid ?? session.tmuxOwnerServerPid;
2870
+ const startTime = session.tmux_owner_server_start_time ?? session.tmuxOwnerServerStartTime;
2871
+ const nativeSessionId = session.tmux_native_session_id ?? session.tmuxNativeSessionId;
2872
+ const paneId = session.pane_id ?? session.paneId;
2873
+ return (
2874
+ typeof socketKey === "string" &&
2875
+ socketKey.length > 0 &&
2876
+ socketKey === serverKey &&
2877
+ Number.isSafeInteger(pid) &&
2878
+ typeof startTime === "string" &&
2879
+ startTime.length > 0 &&
2880
+ typeof nativeSessionId === "string" &&
2881
+ nativeSessionId.length > 0 &&
2882
+ typeof paneId === "string" &&
2883
+ paneId.length > 0
2884
+ );
2885
+ }
2886
+
2887
+ async function proveImmutableTmuxTarget(session: Record<string, unknown>, runner: CommandRunner): Promise<boolean> {
2888
+ const target = typeof session.tmux_target === "string" ? session.tmux_target : session.tmuxTarget;
2889
+ const socketKey = typeof session.tmux_socket_key === "string" ? session.tmux_socket_key : session.tmuxSocketKey;
2890
+ const expectedSessionId = session.tmux_native_session_id ?? session.tmuxNativeSessionId;
2891
+ const expectedPaneId = session.pane_id ?? session.paneId;
2892
+ if (
2893
+ typeof target !== "string" ||
2894
+ typeof socketKey !== "string" ||
2895
+ typeof expectedSessionId !== "string" ||
2896
+ typeof expectedPaneId !== "string"
2897
+ )
2898
+ return false;
2899
+ const identity = await runner([
2900
+ "tmux",
2901
+ "-L",
2902
+ socketKey,
2903
+ "display-message",
2904
+ "-p",
2905
+ "-t",
2906
+ target,
2907
+ "#{session_id} #{pane_id}",
2908
+ ]);
2909
+ const parts = identity.stdout.trim().split(/\s+/);
2910
+ if (identity.exitCode !== 0) return false;
2911
+ return parts.length === 2 && parts[0] === expectedSessionId && parts[1] === expectedPaneId;
2912
+ }
2913
+ async function captureTmuxTail(
2914
+ session: Record<string, unknown>,
2915
+ lines: number,
2916
+ runner: CommandRunner = runCommand,
2917
+ ): Promise<string[]> {
1437
2918
  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}`]);
2919
+ const socketKey = typeof session.tmux_socket_key === "string" ? session.tmux_socket_key : session.tmuxSocketKey;
2920
+ if (
2921
+ !hasPrivateTmuxOwnerIdentity(session) ||
2922
+ typeof socketKey !== "string" ||
2923
+ socketKey.length === 0 ||
2924
+ typeof target !== "string" ||
2925
+ target.length === 0
2926
+ )
2927
+ return [];
2928
+ const pid = session.tmux_owner_server_pid ?? session.tmuxOwnerServerPid;
2929
+ const startTime = session.tmux_owner_server_start_time ?? session.tmuxOwnerServerStartTime;
2930
+ const probe = await coordinatorOwnerIsolationProbe(runner);
2931
+ const proof = await probe.probeServer(socketKey);
2932
+ if (
2933
+ proof.state !== "safe" ||
2934
+ proof.pid !== pid ||
2935
+ proof.startTime !== startTime ||
2936
+ (process.platform === "linux" && proof.cgroup?.classification !== "safe") ||
2937
+ !(await proveImmutableTmuxTarget(session, runner))
2938
+ )
2939
+ return [];
2940
+ const captured = await runner(["tmux", "-L", socketKey, "capture-pane", "-t", target, "-p", "-S", `-${lines}`]);
1440
2941
  if (captured.exitCode !== 0) return [];
1441
2942
  return captured.stdout.split("\n").slice(-lines);
1442
2943
  }
@@ -1445,10 +2946,38 @@ async function sendTmuxPrompt(
1445
2946
  session: Record<string, unknown>,
1446
2947
  prompt: string,
1447
2948
  runner: CommandRunner = runCommand,
2949
+ ownerIsolationProbe?: OwnerIsolationProbe,
1448
2950
  ): Promise<boolean> {
1449
2951
  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);
2952
+ const socketKey = typeof session.tmux_socket_key === "string" ? session.tmux_socket_key : session.tmuxSocketKey;
2953
+ const serverKey =
2954
+ typeof session.tmux_owner_server_key === "string" ? session.tmux_owner_server_key : session.tmuxOwnerServerKey;
2955
+ const pid = session.tmux_owner_server_pid ?? session.tmuxOwnerServerPid;
2956
+ const startTime = session.tmux_owner_server_start_time ?? session.tmuxOwnerServerStartTime;
2957
+ if (
2958
+ typeof target !== "string" ||
2959
+ target.length === 0 ||
2960
+ typeof socketKey !== "string" ||
2961
+ socketKey.length === 0 ||
2962
+ socketKey !== serverKey ||
2963
+ !Number.isSafeInteger(pid) ||
2964
+ typeof startTime !== "string" ||
2965
+ startTime.length === 0
2966
+ )
2967
+ return false;
2968
+ const paneId = session.pane_id ?? session.paneId;
2969
+ if (typeof paneId !== "string" || !(await proveImmutableTmuxTarget(session, runner))) return false;
2970
+ const probe = ownerIsolationProbe ?? (await coordinatorOwnerIsolationProbe(runner));
2971
+ const proveCurrentServer = async (): Promise<boolean> => {
2972
+ const proof = await probe.probeServer(socketKey);
2973
+ return (
2974
+ proof.state === "safe" &&
2975
+ proof.pid === pid &&
2976
+ proof.startTime === startTime &&
2977
+ (process.platform !== "linux" || proof.cgroup?.classification === "safe")
2978
+ );
2979
+ };
2980
+ return await sendTmuxPromptKeys(paneId, prompt, runner, socketKey, proveCurrentServer);
1452
2981
  }
1453
2982
 
1454
2983
  async function hasTmuxSession(
@@ -1456,8 +2985,16 @@ async function hasTmuxSession(
1456
2985
  runner: CommandRunner = runCommand,
1457
2986
  ): Promise<boolean | null> {
1458
2987
  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]);
2988
+ const socketKey = typeof session.tmux_socket_key === "string" ? session.tmux_socket_key : session.tmuxSocketKey;
2989
+ if (
2990
+ !hasPrivateTmuxOwnerIdentity(session) ||
2991
+ typeof socketKey !== "string" ||
2992
+ socketKey.length === 0 ||
2993
+ typeof tmuxSession !== "string" ||
2994
+ tmuxSession.length === 0
2995
+ )
2996
+ return null;
2997
+ const checked = await runner(["tmux", "-L", socketKey, "has-session", "-t", tmuxSession]);
1461
2998
  return checked.exitCode === 0;
1462
2999
  }
1463
3000
 
@@ -1492,7 +3029,8 @@ async function inspectTmuxSession(
1492
3029
  runner: CommandRunner = runCommand,
1493
3030
  ): Promise<Record<string, unknown>> {
1494
3031
  const live = await hasTmuxSession(session, runner);
1495
- const tail = live ? await captureTmuxTail(session, lines) : [];
3032
+ const tail = live ? await captureTmuxTail(session, lines, runner) : [];
3033
+
1496
3034
  return {
1497
3035
  live,
1498
3036
  ...summarizePaneTail(tail),
@@ -1607,7 +3145,7 @@ export async function readCoordinatorArtifact(
1607
3145
  } catch (error) {
1608
3146
  return {
1609
3147
  ok: false,
1610
- reason: (error instanceof Error ? error.message.split(":")[0] : String(error)).replace(/^coordinator_/, ""),
3148
+ reason: publicCoordinatorError(error),
1611
3149
  };
1612
3150
  } finally {
1613
3151
  await handle?.close();
@@ -1621,6 +3159,122 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1621
3159
  const services = options.services ?? {};
1622
3160
  const namespaceDir = coordinatorNamespacePath(config);
1623
3161
  const commandRunner = services.commandRunner ?? runCommand;
3162
+ const loadModelProfiles = services.resolveModelProfiles ?? loadCoordinatorModelProfiles;
3163
+ const readinessTimeoutMs = boundedRuntimeReadinessTimeoutMs(env.GJC_COORDINATOR_MCP_RUNTIME_READINESS_TIMEOUT_MS);
3164
+ async function withSessionTransition<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
3165
+ return await withSessionMutation(`${namespaceDir}::${sessionId}`, operation);
3166
+ }
3167
+
3168
+ async function startCoordinatorSession(
3169
+ cwd: string,
3170
+ prompt?: string,
3171
+ mpreset?: string | null,
3172
+ ): Promise<Record<string, unknown>> {
3173
+ const sessionId = `gjc-coordinator-${randomUUID().slice(0, 8)}`;
3174
+ const launchId = randomUUID();
3175
+ const readinessRoot = path.join(namespaceDir, "launch-readiness");
3176
+ const readinessDir = path.join(readinessRoot, sessionId);
3177
+ await ensureDir(readinessDir);
3178
+ const canonicalRoot = await fs.realpath(readinessRoot);
3179
+ const canonicalDir = await fs.realpath(readinessDir);
3180
+ if (!canonicalDir.startsWith(`${canonicalRoot}${path.sep}`))
3181
+ throw new Error("coordinator_readiness_path_invalid");
3182
+ const readinessMarkerFile = path.join(canonicalDir, `${launchId}.json`);
3183
+ const input: SessionStartInput = {
3184
+ cwd,
3185
+ prompt,
3186
+ namespace: config.namespace,
3187
+ mpreset,
3188
+ worktree: true,
3189
+ sessionId,
3190
+ launchId,
3191
+ readinessMarkerFile,
3192
+ readinessFile: readinessMarkerFile,
3193
+ };
3194
+ const started = services.startSession
3195
+ ? await services.startSession(input)
3196
+ : await startTmuxSession(config, input, namespaceDir, commandRunner, services.ownerIsolationProbe);
3197
+ const record = asRecord(started);
3198
+ if (!record) throw new Error("coordinator_session_command_required");
3199
+ const attestedSessionId = optionalString(record.sessionId ?? record.session_id);
3200
+ const attestedLaunchId = optionalString(record.launchId ?? record.launch_id);
3201
+ const attestedMarkerFile = optionalString(
3202
+ record.readinessMarkerFile ?? record.readiness_marker_file ?? record.readinessFile ?? record.readiness_file,
3203
+ );
3204
+ if (!attestedSessionId || !attestedLaunchId || !attestedMarkerFile)
3205
+ throw new Error("coordinator_launch_attestation_missing");
3206
+ if (
3207
+ attestedSessionId !== sessionId ||
3208
+ attestedLaunchId !== launchId ||
3209
+ (await canonicalizePath(attestedMarkerFile)) !== readinessMarkerFile
3210
+ )
3211
+ throw new Error("coordinator_launch_attestation_mismatch");
3212
+ return normalizeSession({
3213
+ ...record,
3214
+ session_id: sessionId,
3215
+ launch_id: launchId,
3216
+ readiness_marker_file: readinessMarkerFile,
3217
+ readiness_marker_path: readinessMarkerFile,
3218
+ origin: "coordinator_created",
3219
+ runtime_ready_launch_id: null,
3220
+ runtime_ready_at: null,
3221
+ runtime_readiness_latched: false,
3222
+ });
3223
+ }
3224
+
3225
+ function sessionReadinessAuthorityError(
3226
+ reason: "coordinator_launch_metadata_missing" | "session_origin_unverified",
3227
+ session: Record<string, unknown>,
3228
+ ): Error {
3229
+ const error = new Error(reason);
3230
+ Object.assign(error, {
3231
+ sessionId: optionalString(session.session_id),
3232
+ action: "Start a new coordinator session or explicitly register the live external tmux session.",
3233
+ });
3234
+ return error;
3235
+ }
3236
+
3237
+ function assertSessionReadinessAuthority(session: Record<string, unknown>): void {
3238
+ if (session.origin === "registered") return;
3239
+ const tmuxTarget = session.tmux_target ?? session.tmuxTarget;
3240
+ if (
3241
+ session.origin == null &&
3242
+ typeof session.registered_at === "string" &&
3243
+ session.registered_at.length > 0 &&
3244
+ session.authoritative === true &&
3245
+ hasTmuxIdentity(session) &&
3246
+ typeof tmuxTarget === "string" &&
3247
+ tmuxTarget.length > 0
3248
+ )
3249
+ return;
3250
+ if (session.origin === "coordinator_created") {
3251
+ const sessionId = optionalString(session.session_id);
3252
+ const launchId = optionalString(session.launch_id);
3253
+ const markerFile = optionalString(session.readiness_marker_file ?? session.readiness_marker_path);
3254
+ if (!sessionId || !launchId || !markerFile) {
3255
+ throw sessionReadinessAuthorityError("coordinator_launch_metadata_missing", session);
3256
+ }
3257
+ return;
3258
+ }
3259
+ throw sessionReadinessAuthorityError("session_origin_unverified", session);
3260
+ }
3261
+
3262
+ async function awaitRuntimeReadiness(session: Record<string, unknown>): Promise<boolean> {
3263
+ assertSessionReadinessAuthority(session);
3264
+ if (session.origin === "registered" || session.origin == null) return true;
3265
+ const sessionId = optionalString(session.session_id);
3266
+ const launchId = optionalString(session.launch_id);
3267
+ const markerFile = optionalString(session.readiness_marker_file ?? session.readiness_marker_path);
3268
+ if (!sessionId || !launchId || !markerFile) throw new Error("coordinator_launch_metadata_missing");
3269
+ if (session.runtime_ready_launch_id === launchId) return true;
3270
+ return await waitForRuntimeReadinessMarker(
3271
+ markerFile,
3272
+ sessionId,
3273
+ launchId,
3274
+ path.join(namespaceDir, "launch-readiness"),
3275
+ readinessTimeoutMs,
3276
+ );
3277
+ }
1624
3278
 
1625
3279
  async function listSessions(): Promise<unknown[]> {
1626
3280
  if (!config.namespace.profile || !config.namespace.repo) return [];
@@ -1630,6 +3284,57 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1630
3284
  function sessionFile(sessionId: unknown): string {
1631
3285
  return path.join(namespaceDir, "sessions", `${safeExternalId("session", sessionId)}.json`);
1632
3286
  }
3287
+ async function compensateFailedOwnerStart(
3288
+ session: Record<string, unknown>,
3289
+ ownerTransaction: {
3290
+ rollback?: () => Promise<"cleaned" | "failed" | "unverifiable">;
3291
+ } | null,
3292
+ ): Promise<"cleaned" | "failed" | "unverifiable"> {
3293
+ let cleanupStatus: "cleaned" | "failed" | "unverifiable" = "unverifiable";
3294
+ try {
3295
+ cleanupStatus = (await ownerTransaction?.rollback?.()) ?? "unverifiable";
3296
+ } catch {
3297
+ cleanupStatus = "unverifiable";
3298
+ }
3299
+ try {
3300
+ const sessionId = String(session.session_id);
3301
+ const cwd = typeof session.cwd === "string" ? path.resolve(session.cwd) : null;
3302
+ if (!cwd) throw coordinatorStateError("invalid");
3303
+ const state: CoordinatorSessionState = {
3304
+ schema_version: 1,
3305
+ session_id: sessionId,
3306
+ state: "errored",
3307
+ ready_for_input: false,
3308
+ current_turn_id: null,
3309
+ last_turn_id: null,
3310
+ updated_at: new Date().toISOString(),
3311
+ source: "coordinator",
3312
+ live: false,
3313
+ reason: "coordinator_start_rolled_back",
3314
+ cwd,
3315
+ workdir: cwd,
3316
+ session_file: null,
3317
+ };
3318
+ await writeJsonFile(sessionStateFile(namespaceDir, sessionId), state);
3319
+ await fs.rm(sessionFile(sessionId), { force: true });
3320
+ await appendCoordinatorEvent(namespaceDir, {
3321
+ kind: "session.state_changed",
3322
+ sessionId,
3323
+ summary: `Session ${sessionId} start rolled back`,
3324
+ payloadRef: path.relative(namespaceDir, sessionStateFile(namespaceDir, sessionId)),
3325
+ metadata: {
3326
+ state: "errored",
3327
+ ready_for_input: false,
3328
+ live: false,
3329
+ reason: "coordinator_start_rolled_back",
3330
+ },
3331
+ });
3332
+ } catch {
3333
+ return "failed";
3334
+ }
3335
+ return cleanupStatus;
3336
+ }
3337
+
1633
3338
  async function listQuestions(args: Record<string, unknown>): Promise<unknown[]> {
1634
3339
  const sessionId = args.session_id == null ? null : safeExternalId("session", args.session_id);
1635
3340
  const status = typeof args.status === "string" && args.status.length > 0 ? args.status : null;
@@ -1653,11 +3358,12 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1653
3358
  return evidence;
1654
3359
  }
1655
3360
 
1656
- async function activateTurn(session: Record<string, unknown>, turn: TurnRecord): Promise<TurnRecord> {
3361
+ async function prepareActiveTurnUnlocked(session: Record<string, unknown>, turn: TurnRecord): Promise<TurnRecord> {
3362
+ const active = await readActiveTurn(namespaceDir, turn.session_id);
3363
+ if (active && active.turn_id !== turn.turn_id) return active;
1657
3364
  const timestamp = new Date().toISOString();
1658
3365
  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 = {
3366
+ const pending: TurnRecord = {
1661
3367
  ...turn,
1662
3368
  status: "active",
1663
3369
  delivery: {
@@ -1666,90 +3372,312 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1666
3372
  target: typeof target === "string" ? target : null,
1667
3373
  tmux_keys_sent: false,
1668
3374
  prompt_acknowledged: false,
1669
- state: "queued",
3375
+ state: "awaiting_runtime_readiness",
1670
3376
  attempts: [
1671
3377
  {
1672
3378
  delivered: false,
1673
3379
  tmux_keys_sent: false,
1674
3380
  channel: "tmux_keys",
1675
3381
  created_at: timestamp,
1676
- reason: "awaiting_tmux_delivery",
3382
+ reason: "awaiting_runtime_readiness",
1677
3383
  },
1678
3384
  ],
1679
3385
  },
1680
- liveness: { checked_at: timestamp, live, reason: live === false ? "tmux_session_missing" : null },
3386
+ liveness: { checked_at: null, live: null, reason: null },
1681
3387
  started_at: turn.started_at ?? timestamp,
1682
3388
  updated_at: timestamp,
1683
3389
  };
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,
3390
+ await writeTurnRecord(namespaceDir, pending);
3391
+ await writeActiveTurn(namespaceDir, pending);
3392
+ await writeSessionState(namespaceDir, pending.session_id, "booting", {
3393
+ currentTurnId: pending.turn_id,
3394
+ live: null,
3395
+ reason: "awaiting_runtime_readiness",
1690
3396
  });
3397
+ return pending;
3398
+ }
1691
3399
 
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
- {
3400
+ async function completePreparedTurn(session: Record<string, unknown>, pendingTurn: TurnRecord): Promise<TurnRecord> {
3401
+ const ready = await awaitRuntimeReadiness(session);
3402
+ const observedLive = hasTmuxIdentity(session) ? await hasTmuxSession(session, commandRunner) : null;
3403
+ return await withSessionTransition(pendingTurn.session_id, async () => {
3404
+ const current = await readTurnRecord(namespaceDir, pendingTurn.turn_id);
3405
+ const active = await readActiveTurn(namespaceDir, pendingTurn.session_id);
3406
+ if (
3407
+ !current ||
3408
+ !active ||
3409
+ active.turn_id !== pendingTurn.turn_id ||
3410
+ TERMINAL_TURN_STATUSES.has(current.status) ||
3411
+ current.delivery.state !== "awaiting_runtime_readiness" ||
3412
+ current.delivery.tmux_keys_sent === true
3413
+ )
3414
+ return current ?? pendingTurn;
3415
+ if (!ready) {
3416
+ const completedAt = new Date().toISOString();
3417
+ const message = `GJC runtime did not attest interactive input readiness within ${readinessTimeoutMs}ms.`;
3418
+ const failed: TurnRecord = {
3419
+ ...current,
3420
+ status: "failed",
3421
+ delivery: {
3422
+ ...current.delivery,
1705
3423
  delivered: false,
1706
- tmux_keys_sent: tmuxKeysSent,
1707
- channel: "tmux_keys",
1708
- created_at: deliveredAt,
1709
- reason: tmuxKeysSent ? "awaiting_runtime_ack" : "tmux_delivery_unavailable",
3424
+ queued: false,
3425
+ tmux_keys_sent: false,
3426
+ prompt_acknowledged: false,
3427
+ state: "awaiting_runtime_readiness",
1710
3428
  },
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",
3429
+ final_response: {
3430
+ text: message,
3431
+ format: "markdown",
3432
+ source: "coordinator_runtime_readiness",
3433
+ artifact_path: null,
3434
+ truncated: false,
3435
+ },
3436
+ error: { code: RUNTIME_READINESS_TIMEOUT_REASON, message, recoverable: true },
3437
+ evidence: [
3438
+ ...current.evidence,
3439
+ {
3440
+ type: RUNTIME_READINESS_TIMEOUT_REASON,
3441
+ created_at: completedAt,
3442
+ tmux_keys_sent: false,
3443
+ prompt_acknowledged: false,
3444
+ },
3445
+ ],
3446
+ updated_at: completedAt,
3447
+ completed_at: completedAt,
3448
+ };
3449
+ await writeTurnRecord(namespaceDir, failed);
3450
+ await clearActiveTurn(namespaceDir, failed);
3451
+ await writeSessionState(namespaceDir, failed.session_id, "booting", {
3452
+ lastTurnId: failed.turn_id,
3453
+ live: null,
3454
+ reason: RUNTIME_READINESS_TIMEOUT_REASON,
3455
+ });
3456
+ return failed;
3457
+ }
3458
+ const currentSession = asRecord(await readJsonFile(sessionFile(pendingTurn.session_id))) ?? session;
3459
+ if (
3460
+ currentSession.origin === "coordinator_created" &&
3461
+ (currentSession.session_id !== session.session_id ||
3462
+ currentSession.launch_id !== session.launch_id ||
3463
+ currentSession.readiness_marker_file !== session.readiness_marker_file)
3464
+ )
3465
+ return current;
3466
+ const latched =
3467
+ currentSession.origin === "coordinator_created"
3468
+ ? {
3469
+ ...currentSession,
3470
+ runtime_ready_launch_id: currentSession.launch_id,
3471
+ runtime_ready_at: new Date().toISOString(),
3472
+ runtime_readiness_latched: true,
3473
+ }
3474
+ : currentSession;
3475
+ if (latched !== currentSession) await writeJsonFile(sessionFile(pendingTurn.session_id), latched);
3476
+ const target = typeof latched.tmux_target === "string" ? latched.tmux_target : latched.tmuxTarget;
3477
+ const attemptId = `delivery-${randomUUID()}`;
3478
+ const attemptedAt = new Date().toISOString();
3479
+ const delivering: TurnRecord = {
3480
+ ...current,
3481
+ status: "delivering",
3482
+ updated_at: attemptedAt,
3483
+ delivery: {
3484
+ ...current.delivery,
3485
+ queued: false,
3486
+ state: "delivering",
3487
+ attempts: [
3488
+ ...current.delivery.attempts,
3489
+ {
3490
+ attempt_id: attemptId,
3491
+ delivered: false,
3492
+ tmux_keys_sent: false,
3493
+ channel: "tmux_keys",
3494
+ created_at: attemptedAt,
3495
+ reason: "delivery_attempt",
3496
+ },
3497
+ ],
3498
+ },
3499
+ };
3500
+ await writeTurnRecord(namespaceDir, delivering);
3501
+ await writeActiveTurn(namespaceDir, delivering);
3502
+ await writeSessionState(namespaceDir, delivering.session_id, "running", {
3503
+ currentTurnId: delivering.turn_id,
3504
+ live: observedLive,
3505
+ reason: null,
1729
3506
  });
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 },
3507
+ const tmuxKeysSent = await sendTmuxPrompt(
3508
+ latched,
3509
+ delivering.prompt.text,
3510
+ commandRunner,
3511
+ services.ownerIsolationProbe,
3512
+ );
3513
+ const deliveredAt = new Date().toISOString();
3514
+ const delivered: TurnRecord = {
3515
+ ...delivering,
3516
+ status: "active",
3517
+ updated_at: deliveredAt,
3518
+ delivery: {
3519
+ ...delivering.delivery,
3520
+ delivered: false,
3521
+ queued: !tmuxKeysSent,
3522
+ tmux_keys_sent: tmuxKeysSent,
3523
+ prompt_acknowledged: false,
3524
+ state: tmuxKeysSent ? "tmux_keys_sent" : "unavailable",
3525
+ attempts: [
3526
+ ...delivering.delivery.attempts.slice(0, -1),
3527
+ {
3528
+ attempt_id: attemptId,
3529
+ delivered: false,
3530
+ tmux_keys_sent: tmuxKeysSent,
3531
+ channel: "tmux_keys",
3532
+ created_at: deliveredAt,
3533
+ reason: tmuxKeysSent ? "awaiting_runtime_ack" : "tmux_delivery_unavailable",
3534
+ },
3535
+ ],
3536
+ },
3537
+ };
3538
+ await writeTurnRecord(namespaceDir, delivered);
3539
+ await writeActiveTurn(namespaceDir, delivered);
3540
+ await appendCoordinatorEvent(namespaceDir, {
3541
+ kind: tmuxKeysSent ? "tmux.delivery_succeeded" : "tmux.delivery_failed",
3542
+ sessionId: delivered.session_id,
3543
+ turnId: delivered.turn_id,
3544
+ summary: tmuxKeysSent
3545
+ ? `Tmux delivery succeeded for turn ${delivered.turn_id}`
3546
+ : `Tmux delivery failed for turn ${delivered.turn_id}`,
3547
+ payloadRef: path.relative(namespaceDir, turnFile(namespaceDir, delivered.turn_id)),
3548
+ metadata: {
3549
+ target: typeof target === "string" ? target : null,
3550
+ live: observedLive,
3551
+ attempt_id: attemptId,
3552
+ },
3553
+ });
3554
+ return delivered;
1740
3555
  });
1741
- return resolvedTurn;
1742
3556
  }
1743
3557
 
1744
- async function promoteNextQueuedTurn(sessionId: string): Promise<TurnRecord | null> {
3558
+ async function activateTurn(session: Record<string, unknown>, turn: TurnRecord): Promise<TurnRecord> {
3559
+ const pendingTurn = await withSessionTransition(
3560
+ turn.session_id,
3561
+ async () => await prepareActiveTurnUnlocked(session, turn),
3562
+ );
3563
+ if (pendingTurn.turn_id !== turn.turn_id) return pendingTurn;
3564
+ return await completePreparedTurn(session, pendingTurn);
3565
+ }
3566
+
3567
+ async function prepareNextQueuedTurnUnlocked(sessionId: string): Promise<{
3568
+ session: Record<string, unknown>;
3569
+ turn: TurnRecord;
3570
+ } | null> {
1745
3571
  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")
3572
+ if (!session || (await readActiveTurn(namespaceDir, sessionId))) return null;
3573
+ try {
3574
+ assertSessionReadinessAuthority(session);
3575
+ } catch {
3576
+ return null;
3577
+ }
3578
+ const queuedTurns = (await listTurnRecords(namespaceDir))
3579
+ .filter(turn => turn.session_id === sessionId && turn.status === "queued")
1750
3580
  .sort((left, right) => left.created_at.localeCompare(right.created_at));
1751
3581
  const nextTurn = queuedTurns[0];
1752
- return nextTurn ? await activateTurn(session, nextTurn) : null;
3582
+ if (!nextTurn) return null;
3583
+ return { session, turn: await prepareActiveTurnUnlocked(session, nextTurn) };
3584
+ }
3585
+
3586
+ async function reconcileRuntimeAcknowledgementFenced(
3587
+ turnId: string,
3588
+ sessionState: CoordinatorSessionState | null,
3589
+ failOnTimeout: boolean,
3590
+ ): Promise<TurnRecord | null> {
3591
+ const observed = await readTurnRecord(namespaceDir, turnId);
3592
+ if (!observed) return null;
3593
+ const observedTurnId = sessionState?.current_turn_id;
3594
+ return await withSessionTransition(observed.session_id, async () => {
3595
+ const current = await readTurnRecord(namespaceDir, turnId);
3596
+ if (!current || !ACTIVE_TURN_STATUSES.has(current.status)) return current;
3597
+ const currentState = await readSessionState(namespaceDir, current.session_id);
3598
+ const state =
3599
+ sessionState &&
3600
+ sessionState.updated_at === currentState?.updated_at &&
3601
+ observedTurnId === currentState.current_turn_id
3602
+ ? sessionState
3603
+ : currentState;
3604
+ return await reconcileRuntimeAcknowledgement(namespaceDir, current, state, promptAckTimeoutMs, {
3605
+ failOnTimeout,
3606
+ });
3607
+ });
3608
+ }
3609
+
3610
+ async function markTurnTerminalFromSessionStateFenced(
3611
+ turnId: string,
3612
+ observedState: CoordinatorSessionState,
3613
+ ): Promise<TurnRecord | null> {
3614
+ const observed = await readTurnRecord(namespaceDir, turnId);
3615
+ if (!observed) return null;
3616
+ return await withSessionTransition(observed.session_id, async () => {
3617
+ const current = await readTurnRecord(namespaceDir, turnId);
3618
+ if (!current || !ACTIVE_TURN_STATUSES.has(current.status)) return current;
3619
+ const currentState = await readSessionState(namespaceDir, current.session_id);
3620
+ if (!currentState || currentState.updated_at !== observedState.updated_at) return current;
3621
+ const matchesTurn =
3622
+ currentState.current_turn_id === current.turn_id ||
3623
+ (currentState.state === "errored" &&
3624
+ currentState.source === "agent_session_event" &&
3625
+ currentState.current_turn_id == null);
3626
+ if (!matchesTurn || (currentState.state !== "completed" && currentState.state !== "errored")) return current;
3627
+ return await markTurnTerminalFromSessionState(namespaceDir, current, currentState);
3628
+ });
3629
+ }
3630
+
3631
+ async function markTurnFailedForUnavailableSessionFenced(
3632
+ turnId: string,
3633
+ reason: string,
3634
+ ): Promise<TurnRecord | null> {
3635
+ const observed = await readTurnRecord(namespaceDir, turnId);
3636
+ if (!observed) return null;
3637
+ return await withSessionTransition(observed.session_id, async () => {
3638
+ const current = await readTurnRecord(namespaceDir, turnId);
3639
+ if (!current || !ACTIVE_TURN_STATUSES.has(current.status)) return current;
3640
+ return await markTurnFailedForUnavailableSession(namespaceDir, current, reason);
3641
+ });
3642
+ }
3643
+
3644
+ async function preparePromptTurn(
3645
+ session: Record<string, unknown>,
3646
+ prompt: string,
3647
+ options: { force: boolean; queue: boolean; queueWithoutActive?: boolean },
3648
+ ): Promise<
3649
+ | { ok: false; activeTurn: TurnRecord }
3650
+ | { ok: true; turn: TurnRecord; priorActiveTurn: TurnRecord | null; queued: boolean }
3651
+ > {
3652
+ const sessionId = safeExternalId("session", session.session_id);
3653
+ const prepared = await withSessionTransition(sessionId, async () => {
3654
+ const activeTurn = await readActiveTurn(namespaceDir, sessionId);
3655
+ if (activeTurn && !options.force && !options.queue) {
3656
+ return { ok: false as const, activeTurn };
3657
+ }
3658
+ if (activeTurn && options.force) {
3659
+ const timestamp = new Date().toISOString();
3660
+ const superseded: TurnRecord = {
3661
+ ...activeTurn,
3662
+ status: "superseded",
3663
+ updated_at: timestamp,
3664
+ completed_at: timestamp,
3665
+ };
3666
+ await writeTurnRecord(namespaceDir, superseded);
3667
+ await clearActiveTurn(namespaceDir, superseded);
3668
+ }
3669
+ const shouldQueue =
3670
+ options.queue && !options.force && (activeTurn !== null || options.queueWithoutActive === true);
3671
+ const turn = makeTurnRecord(config, sessionId, prompt, shouldQueue ? "queued" : "active");
3672
+ if (shouldQueue) {
3673
+ await writeTurnRecord(namespaceDir, turn);
3674
+ return { ok: true as const, turn, priorActiveTurn: activeTurn, queued: true };
3675
+ }
3676
+ const pending = await prepareActiveTurnUnlocked(session, turn);
3677
+ return { ok: true as const, turn: pending, priorActiveTurn: activeTurn, queued: false };
3678
+ });
3679
+ if (!prepared.ok || prepared.queued) return prepared;
3680
+ return { ...prepared, turn: await completePreparedTurn(session, prepared.turn) };
1753
3681
  }
1754
3682
 
1755
3683
  async function readTurnPayload(
@@ -1766,24 +3694,21 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1766
3694
  let resolvedTurn = turn;
1767
3695
  let advisoryStatus: Record<string, unknown> = { live: false };
1768
3696
  let sessionState = await readSessionState(namespaceDir, turn.session_id);
1769
- resolvedTurn = await reconcileRuntimeAcknowledgement(
1770
- namespaceDir,
1771
- resolvedTurn,
1772
- sessionState,
1773
- promptAckTimeoutMs,
1774
- { failOnTimeout: false },
1775
- );
3697
+ resolvedTurn =
3698
+ (await reconcileRuntimeAcknowledgementFenced(resolvedTurn.turn_id, sessionState, false)) ?? resolvedTurn;
1776
3699
  if (resolvedTurn !== turn) sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
1777
3700
  if (
1778
3701
  sessionState &&
1779
3702
  ACTIVE_TURN_STATUSES.has(resolvedTurn.status) &&
3703
+ runtimeStateMatchesPrivateOwnerGeneration(sessionState, session) &&
1780
3704
  (sessionState.current_turn_id === resolvedTurn.turn_id ||
1781
3705
  (sessionState.state === "errored" &&
1782
3706
  sessionState.source === "agent_session_event" &&
1783
3707
  sessionState.current_turn_id == null)) &&
1784
3708
  (sessionState.state === "completed" || sessionState.state === "errored")
1785
3709
  ) {
1786
- resolvedTurn = await markTurnTerminalFromSessionState(namespaceDir, resolvedTurn, sessionState);
3710
+ resolvedTurn =
3711
+ (await markTurnTerminalFromSessionStateFenced(resolvedTurn.turn_id, sessionState)) ?? resolvedTurn;
1787
3712
  sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
1788
3713
  } else if (
1789
3714
  sessionState &&
@@ -1795,14 +3720,14 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1795
3720
  session &&
1796
3721
  hasTmuxIdentity(session)
1797
3722
  ) {
1798
- resolvedTurn = await markTurnFailedForUnavailableSession(
1799
- namespaceDir,
1800
- resolvedTurn,
1801
- "tmux_delivery_unavailable",
1802
- );
3723
+ resolvedTurn =
3724
+ (await markTurnFailedForUnavailableSessionFenced(resolvedTurn.turn_id, "tmux_delivery_unavailable")) ??
3725
+ resolvedTurn;
1803
3726
  sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
1804
3727
  } else if (!session && ACTIVE_TURN_STATUSES.has(resolvedTurn.status)) {
1805
- resolvedTurn = await markTurnFailedForUnavailableSession(namespaceDir, resolvedTurn, "session_record_missing");
3728
+ resolvedTurn =
3729
+ (await markTurnFailedForUnavailableSessionFenced(resolvedTurn.turn_id, "session_record_missing")) ??
3730
+ resolvedTurn;
1806
3731
  sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
1807
3732
  } else if (session) {
1808
3733
  advisoryStatus = await inspectTmuxSession(session, boundedLineCount(lines), commandRunner);
@@ -1811,21 +3736,15 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1811
3736
  hasTmuxIdentity(session) &&
1812
3737
  advisoryStatus.live === false
1813
3738
  ) {
1814
- resolvedTurn = await markTurnFailedForUnavailableSession(
1815
- namespaceDir,
1816
- resolvedTurn,
1817
- "tmux_session_missing",
1818
- );
3739
+ resolvedTurn =
3740
+ (await markTurnFailedForUnavailableSessionFenced(resolvedTurn.turn_id, "tmux_session_missing")) ??
3741
+ resolvedTurn;
1819
3742
  sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
1820
3743
  }
1821
3744
  }
1822
3745
  if (ACTIVE_TURN_STATUSES.has(resolvedTurn.status)) {
1823
- resolvedTurn = await reconcileRuntimeAcknowledgement(
1824
- namespaceDir,
1825
- resolvedTurn,
1826
- sessionState,
1827
- promptAckTimeoutMs,
1828
- );
3746
+ resolvedTurn =
3747
+ (await reconcileRuntimeAcknowledgementFenced(resolvedTurn.turn_id, sessionState, true)) ?? resolvedTurn;
1829
3748
  if (!ACTIVE_TURN_STATUSES.has(resolvedTurn.status)) {
1830
3749
  sessionState = await readSessionState(namespaceDir, resolvedTurn.session_id);
1831
3750
  }
@@ -1847,51 +3766,59 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1847
3766
  }
1848
3767
 
1849
3768
  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));
3769
+ await preflightCoordinatorMutation(namespaceDir, undefined, {
3770
+ rejectTerminalRuntimeEvidence: false,
3771
+ });
3772
+
3773
+ const turns = (await listTurnRecords(namespaceDir)).filter(turn => ACTIVE_TURN_STATUSES.has(turn.status));
1853
3774
  for (const turn of turns) {
1854
3775
  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
3776
  if (
1866
3777
  sessionState &&
1867
- sessionState.current_turn_id === resolvedTurn.turn_id &&
3778
+ (sessionState.state === "completed" || sessionState.state === "errored") &&
3779
+ (sessionState.current_turn_id === turn.turn_id ||
3780
+ (sessionState.state === "errored" &&
3781
+ sessionState.source === "agent_session_event" &&
3782
+ sessionState.current_turn_id == null))
3783
+ ) {
3784
+ const terminal = await markTurnTerminalFromSessionStateFenced(turn.turn_id, sessionState);
3785
+ if (!terminal || !ACTIVE_TURN_STATUSES.has(terminal.status)) continue;
3786
+ }
3787
+ const acknowledged = (await reconcileRuntimeAcknowledgementFenced(turn.turn_id, sessionState, false)) ?? turn;
3788
+ if (!ACTIVE_TURN_STATUSES.has(acknowledged.status)) continue;
3789
+ if (acknowledged !== turn) sessionState = await readSessionState(namespaceDir, acknowledged.session_id);
3790
+ const session = asRecord(await readJsonFile(sessionFile(acknowledged.session_id)));
3791
+ if (
3792
+ sessionState &&
3793
+ sessionState.current_turn_id === acknowledged.turn_id &&
1868
3794
  sessionState.state === "stale" &&
1869
3795
  sessionState.reason === "tmux_delivery_unavailable" &&
1870
- resolvedTurn.delivery.state === "unavailable" &&
3796
+ acknowledged.delivery.state === "unavailable" &&
1871
3797
  session &&
1872
3798
  hasTmuxIdentity(session)
1873
3799
  ) {
1874
- await markTurnFailedForUnavailableSession(namespaceDir, resolvedTurn, "tmux_delivery_unavailable");
3800
+ await markTurnFailedForUnavailableSessionFenced(acknowledged.turn_id, "tmux_delivery_unavailable");
1875
3801
  continue;
1876
3802
  }
1877
3803
  if (!session) {
1878
- await markTurnFailedForUnavailableSession(namespaceDir, resolvedTurn, "session_record_missing");
3804
+ await markTurnFailedForUnavailableSessionFenced(acknowledged.turn_id, "session_record_missing");
1879
3805
  continue;
1880
3806
  }
1881
3807
  if (hasTmuxIdentity(session) && (await hasTmuxSession(session, commandRunner)) === false) {
1882
- await markTurnFailedForUnavailableSession(namespaceDir, resolvedTurn, "tmux_session_missing");
3808
+ await markTurnFailedForUnavailableSessionFenced(acknowledged.turn_id, "tmux_session_missing");
1883
3809
  continue;
1884
3810
  }
1885
- await reconcileRuntimeAcknowledgement(namespaceDir, resolvedTurn, sessionState, promptAckTimeoutMs);
3811
+ await reconcileRuntimeAcknowledgementFenced(acknowledged.turn_id, sessionState, true);
1886
3812
  }
1887
3813
  }
1888
3814
 
1889
- async function callTool(name: string, args: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
3815
+ async function callToolUnlocked(name: string, args: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
1890
3816
  try {
1891
3817
  if (name === "gjc_coordinator_list_sessions") return { ok: true, sessions: await listSessions() };
1892
3818
  if (name === "gjc_coordinator_register_session") {
1893
3819
  requireCoordinatorMutation(config, "sessions", args);
1894
3820
  const sessionId = safeExternalId("session", args.session_id);
3821
+ await preflightCoordinatorMutation(namespaceDir, sessionId);
1895
3822
  const cwd = await assertCoordinatorWorkdir(config, args.cwd);
1896
3823
  const tmuxSession = safeTmuxSessionName(args.tmux_session);
1897
3824
  const tmuxTarget = safeTmuxTarget(args.tmux_target);
@@ -1915,7 +3842,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1915
3842
  sessionId,
1916
3843
  summary: `Session ${sessionId} registered for coordinator control`,
1917
3844
  payloadRef: path.relative(namespaceDir, sessionFile(sessionId)),
1918
- metadata: { source: optionalString(args.source) ?? "register_session", visible: args.visible !== false },
3845
+ metadata: {
3846
+ source: optionalString(args.source) ?? "register_session",
3847
+ visible: args.visible !== false,
3848
+ },
1919
3849
  });
1920
3850
 
1921
3851
  return {
@@ -1952,7 +3882,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1952
3882
  }
1953
3883
  if (name === "gjc_coordinator_read_tail") {
1954
3884
  const session = asRecord(await readJsonFile(sessionFile(args.session_id)));
1955
- return { ok: true, lines: session ? await captureTmuxTail(session, boundedLineCount(args.lines)) : [] };
3885
+ return {
3886
+ ok: true,
3887
+ lines: session ? await captureTmuxTail(session, boundedLineCount(args.lines), commandRunner) : [],
3888
+ };
1956
3889
  }
1957
3890
  if (name === "gjc_coordinator_list_questions") return { ok: true, questions: await listQuestions(args) };
1958
3891
  if (name === "gjc_coordinator_list_artifacts") return { ok: true, roots: config.allowedRoots };
@@ -1987,13 +3920,18 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
1987
3920
  turns,
1988
3921
  questions,
1989
3922
  reports,
1990
- events: buildCanonicalCoordinatorEvents({ sessionStates, turns, questions, reports }),
3923
+ events: buildCanonicalCoordinatorEvents({
3924
+ sessionStates,
3925
+ turns,
3926
+ questions,
3927
+ reports,
3928
+ }),
1991
3929
  latest_event_seq: await readLatestEventSeq(namespaceDir),
1992
3930
  recent_events: eventSummaries(events.slice(-10)),
1993
3931
  };
1994
3932
  }
1995
3933
  if (name === "gjc_coordinator_watch_events") {
1996
- await reconcileActiveTurnAcknowledgements();
3934
+ await withCoordinatorTransaction(namespaceDir, reconcileActiveTurnAcknowledgements);
1997
3935
  const limit = boundedEventLimit(args.limit);
1998
3936
  const timeoutMs = boundedEventWatchTimeoutMs(args.timeout_ms);
1999
3937
  let events = await readCoordinatorEvents(namespaceDir);
@@ -2003,7 +3941,7 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2003
3941
  const deadline = Date.now() + timeoutMs;
2004
3942
  while (matched.length === 0 && Date.now() < deadline) {
2005
3943
  await waitForCoordinatorEvents(namespaceDir, Math.min(50, Math.max(1, deadline - Date.now())));
2006
- await reconcileActiveTurnAcknowledgements();
3944
+ await withCoordinatorTransaction(namespaceDir, reconcileActiveTurnAcknowledgements);
2007
3945
  events = await readCoordinatorEvents(namespaceDir);
2008
3946
  matched = filterCoordinatorEvents(events, args, limit);
2009
3947
  }
@@ -2025,6 +3963,17 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2025
3963
  const hasPrompt = typeof args.prompt === "string" && args.prompt.trim().length > 0;
2026
3964
  const task = hasTask ? String(args.task) : hasPrompt ? String(args.prompt) : null;
2027
3965
  if (!task) return { ok: false, reason: "task_required" };
3966
+ await preflightCoordinatorMutation(namespaceDir);
3967
+ const mpresetResolution = await resolveCoordinatorMpreset(args.mpreset, loadModelProfiles);
3968
+ if (!mpresetResolution.ok) {
3969
+ return {
3970
+ ok: false,
3971
+ reason: mpresetResolution.reason,
3972
+ mpreset: mpresetResolution.mpreset,
3973
+ available_profiles: mpresetResolution.available_profiles,
3974
+ };
3975
+ }
3976
+
2028
3977
  const promptAliasIgnored = hasTask && hasPrompt;
2029
3978
  const mutationRequested = args.allow_mutation === true;
2030
3979
  const taggedPrompt = workflowPrompt(delegateWorkflow, name, canonicalCwd, task, {
@@ -2036,70 +3985,85 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2036
3985
  let reusedSession = false;
2037
3986
  if (args.session_id != null) {
2038
3987
  const sessionId = safeExternalId("session", args.session_id);
3988
+ await preflightCoordinatorMutation(namespaceDir, sessionId);
2039
3989
  const existing = asRecord(await readJsonFile(sessionFile(sessionId)));
2040
- if (!existing) return { ok: false, reason: "unknown_session", session_id: sessionId };
3990
+ if (!existing)
3991
+ return {
3992
+ ok: false,
3993
+ reason: "unknown_session",
3994
+ session_id: sessionId,
3995
+ };
2041
3996
  const storedCwd = typeof existing.cwd === "string" ? existing.cwd : null;
2042
3997
  const canonicalStored = storedCwd ? await canonicalizePath(storedCwd) : null;
2043
3998
  const canonicalRequested = await canonicalizePath(canonicalCwd);
2044
3999
  if (!canonicalStored || canonicalStored !== canonicalRequested) {
2045
- return { ok: false, reason: "session_cwd_mismatch", session_id: sessionId };
4000
+ return {
4001
+ ok: false,
4002
+ reason: "session_cwd_mismatch",
4003
+ session_id: sessionId,
4004
+ };
4005
+ }
4006
+ if (mpresetResolution.mpreset !== null) {
4007
+ const sessionMpreset = typeof existing.mpreset === "string" ? existing.mpreset : null;
4008
+ if (sessionMpreset !== mpresetResolution.mpreset) {
4009
+ return {
4010
+ ok: false,
4011
+ reason: "mpreset_conflict",
4012
+ session_id: sessionId,
4013
+ session_mpreset: sessionMpreset,
4014
+ requested_mpreset: mpresetResolution.mpreset,
4015
+ };
4016
+ }
2046
4017
  }
4018
+
2047
4019
  session = existing;
2048
4020
  reusedSession = true;
2049
4021
  } 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
- });
4022
+ session = await startCoordinatorSession(canonicalCwd, undefined, mpresetResolution.mpreset);
4023
+ const ownerTransaction = asRecord(session.__coordinatorOwnerTransaction) as {
4024
+ commit?: () => void;
4025
+ rollback?: () => Promise<"cleaned" | "failed" | "unverifiable">;
4026
+ } | null;
4027
+ try {
4028
+ if (mpresetResolution.mpreset) session.mpreset = mpresetResolution.mpreset;
4029
+ await writeJsonFile(sessionFile(session.session_id), session);
4030
+ await appendCoordinatorEvent(namespaceDir, {
4031
+ kind: "session.started",
4032
+ sessionId: String(session.session_id),
4033
+ summary: `Session ${String(session.session_id)} started by coordinator delegate`,
4034
+ payloadRef: path.relative(namespaceDir, sessionFile(session.session_id)),
4035
+ metadata: { delegate: true, workflow: delegateWorkflow },
4036
+ });
4037
+ await writeSessionState(namespaceDir, String(session.session_id), "booting", {
4038
+ live: null,
4039
+ reason: "awaiting_runtime_readiness",
4040
+ });
4041
+ ownerTransaction?.commit?.();
4042
+ } catch (error) {
4043
+ if (ownerTransaction)
4044
+ throw new CoordinatorStartError(
4045
+ "coordinator_tmux_start_failed",
4046
+ await compensateFailedOwnerStart(session, ownerTransaction),
4047
+ );
4048
+ throw error;
4049
+ }
2075
4050
  }
2076
4051
 
2077
4052
  const sessionId = String(session.session_id);
2078
- const activeTurn = reusedSession ? await readActiveTurn(namespaceDir, sessionId) : null;
2079
- if (activeTurn && args.force !== true && args.queue !== true) {
4053
+ assertSessionReadinessAuthority(session);
4054
+ const prepared = await preparePromptTurn(session, taggedPrompt, {
4055
+ force: args.force === true,
4056
+ queue: args.queue === true,
4057
+ });
4058
+ if (!prepared.ok) {
2080
4059
  return {
2081
4060
  ok: false,
2082
4061
  reason: "active_turn_exists",
2083
4062
  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,
4063
+ active_turn_id: prepared.activeTurn.turn_id,
2094
4064
  };
2095
- await writeTurnRecord(namespaceDir, superseded);
2096
- await clearActiveTurn(namespaceDir, superseded);
2097
4065
  }
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);
4066
+ const { turn, priorActiveTurn: activeTurn, queued: shouldQueue } = prepared;
2103
4067
  await appendCoordinatorEvent(namespaceDir, {
2104
4068
  kind: "delegation.started",
2105
4069
  sessionId,
@@ -2173,31 +4137,50 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2173
4137
  if (name === "gjc_coordinator_start_session") {
2174
4138
  requireCoordinatorMutation(config, "sessions", args);
2175
4139
  const cwd = await assertCoordinatorWorkdir(config, args.cwd);
2176
- const input = {
4140
+ await preflightCoordinatorMutation(namespaceDir);
4141
+ const mpresetResolution = await resolveCoordinatorMpreset(args.mpreset, loadModelProfiles);
4142
+ if (!mpresetResolution.ok) {
4143
+ return {
4144
+ ok: false,
4145
+ reason: mpresetResolution.reason,
4146
+ mpreset: mpresetResolution.mpreset,
4147
+ available_profiles: mpresetResolution.available_profiles,
4148
+ };
4149
+ }
4150
+
4151
+ const session = await startCoordinatorSession(
2177
4152
  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
- });
4153
+ typeof args.prompt === "string" ? args.prompt : undefined,
4154
+ mpresetResolution.mpreset,
4155
+ );
4156
+ const ownerTransaction = asRecord(session.__coordinatorOwnerTransaction) as {
4157
+ commit?: () => void;
4158
+ rollback?: () => Promise<"cleaned" | "failed" | "unverifiable">;
4159
+ } | null;
4160
+ let sessionState: CoordinatorSessionState;
4161
+ try {
4162
+ if (mpresetResolution.mpreset) session.mpreset = mpresetResolution.mpreset;
4163
+ await writeJsonFile(sessionFile(session.session_id), session);
4164
+ await appendCoordinatorEvent(namespaceDir, {
4165
+ kind: "session.started",
4166
+ sessionId: String(session.session_id),
4167
+ summary: `Session ${String(session.session_id)} started by coordinator`,
4168
+ payloadRef: path.relative(namespaceDir, sessionFile(session.session_id)),
4169
+ metadata: { prompted: typeof args.prompt === "string" && args.prompt.length > 0 },
4170
+ });
4171
+ sessionState = await writeSessionState(namespaceDir, String(session.session_id), "booting", {
4172
+ live: null,
4173
+ reason: "awaiting_runtime_readiness",
4174
+ });
4175
+ ownerTransaction?.commit?.();
4176
+ } catch (error) {
4177
+ if (ownerTransaction)
4178
+ throw new CoordinatorStartError(
4179
+ "coordinator_tmux_start_failed",
4180
+ await compensateFailedOwnerStart(session, ownerTransaction),
4181
+ );
4182
+ throw error;
4183
+ }
2201
4184
  if (typeof args.prompt === "string" && args.prompt.length > 0) {
2202
4185
  const turn = await activateTurn(
2203
4186
  session,
@@ -2233,36 +4216,28 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2233
4216
  if (name === "gjc_coordinator_send_prompt") {
2234
4217
  requireCoordinatorMutation(config, "sessions", args);
2235
4218
  const sessionId = safeExternalId("session", args.session_id);
4219
+ await preflightCoordinatorMutation(namespaceDir, sessionId);
2236
4220
  const session = asRecord(await readJsonFile(sessionFile(sessionId)));
2237
4221
  if (!session) return { ok: false, reason: "unknown_session", session_id: sessionId };
2238
4222
  if (typeof args.prompt !== "string" || args.prompt.length === 0)
2239
4223
  return { ok: false, reason: "prompt_required" };
2240
- const activeTurn = await readActiveTurn(namespaceDir, sessionId);
2241
- if (activeTurn && args.force !== true && args.queue !== true) {
4224
+ await readSessionState(namespaceDir, sessionId);
4225
+ await readLatestEventSeq(namespaceDir);
4226
+ assertSessionReadinessAuthority(session);
4227
+ const prepared = await preparePromptTurn(session, args.prompt, {
4228
+ force: args.force === true,
4229
+ queue: args.queue === true,
4230
+ queueWithoutActive: true,
4231
+ });
4232
+ if (!prepared.ok) {
2242
4233
  return {
2243
4234
  ok: false,
2244
4235
  reason: "active_turn_exists",
2245
4236
  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,
4237
+ active_turn_id: prepared.activeTurn.turn_id,
2256
4238
  };
2257
- await writeTurnRecord(namespaceDir, superseded);
2258
- await clearActiveTurn(namespaceDir, superseded);
2259
4239
  }
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;
4240
+ const { turn: recordedTurn, priorActiveTurn: activeTurn, queued: shouldQueue } = prepared;
2266
4241
  const prompt = {
2267
4242
  session_id: sessionId,
2268
4243
  turn_id: recordedTurn.turn_id,
@@ -2296,7 +4271,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2296
4271
  const timeoutMs = boundedAwaitTurnTimeoutMs(args.timeout_ms);
2297
4272
  const pollIntervalMs = boundedPollIntervalMs(args.poll_interval_ms);
2298
4273
  const deadline = Date.now() + timeoutMs;
2299
- let payload = await readTurnPayload(args.turn_id, args.session_id, args.lines);
4274
+ let payload = await withCoordinatorTransaction(
4275
+ namespaceDir,
4276
+ async () => await readTurnPayload(args.turn_id, args.session_id, args.lines),
4277
+ );
2300
4278
  while (
2301
4279
  payload.ok === true &&
2302
4280
  !TERMINAL_TURN_STATUSES.has((payload.turn as TurnRecord).status) &&
@@ -2308,7 +4286,10 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2308
4286
  payload.turn as TurnRecord,
2309
4287
  Math.min(pollIntervalMs, remainingMs),
2310
4288
  );
2311
- payload = await readTurnPayload(args.turn_id, args.session_id, args.lines);
4289
+ payload = await withCoordinatorTransaction(
4290
+ namespaceDir,
4291
+ async () => await readTurnPayload(args.turn_id, args.session_id, args.lines),
4292
+ );
2312
4293
  }
2313
4294
  if (payload.ok === true && !TERMINAL_TURN_STATUSES.has((payload.turn as TurnRecord).status)) {
2314
4295
  return {
@@ -2333,6 +4314,16 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2333
4314
  if (args.turn_id != null && question.turn_id !== safeTurnId(args.turn_id)) {
2334
4315
  return { ok: false, reason: "question_turn_mismatch" };
2335
4316
  }
4317
+ await preflightCoordinatorMutation(namespaceDir);
4318
+ const questionSessionId =
4319
+ typeof question.session_id === "string" ? safeExternalId("session", question.session_id) : null;
4320
+ if (!questionSessionId) throw coordinatorStateError("invalid");
4321
+ await preflightCoordinatorMutation(namespaceDir, questionSessionId);
4322
+ if (typeof question.turn_id === "string") {
4323
+ const referencedTurn = await readTurnRecord(namespaceDir, question.turn_id);
4324
+ if (!referencedTurn || referencedTurn.session_id !== questionSessionId)
4325
+ throw coordinatorStateError("invalid");
4326
+ }
2336
4327
  const answeredTurnId = typeof question.turn_id === "string" ? question.turn_id : null;
2337
4328
  const answered = {
2338
4329
  ...question,
@@ -2362,33 +4353,43 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2362
4353
 
2363
4354
  let turn: TurnRecord | null = null;
2364
4355
  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,
4356
+ const observedTurn = await readTurnRecord(namespaceDir, answeredTurnId);
4357
+ if (observedTurn) {
4358
+ turn = await withSessionTransition(observedTurn.session_id, async () => {
4359
+ const current = await readTurnRecord(namespaceDir, answeredTurnId);
4360
+ if (!current || !ACTIVE_TURN_STATUSES.has(current.status)) return current;
4361
+ const timestamp = new Date().toISOString();
4362
+ const answeredTurn: TurnRecord = {
4363
+ ...current,
4364
+ status: "active",
4365
+ question_ids: [...new Set([...current.question_ids, questionId])],
4366
+ updated_at: timestamp,
4367
+ };
4368
+ await writeTurnRecord(namespaceDir, answeredTurn);
4369
+ await writeActiveTurn(namespaceDir, answeredTurn);
4370
+ await writeSessionState(namespaceDir, answeredTurn.session_id, "running", {
4371
+ currentTurnId: answeredTurn.turn_id,
4372
+ live: null,
4373
+ reason: null,
4374
+ });
4375
+ const session = asRecord(await readJsonFile(sessionFile(answeredTurn.session_id)));
4376
+ if (session && typeof args.answer === "string") {
4377
+ await sendTmuxPrompt(session, args.answer, commandRunner, services.ownerIsolationProbe);
4378
+ }
4379
+ return answeredTurn;
2380
4380
  });
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
4381
  }
2385
4382
  }
2386
4383
  return { ok: true, question: answered, ...(turn ? { turn } : {}) };
2387
4384
  }
2388
4385
  if (name === "gjc_coordinator_report_status") {
2389
4386
  requireCoordinatorMutation(config, "reports", args);
4387
+ await preflightCoordinatorMutation(namespaceDir);
4388
+
2390
4389
  const evidence = await validateEvidencePaths(args.evidence_paths);
2391
4390
  const sessionId = args.session_id == null ? null : safeExternalId("session", args.session_id);
4391
+ if (sessionId) await preflightCoordinatorMutation(namespaceDir, sessionId);
4392
+
2392
4393
  const report = {
2393
4394
  session_id: sessionId,
2394
4395
  turn_id: args.turn_id,
@@ -2401,61 +4402,79 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2401
4402
  };
2402
4403
  let turn: TurnRecord | null = null;
2403
4404
  let promotedTurn: TurnRecord | null = null;
4405
+ let promotedPreparation: { session: Record<string, unknown>; turn: TurnRecord } | null = null;
2404
4406
  if (args.turn_id != null) {
2405
4407
  turn = await readTurnRecord(namespaceDir, args.turn_id);
2406
4408
  if (!turn) return { ok: false, reason: "unknown_turn" };
2407
4409
  if (sessionId != null && turn.session_id !== sessionId) {
2408
4410
  return { ok: false, reason: "turn_session_mismatch" };
2409
4411
  }
4412
+ await preflightCoordinatorMutation(namespaceDir, turn.session_id);
2410
4413
  const terminalStatus = asTerminalTurnStatus(args.status);
2411
4414
  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);
4415
+ const transition = await withSessionTransition(turn.session_id, async () => {
4416
+ const current = await readTurnRecord(namespaceDir, turn?.turn_id);
4417
+ if (!current || TERMINAL_TURN_STATUSES.has(current.status)) {
4418
+ return { turn: current, promotion: null };
4419
+ }
4420
+ const timestamp = new Date().toISOString();
4421
+ const terminalTurn: TurnRecord = {
4422
+ ...current,
4423
+ status: terminalStatus,
4424
+ delivery: {
4425
+ ...current.delivery,
4426
+ prompt_acknowledged: true,
4427
+ state: "acknowledged",
4428
+ },
4429
+ final_response: {
4430
+ text:
4431
+ typeof args.summary === "string"
4432
+ ? args.summary
4433
+ : typeof args.blocker === "string"
4434
+ ? args.blocker
4435
+ : null,
4436
+ format: "markdown",
4437
+ source: "report_status",
4438
+ artifact_path: null,
4439
+ truncated: false,
4440
+ },
4441
+ evidence,
4442
+ error:
4443
+ terminalStatus === "failed"
4444
+ ? {
4445
+ code: "reported_failure",
4446
+ message:
4447
+ typeof args.blocker === "string"
4448
+ ? args.blocker
4449
+ : String(args.summary ?? "failed"),
4450
+ recoverable: true,
4451
+ }
4452
+ : null,
4453
+ updated_at: timestamp,
4454
+ completed_at: timestamp,
4455
+ };
4456
+ await writeTurnRecord(namespaceDir, terminalTurn);
4457
+ await clearActiveTurn(namespaceDir, terminalTurn);
4458
+ await writeSessionState(
4459
+ namespaceDir,
4460
+ terminalTurn.session_id,
4461
+ terminalStatus === "failed" ? "errored" : "completed",
4462
+ {
4463
+ lastTurnId: terminalTurn.turn_id,
4464
+ live: null,
4465
+ reason: terminalStatus === "failed" ? "reported_failure" : null,
4466
+ },
4467
+ );
4468
+ return {
4469
+ turn: terminalTurn,
4470
+ promotion: await prepareNextQueuedTurnUnlocked(terminalTurn.session_id),
4471
+ };
4472
+ });
4473
+ turn = transition.turn;
4474
+ promotedPreparation = transition.promotion;
4475
+ if (promotedPreparation) {
4476
+ promotedTurn = await completePreparedTurn(promotedPreparation.session, promotedPreparation.turn);
4477
+ }
2459
4478
  }
2460
4479
  }
2461
4480
  const reportId = `report-${Date.now()}`;
@@ -2471,21 +4490,60 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2471
4490
  ? args.summary
2472
4491
  : `Report ${String(args.status ?? "unknown")} written`,
2473
4492
  payloadRef: path.relative(namespaceDir, reportPath),
2474
- metadata: { status: typeof args.status === "string" ? args.status : null },
4493
+ metadata: {
4494
+ status: typeof args.status === "string" ? args.status : null,
4495
+ },
2475
4496
  });
2476
4497
  return {
2477
4498
  ok: true,
2478
4499
  report,
2479
- ...(turn ? { turn, session_state: await readSessionState(namespaceDir, turn.session_id) } : {}),
4500
+ ...(turn
4501
+ ? {
4502
+ turn,
4503
+ session_state: await readSessionState(namespaceDir, turn.session_id),
4504
+ }
4505
+ : {}),
2480
4506
  ...(promotedTurn ? { promoted_turn: promotedTurn } : {}),
2481
4507
  };
2482
4508
  }
2483
4509
  return { ok: false, reason: "unknown_tool", tool: name };
2484
4510
  } catch (error) {
2485
- return { ok: false, reason: error instanceof Error ? error.message : String(error) };
4511
+ const failure = error as { sessionId?: unknown; action?: unknown };
4512
+ return {
4513
+ ok: false,
4514
+ reason: publicCoordinatorError(error),
4515
+ ...(publicCleanupStatus(error) ? { cleanup_status: publicCleanupStatus(error) } : {}),
4516
+ ...(typeof failure.sessionId === "string" ? { session_id: failure.sessionId } : {}),
4517
+ ...(typeof failure.action === "string" ? { action: failure.action } : {}),
4518
+ };
2486
4519
  }
2487
4520
  }
2488
4521
 
4522
+ async function callToolInternal(name: string, args: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
4523
+ try {
4524
+ if (
4525
+ name === "gjc_coordinator_await_turn" ||
4526
+ name === "gjc_coordinator_watch_events" ||
4527
+ (name === "gjc_coordinator_start_session" && typeof args.prompt === "string" && args.prompt.length > 0) ||
4528
+ name === "gjc_coordinator_send_prompt" ||
4529
+ workflowForDelegateTool(name) !== null
4530
+ ) {
4531
+ return await callToolUnlocked(name, args);
4532
+ }
4533
+ return await withCoordinatorTransaction(namespaceDir, async () => await callToolUnlocked(name, args));
4534
+ } catch (error) {
4535
+ return {
4536
+ ok: false,
4537
+ reason: publicCoordinatorError(error),
4538
+ ...(publicCleanupStatus(error) ? { cleanup_status: publicCleanupStatus(error) } : {}),
4539
+ };
4540
+ }
4541
+ }
4542
+
4543
+ async function callTool(name: string, args: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
4544
+ return publicCoordinatorResponse(await callToolInternal(name, args)) as Record<string, unknown>;
4545
+ }
4546
+
2489
4547
  async function handleJsonRpc(request: JsonRpcRequest): Promise<JsonRpcResponse> {
2490
4548
  const id = request.id ?? null;
2491
4549
  if (request.method === "initialize") {
@@ -2499,8 +4557,15 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2499
4557
  },
2500
4558
  };
2501
4559
  }
4560
+ if (request.method === "ping") {
4561
+ return { jsonrpc: "2.0", id, result: {} };
4562
+ }
2502
4563
  if (request.method === "tools/list") {
2503
- return { jsonrpc: "2.0", id, result: { tools: COORDINATOR_MCP_TOOL_NAMES.map(toolSchema) } };
4564
+ return {
4565
+ jsonrpc: "2.0",
4566
+ id,
4567
+ result: { tools: COORDINATOR_MCP_TOOL_NAMES.map(toolSchema) },
4568
+ };
2504
4569
  }
2505
4570
  if (request.method === "prompts/list") {
2506
4571
  return { jsonrpc: "2.0", id, result: { prompts: [] } };
@@ -2509,17 +4574,31 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
2509
4574
  return { jsonrpc: "2.0", id, result: { resources: [] } };
2510
4575
  }
2511
4576
  if (request.method === "tools/call") {
2512
- const params = (request.params ?? {}) as { name?: string; arguments?: Record<string, unknown> };
4577
+ const params = (request.params ?? {}) as {
4578
+ name?: string;
4579
+ arguments?: Record<string, unknown>;
4580
+ };
2513
4581
  const payload = await callTool(params.name ?? "", params.arguments ?? {});
2514
- return { jsonrpc: "2.0", id, result: textResult(payload, payload.ok === false) };
4582
+ return {
4583
+ jsonrpc: "2.0",
4584
+ id,
4585
+ result: textResult(payload, payload.ok === false),
4586
+ };
2515
4587
  }
2516
- return { jsonrpc: "2.0", id, error: { code: -32601, message: `unknown_method:${request.method}` } };
4588
+ return {
4589
+ jsonrpc: "2.0",
4590
+ id,
4591
+ error: { code: -32601, message: `unknown_method:${request.method}` },
4592
+ };
2517
4593
  }
2518
4594
 
2519
4595
  return { config, callTool, handleJsonRpc, handle: handleJsonRpc };
2520
4596
  }
2521
4597
 
2522
- function legacyToolResult(payload: unknown): { content: Array<{ type: "text"; text: string }>; isError: boolean } {
4598
+ function legacyToolResult(payload: unknown): {
4599
+ content: Array<{ type: "text"; text: string }>;
4600
+ isError: boolean;
4601
+ } {
2523
4602
  const failed = typeof payload === "object" && payload !== null && (payload as { ok?: unknown }).ok === false;
2524
4603
  return textResult(payload, failed);
2525
4604
  }
@@ -2540,13 +4619,21 @@ export async function handleCoordinatorMcpRequest(
2540
4619
  };
2541
4620
  }
2542
4621
  if (request.method === "tools/list") {
2543
- return { jsonrpc: "2.0", id: request.id ?? null, result: { tools: COORDINATOR_MCP_TOOL_NAMES.map(toolSchema) } };
4622
+ return {
4623
+ jsonrpc: "2.0",
4624
+ id: request.id ?? null,
4625
+ result: { tools: COORDINATOR_MCP_TOOL_NAMES.map(toolSchema) },
4626
+ };
2544
4627
  }
2545
4628
  if (request.method === "prompts/list") {
2546
4629
  return { jsonrpc: "2.0", id: request.id ?? null, result: { prompts: [] } };
2547
4630
  }
2548
4631
  if (request.method === "resources/list") {
2549
- return { jsonrpc: "2.0", id: request.id ?? null, result: { resources: [] } };
4632
+ return {
4633
+ jsonrpc: "2.0",
4634
+ id: request.id ?? null,
4635
+ result: { resources: [] },
4636
+ };
2550
4637
  }
2551
4638
  if (request.method !== "tools/call")
2552
4639
  return {
@@ -2554,11 +4641,14 @@ export async function handleCoordinatorMcpRequest(
2554
4641
  id: request.id ?? null,
2555
4642
  error: { code: -32601, message: `unknown_method:${request.method}` },
2556
4643
  };
2557
- const params = (request.params ?? {}) as { name?: string; arguments?: Record<string, unknown> };
4644
+ const params = (request.params ?? {}) as {
4645
+ name?: string;
4646
+ arguments?: Record<string, unknown>;
4647
+ };
2558
4648
  const args = params.arguments ?? {};
2559
4649
  const server = createCoordinatorMcpServer({
2560
4650
  env: options.env ?? process.env,
2561
- services: options.createSession ? { startSession: () => options.createSession?.() } : undefined,
4651
+ services: options.createSession ? { startSession: input => options.createSession?.(input) } : undefined,
2562
4652
  });
2563
4653
  return {
2564
4654
  jsonrpc: "2.0",
@@ -2567,23 +4657,161 @@ export async function handleCoordinatorMcpRequest(
2567
4657
  };
2568
4658
  }
2569
4659
 
2570
- export async function runCoordinatorMcpStdio(options: CoordinatorMcpServerOptions = {}): Promise<void> {
2571
- const server = createCoordinatorMcpServer(options);
4660
+ export interface PumpCoordinatorOptions {
4661
+ /** Max concurrent in-flight *data* (non-control) handlers. Control frames (ping) bypass this. */
4662
+ maxDataConcurrency?: number;
4663
+ /** Max data requests queued waiting for a slot before overflow is rejected as server_busy. */
4664
+ maxQueueDepth?: number;
4665
+ /** Bounded wait for in-flight handlers/writes to settle after input ends. */
4666
+ drainTimeoutMs?: number;
4667
+ }
4668
+
4669
+ /**
4670
+ * Pump a newline-delimited JSON-RPC stream with BOUNDED concurrent dispatch.
4671
+ *
4672
+ * A long-running tool call (e.g. gjc_coordinator_await_turn, which polls for
4673
+ * minutes) must not block the read loop from answering keepalive pings on the
4674
+ * same stdio channel. But naive unbounded concurrency reintroduces its own
4675
+ * hazards, so this pump enforces the safety envelope the coordinator needs:
4676
+ *
4677
+ * - Control frames (ping) bypass the data-concurrency cap → keepalive is always
4678
+ * answerable even while data handlers saturate.
4679
+ * - Data handlers are capped at `maxDataConcurrency`; excess is queued up to
4680
+ * `maxQueueDepth`, then rejected as `server_busy` (bounded memory / fanout).
4681
+ * - `writeLine` failures move the writer to a terminal closed state instead of
4682
+ * poisoning the serialized write chain or escaping as an unhandled rejection;
4683
+ * no writes happen after close.
4684
+ * - On EOF the pump drains in-flight handlers (bounded by `drainTimeoutMs`) and
4685
+ * flushes queued writes before returning, so shutdown never races live work.
4686
+ * - Byte chunks are decoded with a streaming decoder so multibyte characters
4687
+ * split across chunks are not corrupted.
4688
+ */
4689
+ export async function pumpCoordinatorMcpStream(
4690
+ handleJsonRpc: (request: JsonRpcRequest) => Promise<JsonRpcResponse>,
4691
+ input: AsyncIterable<string | Uint8Array>,
4692
+ writeLine: (line: string) => void | Promise<void>,
4693
+ options: PumpCoordinatorOptions = {},
4694
+ ): Promise<void> {
4695
+ const maxDataConcurrency = Math.max(1, options.maxDataConcurrency ?? 32);
4696
+ const maxQueueDepth = Math.max(0, options.maxQueueDepth ?? 256);
4697
+ const drainTimeoutMs = Math.max(0, options.drainTimeoutMs ?? 30_000);
4698
+
4699
+ let writeClosed = false;
4700
+ let draining = false;
4701
+ let writeChain: Promise<void> = Promise.resolve();
4702
+ const inFlight = new Set<Promise<void>>();
4703
+ let activeData = 0;
4704
+ const dataQueue: JsonRpcRequest[] = [];
4705
+
4706
+ const emit = (response: JsonRpcResponse): void => {
4707
+ writeChain = writeChain.then(async () => {
4708
+ if (writeClosed) return;
4709
+ try {
4710
+ await writeLine(`${JSON.stringify(response)}\n`);
4711
+ } catch {
4712
+ writeClosed = true; // terminal writer error: stop, but never poison the chain
4713
+ }
4714
+ });
4715
+ };
4716
+
4717
+ const launch = (request: JsonRpcRequest, control: boolean): void => {
4718
+ const task = (async () => {
4719
+ try {
4720
+ emit(await handleJsonRpc(request));
4721
+ } catch (err) {
4722
+ emit({
4723
+ jsonrpc: "2.0",
4724
+ id: request.id ?? null,
4725
+ error: { code: -32603, message: publicCoordinatorError(err) },
4726
+ });
4727
+ } finally {
4728
+ if (!control) {
4729
+ activeData -= 1;
4730
+ if (!draining) {
4731
+ const next = dataQueue.shift();
4732
+ if (next) {
4733
+ activeData += 1;
4734
+ launch(next, false);
4735
+ }
4736
+ }
4737
+ }
4738
+ }
4739
+ })();
4740
+ inFlight.add(task);
4741
+ void task.finally(() => inFlight.delete(task));
4742
+ };
4743
+
4744
+ const dispatch = (request: JsonRpcRequest): void => {
4745
+ // Notifications (no id) get no response; the coordinator has no side-effecting ones.
4746
+ if (request.id === undefined || request.id === null) return;
4747
+ if (request.method === "ping") {
4748
+ launch(request, true); // control frame: bypass the data cap
4749
+ return;
4750
+ }
4751
+ if (activeData < maxDataConcurrency) {
4752
+ activeData += 1;
4753
+ launch(request, false);
4754
+ return;
4755
+ }
4756
+ if (dataQueue.length < maxQueueDepth) {
4757
+ dataQueue.push(request);
4758
+ return;
4759
+ }
4760
+ emit({
4761
+ jsonrpc: "2.0",
4762
+ id: request.id,
4763
+ error: { code: -32000, message: "server_busy: coordinator request queue is full" },
4764
+ });
4765
+ };
4766
+
4767
+ const decoder = new TextDecoder();
2572
4768
  let buffer = "";
2573
- for await (const chunk of process.stdin) {
2574
- buffer += chunk.toString();
4769
+ for await (const chunk of input) {
4770
+ buffer += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
2575
4771
  let newline = buffer.indexOf("\n");
2576
4772
  while (newline >= 0) {
2577
4773
  const line = buffer.slice(0, newline).trim();
2578
4774
  buffer = buffer.slice(newline + 1);
2579
4775
  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`);
4776
+ let request: JsonRpcRequest | null = null;
4777
+ try {
4778
+ request = JSON.parse(line) as JsonRpcRequest;
4779
+ } catch {
4780
+ request = null; // ignore malformed frames rather than crashing the loop
2584
4781
  }
4782
+ if (request) dispatch(request);
2585
4783
  }
2586
4784
  newline = buffer.indexOf("\n");
2587
4785
  }
2588
4786
  }
4787
+
4788
+ // EOF: stop promoting queued work, then drain in-flight handlers under a bound.
4789
+ draining = true;
4790
+ if (inFlight.size > 0) {
4791
+ const drain = Promise.allSettled(Array.from(inFlight)).then(() => undefined);
4792
+ if (drainTimeoutMs > 0) {
4793
+ let timer: ReturnType<typeof setTimeout> | undefined;
4794
+ const timeout = new Promise<void>(resolve => {
4795
+ timer = setTimeout(resolve, drainTimeoutMs);
4796
+ (timer as { unref?: () => void }).unref?.();
4797
+ });
4798
+ await Promise.race([drain, timeout]);
4799
+ if (timer) clearTimeout(timer);
4800
+ } else {
4801
+ await drain;
4802
+ }
4803
+ }
4804
+ await writeChain;
4805
+ }
4806
+
4807
+ export async function runCoordinatorMcpStdio(options: CoordinatorMcpServerOptions = {}): Promise<void> {
4808
+ const server = createCoordinatorMcpServer(options);
4809
+ await pumpCoordinatorMcpStream(
4810
+ request => server.handleJsonRpc(request),
4811
+ process.stdin,
4812
+ line =>
4813
+ new Promise<void>((resolve, reject) => {
4814
+ process.stdout.write(line, err => (err ? reject(err) : resolve()));
4815
+ }),
4816
+ );
2589
4817
  }