@sayknow-cli/coding-agent 0.3.12 → 0.3.15

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