@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
@@ -0,0 +1,2060 @@
1
+ /**
2
+ * Provenance and lifecycle primitives for tmux-resident SKC owners.
3
+ * This module intentionally accepts and stores only opaque identifiers and
4
+ * process/cgroup metadata; it never reads panes, prompts, environment, or logs.
5
+ */
6
+
7
+ import { Database } from "bun:sqlite";
8
+
9
+ import * as crypto from "node:crypto";
10
+ import * as fsSync from "node:fs";
11
+ import * as fs from "node:fs/promises";
12
+
13
+ import * as path from "node:path";
14
+ import { isCompiledBinary } from "@sayknow-cli/utils/env";
15
+
16
+ export const TMUX_OWNER_ISOLATION_SCHEMA_VERSION = 1;
17
+ export const TMUX_OWNER_ISOLATION_MAX_LINE_BYTES = 16 * 1024;
18
+ export const TMUX_OWNER_ISOLATION_MAX_DIAGNOSTIC_CHARS = 512;
19
+
20
+ const TMUX_OWNER_ISOLATION_CLI_FLAG = "--internal-tmux-owner-isolation";
21
+
22
+ export function tmuxOwnerIsolationBootstrapArgv(): string[] {
23
+ if (isCompiledBinary()) return [process.execPath, TMUX_OWNER_ISOLATION_CLI_FLAG];
24
+ const entry = process.argv[1];
25
+ if (entry && /\.(?:[cm]?js|ts)$/.test(entry) && !/\.test\.[^.]+$/.test(entry)) {
26
+ return [process.execPath, entry, TMUX_OWNER_ISOLATION_CLI_FLAG];
27
+ }
28
+ return [process.execPath, path.resolve(import.meta.dir, "../../bin/skc.js"), TMUX_OWNER_ISOLATION_CLI_FLAG];
29
+ }
30
+
31
+ function scopedExecutionArgv(expectedScope: string): string[] {
32
+ return [
33
+ "systemd-run",
34
+ "--user",
35
+ "--scope",
36
+ "--quiet",
37
+ "--unit",
38
+ expectedScope,
39
+ ...tmuxOwnerIsolationBootstrapArgv(),
40
+ ];
41
+ }
42
+
43
+ export type CgroupClassification = "not_applicable" | "safe" | "unsafe_service" | "unverifiable";
44
+ export type ServerState = "absent" | "safe" | "unsafe" | "unverifiable";
45
+ export type PlanErrorCode =
46
+ | "scope_unavailable"
47
+ | "server_unsafe"
48
+ | "server_unverifiable"
49
+ | "server_race"
50
+ | "scope_bootstrap_failed";
51
+ export type ExpectedTerminalResult = "owner_term_then_session_cleanup";
52
+ export type TerminalSignal = "SIGTERM" | "SIGHUP" | "SIGINT" | "SIGKILL" | "EXIT" | "MANUAL" | "UNKNOWN";
53
+ export type VerdictClassification = "expected_operator_shutdown" | "unexpected_owner_loss" | "non_operator_cleanup";
54
+ export type TerminalObserver = "sidecar" | "raw_monitor";
55
+
56
+ export interface CgroupInfo {
57
+ classification: CgroupClassification;
58
+ scope?: string;
59
+ diagnostic?: string;
60
+ }
61
+
62
+ /** Classify only public cgroup path metadata; malformed Linux data fails closed. */
63
+ export function classifyCgroup(input: { platform: NodeJS.Platform; cgroupText?: string | null }): CgroupInfo {
64
+ if (input.platform !== "linux") return { classification: "not_applicable" };
65
+ const text = input.cgroupText?.trim();
66
+ if (!text)
67
+ return {
68
+ classification: "unverifiable",
69
+ diagnostic: "cgroup_metadata_missing",
70
+ };
71
+ const paths = text
72
+ .split("\n")
73
+ .map(line => line.split(":").at(-1)?.trim())
74
+ .filter((value): value is string => Boolean(value));
75
+ if (paths.length === 0 || paths.some(value => !value.startsWith("/")))
76
+ return {
77
+ classification: "unverifiable",
78
+ diagnostic: "cgroup_metadata_malformed",
79
+ };
80
+ const hasUnrelatedService = paths.some(value =>
81
+ value.split("/").some(component => component.endsWith(".service") && !/^user@\d+\.service$/.test(component)),
82
+ );
83
+ if (hasUnrelatedService)
84
+ return {
85
+ classification: "unsafe_service",
86
+ diagnostic: "service_cgroup_inheritance",
87
+ };
88
+ const scope = paths.find(value => /(?:^|\/)(?:user|session|app|init|skc)[^/]*\.scope(?:\/|$)/.test(value));
89
+ if (scope) return { classification: "safe", scope };
90
+ if (paths.every(value => value === "/")) return { classification: "safe", scope: "/" };
91
+ return {
92
+ classification: "unverifiable",
93
+ diagnostic: "cgroup_ownership_unproven",
94
+ };
95
+ }
96
+
97
+ export function ownerProcessStartTime(platform: NodeJS.Platform, stat: string | null): string | null {
98
+ if (platform !== "linux") return "not_applicable";
99
+ if (!stat) return null;
100
+ const close = stat.lastIndexOf(")");
101
+ if (close < 0) return null;
102
+ const startTime = stat
103
+ .slice(close + 2)
104
+ .trim()
105
+ .split(/\s+/)[19];
106
+ return startTime && /^\d+$/.test(startTime) ? startTime : null;
107
+ }
108
+
109
+ export interface TmuxServerProof {
110
+ state: ServerState;
111
+ pid?: number;
112
+ startTime?: string;
113
+ cgroup?: CgroupInfo;
114
+ sessionNames?: string[];
115
+ }
116
+
117
+ export interface PlanRequest {
118
+ schema_version: 1;
119
+ op: "plan";
120
+ platform: NodeJS.Platform;
121
+ session_id: string;
122
+ owner_generation: string;
123
+ cwd: string;
124
+ state_dir: string;
125
+ socket_key: string;
126
+ tmux_argv: string[];
127
+ baseline: OwnerGenerationBaseline;
128
+ }
129
+
130
+ export interface PublishGenerationRequest {
131
+ schema_version: 1;
132
+ op: "publish_generation";
133
+ session_id: string;
134
+ owner_generation: string;
135
+ state_dir: string;
136
+ baseline: OwnerGenerationBaseline;
137
+ }
138
+
139
+ export interface PublishGenerationResult {
140
+ schema_version: 1;
141
+ ok: true;
142
+ code: "generation_published";
143
+ generation: string;
144
+ }
145
+
146
+ export interface AttemptCapability {
147
+ token: string;
148
+ session_name: string;
149
+ socket_key: string;
150
+ server_absent_before: boolean;
151
+ baseline: OwnerGenerationBaseline;
152
+ expires_at: string;
153
+ }
154
+
155
+ interface PersistedAttempt extends AttemptCapability {
156
+ schema_version: 1;
157
+ generation: string;
158
+ session_id: string;
159
+ created_at: string;
160
+ }
161
+
162
+ export interface DirectExecution {
163
+ mode: "direct";
164
+ argv: string[];
165
+ attempt_session: string;
166
+ server_key: string;
167
+ server_absent_before: boolean;
168
+ server_pid?: number;
169
+ server_start_time?: string;
170
+ }
171
+ export interface ScopedExecution {
172
+ mode: "scoped";
173
+ argv: string[];
174
+ stdin_line: string;
175
+ expected_scope: string;
176
+ attempt: AttemptCapability;
177
+ attempt_session: string;
178
+ server_key: string;
179
+ server_absent_before: true;
180
+ }
181
+ export type PlanExecution = DirectExecution | ScopedExecution;
182
+ export interface PlanSuccess {
183
+ schema_version: 1;
184
+ ok: true;
185
+ code: "not_required" | "unsafe_scope_required";
186
+ execution: PlanExecution;
187
+ classification: CgroupInfo;
188
+ server_state: ServerState;
189
+ }
190
+ export interface TmuxOwnerIsolationExecutionSuccess {
191
+ ok: true;
192
+ code: "executed";
193
+ execution: PlanExecution;
194
+ server: TmuxServerProof;
195
+ server_key: string;
196
+ server_pid: number;
197
+ server_start_time: string;
198
+ server_session: string;
199
+ native_session_id?: string;
200
+ }
201
+ export interface PlanFailure {
202
+ schema_version: 1;
203
+ ok: false;
204
+ code: PlanErrorCode;
205
+ diagnostic: string;
206
+ }
207
+ export type PlanResponse = PlanSuccess | PlanFailure;
208
+
209
+ export interface OwnerIsolationProbe {
210
+ readCallerCgroup(): Promise<string | null>;
211
+ probeServer(socketKey: string, tmuxArgv?: string[]): Promise<TmuxServerProof>;
212
+ }
213
+
214
+ /** Synchronous probe boundary for managed launch paths. */
215
+ export interface OwnerIsolationProbeSync {
216
+ readCallerCgroup(): string | null;
217
+ probeServer(socketKey: string): TmuxServerProof;
218
+ recordAttempt(input: { stateDir: string; sessionId: string; generation: string; attempt: AttemptCapability }): void;
219
+ }
220
+
221
+ export interface TmuxOwnerIsolationExecutionFailure {
222
+ ok: false;
223
+ code: PlanErrorCode;
224
+ diagnostic: string;
225
+ }
226
+ export type TmuxOwnerIsolationExecutionResult = TmuxOwnerIsolationExecutionSuccess | TmuxOwnerIsolationExecutionFailure;
227
+
228
+ /** Synchronous spawn boundary; callers pass argv and scoped stdin unchanged. */
229
+ export interface TmuxOwnerIsolationExecutionDependencies {
230
+ socketKey: string;
231
+ spawn(argv: string[], stdinLine?: string): { exitCode: number | null; stdout?: string };
232
+ probeServer(socketKey: string): TmuxServerProof;
233
+ /** Reads the published owner generation immediately around direct execution. */
234
+ isCurrentGeneration?(): boolean;
235
+ cleanupSpawned?(input: { execution: DirectExecution; nativeSessionId: string; server: TmuxServerProof }): void;
236
+ }
237
+
238
+ function nativeTmuxSessionId(value: unknown): value is string {
239
+ return typeof value === "string" && /^\$\d+$/.test(value);
240
+ }
241
+
242
+ export function isExactScopedBootstrapSuccessReceipt(stdout: string): boolean {
243
+ if (Buffer.byteLength(stdout) > TMUX_OWNER_ISOLATION_MAX_LINE_BYTES) return false;
244
+ const line = stdout.endsWith("\n") ? stdout.slice(0, -1) : stdout;
245
+ if (!line || line.includes("\n") || line.includes("\r")) return false;
246
+ try {
247
+ const receipt = JSON.parse(line) as Record<string, unknown>;
248
+ return (
249
+ Object.keys(receipt).length === 7 &&
250
+ receipt.schema_version === 1 &&
251
+ receipt.ok === true &&
252
+ receipt.code === "bootstrapped" &&
253
+ nativeTmuxSessionId(receipt.native_session_id) &&
254
+ typeof receipt.server_pid === "number" &&
255
+ Number.isSafeInteger(receipt.server_pid) &&
256
+ receipt.server_pid > 0 &&
257
+ nonEmpty(receipt.server_start_time) &&
258
+ nonEmpty(receipt.session_name)
259
+ );
260
+ } catch {
261
+ return false;
262
+ }
263
+ }
264
+
265
+ function safeDiagnostic(value: string): string {
266
+ return value.replace(/[^\x20-\x7e]/g, "?").slice(0, TMUX_OWNER_ISOLATION_MAX_DIAGNOSTIC_CHARS);
267
+ }
268
+ function failure(code: PlanErrorCode, diagnostic: string): PlanFailure {
269
+ return {
270
+ schema_version: 1,
271
+ ok: false,
272
+ code,
273
+ diagnostic: safeDiagnostic(diagnostic),
274
+ };
275
+ }
276
+ function tmuxControlArgv(tmuxArgv: string[]): string[] {
277
+ const command = canonicalNewSession(tmuxArgv);
278
+ return command ? tmuxArgv.slice(0, command.index) : [];
279
+ }
280
+
281
+ /** Accept exactly one tmux new-session command with exactly one explicit session target. */
282
+ function canonicalNewSession(tmuxArgv: string[]): { index: number; session: string } | null {
283
+ const commands = tmuxArgv.map((arg, index) => ({ arg, index })).filter(({ arg }) => arg === "new-session");
284
+ if (commands.length !== 1 || commands[0]!.index < 1) return null;
285
+ let session: string | null = null;
286
+ for (let index = commands[0]!.index + 1; index < tmuxArgv.length; index += 1) {
287
+ const arg = tmuxArgv[index];
288
+ if (arg !== "-s" && arg !== "--session-name") continue;
289
+ const value = tmuxArgv[index + 1];
290
+ if (!nonEmpty(value) || session !== null) return null;
291
+ session = value;
292
+ index += 1;
293
+ }
294
+ return session ? { index: commands[0]!.index, session } : null;
295
+ }
296
+
297
+ function tmuxAttemptSession(tmuxArgv: string[]): string | null {
298
+ return canonicalNewSession(tmuxArgv)?.session ?? null;
299
+ }
300
+
301
+ function validArgv(argv: unknown): argv is string[] {
302
+ return (
303
+ Array.isArray(argv) &&
304
+ argv.length > 0 &&
305
+ typeof argv[0] === "string" &&
306
+ argv[0].length > 0 &&
307
+ !argv[0].includes("\0") &&
308
+ argv.slice(1).every(value => typeof value === "string" && !value.includes("\0"))
309
+ );
310
+ }
311
+
312
+ function isSafeServerProof(
313
+ server: TmuxServerProof,
314
+ platform: NodeJS.Platform = "linux",
315
+ ): server is TmuxServerProof & {
316
+ state: "safe";
317
+ pid: number;
318
+ startTime: string;
319
+ cgroup: CgroupInfo;
320
+ } {
321
+ return (
322
+ server.state === "safe" &&
323
+ typeof server.pid === "number" &&
324
+ Number.isSafeInteger(server.pid) &&
325
+ server.pid > 0 &&
326
+ nonEmpty(server.startTime) &&
327
+ (server.cgroup?.classification === "safe" ||
328
+ (platform !== "linux" && server.cgroup?.classification === "not_applicable"))
329
+ );
330
+ }
331
+ /**
332
+ * Synchronous equivalent of the target-server truth table for managed callers.
333
+ * Attempt persistence is injected so a caller can use its existing atomic state writer.
334
+ */
335
+ export function planTmuxOwnerIsolationSync(request: PlanRequest, probe: OwnerIsolationProbeSync): PlanResponse {
336
+ if (!isPlanRequest(request)) return failure("scope_unavailable", "invalid_plan_request");
337
+ const attemptSession = tmuxAttemptSession(request.tmux_argv);
338
+ if (!attemptSession) return failure("scope_unavailable", "attempt_session_missing");
339
+ if (!isOwnerGenerationBaselineCurrentSync(request.state_dir, request.session_id, request.baseline))
340
+ return failure("scope_unavailable", "owner_generation_stale");
341
+ try {
342
+ const server = probe.probeServer(request.socket_key);
343
+ if (server.state === "unsafe") return failure("server_unsafe", "target_server_unsafe");
344
+ if (server.state === "unverifiable") return failure("server_unverifiable", "target_server_unverifiable");
345
+ if (server.state === "safe" && !isSafeServerProof(server, request.platform))
346
+ return failure("server_unverifiable", "target_server_unverifiable");
347
+ if (server.state === "safe") {
348
+ return {
349
+ schema_version: 1,
350
+ ok: true,
351
+ code: "not_required",
352
+ execution: {
353
+ mode: "direct",
354
+ argv: [...request.tmux_argv],
355
+ attempt_session: attemptSession,
356
+ server_key: request.socket_key,
357
+ server_absent_before: false,
358
+ server_pid: server.pid,
359
+ server_start_time: server.startTime,
360
+ },
361
+ classification: server.cgroup ?? { classification: "safe" },
362
+ server_state: "safe",
363
+ };
364
+ }
365
+ const classification = classifyCgroup({
366
+ platform: request.platform,
367
+ cgroupText: probe.readCallerCgroup(),
368
+ });
369
+ if (classification.classification === "unverifiable")
370
+ return failure("server_unverifiable", classification.diagnostic ?? "caller_cgroup_unverifiable");
371
+ if (classification.classification !== "unsafe_service") {
372
+ return {
373
+ schema_version: 1,
374
+ ok: true,
375
+ code: "not_required",
376
+ execution: {
377
+ mode: "direct",
378
+ argv: [...request.tmux_argv],
379
+ attempt_session: attemptSession,
380
+ server_key: request.socket_key,
381
+ server_absent_before: true,
382
+ },
383
+ classification,
384
+ server_state: "absent",
385
+ };
386
+ }
387
+ const baseline = request.baseline;
388
+ const token = crypto.randomUUID();
389
+ const attempt: AttemptCapability = {
390
+ token,
391
+ session_name: attemptSession,
392
+ socket_key: request.socket_key,
393
+ server_absent_before: true,
394
+ baseline,
395
+ expires_at: new Date(Date.now() + 7_000).toISOString(),
396
+ };
397
+
398
+ probe.recordAttempt({
399
+ stateDir: request.state_dir,
400
+ sessionId: request.session_id,
401
+ generation: request.owner_generation,
402
+ attempt,
403
+ });
404
+ const expectedScope = `skc-owner-${token}.scope`;
405
+ const bootstrap: BootstrapRequest = {
406
+ schema_version: 1,
407
+ op: "bootstrap",
408
+ session_id: request.session_id,
409
+ owner_generation: request.owner_generation,
410
+ state_dir: request.state_dir,
411
+ socket_key: request.socket_key,
412
+ expected_scope: expectedScope,
413
+ tmux_argv: [...request.tmux_argv],
414
+ attempt,
415
+ };
416
+ const stdinLine = JSON.stringify(bootstrap);
417
+ return {
418
+ schema_version: 1,
419
+ ok: true,
420
+ code: "unsafe_scope_required",
421
+ execution: {
422
+ mode: "scoped",
423
+ argv: scopedExecutionArgv(expectedScope),
424
+ stdin_line: stdinLine,
425
+ expected_scope: expectedScope,
426
+ attempt,
427
+ attempt_session: attemptSession,
428
+ server_key: request.socket_key,
429
+ server_absent_before: true,
430
+ },
431
+ classification,
432
+ server_state: "absent",
433
+ };
434
+ } catch {
435
+ return failure("scope_unavailable", "synchronous_probe_failed");
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Runs one planned argv exactly and re-proves the target server before callers
441
+ * perform profile, attach, or cleanup operations.
442
+ */
443
+ export function executeTmuxOwnerIsolationPlanSync(
444
+ plan: PlanResponse,
445
+ deps: TmuxOwnerIsolationExecutionDependencies,
446
+ ): TmuxOwnerIsolationExecutionResult {
447
+ if (!plan.ok) return { ok: false, code: plan.code, diagnostic: plan.diagnostic };
448
+ try {
449
+ const execution = plan.execution;
450
+ if (execution.mode === "direct" && deps.isCurrentGeneration && !deps.isCurrentGeneration())
451
+ return {
452
+ ok: false,
453
+ code: "scope_bootstrap_failed",
454
+ diagnostic: "owner_generation_stale",
455
+ };
456
+ const result =
457
+ execution.mode === "direct"
458
+ ? deps.spawn([...execution.argv])
459
+ : deps.spawn([...execution.argv], execution.stdin_line);
460
+ if (result.exitCode !== 0)
461
+ return {
462
+ ok: false,
463
+ code: "scope_bootstrap_failed",
464
+ diagnostic: "planned_spawn_failed",
465
+ };
466
+ if (execution.mode === "scoped" && !isExactScopedBootstrapSuccessReceipt(result.stdout ?? ""))
467
+ return {
468
+ ok: false,
469
+ code: "scope_bootstrap_failed",
470
+ diagnostic: "scoped_bootstrap_receipt_invalid",
471
+ };
472
+ const nativeSessionId =
473
+ execution.mode === "scoped"
474
+ ? (JSON.parse(result.stdout!.trim()) as { native_session_id: string }).native_session_id
475
+ : nativeTmuxSessionId(result.stdout?.trim())
476
+ ? result.stdout!.trim()
477
+ : undefined;
478
+ const directGenerationStale =
479
+ execution.mode === "direct" && Boolean(deps.isCurrentGeneration && !deps.isCurrentGeneration());
480
+ let server: TmuxServerProof;
481
+ try {
482
+ server = deps.probeServer(
483
+ plan.execution.mode === "scoped" ? plan.execution.attempt.socket_key : deps.socketKey,
484
+ );
485
+ } catch (error) {
486
+ if (directGenerationStale)
487
+ return {
488
+ ok: false,
489
+ code: "scope_bootstrap_failed",
490
+ diagnostic: "owner_generation_stale_cleanup_uncertain",
491
+ };
492
+ throw error;
493
+ }
494
+ if (server.state === "unsafe")
495
+ return directGenerationStale
496
+ ? {
497
+ ok: false,
498
+ code: "scope_bootstrap_failed",
499
+ diagnostic: "owner_generation_stale_cleanup_uncertain",
500
+ }
501
+ : {
502
+ ok: false,
503
+ code: "server_unsafe",
504
+ diagnostic: "target_server_unsafe",
505
+ };
506
+ if (!isSafeServerProof(server, plan.classification.classification === "not_applicable" ? "darwin" : "linux"))
507
+ return directGenerationStale
508
+ ? {
509
+ ok: false,
510
+ code: "scope_bootstrap_failed",
511
+ diagnostic: "owner_generation_stale_cleanup_uncertain",
512
+ }
513
+ : {
514
+ ok: false,
515
+ code: "server_unverifiable",
516
+ diagnostic: "target_server_unverifiable",
517
+ };
518
+ if (execution.mode === "scoped") {
519
+ const receipt = JSON.parse(result.stdout!.trim()) as {
520
+ server_pid: number;
521
+ server_start_time: string;
522
+ session_name: string;
523
+ };
524
+ if (
525
+ receipt.server_pid !== server.pid ||
526
+ receipt.server_start_time !== server.startTime ||
527
+ receipt.session_name !== execution.attempt_session
528
+ )
529
+ return {
530
+ ok: false,
531
+ code: "server_race",
532
+ diagnostic: "bootstrap_server_identity_changed",
533
+ };
534
+ }
535
+ if (directGenerationStale) {
536
+ if (execution.server_absent_before)
537
+ return {
538
+ ok: false,
539
+ code: "scope_bootstrap_failed",
540
+ diagnostic: "owner_generation_stale_cleanup_uncertain",
541
+ };
542
+ if (server.pid !== execution.server_pid || server.startTime !== execution.server_start_time)
543
+ return {
544
+ ok: false,
545
+ code: "scope_bootstrap_failed",
546
+ diagnostic: "owner_generation_stale_cleanup_uncertain",
547
+ };
548
+ if (!nativeSessionId || !deps.cleanupSpawned)
549
+ return {
550
+ ok: false,
551
+ code: "scope_bootstrap_failed",
552
+ diagnostic: "owner_generation_stale_cleanup_uncertain",
553
+ };
554
+ try {
555
+ deps.cleanupSpawned({ execution, nativeSessionId, server });
556
+ } catch {
557
+ return {
558
+ ok: false,
559
+ code: "scope_bootstrap_failed",
560
+ diagnostic: "owner_generation_stale_cleanup_uncertain",
561
+ };
562
+ }
563
+ return {
564
+ ok: false,
565
+ code: "scope_bootstrap_failed",
566
+ diagnostic: "owner_generation_stale",
567
+ };
568
+ }
569
+ if (
570
+ execution.mode === "direct" &&
571
+ !execution.server_absent_before &&
572
+ (server.pid !== execution.server_pid || server.startTime !== execution.server_start_time)
573
+ )
574
+ return {
575
+ ok: false,
576
+ code: "server_race",
577
+ diagnostic: "target_server_identity_changed",
578
+ };
579
+ return {
580
+ ok: true,
581
+ code: "executed",
582
+ execution,
583
+ server,
584
+ server_key: execution.server_key,
585
+ server_pid: server.pid,
586
+ server_start_time: server.startTime,
587
+ server_session: execution.attempt_session,
588
+ ...(nativeSessionId ? { native_session_id: nativeSessionId } : {}),
589
+ };
590
+ } catch {
591
+ return {
592
+ ok: false,
593
+ code: "scope_bootstrap_failed",
594
+ diagnostic: "synchronous_execution_failed",
595
+ };
596
+ }
597
+ }
598
+
599
+ /** Implements the target-server truth table before any tmux operation. */
600
+ export async function planTmuxOwnerIsolation(request: PlanRequest, probe: OwnerIsolationProbe): Promise<PlanResponse> {
601
+ if (!isPlanRequest(request)) return failure("scope_unavailable", "invalid_plan_request");
602
+ const attemptSession = tmuxAttemptSession(request.tmux_argv);
603
+ if (!attemptSession) return failure("scope_unavailable", "attempt_session_missing");
604
+ const currentBaseline = await captureOwnerGenerationBaseline(request.state_dir, request.session_id);
605
+ if (!sameOwnerGenerationBaseline(currentBaseline, request.baseline))
606
+ return failure("scope_unavailable", "owner_generation_stale");
607
+ try {
608
+ const server = await probe.probeServer(request.socket_key, tmuxControlArgv(request.tmux_argv));
609
+ if (server.state === "unsafe") return failure("server_unsafe", "target_server_unsafe");
610
+ if (server.state === "unverifiable") return failure("server_unverifiable", "target_server_unverifiable");
611
+ if (server.state === "safe" && !isSafeServerProof(server, request.platform))
612
+ return failure("server_unverifiable", "target_server_unverifiable");
613
+ if (server.state === "safe") {
614
+ return {
615
+ schema_version: 1,
616
+ ok: true,
617
+ code: "not_required",
618
+ execution: {
619
+ mode: "direct",
620
+ argv: [...request.tmux_argv],
621
+ attempt_session: attemptSession,
622
+ server_key: request.socket_key,
623
+ server_absent_before: false,
624
+ server_pid: server.pid,
625
+ server_start_time: server.startTime,
626
+ },
627
+ classification: server.cgroup ?? { classification: "safe" },
628
+ server_state: "safe",
629
+ };
630
+ }
631
+ const classification = classifyCgroup({
632
+ platform: request.platform,
633
+ cgroupText: await probe.readCallerCgroup(),
634
+ });
635
+ if (classification.classification === "unverifiable")
636
+ return failure("server_unverifiable", classification.diagnostic ?? "caller_cgroup_unverifiable");
637
+ if (classification.classification !== "unsafe_service") {
638
+ return {
639
+ schema_version: 1,
640
+ ok: true,
641
+ code: "not_required",
642
+ execution: {
643
+ mode: "direct",
644
+ argv: [...request.tmux_argv],
645
+ attempt_session: attemptSession,
646
+ server_key: request.socket_key,
647
+ server_absent_before: true,
648
+ },
649
+ classification,
650
+ server_state: "absent",
651
+ };
652
+ }
653
+ const baseline = request.baseline;
654
+ const token = crypto.randomUUID();
655
+ const attempt: AttemptCapability = {
656
+ token,
657
+ session_name: attemptSession,
658
+ socket_key: request.socket_key,
659
+ server_absent_before: true,
660
+ baseline,
661
+ expires_at: new Date(Date.now() + 7_000).toISOString(),
662
+ };
663
+ await writeAttempt(request.state_dir, request.session_id, request.owner_generation, attempt);
664
+ const expectedScope = `skc-owner-${token}.scope`;
665
+ const bootstrap: BootstrapRequest = {
666
+ schema_version: 1,
667
+ op: "bootstrap",
668
+ session_id: request.session_id,
669
+ owner_generation: request.owner_generation,
670
+ state_dir: request.state_dir,
671
+ socket_key: request.socket_key,
672
+ expected_scope: expectedScope,
673
+ tmux_argv: [...request.tmux_argv],
674
+ attempt,
675
+ };
676
+ const stdinLine = JSON.stringify(bootstrap);
677
+ return {
678
+ schema_version: 1,
679
+ ok: true,
680
+ code: "unsafe_scope_required",
681
+ execution: {
682
+ mode: "scoped",
683
+ argv: scopedExecutionArgv(expectedScope),
684
+ stdin_line: stdinLine,
685
+ expected_scope: expectedScope,
686
+ attempt,
687
+ attempt_session: attemptSession,
688
+ server_key: request.socket_key,
689
+ server_absent_before: true,
690
+ },
691
+ classification,
692
+ server_state: "absent",
693
+ };
694
+ } catch {
695
+ return failure("scope_unavailable", "asynchronous_probe_failed");
696
+ }
697
+ }
698
+
699
+ export interface BootstrapRequest {
700
+ schema_version: 1;
701
+ op: "bootstrap";
702
+ session_id: string;
703
+ owner_generation: string;
704
+ state_dir: string;
705
+ socket_key: string;
706
+ expected_scope: string;
707
+ tmux_argv: string[];
708
+ attempt: AttemptCapability;
709
+ }
710
+ export interface BootstrapResult {
711
+ schema_version: 1;
712
+ ok: boolean;
713
+ code: "bootstrapped" | "scope_bootstrap_failed";
714
+ native_session_id?: string;
715
+ server_pid?: number;
716
+ server_start_time?: string;
717
+ session_name?: string;
718
+ diagnostic?: string;
719
+ }
720
+
721
+ export interface BootstrapDependencies {
722
+ readSelfCgroup(): Promise<string | null>;
723
+ spawn(argv: string[]): { exitCode: number | null; stdout?: string };
724
+ probeServer(socketKey: string, tmuxControlArgv?: string[]): Promise<TmuxServerProof>;
725
+ }
726
+
727
+ /** Self-proves the scope and synchronously invokes the exact supplied argv. */
728
+ export async function bootstrapTmuxOwnerIsolation(
729
+ request: BootstrapRequest,
730
+ deps: BootstrapDependencies,
731
+ ): Promise<BootstrapResult> {
732
+ if (!isBootstrapRequest(request))
733
+ return {
734
+ schema_version: 1,
735
+ ok: false,
736
+ code: "scope_bootstrap_failed",
737
+ diagnostic: "invalid_bootstrap_request",
738
+ };
739
+ const paths = lifecyclePaths(request.state_dir, request.session_id, request.owner_generation);
740
+ const generationLockToken = await acquireOwnerGenerationLock(paths, request.session_id);
741
+ if (!generationLockToken)
742
+ return {
743
+ schema_version: 1,
744
+ ok: false,
745
+ code: "scope_bootstrap_failed",
746
+ diagnostic: "generation_lock_contended",
747
+ };
748
+ let spawnedOwner = false;
749
+ try {
750
+ const attemptFile = path.join(paths.root, `attempt-${request.attempt.token}.json`);
751
+ const recordedAttempt = await readJson<PersistedAttempt>(attemptFile);
752
+ const currentBaseline = await captureOwnerGenerationBaseline(request.state_dir, request.session_id);
753
+ const baselineMatches = sameOwnerGenerationBaseline(currentBaseline, request.attempt.baseline);
754
+ const derivedSession = tmuxAttemptSession(request.tmux_argv);
755
+ if (
756
+ !baselineMatches ||
757
+ !validPersistedAttempt(recordedAttempt, request) ||
758
+ derivedSession !== request.attempt.session_name ||
759
+ request.expected_scope !== `skc-owner-${request.attempt.token}.scope`
760
+ )
761
+ return {
762
+ schema_version: 1,
763
+ ok: false,
764
+ code: "scope_bootstrap_failed",
765
+ diagnostic: "attempt_capability_invalid",
766
+ };
767
+
768
+ const expiresAt = Date.parse(recordedAttempt.expires_at);
769
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now() || expiresAt > Date.now() + 7_000)
770
+ return {
771
+ schema_version: 1,
772
+ ok: false,
773
+ code: "scope_bootstrap_failed",
774
+ diagnostic: "attempt_capability_expired",
775
+ };
776
+ try {
777
+ await fs.rename(attemptFile, `${attemptFile}.consumed`);
778
+ } catch {
779
+ return {
780
+ schema_version: 1,
781
+ ok: false,
782
+ code: "scope_bootstrap_failed",
783
+ diagnostic: "attempt_capability_replayed",
784
+ };
785
+ }
786
+ } finally {
787
+ await releaseVerdictLock(generationLockToken);
788
+ }
789
+ try {
790
+ const controlArgv = tmuxControlArgv(request.tmux_argv);
791
+ if (!controlArgv.length)
792
+ return {
793
+ schema_version: 1,
794
+ ok: false,
795
+ code: "scope_bootstrap_failed",
796
+ diagnostic: "tmux_control_argv_invalid",
797
+ };
798
+ const cgroup = classifyCgroup({
799
+ platform: "linux",
800
+ cgroupText: await deps.readSelfCgroup(),
801
+ });
802
+ if (cgroup.classification !== "safe" || !cgroup.scope?.split("/").includes(request.expected_scope))
803
+ return {
804
+ schema_version: 1,
805
+ ok: false,
806
+ code: "scope_bootstrap_failed",
807
+ diagnostic: "scope_self_proof_failed",
808
+ };
809
+ const spawnLockToken = await acquireOwnerGenerationLock(paths, request.session_id);
810
+ if (!spawnLockToken)
811
+ return {
812
+ schema_version: 1,
813
+ ok: false,
814
+ code: "scope_bootstrap_failed",
815
+ diagnostic: "generation_lock_contended",
816
+ };
817
+ let result: { exitCode: number | null; stdout?: string } | null = null;
818
+ try {
819
+ const currentBaseline = await captureOwnerGenerationBaseline(request.state_dir, request.session_id);
820
+ const baselineMatches = sameOwnerGenerationBaseline(currentBaseline, request.attempt.baseline);
821
+ if (!baselineMatches)
822
+ return {
823
+ schema_version: 1,
824
+ ok: false,
825
+ code: "scope_bootstrap_failed",
826
+ diagnostic: "attempt_capability_invalid",
827
+ };
828
+ result = deps.spawn([...request.tmux_argv]);
829
+ } finally {
830
+ await releaseVerdictLock(spawnLockToken);
831
+ }
832
+ spawnedOwner = result?.exitCode === 0;
833
+ if (result?.exitCode !== 0)
834
+ return {
835
+ schema_version: 1,
836
+ ok: false,
837
+ code: "scope_bootstrap_failed",
838
+ diagnostic: "tmux_spawn_failed",
839
+ };
840
+ const nativeSessionId = result?.stdout?.trim();
841
+ if (!nativeTmuxSessionId(nativeSessionId))
842
+ return {
843
+ schema_version: 1,
844
+ ok: false,
845
+ code: "scope_bootstrap_failed",
846
+ diagnostic: "native_session_identity_unavailable_cleanup_uncertain",
847
+ };
848
+ const proof = await deps.probeServer(request.socket_key, controlArgv);
849
+ if (!isSafeServerProof(proof))
850
+ return {
851
+ schema_version: 1,
852
+ ok: false,
853
+ code: "scope_bootstrap_failed",
854
+ diagnostic: "server_proof_failed_cleanup_uncertain",
855
+ };
856
+ return {
857
+ schema_version: 1,
858
+ ok: true,
859
+ code: "bootstrapped",
860
+ native_session_id: nativeSessionId,
861
+ server_pid: proof.pid,
862
+ server_start_time: proof.startTime,
863
+ session_name: request.attempt.session_name,
864
+ };
865
+ } catch {
866
+ return {
867
+ schema_version: 1,
868
+ ok: false,
869
+ code: "scope_bootstrap_failed",
870
+ diagnostic: spawnedOwner ? "bootstrap_cleanup_uncertain" : "bootstrap_execution_failed",
871
+ };
872
+ }
873
+ }
874
+
875
+ export interface OwnerIntent {
876
+ schema_version: 1;
877
+ intent_id: string;
878
+ generation: string;
879
+ session_id: string;
880
+ server_key: string;
881
+ expected_terminal: { signal: "SIGTERM"; result: ExpectedTerminalResult };
882
+ dispatch_id: string;
883
+ created_at: string;
884
+ expires_at: string;
885
+ state: "pending";
886
+ }
887
+ export interface OwnerVerdict {
888
+ schema_version: 1;
889
+ generation: string;
890
+ session_id: string;
891
+ server_key: string;
892
+ observed_at: string;
893
+ signal: TerminalSignal;
894
+ exit_code: number | null;
895
+ result: string;
896
+ observer: TerminalObserver;
897
+ classification: VerdictClassification;
898
+ reason: string;
899
+ intent_id?: string;
900
+ dedupe_key: string;
901
+ }
902
+ export interface OwnerIncident {
903
+ schema_version: 1;
904
+ generation: string;
905
+ session_id: string;
906
+ dedupe_key: string;
907
+ created_at: string;
908
+ classification: "unexpected_owner_loss";
909
+ }
910
+ export interface ObserveTerminalRequest {
911
+ schema_version: 1;
912
+ op: "observe_terminal";
913
+ session_id: string;
914
+ owner_generation: string;
915
+ state_dir: string;
916
+ socket_key: string;
917
+ observer: TerminalObserver;
918
+ observed_at: string;
919
+ signal: TerminalSignal;
920
+ exit_code: number | null;
921
+ exit_kind: string;
922
+ reason: string;
923
+ operator_dispatch_id?: string;
924
+ }
925
+ export interface LifecyclePaths {
926
+ root: string;
927
+ generation: string;
928
+ generationFile: string;
929
+ generationMarkerFile: string;
930
+ intentFile: string;
931
+ verdictFile: string;
932
+ verdictAliasFile: string;
933
+ incidentFile: string;
934
+ lockDatabaseFile: string;
935
+ journalFile: string;
936
+ }
937
+ export function lifecyclePaths(stateDir: string, sessionId: string, generation: string): LifecyclePaths {
938
+ const root = path.join(stateDir, sessionId, "owner-lifecycle");
939
+ return {
940
+ root,
941
+ generation,
942
+ generationFile: path.join(root, "generation.json"),
943
+ generationMarkerFile: path.join(root, `generation-${encodeURIComponent(generation)}.published.json`),
944
+ intentFile: path.join(root, `intent-${generation}.json`),
945
+ verdictFile: path.join(root, `verdict-${generation}.json`),
946
+ verdictAliasFile: path.join(root, "verdict.json"),
947
+
948
+ incidentFile: path.join(root, `incident-${generation}.json`),
949
+ lockDatabaseFile: path.join(root, "owner-locks.sqlite"),
950
+ journalFile: path.join(root, `verdict-${generation}.journal`),
951
+ };
952
+ }
953
+
954
+ export async function replaceOwnerGeneration(
955
+ stateDir: string,
956
+ sessionId: string,
957
+ generation: string = crypto.randomUUID(),
958
+ expectedBaseline?: OwnerGenerationBaseline,
959
+ ): Promise<string> {
960
+ const paths = lifecyclePaths(stateDir, sessionId, generation);
961
+ const token = await acquireOwnerGenerationLock(paths, sessionId);
962
+ if (!token) throw new Error("generation_lock_contended");
963
+ try {
964
+ const previous = await captureOwnerGenerationBaseline(stateDir, sessionId);
965
+ if (expectedBaseline && !sameOwnerGenerationBaseline(previous, expectedBaseline))
966
+ throw new Error("generation_baseline_changed");
967
+ const published = {
968
+ schema_version: 1 as const,
969
+ generation,
970
+ session_id: sessionId,
971
+ published_at: new Date().toISOString(),
972
+ };
973
+ await publishImmutableGenerationMarker(paths.generationMarkerFile, published);
974
+ if (previous.state === "current" && previous.generation !== generation)
975
+ await ensureGenerationMarker(
976
+ lifecyclePaths(stateDir, sessionId, previous.generation).generationMarkerFile,
977
+ generationPublicationRecord(previous),
978
+ );
979
+ await atomicWrite(paths.generationFile, published);
980
+ if (previous.state === "current" && previous.generation !== generation) {
981
+ const prior = lifecyclePaths(stateDir, sessionId, previous.generation);
982
+ const intent = await readJson<OwnerIntent>(prior.intentFile);
983
+ if (intent?.state === "pending")
984
+ await fs.rename(prior.intentFile, `${prior.intentFile}.invalidated`).catch(() => undefined);
985
+ }
986
+ return generation;
987
+ } finally {
988
+ await releaseVerdictLock(token);
989
+ }
990
+ }
991
+
992
+ export type OwnerGenerationBaseline =
993
+ | { state: "absent" }
994
+ | {
995
+ state: "current";
996
+ schema_version: 1;
997
+ generation: string;
998
+ session_id: string;
999
+ published_at: string;
1000
+ };
1001
+
1002
+ function isOwnerGenerationBaseline(value: unknown): value is OwnerGenerationBaseline {
1003
+ return (
1004
+ isRecord(value) &&
1005
+ ((Object.keys(value).length === 1 && value.state === "absent") ||
1006
+ (hasOnlyKeys(value, ["state", "schema_version", "generation", "session_id", "published_at"]) &&
1007
+ value.state === "current" &&
1008
+ value.schema_version === 1 &&
1009
+ nonEmpty(value.generation) &&
1010
+ nonEmpty(value.session_id) &&
1011
+ isCanonicalUtcTimestamp(value.published_at)))
1012
+ );
1013
+ }
1014
+
1015
+ function generationPublicationRecord(baseline: Extract<OwnerGenerationBaseline, { state: "current" }>): {
1016
+ schema_version: 1;
1017
+ generation: string;
1018
+ session_id: string;
1019
+ published_at: string;
1020
+ } {
1021
+ return {
1022
+ schema_version: baseline.schema_version,
1023
+ generation: baseline.generation,
1024
+ session_id: baseline.session_id,
1025
+ published_at: baseline.published_at,
1026
+ };
1027
+ }
1028
+
1029
+ function sameOwnerGenerationBaseline(left: OwnerGenerationBaseline, right: OwnerGenerationBaseline): boolean {
1030
+ return (
1031
+ left.state === right.state &&
1032
+ (left.state === "absent" ||
1033
+ (right.state === "current" &&
1034
+ left.schema_version === right.schema_version &&
1035
+ left.generation === right.generation &&
1036
+ left.session_id === right.session_id &&
1037
+ left.published_at === right.published_at))
1038
+ );
1039
+ }
1040
+
1041
+ export async function captureOwnerGenerationBaseline(
1042
+ stateDir: string,
1043
+ sessionId: string,
1044
+ ): Promise<OwnerGenerationBaseline> {
1045
+ const file = lifecyclePaths(stateDir, sessionId, "baseline").generationFile;
1046
+ try {
1047
+ await fs.access(file);
1048
+ } catch {
1049
+ return { state: "absent" };
1050
+ }
1051
+ const record = await readJson<unknown>(file);
1052
+ if (
1053
+ !isRecord(record) ||
1054
+ !hasOnlyKeys(record, ["schema_version", "generation", "session_id", "published_at"]) ||
1055
+ record.schema_version !== 1 ||
1056
+ record.session_id !== sessionId ||
1057
+ !nonEmpty(record.generation) ||
1058
+ !isCanonicalUtcTimestamp(record.published_at)
1059
+ )
1060
+ throw new Error("baseline_generation_corrupt");
1061
+ return {
1062
+ state: "current",
1063
+ schema_version: record.schema_version,
1064
+ generation: record.generation,
1065
+ session_id: record.session_id,
1066
+ published_at: record.published_at,
1067
+ };
1068
+ }
1069
+
1070
+ /** Captures the full immutable generation record for a planned owner launch. */
1071
+ export function captureOwnerGenerationBaselineSync(stateDir: string, sessionId: string): OwnerGenerationBaseline {
1072
+ const paths = lifecyclePaths(stateDir, sessionId, "baseline");
1073
+ if (!fsSync.existsSync(paths.generationFile)) return { state: "absent" };
1074
+ let record: unknown;
1075
+ try {
1076
+ record = JSON.parse(fsSync.readFileSync(paths.generationFile, "utf8"));
1077
+ } catch {
1078
+ throw new Error("baseline_generation_corrupt");
1079
+ }
1080
+ if (
1081
+ !isRecord(record) ||
1082
+ !hasOnlyKeys(record, ["schema_version", "generation", "session_id", "published_at"]) ||
1083
+ record.schema_version !== 1 ||
1084
+ record.session_id !== sessionId ||
1085
+ !nonEmpty(record.generation) ||
1086
+ !isCanonicalUtcTimestamp(record.published_at)
1087
+ )
1088
+ throw new Error("baseline_generation_corrupt");
1089
+ return {
1090
+ state: "current",
1091
+ schema_version: record.schema_version,
1092
+ generation: record.generation,
1093
+ session_id: record.session_id,
1094
+ published_at: record.published_at,
1095
+ };
1096
+ }
1097
+
1098
+ export function isOwnerGenerationBaselineCurrentSync(
1099
+ stateDir: string,
1100
+ sessionId: string,
1101
+ baseline: OwnerGenerationBaseline,
1102
+ ): boolean {
1103
+ try {
1104
+ const current = captureOwnerGenerationBaselineSync(stateDir, sessionId);
1105
+ if (baseline.state === "absent") return current.state === "absent";
1106
+ return (
1107
+ current.state === "current" &&
1108
+ baseline.schema_version === current.schema_version &&
1109
+ baseline.generation === current.generation &&
1110
+ baseline.session_id === current.session_id &&
1111
+ baseline.published_at === current.published_at
1112
+ );
1113
+ } catch {
1114
+ return false;
1115
+ }
1116
+ }
1117
+
1118
+ /** Synchronous publication for managed launch paths, serialized by a SQLite write transaction. */
1119
+ export function replaceOwnerGenerationSync(
1120
+ stateDir: string,
1121
+ sessionId: string,
1122
+ generation: string,
1123
+ expectedBaseline: OwnerGenerationBaseline,
1124
+ ): string {
1125
+ const paths = lifecyclePaths(stateDir, sessionId, generation);
1126
+ const db = acquireSqliteLockSync(paths, 7_000);
1127
+ if (!db) throw new Error("generation_lock_contended");
1128
+ const temporaryGeneration = `${paths.generationFile}.${crypto.randomUUID()}.tmp`;
1129
+ try {
1130
+ if (!isOwnerGenerationBaselineCurrentSync(stateDir, sessionId, expectedBaseline))
1131
+ throw new Error("baseline_generation_changed");
1132
+ const previous = captureOwnerGenerationBaselineSync(stateDir, sessionId);
1133
+ const published = {
1134
+ schema_version: 1 as const,
1135
+ generation,
1136
+ session_id: sessionId,
1137
+ published_at: new Date().toISOString(),
1138
+ };
1139
+ publishImmutableGenerationMarkerSync(paths.generationMarkerFile, published);
1140
+ if (previous.state === "current" && previous.generation !== generation)
1141
+ ensureGenerationMarkerSync(
1142
+ lifecyclePaths(stateDir, sessionId, previous.generation).generationMarkerFile,
1143
+ generationPublicationRecord(previous),
1144
+ );
1145
+ fsSync.writeFileSync(temporaryGeneration, `${JSON.stringify(published)}\n`, { mode: 0o600, flag: "wx" });
1146
+ fsSync.renameSync(temporaryGeneration, paths.generationFile);
1147
+ if (previous.state === "current" && previous.generation !== generation) {
1148
+ const priorIntent = lifecyclePaths(stateDir, sessionId, previous.generation).intentFile;
1149
+ try {
1150
+ const intent = JSON.parse(fsSync.readFileSync(priorIntent, "utf8")) as { state?: unknown };
1151
+ if (intent.state === "pending") fsSync.renameSync(priorIntent, `${priorIntent}.invalidated`);
1152
+ } catch {}
1153
+ }
1154
+ db.exec("COMMIT");
1155
+ return generation;
1156
+ } catch (error) {
1157
+ try {
1158
+ db.exec("ROLLBACK");
1159
+ } catch {}
1160
+
1161
+ throw error;
1162
+ } finally {
1163
+ try {
1164
+ fsSync.unlinkSync(temporaryGeneration);
1165
+ } catch {}
1166
+ db.close();
1167
+ }
1168
+ }
1169
+
1170
+ /** Publishes a raw-owner generation through the canonical SQLite-serialized CAS path. */
1171
+ export function publishOwnerGenerationSync(request: PublishGenerationRequest): PublishGenerationResult {
1172
+ const generation = replaceOwnerGenerationSync(
1173
+ request.state_dir,
1174
+ request.session_id,
1175
+ request.owner_generation,
1176
+ request.baseline,
1177
+ );
1178
+ return { schema_version: 1, ok: true, code: "generation_published", generation };
1179
+ }
1180
+
1181
+ export async function createOwnerIntent(
1182
+ stateDir: string,
1183
+ input: Omit<OwnerIntent, "schema_version" | "intent_id" | "state">,
1184
+ ): Promise<OwnerIntent> {
1185
+ const intent: OwnerIntent = {
1186
+ schema_version: 1,
1187
+ intent_id: crypto.randomUUID(),
1188
+ state: "pending",
1189
+ ...input,
1190
+ };
1191
+ if (!isValidOwnerIntent(intent)) throw new Error("owner_intent_invalid");
1192
+ const paths = lifecyclePaths(stateDir, input.session_id, input.generation);
1193
+ await fs.mkdir(paths.root, { recursive: true });
1194
+ const priorIntent = await Promise.any(
1195
+ ["", ".consumed", ".cancelled", ".expired", ".invalidated"].map(suffix =>
1196
+ fs.access(`${paths.intentFile}${suffix}`),
1197
+ ),
1198
+ )
1199
+ .then(() => true)
1200
+ .catch(() => false);
1201
+ if (priorIntent) throw new Error("owner_intent_replay");
1202
+ const handle = await fs.open(paths.intentFile, "wx", 0o600).catch(error => {
1203
+ if (isCode(error, "EEXIST")) throw new Error("owner_intent_replay");
1204
+ throw error;
1205
+ });
1206
+ try {
1207
+ await handle.writeFile(`${JSON.stringify(intent)}\n`);
1208
+ await handle.sync();
1209
+ } finally {
1210
+ await handle.close();
1211
+ }
1212
+ await fsyncDirectory(paths.root);
1213
+ return intent;
1214
+ }
1215
+
1216
+ const VERDICT_LOCK_WAIT_TIMEOUT_MS = 250;
1217
+ const GENERATION_LOCK_WAIT_TIMEOUT_MS = 7_000;
1218
+
1219
+ interface LiveSqliteLock {
1220
+ database: Database;
1221
+ file: string;
1222
+ active: boolean;
1223
+ }
1224
+
1225
+ const liveSqliteLocks = new Map<string, LiveSqliteLock>();
1226
+
1227
+ function configureLockDatabase(database: Database, waitMs: number): void {
1228
+ database.exec(`PRAGMA busy_timeout = ${waitMs}`);
1229
+ }
1230
+
1231
+ function setLockDatabaseMode(file: string): void {
1232
+ try {
1233
+ fsSync.chmodSync(file, 0o600);
1234
+ } catch {}
1235
+ }
1236
+
1237
+ async function acquireSqliteLock(paths: LifecyclePaths, waitMs: number): Promise<string | null> {
1238
+ const deadline = Date.now() + waitMs;
1239
+ try {
1240
+ await fs.mkdir(paths.root, { recursive: true, mode: 0o700 });
1241
+ } catch {
1242
+ return null;
1243
+ }
1244
+ while (Date.now() <= deadline) {
1245
+ if ([...liveSqliteLocks.values()].some(lock => lock.active && lock.file === paths.lockDatabaseFile)) {
1246
+ await Bun.sleep(Math.min(10, Math.max(0, deadline - Date.now())));
1247
+ continue;
1248
+ }
1249
+ try {
1250
+ const database = new Database(paths.lockDatabaseFile);
1251
+ try {
1252
+ setLockDatabaseMode(paths.lockDatabaseFile);
1253
+ configureLockDatabase(database, Math.max(0, deadline - Date.now()));
1254
+ database.exec("BEGIN IMMEDIATE");
1255
+ const token = crypto.randomUUID();
1256
+ liveSqliteLocks.set(token, { database, file: paths.lockDatabaseFile, active: true });
1257
+ return token;
1258
+ } catch {
1259
+ database.close();
1260
+ }
1261
+ } catch {}
1262
+ if (Date.now() < deadline) await Bun.sleep(Math.min(10, deadline - Date.now()));
1263
+ }
1264
+ return null;
1265
+ }
1266
+
1267
+ function acquireSqliteLockSync(paths: LifecyclePaths, waitMs: number): Database | null {
1268
+ try {
1269
+ fsSync.mkdirSync(paths.root, { recursive: true, mode: 0o700 });
1270
+ const database = new Database(paths.lockDatabaseFile);
1271
+ try {
1272
+ setLockDatabaseMode(paths.lockDatabaseFile);
1273
+ configureLockDatabase(database, waitMs);
1274
+ database.exec("BEGIN IMMEDIATE");
1275
+ return database;
1276
+ } catch {
1277
+ database.close();
1278
+ return null;
1279
+ }
1280
+ } catch {
1281
+ return null;
1282
+ }
1283
+ }
1284
+
1285
+ async function acquireVerdictLock(paths: LifecyclePaths, _serverKey: string): Promise<string | null> {
1286
+ return acquireSqliteLock(paths, VERDICT_LOCK_WAIT_TIMEOUT_MS);
1287
+ }
1288
+
1289
+ async function acquireOwnerGenerationLock(paths: LifecyclePaths, _sessionId: string): Promise<string | null> {
1290
+ return acquireSqliteLock(paths, GENERATION_LOCK_WAIT_TIMEOUT_MS);
1291
+ }
1292
+
1293
+ async function ownsVerdictLock(token: string): Promise<boolean> {
1294
+ return liveSqliteLocks.get(token)?.active === true;
1295
+ }
1296
+
1297
+ async function releaseVerdictLock(token: string): Promise<void> {
1298
+ const lock = liveSqliteLocks.get(token);
1299
+ if (!lock?.active) return;
1300
+ lock.active = false;
1301
+ liveSqliteLocks.delete(token);
1302
+ try {
1303
+ lock.database.exec("COMMIT");
1304
+ } catch {
1305
+ try {
1306
+ lock.database.exec("ROLLBACK");
1307
+ } catch {}
1308
+ } finally {
1309
+ lock.database.close();
1310
+ }
1311
+ }
1312
+
1313
+ const ALLOWED_TERMINAL_EXIT_KINDS = new Set(["owner_lost", "cleanup", "process_postmortem", "exit"]);
1314
+ const ALLOWED_TERMINAL_REASONS = new Set([
1315
+ "tmux_session_missing",
1316
+ "process_postmortem",
1317
+ "owner_exit",
1318
+ "sidecar",
1319
+ "integration",
1320
+ "test",
1321
+ "raw_terminal",
1322
+ "terminal_observation",
1323
+ ]);
1324
+
1325
+ function normalizeTerminalObservation(request: ObserveTerminalRequest): ObserveTerminalRequest {
1326
+ return {
1327
+ ...request,
1328
+ exit_kind: ALLOWED_TERMINAL_EXIT_KINDS.has(request.exit_kind) ? request.exit_kind : "unknown_terminal",
1329
+ reason: ALLOWED_TERMINAL_REASONS.has(request.reason) ? request.reason : "terminal_observation",
1330
+ };
1331
+ }
1332
+
1333
+ /** Publish exactly one terminal verdict. Existing valid verdicts always win. */
1334
+ export async function observeOwnerTerminal(request: ObserveTerminalRequest): Promise<OwnerVerdict> {
1335
+ if (!isObserveTerminalRequest(request)) throw new Error("terminal_observation_invalid");
1336
+ const paths = lifecyclePaths(request.state_dir, request.session_id, request.owner_generation);
1337
+ let observation = normalizeTerminalObservation(request);
1338
+ const deadline = Date.now() + VERDICT_LOCK_WAIT_TIMEOUT_MS;
1339
+ let token: string | null = null;
1340
+ while (!token) {
1341
+ const current = await readJson<{ generation?: string }>(paths.generationFile);
1342
+ if (current?.generation !== request.owner_generation) throw new Error("generation_mismatch");
1343
+ token = await acquireVerdictLock(paths, request.socket_key);
1344
+ if (token) break;
1345
+ if (Date.now() >= deadline) throw new Error("verdict_lock_contended");
1346
+ await Bun.sleep(Math.min(10, Math.max(0, deadline - Date.now())));
1347
+ }
1348
+ if (!token) throw new Error("verdict_lock_contended");
1349
+ try {
1350
+ const current = await readJson<{ generation?: string }>(paths.generationFile);
1351
+ if (current?.generation !== request.owner_generation) throw new Error("generation_mismatch");
1352
+ const published = await readJson<OwnerVerdict>(paths.verdictFile);
1353
+ if (published && isValidOwnerVerdict(published, request)) return reconcileTerminalArtifacts(paths, published);
1354
+ const recoveredJournal = await readJson<{
1355
+ schema_version?: number;
1356
+ observation?: unknown;
1357
+ }>(paths.journalFile);
1358
+ if (recoveredJournal?.schema_version === 1 && isObserveTerminalRequest(recoveredJournal.observation)) {
1359
+ const recovered = recoveredJournal.observation;
1360
+ if (
1361
+ recovered.session_id === request.session_id &&
1362
+ recovered.owner_generation === request.owner_generation &&
1363
+ recovered.state_dir === request.state_dir &&
1364
+ recovered.socket_key === request.socket_key
1365
+ )
1366
+ observation = normalizeTerminalObservation(recovered);
1367
+ }
1368
+ await atomicWrite(paths.journalFile, {
1369
+ schema_version: 1,
1370
+ observation,
1371
+ token,
1372
+ });
1373
+ const intent = await readJson<OwnerIntent>(paths.intentFile);
1374
+ const expected = isExpectedIntent(intent, observation);
1375
+ const verdict: OwnerVerdict = {
1376
+ schema_version: 1,
1377
+ generation: observation.owner_generation,
1378
+ session_id: observation.session_id,
1379
+ server_key: observation.socket_key,
1380
+ observed_at: observation.observed_at,
1381
+ signal: observation.signal,
1382
+ exit_code: observation.exit_code,
1383
+ result: expected ? "owner_term_then_session_cleanup" : observation.exit_kind,
1384
+ observer: observation.observer,
1385
+ classification: expected
1386
+ ? "expected_operator_shutdown"
1387
+ : observation.exit_kind === "cleanup"
1388
+ ? "non_operator_cleanup"
1389
+ : "unexpected_owner_loss",
1390
+ reason: observation.reason,
1391
+ ...(expected && intent ? { intent_id: intent.intent_id } : {}),
1392
+ dedupe_key: `owner-loss:${observation.session_id}:${observation.owner_generation}`,
1393
+ };
1394
+ if (!(await ownsVerdictLock(token))) throw new Error("verdict_lock_lost");
1395
+ let winner: OwnerVerdict;
1396
+ try {
1397
+ winner = await publishImmutableVerdict(paths.verdictFile, verdict, observation);
1398
+ } catch (error) {
1399
+ if (verdict.classification === "unexpected_owner_loss")
1400
+ await publishImmutableIncident(paths.incidentFile, {
1401
+ schema_version: 1,
1402
+ generation: verdict.generation,
1403
+ session_id: verdict.session_id,
1404
+ dedupe_key: verdict.dedupe_key,
1405
+ created_at: verdict.observed_at,
1406
+ classification: "unexpected_owner_loss",
1407
+ });
1408
+ throw error;
1409
+ }
1410
+ if (winner !== verdict) return reconcileTerminalArtifacts(paths, winner);
1411
+ if (expected && intent) {
1412
+ try {
1413
+ await fs.rename(paths.intentFile, `${paths.intentFile}.consumed`);
1414
+ } catch (error) {
1415
+ if (!isCode(error, "ENOENT")) throw new Error("owner_intent_consumption_failed");
1416
+ const consumed = await readJson<OwnerIntent>(`${paths.intentFile}.consumed`);
1417
+ if (!consumed || !isValidOwnerIntent(consumed, observation))
1418
+ throw new Error("owner_intent_consumption_failed");
1419
+ }
1420
+ }
1421
+
1422
+ if (verdict.classification === "unexpected_owner_loss")
1423
+ await publishImmutableIncident(paths.incidentFile, {
1424
+ schema_version: 1,
1425
+ generation: verdict.generation,
1426
+ session_id: verdict.session_id,
1427
+ dedupe_key: verdict.dedupe_key,
1428
+ created_at: verdict.observed_at,
1429
+ classification: "unexpected_owner_loss",
1430
+ });
1431
+ await publishCurrentVerdictAlias(paths, verdict);
1432
+ await fs.unlink(paths.journalFile).catch(() => undefined);
1433
+ return verdict;
1434
+ } finally {
1435
+ await releaseVerdictLock(token);
1436
+ }
1437
+ }
1438
+
1439
+ async function publishCurrentVerdictAlias(paths: LifecyclePaths, verdict: OwnerVerdict): Promise<void> {
1440
+ const current = await readJson<{
1441
+ schema_version?: number;
1442
+ session_id?: string;
1443
+ generation?: string;
1444
+ }>(paths.generationFile);
1445
+ if (
1446
+ current?.schema_version !== 1 ||
1447
+ current.session_id !== verdict.session_id ||
1448
+ current.generation !== verdict.generation
1449
+ )
1450
+ throw new Error("generation_mismatch");
1451
+ await atomicWrite(paths.verdictAliasFile, {
1452
+ ...verdict,
1453
+ owner_generation: verdict.generation,
1454
+ });
1455
+ }
1456
+ async function reconcileTerminalArtifacts(paths: LifecyclePaths, verdict: OwnerVerdict): Promise<OwnerVerdict> {
1457
+ if (verdict.classification === "expected_operator_shutdown" && verdict.intent_id) {
1458
+ const intent = await readJson<OwnerIntent>(paths.intentFile);
1459
+ if (intent?.intent_id === verdict.intent_id) {
1460
+ try {
1461
+ await fs.rename(paths.intentFile, `${paths.intentFile}.consumed`);
1462
+ } catch {
1463
+ throw new Error("owner_intent_consumption_failed");
1464
+ }
1465
+ }
1466
+ }
1467
+ if (verdict.classification === "unexpected_owner_loss")
1468
+ await publishImmutableIncident(paths.incidentFile, {
1469
+ schema_version: 1,
1470
+ generation: verdict.generation,
1471
+ session_id: verdict.session_id,
1472
+ dedupe_key: verdict.dedupe_key,
1473
+ created_at: verdict.observed_at,
1474
+ classification: "unexpected_owner_loss",
1475
+ });
1476
+ await publishCurrentVerdictAlias(paths, verdict);
1477
+ await fs.unlink(paths.journalFile).catch(() => undefined);
1478
+ return verdict;
1479
+ }
1480
+
1481
+ /** Strictly validate the persisted authorization, optionally against its terminal observation. */
1482
+ export function isValidOwnerIntent(intent: unknown, request?: ObserveTerminalRequest): intent is OwnerIntent {
1483
+ if (
1484
+ !isRecord(intent) ||
1485
+ !hasOnlyKeys(intent, [
1486
+ "schema_version",
1487
+ "intent_id",
1488
+ "generation",
1489
+ "session_id",
1490
+ "server_key",
1491
+ "expected_terminal",
1492
+ "dispatch_id",
1493
+ "created_at",
1494
+ "expires_at",
1495
+ "state",
1496
+ ]) ||
1497
+ intent.schema_version !== 1 ||
1498
+ !nonEmpty(intent.intent_id) ||
1499
+ !nonEmpty(intent.generation) ||
1500
+ !nonEmpty(intent.session_id) ||
1501
+ !nonEmpty(intent.server_key) ||
1502
+ !isRecord(intent.expected_terminal) ||
1503
+ !hasOnlyKeys(intent.expected_terminal, ["signal", "result"]) ||
1504
+ intent.expected_terminal.signal !== "SIGTERM" ||
1505
+ intent.expected_terminal.result !== "owner_term_then_session_cleanup" ||
1506
+ !nonEmpty(intent.dispatch_id) ||
1507
+ !nonEmpty(intent.created_at) ||
1508
+ !nonEmpty(intent.expires_at) ||
1509
+ intent.state !== "pending"
1510
+ )
1511
+ return false;
1512
+ const createdAt = Date.parse(intent.created_at);
1513
+ const expiresAt = Date.parse(intent.expires_at);
1514
+ if (!Number.isFinite(createdAt) || !Number.isFinite(expiresAt) || createdAt > expiresAt) return false;
1515
+ if (!request) return true;
1516
+ const observedAt = Date.parse(request.observed_at);
1517
+ return (
1518
+ Number.isFinite(observedAt) &&
1519
+ createdAt <= observedAt &&
1520
+ observedAt < expiresAt &&
1521
+ intent.generation === request.owner_generation &&
1522
+ intent.session_id === request.session_id &&
1523
+ intent.server_key === request.socket_key &&
1524
+ intent.dispatch_id === request.operator_dispatch_id &&
1525
+ request.signal === "SIGTERM"
1526
+ );
1527
+ }
1528
+
1529
+ function isExpectedIntent(intent: OwnerIntent | null, request: ObserveTerminalRequest): boolean {
1530
+ return isValidOwnerIntent(intent, request);
1531
+ }
1532
+ /** Accept only real UTC timestamps in the canonical evidence serialization. */
1533
+ export function isCanonicalUtcTimestamp(value: unknown): value is string {
1534
+ if (typeof value !== "string") return false;
1535
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{3}))?Z$/.exec(value);
1536
+ if (!match) return false;
1537
+ const [, yearText, monthText, dayText, hourText, minuteText, secondText] = match;
1538
+ const year = Number(yearText);
1539
+ const month = Number(monthText);
1540
+ const day = Number(dayText);
1541
+ const hour = Number(hourText);
1542
+ const minute = Number(minuteText);
1543
+ const second = Number(secondText);
1544
+ if (month < 1 || month > 12 || hour > 23 || minute > 59 || second > 59) return false;
1545
+ const daysInMonth =
1546
+ month === 2
1547
+ ? year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
1548
+ ? 29
1549
+ : 28
1550
+ : [4, 6, 9, 11].includes(month)
1551
+ ? 30
1552
+ : 31;
1553
+ return day >= 1 && day <= daysInMonth;
1554
+ }
1555
+
1556
+ /** Strictly validate a complete persisted verdict, optionally against its observation. */
1557
+ export function isValidOwnerVerdict(verdict: unknown, request?: ObserveTerminalRequest): verdict is OwnerVerdict {
1558
+ if (
1559
+ !isRecord(verdict) ||
1560
+ !hasOnlyKeys(verdict, [
1561
+ "schema_version",
1562
+ "generation",
1563
+ "session_id",
1564
+ "server_key",
1565
+ "observed_at",
1566
+ "signal",
1567
+ "exit_code",
1568
+ "result",
1569
+ "observer",
1570
+ "classification",
1571
+ "reason",
1572
+ "intent_id",
1573
+ "dedupe_key",
1574
+ ]) ||
1575
+ verdict.schema_version !== 1 ||
1576
+ !nonEmpty(verdict.generation) ||
1577
+ !nonEmpty(verdict.session_id) ||
1578
+ !nonEmpty(verdict.server_key) ||
1579
+ !isCanonicalUtcTimestamp(verdict.observed_at) ||
1580
+ !isTerminalSignal(verdict.signal) ||
1581
+ (verdict.exit_code !== null && !Number.isSafeInteger(verdict.exit_code)) ||
1582
+ !nonEmpty(verdict.result) ||
1583
+ verdict.result.length > 64 ||
1584
+ !isTerminalObserver(verdict.observer) ||
1585
+ (verdict.classification !== "expected_operator_shutdown" &&
1586
+ verdict.classification !== "unexpected_owner_loss" &&
1587
+ verdict.classification !== "non_operator_cleanup") ||
1588
+ !nonEmpty(verdict.reason) ||
1589
+ !ALLOWED_TERMINAL_REASONS.has(verdict.reason) ||
1590
+ (verdict.intent_id !== undefined && !nonEmpty(verdict.intent_id)) ||
1591
+ verdict.dedupe_key !== `owner-loss:${verdict.session_id}:${verdict.generation}`
1592
+ )
1593
+ return false;
1594
+ const expected = verdict.classification === "expected_operator_shutdown";
1595
+ if (
1596
+ (expected &&
1597
+ (verdict.signal !== "SIGTERM" ||
1598
+ verdict.result !== "owner_term_then_session_cleanup" ||
1599
+ !nonEmpty(verdict.intent_id))) ||
1600
+ (!expected && verdict.intent_id !== undefined) ||
1601
+ (verdict.classification === "non_operator_cleanup" && verdict.result !== "cleanup") ||
1602
+ (verdict.classification === "unexpected_owner_loss" &&
1603
+ !ALLOWED_TERMINAL_EXIT_KINDS.has(verdict.result) &&
1604
+ verdict.result !== "unknown_terminal")
1605
+ )
1606
+ return false;
1607
+ return (
1608
+ !request ||
1609
+ (verdict.generation === request.owner_generation &&
1610
+ verdict.session_id === request.session_id &&
1611
+ verdict.server_key === request.socket_key)
1612
+ );
1613
+ }
1614
+
1615
+ export interface ExactOwnerCloseRequest {
1616
+ stateDir: string;
1617
+ sessionId: string;
1618
+ generation: string;
1619
+ serverKey: string;
1620
+ pid: number;
1621
+ startTime: string;
1622
+ dispatchId: string;
1623
+ createdAt: string;
1624
+ expiresAt: string;
1625
+ }
1626
+ export interface ExactOwnerCloseDependencies {
1627
+ readStartTime(pid: number): Promise<string | null>;
1628
+ sendSigterm(pid: number): Promise<void>;
1629
+ waitForVerdict(): Promise<OwnerVerdict | null>;
1630
+ cleanupSession(): Promise<void>;
1631
+ }
1632
+
1633
+ async function isCurrentOwnerGeneration(stateDir: string, sessionId: string, generation: string): Promise<boolean> {
1634
+ return (
1635
+ (await readJson<{ generation?: string }>(lifecyclePaths(stateDir, sessionId, generation).generationFile))
1636
+ ?.generation === generation
1637
+ );
1638
+ }
1639
+
1640
+ /** Authorizes only an exact, start-time-validated SIGTERM before compatibility cleanup. */
1641
+ export async function closeExactTmuxOwner(
1642
+ request: ExactOwnerCloseRequest,
1643
+ deps: ExactOwnerCloseDependencies,
1644
+ ): Promise<OwnerVerdict> {
1645
+ const paths = lifecyclePaths(request.stateDir, request.sessionId, request.generation);
1646
+ const generationLockToken = await acquireOwnerGenerationLock(paths, request.sessionId);
1647
+ if (!generationLockToken) throw new Error("generation_lock_contended");
1648
+ let intent: OwnerIntent;
1649
+ try {
1650
+ if ((await deps.readStartTime(request.pid)) !== request.startTime) throw new Error("owner_pid_identity_mismatch");
1651
+ if (!(await isCurrentOwnerGeneration(request.stateDir, request.sessionId, request.generation)))
1652
+ throw new Error("owner_generation_mismatch");
1653
+ intent = await createOwnerIntent(request.stateDir, {
1654
+ generation: request.generation,
1655
+ session_id: request.sessionId,
1656
+ server_key: request.serverKey,
1657
+ expected_terminal: {
1658
+ signal: "SIGTERM",
1659
+ result: "owner_term_then_session_cleanup",
1660
+ },
1661
+ dispatch_id: request.dispatchId,
1662
+ created_at: request.createdAt,
1663
+ expires_at: request.expiresAt,
1664
+ });
1665
+ if (!(await isCurrentOwnerGeneration(request.stateDir, request.sessionId, request.generation))) {
1666
+ await fs.rename(paths.intentFile, `${paths.intentFile}.cancelled`).catch(() => undefined);
1667
+ throw new Error("owner_generation_mismatch");
1668
+ }
1669
+ if (!Number.isFinite(Date.parse(intent.expires_at)) || Date.parse(intent.expires_at) <= Date.now()) {
1670
+ await fs.rename(paths.intentFile, `${paths.intentFile}.expired`).catch(() => undefined);
1671
+ throw new Error("owner_term_verdict_timeout");
1672
+ }
1673
+ try {
1674
+ // The lock spans the final start-time proof and dispatch so replacement cannot revoke authorization between them.
1675
+ if ((await deps.readStartTime(request.pid)) !== request.startTime)
1676
+ throw new Error("owner_pid_identity_mismatch");
1677
+ if (!(await isCurrentOwnerGeneration(request.stateDir, request.sessionId, request.generation)))
1678
+ throw new Error("owner_generation_mismatch");
1679
+ await deps.sendSigterm(request.pid);
1680
+ } catch (error: unknown) {
1681
+ await fs.rename(paths.intentFile, `${paths.intentFile}.cancelled`).catch(() => undefined);
1682
+ throw error;
1683
+ }
1684
+ } finally {
1685
+ await releaseVerdictLock(generationLockToken);
1686
+ }
1687
+ const verdict = await deps.waitForVerdict();
1688
+ if (!verdict || verdict.intent_id !== intent.intent_id || verdict.classification !== "expected_operator_shutdown") {
1689
+ await fs.rename(paths.intentFile, `${paths.intentFile}.expired`).catch(() => undefined);
1690
+ throw new Error("owner_term_verdict_timeout");
1691
+ }
1692
+ await deps.cleanupSession();
1693
+ return verdict;
1694
+ }
1695
+
1696
+ export function parseOwnerIsolationRequest(
1697
+ line: string,
1698
+ ): PlanRequest | BootstrapRequest | PublishGenerationRequest | ObserveTerminalRequest | null {
1699
+ if (Buffer.byteLength(line) > TMUX_OWNER_ISOLATION_MAX_LINE_BYTES || line.includes("\n")) return null;
1700
+ try {
1701
+ const parsed: unknown = JSON.parse(line);
1702
+ if (!isRecord(parsed)) return null;
1703
+ if (
1704
+ isPlanRequest(parsed) ||
1705
+ isBootstrapRequest(parsed) ||
1706
+ isPublishGenerationRequest(parsed) ||
1707
+ isObserveTerminalRequest(parsed)
1708
+ )
1709
+ return parsed;
1710
+ return null;
1711
+ } catch {
1712
+ return null;
1713
+ }
1714
+ }
1715
+ export function serializeOwnerIsolationResponse(
1716
+ response: PlanResponse | BootstrapResult | PublishGenerationResult | OwnerVerdict,
1717
+ ): string {
1718
+ const serialized = JSON.stringify(response);
1719
+ if (Buffer.byteLength(serialized) < TMUX_OWNER_ISOLATION_MAX_LINE_BYTES) return serialized;
1720
+ return JSON.stringify({
1721
+ schema_version: 1,
1722
+ ok: false,
1723
+ code: "scope_unavailable",
1724
+ diagnostic: "response_too_large",
1725
+ } satisfies PlanFailure);
1726
+ }
1727
+ function isPlanRequest(request: unknown): request is PlanRequest {
1728
+ return (
1729
+ isRecord(request) &&
1730
+ hasOnlyKeys(request, [
1731
+ "schema_version",
1732
+ "op",
1733
+ "platform",
1734
+ "session_id",
1735
+ "owner_generation",
1736
+ "cwd",
1737
+ "state_dir",
1738
+ "socket_key",
1739
+ "tmux_argv",
1740
+ "baseline",
1741
+ ]) &&
1742
+ request.schema_version === 1 &&
1743
+ request.op === "plan" &&
1744
+ isPlatform(request.platform) &&
1745
+ typeof request.cwd === "string" &&
1746
+ nonEmpty(request.session_id) &&
1747
+ nonEmpty(request.owner_generation) &&
1748
+ nonEmpty(request.state_dir) &&
1749
+ nonEmpty(request.socket_key) &&
1750
+ isOwnerGenerationBaseline(request.baseline) &&
1751
+ validArgv(request.tmux_argv)
1752
+ );
1753
+ }
1754
+ function isBootstrapRequest(request: unknown): request is BootstrapRequest {
1755
+ return (
1756
+ isRecord(request) &&
1757
+ hasOnlyKeys(request, [
1758
+ "schema_version",
1759
+ "op",
1760
+ "session_id",
1761
+ "owner_generation",
1762
+ "state_dir",
1763
+ "socket_key",
1764
+ "expected_scope",
1765
+ "tmux_argv",
1766
+ "attempt",
1767
+ ]) &&
1768
+ request.schema_version === 1 &&
1769
+ request.op === "bootstrap" &&
1770
+ nonEmpty(request.session_id) &&
1771
+ nonEmpty(request.owner_generation) &&
1772
+ nonEmpty(request.state_dir) &&
1773
+ nonEmpty(request.socket_key) &&
1774
+ nonEmpty(request.expected_scope) &&
1775
+ validArgv(request.tmux_argv) &&
1776
+ isAttemptCapability(request.attempt) &&
1777
+ request.attempt.socket_key === request.socket_key &&
1778
+ request.attempt.server_absent_before
1779
+ );
1780
+ }
1781
+ function isPublishGenerationRequest(request: unknown): request is PublishGenerationRequest {
1782
+ return (
1783
+ isRecord(request) &&
1784
+ hasOnlyKeys(request, ["schema_version", "op", "session_id", "owner_generation", "state_dir", "baseline"]) &&
1785
+ request.schema_version === 1 &&
1786
+ request.op === "publish_generation" &&
1787
+ nonEmpty(request.session_id) &&
1788
+ nonEmpty(request.owner_generation) &&
1789
+ nonEmpty(request.state_dir) &&
1790
+ isOwnerGenerationBaseline(request.baseline)
1791
+ );
1792
+ }
1793
+
1794
+ function isObserveTerminalRequest(request: unknown): request is ObserveTerminalRequest {
1795
+ return (
1796
+ isRecord(request) &&
1797
+ hasOnlyKeys(request, [
1798
+ "schema_version",
1799
+ "op",
1800
+ "session_id",
1801
+ "owner_generation",
1802
+ "state_dir",
1803
+ "socket_key",
1804
+ "observer",
1805
+ "observed_at",
1806
+ "signal",
1807
+ "exit_code",
1808
+ "exit_kind",
1809
+ "reason",
1810
+ "operator_dispatch_id",
1811
+ ]) &&
1812
+ request.schema_version === 1 &&
1813
+ request.op === "observe_terminal" &&
1814
+ nonEmpty(request.session_id) &&
1815
+ nonEmpty(request.owner_generation) &&
1816
+ nonEmpty(request.state_dir) &&
1817
+ nonEmpty(request.socket_key) &&
1818
+ isTerminalObserver(request.observer) &&
1819
+ isCanonicalUtcTimestamp(request.observed_at) &&
1820
+ isTerminalSignal(request.signal) &&
1821
+ nonEmpty(request.exit_kind) &&
1822
+ request.exit_kind.length <= 64 &&
1823
+ nonEmpty(request.reason) &&
1824
+ request.reason.length <= 64 &&
1825
+ (request.exit_code === null || Number.isSafeInteger(request.exit_code)) &&
1826
+ (request.operator_dispatch_id === undefined || nonEmpty(request.operator_dispatch_id))
1827
+ );
1828
+ }
1829
+ function isAttemptCapability(value: unknown): value is AttemptCapability {
1830
+ return (
1831
+ isRecord(value) &&
1832
+ hasOnlyKeys(value, ["token", "session_name", "socket_key", "server_absent_before", "baseline", "expires_at"]) &&
1833
+ nonEmpty(value.token) &&
1834
+ nonEmpty(value.session_name) &&
1835
+ nonEmpty(value.socket_key) &&
1836
+ typeof value.server_absent_before === "boolean" &&
1837
+ isOwnerGenerationBaseline(value.baseline) &&
1838
+ nonEmpty(value.expires_at) &&
1839
+ Number.isFinite(Date.parse(value.expires_at))
1840
+ );
1841
+ }
1842
+
1843
+ function isPlatform(value: unknown): value is NodeJS.Platform {
1844
+ return (
1845
+ value === "aix" ||
1846
+ value === "android" ||
1847
+ value === "darwin" ||
1848
+ value === "freebsd" ||
1849
+ value === "haiku" ||
1850
+ value === "linux" ||
1851
+ value === "netbsd" ||
1852
+ value === "openbsd" ||
1853
+ value === "sunos" ||
1854
+ value === "win32" ||
1855
+ value === "cygwin"
1856
+ );
1857
+ }
1858
+ function isTerminalSignal(value: unknown): value is TerminalSignal {
1859
+ return (
1860
+ value === "SIGTERM" ||
1861
+ value === "SIGHUP" ||
1862
+ value === "SIGINT" ||
1863
+ value === "SIGKILL" ||
1864
+ value === "EXIT" ||
1865
+ value === "MANUAL" ||
1866
+ value === "UNKNOWN"
1867
+ );
1868
+ }
1869
+ function validPersistedAttempt(value: PersistedAttempt | null, request: BootstrapRequest): value is PersistedAttempt {
1870
+ return Boolean(
1871
+ value &&
1872
+ isOwnerGenerationBaseline(value.baseline) &&
1873
+ value.schema_version === 1 &&
1874
+ value.generation === request.owner_generation &&
1875
+ value.session_id === request.session_id &&
1876
+ value.token === request.attempt.token &&
1877
+ value.session_name === request.attempt.session_name &&
1878
+ value.socket_key === request.socket_key &&
1879
+ value.server_absent_before === true &&
1880
+ sameOwnerGenerationBaseline(value.baseline, request.attempt.baseline) &&
1881
+ value.expires_at === request.attempt.expires_at &&
1882
+ nonEmpty(value.created_at),
1883
+ );
1884
+ }
1885
+
1886
+ function isTerminalObserver(value: unknown): value is TerminalObserver {
1887
+ return value === "sidecar" || value === "raw_monitor";
1888
+ }
1889
+ function hasOnlyKeys(record: Record<string, unknown>, allowed: string[]): boolean {
1890
+ return Object.keys(record).every(key => allowed.includes(key));
1891
+ }
1892
+ function nonEmpty(value: unknown): value is string {
1893
+ return typeof value === "string" && value.length > 0;
1894
+ }
1895
+ function isRecord(value: unknown): value is Record<string, unknown> {
1896
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1897
+ }
1898
+ function isCode(error: unknown, code: string): boolean {
1899
+ return isRecord(error) && error.code === code;
1900
+ }
1901
+ async function readJson<T>(file: string): Promise<T | null> {
1902
+ try {
1903
+ const parsed: unknown = JSON.parse(await fs.readFile(file, "utf8"));
1904
+ return isRecord(parsed) ? (parsed as T) : null;
1905
+ } catch {
1906
+ return null;
1907
+ }
1908
+ }
1909
+ async function publishImmutableVerdict(
1910
+ file: string,
1911
+ verdict: OwnerVerdict,
1912
+ request: ObserveTerminalRequest,
1913
+ ): Promise<OwnerVerdict> {
1914
+ const handle = await fs.open(file, "wx", 0o600).catch(async error => {
1915
+ if (!isCode(error, "EEXIST")) throw error;
1916
+ const existing = await readJson<OwnerVerdict>(file);
1917
+ if (!existing) throw new Error("immutable_record_corrupt");
1918
+ if (!isValidOwnerVerdict(existing, request)) throw new Error("immutable_record_conflict");
1919
+ return null;
1920
+ });
1921
+ if (!handle) {
1922
+ const existing = await readJson<OwnerVerdict>(file);
1923
+ if (!existing) throw new Error("immutable_record_corrupt");
1924
+ if (!isValidOwnerVerdict(existing, request)) throw new Error("immutable_record_conflict");
1925
+ return existing;
1926
+ }
1927
+ try {
1928
+ await handle.writeFile(`${JSON.stringify(verdict)}\n`);
1929
+ await handle.sync();
1930
+ } finally {
1931
+ await handle.close();
1932
+ }
1933
+ await fsyncDirectory(path.dirname(file));
1934
+ return verdict;
1935
+ }
1936
+ async function publishImmutableIncident(file: string, incident: OwnerIncident): Promise<OwnerIncident> {
1937
+ const handle = await fs.open(file, "wx", 0o600).catch(async error => {
1938
+ if (!isCode(error, "EEXIST")) throw error;
1939
+ const existing = await readJson<OwnerIncident>(file);
1940
+ if (!existing) throw new Error("immutable_record_corrupt");
1941
+ if (JSON.stringify(existing) !== JSON.stringify(incident)) throw new Error("immutable_record_conflict");
1942
+ return null;
1943
+ });
1944
+ if (!handle) return incident;
1945
+ try {
1946
+ await handle.writeFile(`${JSON.stringify(incident)}\n`);
1947
+ await handle.sync();
1948
+ } finally {
1949
+ await handle.close();
1950
+ }
1951
+ await fsyncDirectory(path.dirname(file));
1952
+ return incident;
1953
+ }
1954
+
1955
+ async function publishImmutableGenerationMarker(
1956
+ file: string,
1957
+ generation: { schema_version: 1; generation: string; session_id: string; published_at: string },
1958
+ ): Promise<void> {
1959
+ const handle = await fs.open(file, "wx", 0o600).catch(error => {
1960
+ if (isCode(error, "EEXIST")) throw new Error("generation_replay");
1961
+ throw error;
1962
+ });
1963
+ try {
1964
+ await handle.writeFile(`${JSON.stringify(generation)}\n`);
1965
+ await handle.sync();
1966
+ } finally {
1967
+ await handle.close();
1968
+ }
1969
+ await fsyncDirectory(path.dirname(file));
1970
+ }
1971
+
1972
+ function publishImmutableGenerationMarkerSync(
1973
+ file: string,
1974
+ generation: { schema_version: 1; generation: string; session_id: string; published_at: string },
1975
+ ): void {
1976
+ try {
1977
+ fsSync.writeFileSync(file, `${JSON.stringify(generation)}\n`, { mode: 0o600, flag: "wx" });
1978
+ } catch (error) {
1979
+ if (isCode(error, "EEXIST")) throw new Error("generation_replay");
1980
+ throw error;
1981
+ }
1982
+ }
1983
+
1984
+ async function ensureGenerationMarker(
1985
+ file: string,
1986
+ generation: { schema_version: 1; generation: string; session_id: string; published_at: string },
1987
+ ): Promise<void> {
1988
+ try {
1989
+ await publishImmutableGenerationMarker(file, generation);
1990
+ } catch (error) {
1991
+ if ((error as Error).message !== "generation_replay") throw error;
1992
+ const existing = await readJson<typeof generation>(file);
1993
+ if (JSON.stringify(existing) !== JSON.stringify(generation)) throw error;
1994
+ }
1995
+ }
1996
+
1997
+ function ensureGenerationMarkerSync(
1998
+ file: string,
1999
+ generation: { schema_version: 1; generation: string; session_id: string; published_at: string },
2000
+ ): void {
2001
+ try {
2002
+ publishImmutableGenerationMarkerSync(file, generation);
2003
+ } catch (error) {
2004
+ if ((error as Error).message !== "generation_replay") throw error;
2005
+ let existing: unknown;
2006
+ try {
2007
+ existing = JSON.parse(fsSync.readFileSync(file, "utf8"));
2008
+ } catch {
2009
+ throw error;
2010
+ }
2011
+ if (JSON.stringify(existing) !== JSON.stringify(generation)) throw error;
2012
+ }
2013
+ }
2014
+
2015
+ async function atomicWrite(file: string, data: object): Promise<void> {
2016
+ const temp = `${file}.${crypto.randomUUID()}.tmp`;
2017
+ const handle = await fs.open(temp, "wx", 0o600);
2018
+ try {
2019
+ await handle.writeFile(`${JSON.stringify(data)}\n`);
2020
+ await handle.sync();
2021
+ } finally {
2022
+ await handle.close();
2023
+ }
2024
+ await fs.rename(temp, file);
2025
+ await fsyncDirectory(path.dirname(file));
2026
+ }
2027
+ async function fsyncDirectory(directoryPath: string): Promise<void> {
2028
+ const directory = await fs.open(directoryPath, "r");
2029
+ try {
2030
+ await directory.sync();
2031
+ } finally {
2032
+ await directory.close();
2033
+ }
2034
+ }
2035
+ async function writeAttempt(
2036
+ stateDir: string,
2037
+ sessionId: string,
2038
+ generation: string,
2039
+ attempt: AttemptCapability,
2040
+ ): Promise<void> {
2041
+ const root = lifecyclePaths(stateDir, sessionId, generation).root;
2042
+ await fs.mkdir(root, { recursive: true, mode: 0o700 });
2043
+ const file = path.join(root, `attempt-${attempt.token}.json`);
2044
+ const handle = await fs.open(file, "wx", 0o600);
2045
+ try {
2046
+ await handle.writeFile(
2047
+ `${JSON.stringify({
2048
+ schema_version: 1,
2049
+ generation,
2050
+ session_id: sessionId,
2051
+ ...attempt,
2052
+ created_at: new Date().toISOString(),
2053
+ } satisfies PersistedAttempt)}\n`,
2054
+ );
2055
+ await handle.sync();
2056
+ } finally {
2057
+ await handle.close();
2058
+ }
2059
+ await fsyncDirectory(root);
2060
+ }