@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
@@ -8,19 +8,39 @@
8
8
  */
9
9
  import * as crypto from "node:crypto";
10
10
  import * as fs from "node:fs";
11
+ import * as fsPromises from "node:fs/promises";
11
12
  import * as path from "node:path";
12
-
13
+ import { tmuxRuntimeSessionPath } from "../skc-runtime/session-layout";
14
+ import {
15
+ SKC_COORDINATOR_SESSION_ID_ENV,
16
+ SKC_COORDINATOR_SESSION_STATE_FILE_ENV,
17
+ SKC_TMUX_OWNER_GENERATION_ENV,
18
+ SKC_TMUX_OWNER_SERVER_KEY_ENV,
19
+ SKC_TMUX_OWNER_STATE_DIR_ENV,
20
+ } from "../skc-runtime/session-state-sidecar";
13
21
  import {
14
- buildSkcTmuxExactOptionTarget,
15
22
  buildSkcTmuxProfileCommands,
23
+ buildSkcTmuxSessionSlug,
24
+ resolveSkcTmuxBinary,
16
25
  resolveSkcTmuxCommand,
17
26
  } from "../skc-runtime/tmux-common";
27
+ import {
28
+ captureOwnerGenerationBaseline,
29
+ classifyCgroup,
30
+ isExactScopedBootstrapSuccessReceipt,
31
+ type OwnerGenerationBaseline,
32
+ type OwnerIsolationProbe,
33
+ planTmuxOwnerIsolation,
34
+ replaceOwnerGeneration,
35
+ type TmuxServerProof,
36
+ } from "../skc-runtime/tmux-owner-isolation";
18
37
  import {
19
38
  findSkcTmuxSessionByName,
20
39
  forceCloseSkcTmuxSession,
21
40
  listSkcTmuxSessions,
22
- statusSkcTmuxSession,
41
+ type SkcTmuxSessionStatus,
23
42
  } from "../skc-runtime/tmux-sessions";
43
+
24
44
  import type { ResumeCandidate, SessionCreateFrame, SessionLifecycleRequest, SessionLifecycleResponse } from "./index";
25
45
  import { normalizeLifecyclePath } from "./lifecycle-commands";
26
46
  import {
@@ -28,6 +48,7 @@ import {
28
48
  type CreateEffectResult,
29
49
  handleLifecycleRequest,
30
50
  type LedgerDoc,
51
+ type LedgerEntry,
31
52
  type LedgerStore,
32
53
  type LifecycleOutcome,
33
54
  type OrchestratorDeps,
@@ -61,36 +82,132 @@ export type LifecycleControlServerFactory = (input: {
61
82
  }) => LifecycleControlServer;
62
83
 
63
84
  /** Atomic + fsynced file-backed idempotency ledger store. */
85
+ function ledgerReadError(error: unknown): Error {
86
+ const errorCode = (error as NodeJS.ErrnoException | undefined)?.code;
87
+ const code = typeof errorCode === "string" ? errorCode : "invalid";
88
+ return new Error(`skc_lifecycle_ledger_read_failed:${code.slice(0, 32)}`);
89
+ }
90
+
91
+ function isLedgerDoc(value: unknown): value is LedgerDoc {
92
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
93
+ const doc = value as { version?: unknown; entries?: unknown };
94
+ if (doc.version !== 1 || !doc.entries || typeof doc.entries !== "object" || Array.isArray(doc.entries)) return false;
95
+ return Object.values(doc.entries).every(entry => {
96
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
97
+ const candidate = entry as Partial<LedgerEntry>;
98
+ return (
99
+ typeof candidate.requestHash === "string" &&
100
+ (candidate.state === "in_progress" ||
101
+ candidate.state === "success" ||
102
+ candidate.state === "failure" ||
103
+ candidate.state === "terminal_uncertain") &&
104
+ typeof candidate.requestId === "string" &&
105
+ (candidate.verb === "session_create" ||
106
+ candidate.verb === "session_close" ||
107
+ candidate.verb === "session_resume") &&
108
+ typeof candidate.createdAt === "number" &&
109
+ typeof candidate.updatedAt === "number" &&
110
+ !!candidate.targetSummary &&
111
+ typeof candidate.targetSummary === "object" &&
112
+ !Array.isArray(candidate.targetSummary)
113
+ );
114
+ });
115
+ }
116
+
117
+ function isUnsupportedDirectorySyncError(error: unknown): boolean {
118
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
119
+ return code === "EINVAL" || code === "ENOTSUP" || code === "EOPNOTSUPP";
120
+ }
121
+
122
+ function recordDirectorySyncCompatibilityDiagnostic(): void {
123
+ try {
124
+ process.stderr.write("skc lifecycle ledger directory sync unsupported\n");
125
+ } catch {
126
+ // Compatibility diagnostics must not alter ledger durability semantics.
127
+ }
128
+ }
129
+
130
+ function fsyncLedgerParentDirectory(directory: string): void {
131
+ const dirFd = fs.openSync(directory, "r");
132
+ let syncFailure: unknown;
133
+ let closeFailure: unknown;
134
+ try {
135
+ fs.fsyncSync(dirFd);
136
+ } catch (error) {
137
+ if (isUnsupportedDirectorySyncError(error)) recordDirectorySyncCompatibilityDiagnostic();
138
+ else syncFailure = error;
139
+ } finally {
140
+ try {
141
+ fs.closeSync(dirFd);
142
+ } catch (error) {
143
+ closeFailure = error;
144
+ }
145
+ }
146
+ if (syncFailure !== undefined && closeFailure !== undefined)
147
+ throw new AggregateError([syncFailure, closeFailure], "ledger directory sync failed");
148
+ if (syncFailure !== undefined) throw syncFailure;
149
+ if (closeFailure !== undefined) throw closeFailure;
150
+ }
151
+
64
152
  export function fileLedgerStore(idempotencyFile: string): LedgerStore {
65
153
  return {
66
154
  async read(): Promise<LedgerDoc> {
155
+ let contents: string;
67
156
  try {
68
- return JSON.parse(fs.readFileSync(idempotencyFile, "utf8")) as LedgerDoc;
69
- } catch {
70
- return { version: 1, entries: {} };
157
+ contents = fs.readFileSync(idempotencyFile, "utf8");
158
+ } catch (error) {
159
+ const code = (error as NodeJS.ErrnoException).code;
160
+ if (code === "ENOENT") return { version: 1, entries: {} };
161
+ throw ledgerReadError(error);
162
+ }
163
+ try {
164
+ const doc = JSON.parse(contents) as unknown;
165
+ if (!isLedgerDoc(doc)) throw new Error("invalid");
166
+ return doc;
167
+ } catch (error) {
168
+ throw ledgerReadError(error);
71
169
  }
72
170
  },
73
171
  async write(doc: LedgerDoc): Promise<void> {
74
172
  fs.mkdirSync(path.dirname(idempotencyFile), { recursive: true });
75
173
  const tmp = `${idempotencyFile}.${process.pid}.${Date.now()}.tmp`;
76
- const fd = fs.openSync(tmp, "w", 0o600);
77
- fs.writeSync(fd, JSON.stringify(doc));
78
- fs.fsyncSync(fd);
79
- fs.closeSync(fd);
80
- fs.renameSync(tmp, idempotencyFile);
81
- // fsync the parent directory so the rename itself is durable across a
82
- // crash / power loss (the temp-file fsync alone does not persist the
83
- // directory entry).
174
+ let fd: number | undefined;
175
+ let writeFailure: unknown;
176
+ let closeFailure: unknown;
84
177
  try {
85
- const dirFd = fs.openSync(path.dirname(idempotencyFile), "r");
178
+ fd = fs.openSync(tmp, "w", 0o600);
179
+ const encodedDoc = Buffer.from(JSON.stringify(doc), "utf8");
180
+ let offset = 0;
181
+ while (offset < encodedDoc.length) {
182
+ const written = fs.writeSync(fd, encodedDoc, offset, encodedDoc.length - offset);
183
+ if (written === 0) throw new Error("Short write");
184
+ offset += written;
185
+ }
186
+ fs.fsyncSync(fd);
187
+ } catch (error) {
188
+ writeFailure = error;
189
+ } finally {
190
+ if (fd !== undefined) {
191
+ try {
192
+ fs.closeSync(fd);
193
+ } catch (error) {
194
+ closeFailure = error;
195
+ }
196
+ }
197
+ }
198
+ if (writeFailure !== undefined || closeFailure !== undefined) {
86
199
  try {
87
- fs.fsyncSync(dirFd);
88
- } finally {
89
- fs.closeSync(dirFd);
200
+ fs.unlinkSync(tmp);
201
+ } catch {
202
+ // The failed temporary file is never published.
90
203
  }
91
- } catch {
92
- // Some platforms reject directory fsync; the rename is still atomic.
204
+ if (writeFailure !== undefined && closeFailure !== undefined)
205
+ throw new AggregateError([writeFailure, closeFailure], "ledger temporary write failed");
206
+ throw writeFailure ?? closeFailure;
93
207
  }
208
+ fs.renameSync(tmp, idempotencyFile);
209
+ // The temp-file fsync does not persist the rename's directory entry.
210
+ fsyncLedgerParentDirectory(path.dirname(idempotencyFile));
94
211
  },
95
212
  };
96
213
  }
@@ -150,77 +267,598 @@ export function buildCreateArgv(
150
267
  return { cwd, args: extraArgs };
151
268
  }
152
269
 
153
- /** Real daemon-safe tmux launcher: detached `tmux new-session -d` + SKC tags. */
154
- export function daemonSpawnCreate(env: NodeJS.ProcessEnv = process.env) {
270
+ function isKnownNoServerDiagnostic(stderr: string): boolean {
271
+ const diagnostic = stderr.trim().toLowerCase();
272
+ return (
273
+ diagnostic.length > 0 &&
274
+ diagnostic.length <= 512 &&
275
+ /no server running|failed to connect to server|error connecting to/.test(diagnostic)
276
+ );
277
+ }
278
+
279
+ function lifecycleOwnerIsolationProbe(tmux: string, env: NodeJS.ProcessEnv): OwnerIsolationProbe {
280
+ return {
281
+ readCallerCgroup: async () =>
282
+ process.platform === "linux" ? await fsPromises.readFile("/proc/self/cgroup", "utf8").catch(() => null) : null,
283
+ probeServer: async (serverKey: string): Promise<TmuxServerProof> => {
284
+ const result = Bun.spawnSync([tmux, "-L", serverKey, "list-sessions", "-F", "#{pid}\t#{session_name}"], {
285
+ stdout: "pipe",
286
+ stderr: "pipe",
287
+ env,
288
+ });
289
+ const stderr = result.stderr.toString();
290
+ if (result.exitCode !== 0)
291
+ return isKnownNoServerDiagnostic(stderr) ? { state: "absent" } : { state: "unverifiable" };
292
+ const lines = result.stdout
293
+ .toString()
294
+ .split("\n")
295
+ .map(line => line.trim())
296
+ .filter(Boolean);
297
+ const [pidText] = lines[0]?.split("\t") ?? [];
298
+ const pid = Number(pidText);
299
+ const sessionNames = lines.map(line => line.split("\t")[1]).filter((name): name is string => Boolean(name));
300
+ if (!Number.isSafeInteger(pid) || pid <= 0) return { state: "unverifiable" };
301
+ if (process.platform !== "linux")
302
+ return {
303
+ state: "safe",
304
+ pid,
305
+ startTime: "not_applicable",
306
+ cgroup: { classification: "not_applicable" },
307
+ sessionNames,
308
+ };
309
+ const [cgroupText, stat] = await Promise.all([
310
+ fsPromises.readFile(`/proc/${pid}/cgroup`, "utf8").catch(() => null),
311
+ fsPromises.readFile(`/proc/${pid}/stat`, "utf8").catch(() => null),
312
+ ]);
313
+ const cgroup = classifyCgroup({ platform: process.platform, cgroupText });
314
+ const startTime = stat
315
+ ?.slice(stat.lastIndexOf(")") + 2)
316
+ .trim()
317
+ .split(/\s+/)[19];
318
+ if (!startTime) return { state: "unverifiable", pid, cgroup, sessionNames };
319
+ return {
320
+ state:
321
+ cgroup.classification === "safe"
322
+ ? "safe"
323
+ : cgroup.classification === "unsafe_service"
324
+ ? "unsafe"
325
+ : "unverifiable",
326
+ pid,
327
+ startTime,
328
+ cgroup,
329
+ sessionNames,
330
+ };
331
+ },
332
+ };
333
+ }
334
+
335
+ function lifecycleRuntimeStateFile(cwd: string, sessionId: string, tmuxSession: string): string {
336
+ return tmuxRuntimeSessionPath(cwd, sessionId, buildSkcTmuxSessionSlug(tmuxSession));
337
+ }
338
+
339
+ async function preflightLifecycleTmuxOwner(input: {
340
+ tmux: string;
341
+ env: NodeJS.ProcessEnv;
342
+ ownerIsolationProbe?: OwnerIsolationProbe;
343
+ }): Promise<void> {
344
+ const probe = input.ownerIsolationProbe ?? lifecycleOwnerIsolationProbe(input.tmux, input.env);
345
+ let server: TmuxServerProof;
346
+ try {
347
+ server = await probe.probeServer("default");
348
+ } catch {
349
+ throw new Error("skc_lifecycle_owner_server_unverifiable");
350
+ }
351
+ if (server.state === "unsafe") throw new Error("skc_lifecycle_owner_server_unsafe");
352
+ if (server.state === "unverifiable") throw new Error("skc_lifecycle_owner_server_unverifiable");
353
+ if (
354
+ server.state === "safe" &&
355
+ (!server.pid || !server.startTime || (process.platform === "linux" && server.cgroup?.classification !== "safe"))
356
+ )
357
+ throw new Error("skc_lifecycle_owner_server_unverifiable");
358
+ if (server.state === "absent") {
359
+ try {
360
+ if (
361
+ classifyCgroup({ platform: process.platform, cgroupText: await probe.readCallerCgroup() })
362
+ .classification === "unverifiable"
363
+ )
364
+ throw new Error("skc_lifecycle_owner_server_unverifiable");
365
+ } catch (error) {
366
+ if (error instanceof Error && error.message === "skc_lifecycle_owner_server_unverifiable") throw error;
367
+ throw new Error("skc_lifecycle_owner_server_unverifiable");
368
+ }
369
+ }
370
+ }
371
+
372
+ interface LifecycleAttemptExecution {
373
+ attemptCreated: true;
374
+ serverKey: string;
375
+ serverPid?: number;
376
+ serverStartTime?: string;
377
+ nativeSessionId?: string;
378
+ attemptSession: string;
379
+ }
380
+
381
+ function nativeTmuxSessionIdFromSpawn(stdout: string): string | undefined {
382
+ const value = stdout.endsWith("\n") ? stdout.slice(0, -1) : stdout;
383
+ return !value.includes("\n") && !value.includes("\r") && /^\$\d+$/.test(value) ? value : undefined;
384
+ }
385
+
386
+ async function isLifecycleGenerationUnchanged(
387
+ stateDir: string,
388
+ sessionId: string,
389
+ previousBaseline: OwnerGenerationBaseline,
390
+ ): Promise<boolean> {
391
+ try {
392
+ return (
393
+ JSON.stringify(await captureOwnerGenerationBaseline(stateDir, sessionId)) === JSON.stringify(previousBaseline)
394
+ );
395
+ } catch {
396
+ return false;
397
+ }
398
+ }
399
+
400
+ async function executeLifecycleTmuxOwnerPlan(input: {
401
+ tmux: string;
402
+ env: NodeJS.ProcessEnv;
403
+ sessionId: string;
404
+ generation: string;
405
+ stateDir: string;
406
+ cwd: string;
407
+ argv: string[];
408
+ sessionName: string;
409
+ ownerIsolationProbe?: OwnerIsolationProbe;
410
+ prepareSpawn?: () => void;
411
+ onAttemptCreated?: (attempt: LifecycleAttemptExecution) => void;
412
+ previousBaseline: OwnerGenerationBaseline;
413
+ }): Promise<LifecycleAttemptExecution> {
414
+ const probe = input.ownerIsolationProbe ?? lifecycleOwnerIsolationProbe(input.tmux, input.env);
415
+ await preflightLifecycleTmuxOwner(input);
416
+ if (!(await isLifecycleGenerationUnchanged(input.stateDir, input.sessionId, input.previousBaseline)))
417
+ throw new Error("skc_lifecycle_owner_generation_changed");
418
+ const plan = await planTmuxOwnerIsolation(
419
+ {
420
+ schema_version: 1,
421
+ op: "plan",
422
+ platform: process.platform,
423
+ session_id: input.sessionId,
424
+ owner_generation: input.generation,
425
+ baseline: input.previousBaseline,
426
+ cwd: input.cwd,
427
+ state_dir: input.stateDir,
428
+ socket_key: "default",
429
+ tmux_argv: input.argv,
430
+ },
431
+ probe,
432
+ );
433
+ if (!plan.ok) throw new Error(`skc_lifecycle_owner_${plan.code}`);
434
+ const preSpawnProof =
435
+ plan.execution.mode === "direct" && plan.server_state === "safe"
436
+ ? await probe.probeServer(plan.execution.server_key)
437
+ : undefined;
438
+ if (
439
+ preSpawnProof &&
440
+ (preSpawnProof.state !== "safe" ||
441
+ !preSpawnProof.pid ||
442
+ !preSpawnProof.startTime ||
443
+ (process.platform === "linux" && preSpawnProof.cgroup?.classification !== "safe"))
444
+ )
445
+ throw new Error("skc_lifecycle_owner_server_unverifiable");
446
+ input.prepareSpawn?.();
447
+ if (!(await isLifecycleGenerationUnchanged(input.stateDir, input.sessionId, input.previousBaseline)))
448
+ throw new Error("skc_lifecycle_owner_generation_changed");
449
+ const created = Bun.spawnSync(plan.execution.argv, {
450
+ stdout: "pipe",
451
+ stderr: "pipe",
452
+ env: input.env,
453
+ ...(plan.execution.mode === "scoped" ? { stdin: new TextEncoder().encode(plan.execution.stdin_line) } : {}),
454
+ });
455
+ const rawStdout = created.stdout.toString();
456
+ const rawScopedReceipt =
457
+ plan.execution.mode === "scoped" && isExactScopedBootstrapSuccessReceipt(rawStdout)
458
+ ? (JSON.parse(rawStdout.endsWith("\n") ? rawStdout.slice(0, -1) : rawStdout) as {
459
+ native_session_id: string;
460
+ server_pid: number;
461
+ server_start_time: string;
462
+ session_name: string;
463
+ })
464
+ : undefined;
465
+ const scopedReceipt =
466
+ rawScopedReceipt?.session_name === plan.execution.attempt_session ? rawScopedReceipt : undefined;
467
+ const attempt: LifecycleAttemptExecution = {
468
+ attemptCreated: true,
469
+ serverKey: plan.execution.server_key,
470
+ nativeSessionId:
471
+ plan.execution.mode === "scoped" ? scopedReceipt?.native_session_id : nativeTmuxSessionIdFromSpawn(rawStdout),
472
+ attemptSession: plan.execution.attempt_session,
473
+ ...(scopedReceipt
474
+ ? { serverPid: scopedReceipt.server_pid, serverStartTime: scopedReceipt.server_start_time }
475
+ : preSpawnProof
476
+ ? { serverPid: preSpawnProof.pid, serverStartTime: preSpawnProof.startTime }
477
+ : {}),
478
+ };
479
+ input.onAttemptCreated?.(attempt);
480
+ if (created.exitCode !== 0) {
481
+ if (!attempt.nativeSessionId || attempt.serverPid === undefined || attempt.serverStartTime === undefined)
482
+ throw new Error("skc_lifecycle_spawn_failed_cleanup_uncertain");
483
+ throw new Error("skc_lifecycle_spawn_failed");
484
+ }
485
+ if (!attempt.nativeSessionId) throw new Error("skc_lifecycle_spawn_failed_cleanup_uncertain");
486
+ const proof = await probe.probeServer(plan.execution.server_key);
487
+ if (
488
+ proof.state !== "safe" ||
489
+ !proof.pid ||
490
+ !proof.startTime ||
491
+ !proof.sessionNames?.includes(plan.execution.attempt_session) ||
492
+ (process.platform === "linux" && proof.cgroup?.classification !== "safe") ||
493
+ (preSpawnProof && (proof.pid !== preSpawnProof.pid || proof.startTime !== preSpawnProof.startTime)) ||
494
+ (scopedReceipt && (proof.pid !== scopedReceipt.server_pid || proof.startTime !== scopedReceipt.server_start_time))
495
+ )
496
+ throw new Error("skc_lifecycle_owner_server_unverifiable");
497
+ attempt.serverPid = proof.pid;
498
+ attempt.serverStartTime = proof.startTime;
499
+ if (
500
+ !(await reproveLifecycleAttempt({
501
+ tmux: input.tmux,
502
+ env: input.env,
503
+ serverKey: attempt.serverKey,
504
+ nativeSessionId: attempt.nativeSessionId,
505
+ attemptSession: attempt.attemptSession,
506
+ expectedServerPid: attempt.serverPid,
507
+ expectedServerStartTime: attempt.serverStartTime,
508
+ ownerIsolationProbe: input.ownerIsolationProbe,
509
+ }))
510
+ )
511
+ throw new Error("skc_lifecycle_owner_server_unverifiable");
512
+
513
+ if (!(await isLifecycleGenerationUnchanged(input.stateDir, input.sessionId, input.previousBaseline)))
514
+ throw new Error("skc_lifecycle_owner_generation_changed");
515
+ return attempt;
516
+ }
517
+
518
+ function hasExactNativeSessionBinding(input: {
519
+ tmux: string;
520
+ env: NodeJS.ProcessEnv;
521
+ serverKey: string;
522
+ nativeSessionId: string;
523
+ attemptSession: string;
524
+ }): boolean {
525
+ const result = Bun.spawnSync(
526
+ [
527
+ input.tmux,
528
+ "-L",
529
+ input.serverKey,
530
+ "display-message",
531
+ "-p",
532
+ "-t",
533
+ input.nativeSessionId,
534
+ "#{session_id}\t#{session_name}",
535
+ ],
536
+ { stdout: "pipe", stderr: "pipe", env: input.env },
537
+ );
538
+ if (result.exitCode !== 0) return false;
539
+ const output = result.stdout.toString();
540
+ const line = output.endsWith("\n") ? output.slice(0, -1) : output;
541
+ return line === `${input.nativeSessionId}\t${input.attemptSession}`;
542
+ }
543
+
544
+ function cleanupUncertain(): Error {
545
+ return new Error("skc_lifecycle_cleanup_uncertain");
546
+ }
547
+
548
+ async function reproveLifecycleAttempt(input: {
549
+ tmux: string;
550
+ env: NodeJS.ProcessEnv;
551
+ serverKey: string;
552
+ nativeSessionId: string;
553
+ attemptSession: string;
554
+ expectedServerPid: number;
555
+ expectedServerStartTime: string;
556
+ ownerIsolationProbe?: OwnerIsolationProbe;
557
+ }): Promise<boolean> {
558
+ const probe = input.ownerIsolationProbe ?? lifecycleOwnerIsolationProbe(input.tmux, input.env);
559
+ const isExpectedServer = (proof: TmuxServerProof): boolean =>
560
+ proof.state === "safe" &&
561
+ proof.pid === input.expectedServerPid &&
562
+ proof.startTime === input.expectedServerStartTime &&
563
+ proof.sessionNames?.includes(input.attemptSession) === true &&
564
+ (process.platform !== "linux" || proof.cgroup?.classification === "safe");
565
+ let proof: TmuxServerProof;
566
+ try {
567
+ proof = await probe.probeServer(input.serverKey);
568
+ } catch {
569
+ return false;
570
+ }
571
+ if (!isExpectedServer(proof) || !hasExactNativeSessionBinding(input)) return false;
572
+ try {
573
+ proof = await probe.probeServer(input.serverKey);
574
+ } catch {
575
+ return false;
576
+ }
577
+ return isExpectedServer(proof);
578
+ }
579
+
580
+ async function cleanupLifecycleAttempt(input: {
581
+ tmux: string;
582
+ env: NodeJS.ProcessEnv;
583
+ serverKey: string;
584
+ nativeSessionId?: string;
585
+ attemptSession: string;
586
+ expectedServerPid?: number;
587
+ expectedServerStartTime?: string;
588
+ ownerIsolationProbe?: OwnerIsolationProbe;
589
+ }): Promise<void> {
590
+ if (!input.nativeSessionId || input.expectedServerPid === undefined || input.expectedServerStartTime === undefined)
591
+ throw cleanupUncertain();
592
+ // External reproof narrows the window and detects obvious replacement before
593
+ // mutation, but the server evaluates the identity predicate atomically.
594
+ if (
595
+ !(await reproveLifecycleAttempt({
596
+ tmux: input.tmux,
597
+ env: input.env,
598
+ serverKey: input.serverKey,
599
+ nativeSessionId: input.nativeSessionId,
600
+ attemptSession: input.attemptSession,
601
+ expectedServerPid: input.expectedServerPid,
602
+ expectedServerStartTime: input.expectedServerStartTime,
603
+ ownerIsolationProbe: input.ownerIsolationProbe,
604
+ }))
605
+ )
606
+ throw cleanupUncertain();
607
+ const guarded = Bun.spawnSync(
608
+ [
609
+ input.tmux,
610
+ "-L",
611
+ input.serverKey,
612
+ "if-shell",
613
+ "-t",
614
+ input.nativeSessionId,
615
+ "-F",
616
+ lifecycleMetadataPredicate(input.expectedServerPid, input.nativeSessionId, input.attemptSession),
617
+ `kill-session -t '${input.nativeSessionId}' ; display-message -p __skc_lifecycle_cleanup_ok__`,
618
+ "display-message -p __skc_lifecycle_cleanup_refused__",
619
+ ],
620
+ { stdout: "pipe", stderr: "pipe", env: input.env },
621
+ );
622
+ if (guarded.exitCode !== 0 || guarded.stdout.toString().trim() !== "__skc_lifecycle_cleanup_ok__")
623
+ throw cleanupUncertain();
624
+ }
625
+
626
+ async function assertLifecycleTmuxServerSafe(input: {
627
+ tmux: string;
628
+ env: NodeJS.ProcessEnv;
629
+ ownerIsolationProbe?: OwnerIsolationProbe;
630
+ }): Promise<void> {
631
+ const proof = await (input.ownerIsolationProbe ?? lifecycleOwnerIsolationProbe(input.tmux, input.env)).probeServer(
632
+ "default",
633
+ );
634
+ if (
635
+ proof.state !== "safe" ||
636
+ !proof.pid ||
637
+ !proof.startTime ||
638
+ (process.platform === "linux" && proof.cgroup?.classification !== "safe")
639
+ )
640
+ throw new Error(`skc_lifecycle_owner_${proof.state === "unsafe" ? "server_unsafe" : "server_unverifiable"}`);
641
+ }
642
+
643
+ /** Real daemon-safe tmux launcher: canonical owner-isolation plan + SKC tags. */
644
+ export function daemonSpawnCreate(
645
+ env: NodeJS.ProcessEnv = process.env,
646
+ opts: { ownerIsolationProbe?: OwnerIsolationProbe } = {},
647
+ ) {
155
648
  return async (
156
649
  frame: SessionCreateFrame,
157
650
  ids: { lifecycleRequestId: string; intendedSessionId: string; startupPromptRef?: string },
158
651
  ): Promise<CreateEffectResult> => {
159
- const tmux = resolveSkcTmuxCommand(env);
652
+ const tmuxBinary = resolveSkcTmuxBinary({ env });
653
+ if (tmuxBinary.isPsmux) throw new Error("skc_lifecycle_psmux_unsupported");
654
+ const tmux = tmuxBinary.command;
160
655
  const name = tmuxSessionNameFor(ids.intendedSessionId);
161
656
  const { cwd, args } = buildCreateArgv(frame, ids);
162
- // A `plain_dir` target is a NEW working directory: create it before spawn
163
- // so `/session_create dir <newdir>` works as documented.
164
- if (frame.target.kind === "plain_dir") {
165
- fs.mkdirSync(cwd, { recursive: true });
166
- }
167
- // Detached: no interactive TTY needed (daemon-safe).
657
+ const sessionStateFile = lifecycleRuntimeStateFile(cwd, ids.intendedSessionId, name);
658
+ const stateDir = path.dirname(sessionStateFile);
659
+ const generation = crypto.randomUUID();
660
+ const previousBaseline = await captureOwnerGenerationBaseline(stateDir, ids.intendedSessionId);
661
+ // Detached: no interactive TTY needed (daemon-safe). These values contain
662
+ // only opaque ids and paths needed by the resident sidecar to publish its
663
+ // exact-owner terminal verdict.
168
664
  const childEnv: Record<string, string> = {
169
665
  SKC_TMUX_LAUNCHED: "1",
170
666
  SKC_NOTIFICATIONS: "1",
171
667
  SKC_SESSION_ID: ids.intendedSessionId,
172
668
  SKC_LIFECYCLE_REQUEST_ID: ids.lifecycleRequestId,
669
+ [SKC_COORDINATOR_SESSION_ID_ENV]: ids.intendedSessionId,
670
+ [SKC_COORDINATOR_SESSION_STATE_FILE_ENV]: sessionStateFile,
671
+ [SKC_TMUX_OWNER_GENERATION_ENV]: generation,
672
+ [SKC_TMUX_OWNER_STATE_DIR_ENV]: stateDir,
673
+ [SKC_TMUX_OWNER_SERVER_KEY_ENV]: "default",
173
674
  };
174
675
  if (ids.startupPromptRef) childEnv.SKC_STARTUP_PROMPT_REF = ids.startupPromptRef;
175
676
  const envPairs = Object.entries(childEnv)
176
677
  .map(([k, v]) => `${k}=${shellQuote(v)}`)
177
678
  .join(" ");
178
679
  const command = `cd ${shellQuote(cwd)} && exec env ${envPairs} skc ${args.map(shellQuote).join(" ")}`;
179
- const created = Bun.spawnSync([tmux, "new-session", "-d", "-s", name, "sh", "-c", command], {
180
- stdout: "pipe",
181
- stderr: "pipe",
182
- env,
183
- });
184
- if (created.exitCode !== 0) {
185
- throw new Error(created.stderr.toString().trim() || "skc_lifecycle_spawn_failed");
186
- }
187
- const target = buildSkcTmuxExactOptionTarget(name);
188
- const metaCommands = buildSkcTmuxProfileCommands(target, env, {
189
- sessionId: ids.intendedSessionId,
190
- project: cwd,
191
- });
192
- for (const cmd of metaCommands) {
193
- Bun.spawnSync([tmux, ...cmd.args], { stdout: "pipe", stderr: "pipe", env });
680
+ let ownerExecution: LifecycleAttemptExecution | undefined;
681
+ try {
682
+ ownerExecution = await executeLifecycleTmuxOwnerPlan({
683
+ tmux,
684
+ env,
685
+ sessionId: ids.intendedSessionId,
686
+ generation,
687
+ stateDir,
688
+ cwd,
689
+ sessionName: name,
690
+ argv: [tmux, "new-session", "-d", "-P", "-F", "#{session_id}", "-s", name, "sh", "-c", command],
691
+ ownerIsolationProbe: opts.ownerIsolationProbe,
692
+ onAttemptCreated: attempt => {
693
+ ownerExecution = attempt;
694
+ },
695
+ previousBaseline,
696
+
697
+ prepareSpawn: () => {
698
+ if (frame.target.kind === "plain_dir") fs.mkdirSync(cwd, { recursive: true });
699
+ },
700
+ });
701
+ if (!ownerExecution.nativeSessionId || ownerExecution.serverPid === undefined)
702
+ throw new Error("skc_lifecycle_owner_server_unverifiable");
703
+ const target = `${ownerExecution.nativeSessionId}:`;
704
+ await applyRequiredLifecycleTmuxMetadata(
705
+ tmux,
706
+ target,
707
+ env,
708
+ {
709
+ sessionId: ids.intendedSessionId,
710
+ sessionStateFile,
711
+ project: cwd,
712
+ ownerGeneration: generation,
713
+ ownerServerKey: ownerExecution.serverKey,
714
+ },
715
+ {
716
+ nativeSessionId: ownerExecution.nativeSessionId,
717
+ attemptSession: ownerExecution.attemptSession,
718
+ serverPid: ownerExecution.serverPid,
719
+ },
720
+ );
721
+ if (
722
+ !ownerExecution.nativeSessionId ||
723
+ ownerExecution.serverPid === undefined ||
724
+ ownerExecution.serverStartTime === undefined ||
725
+ !(await reproveLifecycleAttempt({
726
+ tmux,
727
+ env,
728
+ serverKey: ownerExecution.serverKey,
729
+ nativeSessionId: ownerExecution.nativeSessionId,
730
+ attemptSession: ownerExecution.attemptSession,
731
+ expectedServerPid: ownerExecution.serverPid,
732
+ expectedServerStartTime: ownerExecution.serverStartTime,
733
+ ownerIsolationProbe: opts.ownerIsolationProbe,
734
+ }))
735
+ )
736
+ throw new Error("skc_lifecycle_owner_server_unverifiable");
737
+ if (!(await isLifecycleGenerationUnchanged(stateDir, ids.intendedSessionId, previousBaseline)))
738
+ throw new Error("skc_lifecycle_owner_generation_changed");
739
+ await replaceOwnerGeneration(stateDir, ids.intendedSessionId, generation, previousBaseline);
740
+ } catch (error) {
741
+ let cleanupFailure: unknown;
742
+ if (ownerExecution?.attemptCreated) {
743
+ try {
744
+ await cleanupLifecycleAttempt({
745
+ tmux,
746
+ env,
747
+ serverKey: ownerExecution.serverKey,
748
+ nativeSessionId: ownerExecution.nativeSessionId,
749
+ attemptSession: ownerExecution.attemptSession,
750
+ expectedServerPid: ownerExecution.serverPid,
751
+ expectedServerStartTime: ownerExecution.serverStartTime,
752
+ ownerIsolationProbe: opts.ownerIsolationProbe,
753
+ });
754
+ } catch (cleanupError) {
755
+ cleanupFailure = cleanupError;
756
+ }
757
+ }
758
+ if (cleanupFailure !== undefined)
759
+ throw new AggregateError([error, cleanupFailure], "skc_lifecycle_cleanup_uncertain");
760
+ throw error;
194
761
  }
195
- const status = statusSkcTmuxSession(name, env);
762
+
196
763
  return {
197
764
  sessionId: ids.intendedSessionId,
198
765
  tmuxSession: name,
199
- sessionStateFile: status.sessionStateFile,
766
+ sessionStateFile,
200
767
  endpointUrl: "",
201
768
  topicThreadId: "",
202
769
  };
203
770
  };
204
771
  }
205
772
 
773
+ function tmuxCommandArgument(value: string): string {
774
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("$", "\\$").replaceAll("`", "\\`")}"`;
775
+ }
776
+
777
+ function lifecycleMetadataPredicate(expectedPid: number, nativeSessionId: string, attemptSession: string): string {
778
+ return `#{&&:#{==:#{pid},${expectedPid}},#{&&:#{==:#{session_id},${nativeSessionId}},#{==:#{session_name},${attemptSession}}}}`;
779
+ }
780
+
781
+ async function applyRequiredLifecycleTmuxMetadata(
782
+ tmux: string,
783
+ target: string,
784
+ env: NodeJS.ProcessEnv,
785
+ metadata: {
786
+ sessionId: string;
787
+ sessionStateFile: string;
788
+ project: string;
789
+ ownerGeneration: string;
790
+ ownerServerKey: string;
791
+ },
792
+ attempt: { nativeSessionId: string; attemptSession: string; serverPid: number },
793
+ ): Promise<void> {
794
+ if (
795
+ !metadata.sessionId.trim() ||
796
+ !metadata.sessionStateFile.trim() ||
797
+ !metadata.project.trim() ||
798
+ !metadata.ownerGeneration.trim() ||
799
+ !metadata.ownerServerKey.trim()
800
+ )
801
+ throw new Error("skc_lifecycle_metadata_required_missing");
802
+ const commands = buildSkcTmuxProfileCommands(target, env, metadata)
803
+ .map(command => command.args.map(tmuxCommandArgument).join(" "))
804
+ .join(" ; ");
805
+ const result = Bun.spawnSync(
806
+ [
807
+ tmux,
808
+ "-L",
809
+ metadata.ownerServerKey,
810
+ "if-shell",
811
+ "-t",
812
+ attempt.nativeSessionId,
813
+ "-F",
814
+ lifecycleMetadataPredicate(attempt.serverPid, attempt.nativeSessionId, attempt.attemptSession),
815
+ `${commands} ; display-message -p __skc_lifecycle_metadata_ok__`,
816
+ "display-message -p __skc_lifecycle_metadata_refused__",
817
+ ],
818
+ { stdout: "pipe", stderr: "pipe", env },
819
+ );
820
+ if (result.exitCode !== 0 || result.stdout.toString().trim() !== "__skc_lifecycle_metadata_ok__")
821
+ throw new Error("skc_lifecycle_metadata_write_failed");
822
+ }
823
+
206
824
  /** Real force-close effect (SKC-managed only, id-matched). */
207
- export function daemonCloseSession(env: NodeJS.ProcessEnv = process.env) {
825
+ export function daemonCloseSession(
826
+ env: NodeJS.ProcessEnv = process.env,
827
+ deps: {
828
+ forceClose?: (
829
+ name: string,
830
+ env: NodeJS.ProcessEnv,
831
+ expectedSessionId?: string,
832
+ sessionStateFile?: string,
833
+ ) => Promise<void>;
834
+ findSession?: (name: string, env: NodeJS.ProcessEnv) => SkcTmuxSessionStatus | undefined;
835
+ } = {},
836
+ ) {
208
837
  return async (target: { sessionId: string; tmuxSession?: string; sessionStateFile?: string }) => {
209
838
  const name = target.tmuxSession ?? tmuxSessionNameFor(target.sessionId);
210
- forceCloseSkcTmuxSession(name, env, target.sessionId, target.sessionStateFile);
211
- return { processGone: findSkcTmuxSessionByName(name, env) === undefined };
839
+ await (deps.forceClose ?? forceCloseSkcTmuxSession)(name, env, target.sessionId, target.sessionStateFile);
840
+ return { processGone: (deps.findSession ?? findSkcTmuxSessionByName)(name, env) === undefined };
212
841
  };
213
842
  }
214
843
 
215
844
  /** Real resume effect: reattach if a live SKC session matches; else resolve the
216
845
  * prefix against saved history and fail closed (`ambiguous`/`notFound`) before
217
846
  * cold-restarting exactly one resolved session via the daemon-safe launcher. */
218
- export function daemonResumeSession(env: NodeJS.ProcessEnv = process.env, opts: { sessionsRoot?: string } = {}) {
847
+ export function daemonResumeSession(
848
+ env: NodeJS.ProcessEnv = process.env,
849
+ opts: {
850
+ sessionsRoot?: string;
851
+ listSessions?: (env: NodeJS.ProcessEnv) => SkcTmuxSessionStatus[];
852
+ ownerIsolationProbe?: OwnerIsolationProbe;
853
+ } = {},
854
+ ) {
219
855
  return async (target: {
220
856
  sessionIdOrPrefix: string;
221
857
  path?: string;
222
858
  }): Promise<ResumeEffectResult | { ambiguous: ResumeCandidate[] } | { notFound: true }> => {
223
- const live = listSkcTmuxSessions(env).filter(
859
+ const tmuxBinary = resolveSkcTmuxBinary({ env });
860
+ if (tmuxBinary.isPsmux) throw new Error("skc_lifecycle_psmux_unsupported");
861
+ const live = (opts.listSessions?.(env) ?? listSkcTmuxSessions(env)).filter(
224
862
  s => s.sessionId === target.sessionIdOrPrefix || s.sessionId?.startsWith(target.sessionIdOrPrefix),
225
863
  );
226
864
  if (live.length > 1) {
@@ -230,6 +868,11 @@ export function daemonResumeSession(env: NodeJS.ProcessEnv = process.env, opts:
230
868
  }
231
869
  if (live.length === 1) {
232
870
  const s = live[0]!;
871
+ await assertLifecycleTmuxServerSafe({
872
+ tmux: resolveSkcTmuxCommand(env),
873
+ env,
874
+ ownerIsolationProbe: opts.ownerIsolationProbe,
875
+ });
233
876
  return {
234
877
  sessionId: s.sessionId ?? s.name,
235
878
  tmuxSession: s.name,
@@ -261,26 +904,111 @@ export function daemonResumeSession(env: NodeJS.ProcessEnv = process.env, opts:
261
904
  }
262
905
  const resolvedResumeCwd = resumeCwd ? path.resolve(resumeCwd) : undefined;
263
906
  const resumeCwdStat = resolvedResumeCwd ? fs.statSync(resolvedResumeCwd, { throwIfNoEntry: false }) : undefined;
264
- if (!resumeCwdStat?.isDirectory()) {
907
+ if (typeof resolvedResumeCwd !== "string" || !resumeCwdStat?.isDirectory()) {
265
908
  throw new Error(`skc_lifecycle_resume_cwd_unavailable: ${resolvedResumeCwd ?? "(missing)"}`);
266
909
  }
267
- const tmux = resolveSkcTmuxCommand(env);
910
+ const tmux = tmuxBinary.command;
268
911
  const name = tmuxSessionNameFor(resumeId);
269
- const cwdPrefix = resolvedResumeCwd ? `cd ${shellQuote(resolvedResumeCwd)} && ` : "";
270
- const command = `${cwdPrefix}exec env SKC_TMUX_LAUNCHED=1 SKC_NOTIFICATIONS=1 skc --resume ${shellQuote(resumeId)}`;
271
- const r = Bun.spawnSync([tmux, "new-session", "-d", "-s", name, "sh", "-c", command], {
272
- stdout: "pipe",
273
- stderr: "pipe",
274
- env,
275
- });
276
- if (r.exitCode !== 0) throw new Error(r.stderr.toString().trim() || "skc_lifecycle_resume_failed");
277
- const tgt = buildSkcTmuxExactOptionTarget(name);
278
- for (const cmd of buildSkcTmuxProfileCommands(tgt, env, { sessionId: resumeId, project: resolvedResumeCwd })) {
279
- Bun.spawnSync([tmux, ...cmd.args], { stdout: "pipe", stderr: "pipe", env });
912
+ const sessionStateFile = lifecycleRuntimeStateFile(resolvedResumeCwd, resumeId, name);
913
+ const stateDir = path.dirname(sessionStateFile);
914
+ const generation = crypto.randomUUID();
915
+ const previousBaseline = await captureOwnerGenerationBaseline(stateDir, resumeId);
916
+ const childEnv: Record<string, string> = {
917
+ SKC_TMUX_LAUNCHED: "1",
918
+ SKC_NOTIFICATIONS: "1",
919
+ [SKC_COORDINATOR_SESSION_ID_ENV]: resumeId,
920
+ [SKC_COORDINATOR_SESSION_STATE_FILE_ENV]: sessionStateFile,
921
+ [SKC_TMUX_OWNER_GENERATION_ENV]: generation,
922
+ [SKC_TMUX_OWNER_STATE_DIR_ENV]: stateDir,
923
+ [SKC_TMUX_OWNER_SERVER_KEY_ENV]: "default",
924
+ };
925
+ const envPairs = Object.entries(childEnv)
926
+ .map(([key, value]) => `${key}=${shellQuote(value)}`)
927
+ .join(" ");
928
+ const command = `cd ${shellQuote(resolvedResumeCwd)} && exec env ${envPairs} skc --resume ${shellQuote(resumeId)}`;
929
+ let ownerExecution: LifecycleAttemptExecution | undefined;
930
+ try {
931
+ ownerExecution = await executeLifecycleTmuxOwnerPlan({
932
+ tmux,
933
+ env,
934
+ sessionId: resumeId,
935
+ generation,
936
+ stateDir,
937
+ cwd: resolvedResumeCwd,
938
+ sessionName: name,
939
+ argv: [tmux, "new-session", "-d", "-P", "-F", "#{session_id}", "-s", name, "sh", "-c", command],
940
+ ownerIsolationProbe: opts.ownerIsolationProbe,
941
+ onAttemptCreated: attempt => {
942
+ ownerExecution = attempt;
943
+ },
944
+ previousBaseline,
945
+ });
946
+ if (!ownerExecution.nativeSessionId || ownerExecution.serverPid === undefined)
947
+ throw new Error("skc_lifecycle_owner_server_unverifiable");
948
+ const tgt = `${ownerExecution.nativeSessionId}:`;
949
+ await applyRequiredLifecycleTmuxMetadata(
950
+ tmux,
951
+ tgt,
952
+ env,
953
+ {
954
+ sessionId: resumeId,
955
+ sessionStateFile,
956
+ project: resolvedResumeCwd,
957
+ ownerGeneration: generation,
958
+ ownerServerKey: ownerExecution.serverKey,
959
+ },
960
+ {
961
+ nativeSessionId: ownerExecution.nativeSessionId,
962
+ attemptSession: ownerExecution.attemptSession,
963
+ serverPid: ownerExecution.serverPid,
964
+ },
965
+ );
966
+ if (
967
+ !ownerExecution.nativeSessionId ||
968
+ ownerExecution.serverPid === undefined ||
969
+ ownerExecution.serverStartTime === undefined ||
970
+ !(await reproveLifecycleAttempt({
971
+ tmux,
972
+ env,
973
+ serverKey: ownerExecution.serverKey,
974
+ nativeSessionId: ownerExecution.nativeSessionId,
975
+ attemptSession: ownerExecution.attemptSession,
976
+ expectedServerPid: ownerExecution.serverPid,
977
+ expectedServerStartTime: ownerExecution.serverStartTime,
978
+ ownerIsolationProbe: opts.ownerIsolationProbe,
979
+ }))
980
+ )
981
+ throw new Error("skc_lifecycle_owner_server_unverifiable");
982
+ if (!(await isLifecycleGenerationUnchanged(stateDir, resumeId, previousBaseline)))
983
+ throw new Error("skc_lifecycle_owner_generation_changed");
984
+ await replaceOwnerGeneration(stateDir, resumeId, generation, previousBaseline);
985
+ } catch (error) {
986
+ let cleanupFailure: unknown;
987
+ if (ownerExecution?.attemptCreated) {
988
+ try {
989
+ await cleanupLifecycleAttempt({
990
+ tmux,
991
+ env,
992
+ serverKey: ownerExecution.serverKey,
993
+ nativeSessionId: ownerExecution.nativeSessionId,
994
+ attemptSession: ownerExecution.attemptSession,
995
+ expectedServerPid: ownerExecution.serverPid,
996
+ expectedServerStartTime: ownerExecution.serverStartTime,
997
+ ownerIsolationProbe: opts.ownerIsolationProbe,
998
+ });
999
+ } catch (cleanupError) {
1000
+ cleanupFailure = cleanupError;
1001
+ }
1002
+ }
1003
+ if (cleanupFailure !== undefined)
1004
+ throw new AggregateError([error, cleanupFailure], "skc_lifecycle_cleanup_uncertain");
1005
+ throw error;
280
1006
  }
1007
+
281
1008
  return {
282
1009
  sessionId: resumeId,
283
1010
  tmuxSession: name,
1011
+ sessionStateFile,
284
1012
  endpointUrl: "",
285
1013
  topicThreadId: "",
286
1014
  mode: "cold_restarted",
@@ -343,6 +1071,113 @@ export function outcomeToResponse(frame: SessionLifecycleRequest, outcome: Lifec
343
1071
  };
344
1072
  }
345
1073
 
1074
+ const MAX_LIFECYCLE_REQUEST_ID_BYTES = 128;
1075
+
1076
+ function hasLoneUtf16Surrogate(value: string): boolean {
1077
+ for (let index = 0; index < value.length; index += 1) {
1078
+ const codeUnit = value.charCodeAt(index);
1079
+ if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
1080
+ if (index + 1 >= value.length || value.charCodeAt(index + 1) < 0xdc00 || value.charCodeAt(index + 1) > 0xdfff)
1081
+ return true;
1082
+ index += 1;
1083
+ } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
1084
+ return true;
1085
+ }
1086
+ }
1087
+ return false;
1088
+ }
1089
+
1090
+ function boundedLifecycleRequestId(requestId: unknown): string {
1091
+ if (typeof requestId !== "string" || hasLoneUtf16Surrogate(requestId)) return "";
1092
+ let bounded = "";
1093
+ let byteLength = 0;
1094
+ for (const codePoint of requestId) {
1095
+ const codePointBytes = Buffer.byteLength(codePoint, "utf8");
1096
+ if (byteLength + codePointBytes > MAX_LIFECYCLE_REQUEST_ID_BYTES) break;
1097
+ bounded += codePoint;
1098
+ byteLength += codePointBytes;
1099
+ }
1100
+ return bounded;
1101
+ }
1102
+
1103
+ function lifecycleFailureResponse(requestId: unknown): SessionLifecycleResponse {
1104
+ return {
1105
+ type: "session_lifecycle_error",
1106
+ requestId: boundedLifecycleRequestId(requestId),
1107
+ status: "error",
1108
+ reason: "terminal_uncertain",
1109
+ message: "request could not be processed",
1110
+ };
1111
+ }
1112
+
1113
+ function recordLifecycleControlDiagnostic(): void {
1114
+ try {
1115
+ process.stderr.write("skc lifecycle control request failed\n");
1116
+ } catch {
1117
+ // Diagnostics must not suppress the fixed wire failure response or recovery.
1118
+ }
1119
+ }
1120
+
1121
+ function isRecord(value: unknown): value is Record<string, unknown> {
1122
+ return !!value && typeof value === "object" && !Array.isArray(value);
1123
+ }
1124
+
1125
+ function isSafeLifecycleId(value: unknown): value is string {
1126
+ return typeof value === "string" && !hasLoneUtf16Surrogate(value);
1127
+ }
1128
+
1129
+ /** The native server authenticates callbacks before redacting the control token. */
1130
+ type AuthenticatedNativeLifecycleRequest =
1131
+ | Omit<SessionCreateFrame, "token">
1132
+ | Omit<Extract<SessionLifecycleRequest, { type: "session_close" }>, "token">
1133
+ | Omit<Extract<SessionLifecycleRequest, { type: "session_resume" }>, "token">;
1134
+
1135
+ function isBoundedRequestFrame(frame: unknown): frame is AuthenticatedNativeLifecycleRequest {
1136
+ if (
1137
+ !isRecord(frame) ||
1138
+ !isSafeLifecycleId(frame.requestId) ||
1139
+ Buffer.byteLength(frame.requestId, "utf8") > MAX_LIFECYCLE_REQUEST_ID_BYTES
1140
+ )
1141
+ return false;
1142
+ if (
1143
+ !isSafeLifecycleId(frame.chatId) ||
1144
+ (typeof frame.token !== "undefined" && typeof frame.token !== "string") ||
1145
+ !Number.isSafeInteger(frame.updateId)
1146
+ )
1147
+ return false;
1148
+ if (!isRecord(frame.target)) return false;
1149
+ switch (frame.type) {
1150
+ case "session_create":
1151
+ return (
1152
+ isSafeLifecycleId(frame.lifecycleRequestId) &&
1153
+ isSafeLifecycleId(frame.intendedSessionId) &&
1154
+ (typeof frame.startupPromptRef === "undefined" || typeof frame.startupPromptRef === "string") &&
1155
+ (typeof frame.modelPreset === "undefined" || typeof frame.modelPreset === "string") &&
1156
+ ((frame.target.kind === "worktree" &&
1157
+ typeof frame.target.repo === "string" &&
1158
+ typeof frame.target.branch === "string") ||
1159
+ ((frame.target.kind === "existing_path" || frame.target.kind === "plain_dir") &&
1160
+ typeof frame.target.path === "string"))
1161
+ );
1162
+ case "session_close":
1163
+ return (
1164
+ isSafeLifecycleId(frame.target.sessionId) &&
1165
+ (typeof frame.target.tmuxSession === "undefined" || isSafeLifecycleId(frame.target.tmuxSession)) &&
1166
+ (typeof frame.target.sessionStateFile === "undefined" ||
1167
+ typeof frame.target.sessionStateFile === "string") &&
1168
+ (typeof frame.force === "undefined" || typeof frame.force === "boolean")
1169
+ );
1170
+ case "session_resume":
1171
+ return (
1172
+ isSafeLifecycleId(frame.target.sessionIdOrPrefix) &&
1173
+ (typeof frame.target.path === "undefined" || typeof frame.target.path === "string") &&
1174
+ (typeof frame.startupPromptRef === "undefined" || typeof frame.startupPromptRef === "string")
1175
+ );
1176
+ default:
1177
+ return false;
1178
+ }
1179
+ }
1180
+
346
1181
  /**
347
1182
  * Wire a control server's lifecycle requests through the orchestrator.
348
1183
  *
@@ -356,21 +1191,39 @@ export function outcomeToResponse(frame: SessionLifecycleRequest, outcome: Lifec
356
1191
  export function attachLifecycleControl(server: ControlServerLike, deps: OrchestratorDeps): void {
357
1192
  let queue: Promise<void> = Promise.resolve();
358
1193
  server.onLifecycleRequest((err, req) => {
359
- if (err) return;
360
- let frame: SessionLifecycleRequest;
361
- try {
362
- frame = JSON.parse(req.payloadJson) as SessionLifecycleRequest;
363
- } catch {
1194
+ if (err) {
1195
+ recordLifecycleControlDiagnostic();
364
1196
  return;
365
1197
  }
366
- queue = queue
367
- .then(async () => {
368
- const outcome = await handleLifecycleRequest(frame, deps);
369
- server.respond(JSON.stringify(outcomeToResponse(frame, outcome)));
370
- })
371
- .catch(() => {
372
- // A handler failure must not break the queue for later requests.
373
- });
1198
+ const envelopeRequestId = boundedLifecycleRequestId(req.requestId);
1199
+ const run = async (): Promise<void> => {
1200
+ let frame: AuthenticatedNativeLifecycleRequest | undefined;
1201
+
1202
+ try {
1203
+ const parsed = JSON.parse(req.payloadJson) as unknown;
1204
+ if (!isBoundedRequestFrame(parsed)) throw new Error("invalid lifecycle frame");
1205
+ frame = parsed;
1206
+ const outcome = await handleLifecycleRequest(frame as SessionLifecycleRequest, deps);
1207
+
1208
+ server.respond(
1209
+ JSON.stringify(
1210
+ outcome.status === "error" && outcome.reason === "terminal_uncertain"
1211
+ ? lifecycleFailureResponse(frame.requestId)
1212
+ : outcomeToResponse(frame as SessionLifecycleRequest, outcome),
1213
+ ),
1214
+ );
1215
+ } catch {
1216
+ recordLifecycleControlDiagnostic();
1217
+ // A valid frame preserves its immutable request id; malformed frames use
1218
+ // only the bounded native envelope id.
1219
+ server.respond(JSON.stringify(lifecycleFailureResponse(frame?.requestId ?? envelopeRequestId)));
1220
+ }
1221
+ };
1222
+ // Recover queue liveness only after this request has attempted its own
1223
+ // failure response. A response transport failure cannot poison later work.
1224
+ queue = queue.then(run, run).catch(() => {
1225
+ recordLifecycleControlDiagnostic();
1226
+ });
374
1227
  });
375
1228
  }
376
1229