@platless/axom-cli 0.1.0 → 0.5.9

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 (281) hide show
  1. package/README.md +1 -1
  2. package/dist/authScopes.js +19 -0
  3. package/dist/axom-patch/cli/README.md +132 -0
  4. package/dist/axom-patch/cli/account-limits/README.md +27 -0
  5. package/dist/axom-patch/cli/account-limits/antigravity-proxy.mjs +47 -0
  6. package/dist/axom-patch/cli/account-limits/antigravity-read.mjs +90 -0
  7. package/dist/axom-patch/cli/account-limits/antigravity.mjs +46 -0
  8. package/dist/axom-patch/cli/account-limits/generate.py +120 -0
  9. package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +64 -0
  10. package/dist/axom-patch/cli/account-limits/normalize.mjs +151 -0
  11. package/dist/axom-patch/cli/claude-account-limits.json +37 -0
  12. package/dist/axom-patch/cli/codex-account-limits.json +37 -0
  13. package/dist/axom-patch/cli/commandcode-account-limits.json +30 -0
  14. package/dist/axom-patch/cli/nightly-task.md +152 -0
  15. package/dist/axom-patch/cli/pi-steering.json +37 -0
  16. package/dist/axom-patch/cli/pi-subagent-subwork.json +23 -0
  17. package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +38 -0
  18. package/dist/axom-patch/cli/probes/pi-steering.mjs +80 -0
  19. package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
  20. package/dist/axom-patch/cli/probes/qoder-steering.mjs +89 -0
  21. package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +61 -0
  22. package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
  23. package/dist/axom-patch/cli/qoder-background-terminal.md +82 -0
  24. package/dist/axom-patch/cli/qoder-foreground-output.md +42 -0
  25. package/dist/axom-patch/cli/qoder-steering.json +52 -0
  26. package/dist/axom-patch/cli/qoder-steering.md +30 -0
  27. package/dist/axom-patch/cli/qoder-subagent-origin.json +73 -0
  28. package/dist/axom-patch/cli/qoder-subagent-subwork.md +73 -0
  29. package/dist/axom-patch/cli/runtime.json +63 -0
  30. package/dist/axom-patch/cli/steering/claude-steering-js.patch +89 -0
  31. package/dist/axom-patch/cli/steering/claude-steering-types.patch +28 -0
  32. package/dist/axom-patch/cli/steering/codex-steering.patch +57 -0
  33. package/dist/axom-patch/cli/steering/pi-steering.js +79 -0
  34. package/dist/axom-patch/cli/zcode-account-limits.json +31 -0
  35. package/dist/axom-patch/cli/zcode-models.json +38 -0
  36. package/dist/axom-patch/cli/zcode-subagent-subwork.json +73 -0
  37. package/dist/cli.js +36 -1
  38. package/dist/commands/auth.js +4 -13
  39. package/dist/commands/execute.js +4 -4
  40. package/dist/commands/record.js +6 -6
  41. package/dist/commands/struct.js +4 -4
  42. package/dist/commands/workstation.js +2 -2
  43. package/dist/credentials.js +2 -2
  44. package/dist/install/claude/commands.js +13 -13
  45. package/dist/keepers/acp/keeper.mjs +3083 -0
  46. package/dist/keepers/acp/manifest.json +9 -0
  47. package/dist/keepers/terminal/keeper.mjs +8469 -0
  48. package/dist/keepers/terminal/manifest.json +9 -0
  49. package/dist/keepers/terminal/node_modules/node-pty/lib/conpty_console_list_agent.js +25 -0
  50. package/dist/keepers/terminal/node_modules/node-pty/lib/eventEmitter2.js +47 -0
  51. package/dist/keepers/terminal/node_modules/node-pty/lib/index.js +52 -0
  52. package/dist/keepers/terminal/node_modules/node-pty/lib/interfaces.js +7 -0
  53. package/dist/keepers/terminal/node_modules/node-pty/lib/shared/conout.js +10 -0
  54. package/dist/keepers/terminal/node_modules/node-pty/lib/terminal.js +190 -0
  55. package/dist/keepers/terminal/node_modules/node-pty/lib/types.js +7 -0
  56. package/dist/keepers/terminal/node_modules/node-pty/lib/unixTerminal.js +351 -0
  57. package/dist/keepers/terminal/node_modules/node-pty/lib/utils.js +38 -0
  58. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsConoutConnection.js +131 -0
  59. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsPtyAgent.js +344 -0
  60. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsTerminal.js +209 -0
  61. package/dist/keepers/terminal/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
  62. package/dist/keepers/terminal/node_modules/node-pty/package.json +66 -0
  63. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
  64. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
  65. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/pty.node +0 -0
  66. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
  67. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-arm64/pty.node +0 -0
  68. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-x64/pty.node +0 -0
  69. package/dist/mcp.js +32 -14
  70. package/dist/runtime-deps/qoder-sdk/package-lock.json +1189 -0
  71. package/dist/runtime-deps/qoder-sdk/package.json +8 -0
  72. package/dist/test/keeperKit.js +67 -0
  73. package/dist/workstation/acp/acpAgents.js +2514 -681
  74. package/dist/workstation/acp/acpClient.js +95 -318
  75. package/dist/workstation/acp/activityBusy.js +43 -0
  76. package/dist/workstation/acp/activityReporting.js +54 -0
  77. package/dist/workstation/acp/adoption.js +28 -356
  78. package/dist/workstation/acp/authUrlSniffer.js +36 -0
  79. package/dist/workstation/acp/binaryInstall.js +100 -27
  80. package/dist/workstation/acp/builds/gc.js +245 -0
  81. package/dist/workstation/acp/builds/install/binary.js +28 -0
  82. package/dist/workstation/acp/builds/install/npm.js +235 -0
  83. package/dist/workstation/acp/builds/install/uv.js +254 -0
  84. package/dist/workstation/acp/builds/migrate.js +195 -0
  85. package/dist/workstation/acp/builds/model.js +128 -0
  86. package/dist/workstation/acp/builds/nodeGate.js +100 -0
  87. package/dist/workstation/acp/builds/store.js +352 -0
  88. package/dist/workstation/acp/commandTableProbe.js +68 -0
  89. package/dist/workstation/acp/configOptions.js +103 -1
  90. package/dist/workstation/acp/delegationMcp.js +24 -0
  91. package/dist/workstation/acp/envSecrets.js +27 -0
  92. package/dist/workstation/acp/initializeResponse.js +285 -0
  93. package/dist/workstation/acp/installErrors.js +12 -2
  94. package/dist/workstation/acp/installProbe.js +1 -1
  95. package/dist/workstation/acp/managedPatches.js +144 -0
  96. package/dist/workstation/acp/outbox.js +330 -0
  97. package/dist/workstation/acp/patchAssets.js +80 -0
  98. package/dist/workstation/acp/permission.js +335 -37
  99. package/dist/workstation/acp/processor/checkpoint.js +87 -0
  100. package/dist/workstation/acp/processor/commands.js +35 -0
  101. package/dist/workstation/acp/processor/control.js +355 -0
  102. package/dist/workstation/acp/processor/emit.js +26 -0
  103. package/dist/workstation/acp/processor/jsonrpc.js +172 -0
  104. package/dist/workstation/acp/processor/memoryLog.js +49 -0
  105. package/dist/workstation/acp/processor/origins.js +48 -0
  106. package/dist/workstation/acp/processor/output.js +67 -0
  107. package/dist/workstation/acp/processor/projection.js +42 -0
  108. package/dist/workstation/acp/processor/record.js +128 -0
  109. package/dist/workstation/acp/processor/runtime.js +181 -0
  110. package/dist/workstation/acp/processor/sdkSchemas.js +61 -0
  111. package/dist/workstation/acp/processor/session.js +525 -0
  112. package/dist/workstation/acp/processor/sideChannel.js +80 -0
  113. package/dist/workstation/acp/processor/state.js +74 -0
  114. package/dist/workstation/acp/processor/step.js +459 -0
  115. package/dist/workstation/acp/processor/test/fixtureLog.js +65 -0
  116. package/dist/workstation/acp/processor/test/harness.js +120 -0
  117. package/dist/workstation/acp/processor/test/largeIdEcho.js +50 -0
  118. package/dist/workstation/acp/processor/test/rawAgent.js +70 -0
  119. package/dist/workstation/acp/processor/transport/generations.js +76 -0
  120. package/dist/workstation/acp/processor/transport/keeper.js +130 -0
  121. package/dist/workstation/acp/processor/transport/local.js +269 -0
  122. package/dist/workstation/acp/processor/transport/types.js +78 -0
  123. package/dist/workstation/acp/processor/updates.js +364 -0
  124. package/dist/workstation/acp/processorSession.js +1512 -0
  125. package/dist/workstation/acp/qoderStartup.js +45 -0
  126. package/dist/workstation/acp/registry/catalog.js +385 -4
  127. package/dist/workstation/acp/registry/detect.js +55 -0
  128. package/dist/workstation/acp/registry/launch.js +61 -119
  129. package/dist/workstation/acp/sessionUpdateMapper.js +56 -27
  130. package/dist/workstation/acp/steering.js +58 -0
  131. package/dist/workstation/acp/switching.js +114 -0
  132. package/dist/workstation/acp/terminalLogin.js +171 -0
  133. package/dist/workstation/acp/terminalLoginSpec.js +41 -0
  134. package/dist/workstation/acp/test/adapterFixtures.js +256 -0
  135. package/dist/workstation/acp/test/daemonLife.js +106 -0
  136. package/dist/workstation/acp/test/fakeAgent.js +185 -9
  137. package/dist/workstation/acp/test/keeperWorld.js +267 -0
  138. package/dist/workstation/acp/test/memoryOutbox.js +55 -0
  139. package/dist/workstation/acp/test/raw/fixtures.js +196 -0
  140. package/dist/workstation/acp/test/raw/qoderCaptures.js +125 -0
  141. package/dist/workstation/acp/test/raw/sanitize.js +344 -0
  142. package/dist/workstation/acp/test/raw/sources.js +249 -0
  143. package/dist/workstation/acp/test/realAdapter.js +377 -0
  144. package/dist/workstation/acp/test/scriptAgent.js +206 -0
  145. package/dist/workstation/acp/test/sessionFrames.js +42 -0
  146. package/dist/workstation/acp/timedCommand.js +4 -2
  147. package/dist/workstation/acp/uvxInstall.js +72 -122
  148. package/dist/workstation/acp/validate.js +33 -5
  149. package/dist/workstation/agentFlow.js +103 -0
  150. package/dist/workstation/agentSessions.js +1686 -348
  151. package/dist/workstation/channels/agent.js +14 -6
  152. package/dist/workstation/channels/computer.js +203 -7
  153. package/dist/workstation/channels/computerExecs.js +200 -0
  154. package/dist/workstation/channels/management.js +86 -6
  155. package/dist/workstation/config.js +3 -1
  156. package/dist/workstation/connection/encryptedChannel.js +88 -21
  157. package/dist/workstation/daemon.js +309 -6
  158. package/dist/workstation/daemonctl.js +28 -2
  159. package/dist/workstation/desktop/binding.js +36 -0
  160. package/dist/workstation/desktop/bridge.js +212 -0
  161. package/dist/workstation/desktop/flow-control.js +52 -0
  162. package/dist/workstation/desktop/focused-text.js +130 -0
  163. package/dist/workstation/desktop/protocol.js +35 -0
  164. package/dist/workstation/exec/attach.js +126 -0
  165. package/dist/workstation/exec/command.js +40 -0
  166. package/dist/workstation/exec/durable.js +517 -0
  167. package/dist/workstation/exec/fallback.js +106 -0
  168. package/dist/workstation/exec/policy.js +837 -0
  169. package/dist/workstation/exec/shaping.js +211 -0
  170. package/dist/workstation/exec.js +3 -112
  171. package/dist/workstation/files/adapters/computerFileFrames.js +51 -0
  172. package/dist/workstation/files/adapters/toolRequests.js +121 -0
  173. package/dist/workstation/files/client.js +455 -0
  174. package/dist/workstation/files/errors.js +28 -0
  175. package/dist/workstation/files/fence.js +350 -0
  176. package/dist/workstation/files/find/fileIndex.js +153 -0
  177. package/dist/workstation/files/find/fuzzy.js +155 -0
  178. package/dist/workstation/files/host.js +122 -0
  179. package/dist/workstation/files/hostProtocol.js +2 -0
  180. package/dist/workstation/files/media/cache.js +620 -0
  181. package/dist/workstation/files/media/encoding.js +308 -0
  182. package/dist/workstation/files/media/estimator.js +100 -0
  183. package/dist/workstation/files/media/media.js +325 -0
  184. package/dist/workstation/files/media/packedAudio.js +23 -0
  185. package/dist/workstation/files/media/scheduler.js +551 -0
  186. package/dist/workstation/files/media/types.js +5 -0
  187. package/dist/workstation/files/scope.js +45 -0
  188. package/dist/workstation/files/structured.js +284 -0
  189. package/dist/workstation/files/text.js +356 -0
  190. package/dist/workstation/files/uploads.js +351 -0
  191. package/dist/workstation/journal.js +65 -236
  192. package/dist/workstation/keepers/acp/client.js +101 -0
  193. package/dist/workstation/keepers/acp/envelope.js +62 -0
  194. package/dist/workstation/keepers/acp/host.js +419 -0
  195. package/dist/workstation/keepers/acp/main.js +18 -0
  196. package/dist/workstation/keepers/acp/memoryStream.js +121 -0
  197. package/dist/workstation/keepers/acp/params.js +137 -0
  198. package/dist/workstation/keepers/acp/protocol.js +66 -0
  199. package/dist/workstation/keepers/acp/recover.js +120 -0
  200. package/dist/workstation/keepers/acp/session.js +584 -0
  201. package/dist/workstation/keepers/acp/stderr.js +154 -0
  202. package/dist/workstation/keepers/kit/activity.js +103 -0
  203. package/dist/workstation/keepers/kit/bootstrap.js +164 -0
  204. package/dist/workstation/keepers/kit/buildVersion.js +51 -0
  205. package/dist/workstation/keepers/kit/bundle.js +120 -0
  206. package/dist/workstation/keepers/kit/client.js +432 -0
  207. package/dist/workstation/keepers/kit/identity.js +21 -0
  208. package/dist/workstation/keepers/kit/ipc.js +112 -0
  209. package/dist/workstation/keepers/kit/launchConfig.js +8 -0
  210. package/dist/workstation/keepers/kit/launcher.js +249 -0
  211. package/dist/workstation/keepers/kit/lock.js +123 -0
  212. package/dist/workstation/keepers/kit/orphanSweep.js +86 -0
  213. package/dist/workstation/keepers/kit/paths.js +75 -0
  214. package/dist/workstation/keepers/kit/protocol.js +43 -0
  215. package/dist/workstation/keepers/kit/server.js +518 -0
  216. package/dist/workstation/keepers/kit/streamLog.js +697 -0
  217. package/dist/workstation/keepers/legacy.js +296 -0
  218. package/dist/workstation/keepers/policy.js +576 -0
  219. package/dist/workstation/keepers/stop.js +34 -0
  220. package/dist/workstation/keepers/terminal/client.js +213 -0
  221. package/dist/workstation/keepers/terminal/exec.js +981 -0
  222. package/dist/workstation/keepers/terminal/generations.js +129 -0
  223. package/dist/workstation/keepers/terminal/keeper.js +62 -0
  224. package/dist/workstation/keepers/terminal/main.js +43 -0
  225. package/dist/workstation/keepers/terminal/orphanSweep.js +3 -0
  226. package/dist/workstation/keepers/terminal/params.js +27 -0
  227. package/dist/workstation/keepers/terminal/protocol.js +56 -0
  228. package/dist/workstation/keepers/terminal/pty.js +330 -0
  229. package/dist/workstation/oauth/deviceAuthorization.js +21 -12
  230. package/dist/workstation/protocol/accountLimits.js +47 -0
  231. package/dist/workstation/protocol/agent.js +201 -8
  232. package/dist/workstation/protocol/common.js +36 -0
  233. package/dist/workstation/protocol/computer.js +107 -7
  234. package/dist/workstation/protocol/management.js +50 -2
  235. package/dist/workstation/qoderAdapter/backgroundProcess.js +118 -0
  236. package/dist/workstation/qoderAdapter/backgroundTerminal.js +193 -0
  237. package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +372 -0
  238. package/dist/workstation/qoderAdapter/callbackContext.js +76 -0
  239. package/dist/workstation/qoderAdapter/completedContinuation.js +139 -0
  240. package/dist/workstation/qoderAdapter/contract.js +78 -0
  241. package/dist/workstation/qoderAdapter/foregroundOutput.js +36 -0
  242. package/dist/workstation/qoderAdapter/history.js +85 -0
  243. package/dist/workstation/qoderAdapter/identityIndex.js +191 -0
  244. package/dist/workstation/qoderAdapter/inputQueue.js +42 -0
  245. package/dist/workstation/qoderAdapter/main.js +95 -0
  246. package/dist/workstation/qoderAdapter/mapper.js +117 -0
  247. package/dist/workstation/qoderAdapter/observerPlugin.js +245 -0
  248. package/dist/workstation/qoderAdapter/observerReceiver.js +187 -0
  249. package/dist/workstation/qoderAdapter/permissions.js +61 -0
  250. package/dist/workstation/qoderAdapter/profile.js +93 -0
  251. package/dist/workstation/qoderAdapter/sdkDriver.js +48 -0
  252. package/dist/workstation/qoderAdapter/sdkRuntime.js +93 -0
  253. package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +145 -0
  254. package/dist/workstation/qoderAdapter/session.js +319 -0
  255. package/dist/workstation/qoderAdapter/subagentMeta.js +28 -0
  256. package/dist/workstation/qoderAdapter/subwork.js +85 -0
  257. package/dist/workstation/qoderAdapter/taskControl.js +38 -0
  258. package/dist/workstation/qoderAdapter/taskLedger.js +163 -0
  259. package/dist/workstation/qoderAdapter/types.js +1 -0
  260. package/dist/workstation/qoderAdapter/usage.js +37 -0
  261. package/dist/workstation/register.js +4 -0
  262. package/dist/workstation/systemMetrics.js +110 -1
  263. package/dist/workstation/tools/browser.js +394 -0
  264. package/dist/workstation/tools/client.js +277 -0
  265. package/dist/workstation/tools/info.js +50 -0
  266. package/dist/workstation/tools/keeperProcess.js +124 -0
  267. package/dist/workstation/tools/router.js +55 -0
  268. package/dist/workstation/tools/terminalRouter.js +222 -0
  269. package/dist/workstation/tools/validation.js +86 -0
  270. package/dist/workstation/update/coordinator.js +32 -0
  271. package/dist/workstation/update/npmInstaller.js +2 -0
  272. package/dist/workstation/update/ptyPreflight.js +45 -0
  273. package/dist/workstation/update/taskRegistry.js +141 -23
  274. package/dist/workstation/update/watchdog.js +66 -0
  275. package/package.json +20 -7
  276. package/skills/axom/SKILL.md +55 -17
  277. package/skills/axom-definition/SKILL.md +32 -32
  278. package/skills/axom-platform/SKILL.md +39 -39
  279. package/dist/workstation/acp/acpSession.js +0 -1047
  280. package/dist/workstation/acp/contract.js +0 -494
  281. package/dist/workstation/acp/draftInterceptor.js +0 -154
@@ -0,0 +1,576 @@
1
+ // Daemon-side: what the Workstations page sees of the keepers (heartbeat `keepers[]`, spec §8.1) and the keeper REPLACEMENT POLICY
2
+ // (spec §8.3, Task 26). Not a keeper-side module: keeper processes never import it. Daemon business modules (acp/, agentSessions,
3
+ // update/) are reached only through the injected sides below.
4
+ //
5
+ // replacement of a generation (computed here, locally, after the CLI that brought the new build was installed — the server only
6
+ // PREDICTS it from the release manifest before an update, Task 27):
7
+ // none it is the current build's generation
8
+ // available another build, its protocol inside the current build's `daemonProtocol` window: NOTHING happens by itself (an older
9
+ // keeper-terminal even keeps serving new work while the current build is not running, keepers/terminal/generations.ts)
10
+ // required its protocol is outside that window
11
+ // mandatory the current build's manifest says `mandatory`
12
+ //
13
+ // Policy, acted on at most once per (kind, generation) — a decision is persisted (`<configDir>/keepers/policy.json`), so neither a
14
+ // daemon restart nor a user's cancel brings an automatic one back:
15
+ // terminal required/mandatory: queued `when-idle` — new work goes to the current build; the older generation is stopped once it
16
+ // has no open PTY and no running exec (`ptyOpen == 0 && execRunning == 0`). The user may queue it for an `available`
17
+ // one (replace-when-idle), stop it now (replace-now: its PTYs and commands end, listed in `affected`) or cancel.
18
+ // acp required/mandatory: rolling — new sessions already go to the current generation (Task 21); every session held on an
19
+ // older one gets a persistent intent to move there when idle (`moveOffOlderKeeperGenerations`, Task 24); an older
20
+ // generation that hosts no adapter any more is stopped (`retireIdleGenerations`). Never for `available` unless asked.
21
+ // replace-now stops the older generations at once (their in-flight turns end; the sessions reopen through session/load).
22
+ // legacy the legacy tools keeper (spec §9) retires by itself (keepers/legacy.ts); it is reported `required`/`when-idle`, and
23
+ // replace-now stops it at once.
24
+ import { promises as fs } from 'node:fs';
25
+ import path from 'node:path';
26
+ import { keeperOneShot, readKeeperToken } from './kit/client.js';
27
+ import { inProtocolWindow, isProtocolWindow, protocolWindow } from './kit/protocol.js';
28
+ import { TERMINAL_PROTOCOL_VERSION } from './terminal/protocol.js';
29
+ import { ACP_PROTOCOL_VERSION } from './acp/protocol.js';
30
+ import { KEEPER_AFFECTED_MAX_ITEMS, KEEPER_LABEL_MAX_CHARS, KEEPER_STATUS_MAX_ENTRIES, KEEPER_STATUS_MAX_ITEMS, } from '../protocol/management.js';
31
+ /** How often the policy looks at the keepers (and acts on queued replacements). */
32
+ export const KEEPER_POLICY_TICK_MS = 30_000;
33
+ /** One keeper's sample (info + activity on fresh connections) may take this long; past it the keeper is `unavailable`. */
34
+ export const KEEPER_SAMPLE_TIMEOUT_MS = 1000;
35
+ /** A sample this young is reused (the heartbeat and the policy tick share it). */
36
+ export const KEEPER_SAMPLE_REUSE_MS = 2000;
37
+ /** Decisions kept in the policy file (oldest dropped first). */
38
+ export const KEEPER_DECISIONS_LIMIT = 64;
39
+ /** `keeper_control` results kept for a repeated commandId. */
40
+ export const KEEPER_CONTROL_RESULTS_LIMIT = 64;
41
+ /** The policy file, under `<configDir>/keepers/`. */
42
+ export const KEEPER_POLICY_FILE = 'policy.json';
43
+ /** What replacing generation `gen` takes (see the file comment). `protocolVersion` null = unknown (not reachable): `available`. */
44
+ export function replacementOf(input) {
45
+ if (input.gen === input.current)
46
+ return 'none';
47
+ if (input.protocolVersion !== null && !inProtocolWindow(input.window, input.protocolVersion))
48
+ return 'required';
49
+ return input.mandatory ? 'mandatory' : 'available';
50
+ }
51
+ /** Whether a replacement of this kind happens without the user asking (spec §8.3): never for `available`. */
52
+ export const replacesByItself = (replacement) => replacement === 'required' || replacement === 'mandatory';
53
+ /** A keeper-terminal generation may be stopped by a queued replacement: no open PTY and no running exec. */
54
+ export function terminalIdle(metrics) {
55
+ return (metrics.ptyOpen ?? 0) === 0 && (metrics.execRunning ?? 0) === 0;
56
+ }
57
+ const clip = (text, max = KEEPER_LABEL_MAX_CHARS) => (text.length > max ? text.slice(0, max) : text);
58
+ /** A label is a name, never a command line: the first word, without a path. */
59
+ export function nameLabel(text, fallback) {
60
+ const first = (text ?? '').trim().split(/\s+/)[0] ?? '';
61
+ const base = first.includes('/') ? path.basename(first) : first;
62
+ return clip(base || fallback);
63
+ }
64
+ /** A shared keeper collection that does not finish within this is abandoned (its callers fail; the next call starts anew). */
65
+ export const KEEPER_COLLECT_TIMEOUT_MS = 5000;
66
+ export class KeeperSampleTimeoutError extends Error {
67
+ constructor(timeoutMs) { super(`Keepers could not be sampled within ${timeoutMs} ms`); this.name = 'KeeperSampleTimeoutError'; }
68
+ }
69
+ /**
70
+ * The default probe: `keeper.info` and `keeper.activity`, each on its own fresh connection (window-exempt methods; the hello
71
+ * accepts any protocol, so a keeper outside the window is still described). Starts nothing.
72
+ */
73
+ export function oneShotProbe(pathsFor) {
74
+ return async (kind, gen, timeoutMs) => {
75
+ const paths = pathsFor(kind, gen);
76
+ const token = await readKeeperToken(paths.tokenFile);
77
+ const [info, activity] = await Promise.all([
78
+ keeperOneShot(paths.socket, 'keeper.info', {}, timeoutMs, { kind, token }),
79
+ keeperOneShot(paths.socket, 'keeper.activity', {}, timeoutMs, { kind, token }),
80
+ ]);
81
+ if (info.gen !== gen || info.kind !== kind)
82
+ throw new Error('keeper identity mismatch');
83
+ return { hello: info, draining: info.draining === true, activity: activity };
84
+ };
85
+ }
86
+ /** `keeper.drain` on a fresh connection (window-exempt). Starts nothing. */
87
+ export function oneShotDrain(pathsFor, timeoutMs = 2000) {
88
+ return async (kind, gen) => {
89
+ const paths = pathsFor(kind, gen);
90
+ await keeperOneShot(paths.socket, 'keeper.drain', {}, timeoutMs, { kind, token: await readKeeperToken(paths.tokenFile) });
91
+ };
92
+ }
93
+ const decisionKey = (kind, gen) => `${kind}:${gen}`;
94
+ export class KeeperPolicy {
95
+ options;
96
+ decisions = new Map();
97
+ loaded;
98
+ sample;
99
+ collecting;
100
+ results = new Map();
101
+ /** Policy actions run one at a time (decisions, stops). */
102
+ acting = Promise.resolve();
103
+ now;
104
+ log;
105
+ constructor(options) {
106
+ this.options = options;
107
+ this.now = options.now ?? Date.now;
108
+ this.log = options.log ?? (() => { });
109
+ }
110
+ // ---- decisions (persisted) --------------------------------------------------------------------------------------------------
111
+ get file() { return path.join(this.options.dir, KEEPER_POLICY_FILE); }
112
+ load() {
113
+ this.loaded ??= (async () => {
114
+ try {
115
+ const value = JSON.parse(await fs.readFile(this.file, 'utf8'));
116
+ if (value.v === 1 && value.decisions && typeof value.decisions === 'object') {
117
+ for (const [key, decision] of Object.entries(value.decisions)) {
118
+ if (decision && ['when-idle', 'cancelled', 'done'].includes(decision.mode) && ['auto', 'user'].includes(decision.by))
119
+ this.decisions.set(key, decision);
120
+ }
121
+ }
122
+ }
123
+ catch { /* none yet, or unreadable: start empty (an automatic decision may then be taken again — harmless) */ }
124
+ })();
125
+ return this.loaded;
126
+ }
127
+ async save() {
128
+ while (this.decisions.size > KEEPER_DECISIONS_LIMIT)
129
+ this.decisions.delete(this.decisions.keys().next().value);
130
+ const body = JSON.stringify({ v: 1, decisions: Object.fromEntries(this.decisions) });
131
+ try {
132
+ await fs.mkdir(this.options.dir, { recursive: true, mode: 0o700 });
133
+ const temp = `${this.file}.${process.pid}.${this.now()}.tmp`;
134
+ await fs.writeFile(temp, body, { mode: 0o600 });
135
+ await fs.rename(temp, this.file);
136
+ }
137
+ catch (error) {
138
+ this.log(`[keepers] policy file not written: ${error instanceof Error ? error.message : String(error)}`);
139
+ }
140
+ }
141
+ async decide(kind, gen, decision) {
142
+ const key = decisionKey(kind, gen);
143
+ this.decisions.delete(key);
144
+ this.decisions.set(key, decision);
145
+ this.sample = undefined; // a status sampled before carries the old `pending`
146
+ await this.save();
147
+ }
148
+ /** The decision on (kind, gen), if any (tests and diagnostics). */
149
+ async decisionOf(kind, gen) {
150
+ await this.load();
151
+ return this.decisions.get(decisionKey(kind, gen));
152
+ }
153
+ pendingOf(kind, gen) {
154
+ return this.decisions.get(decisionKey(kind, gen))?.mode === 'when-idle' ? 'when-idle' : null;
155
+ }
156
+ serialized(work) {
157
+ const run = this.acting.catch(() => undefined).then(work);
158
+ this.acting = run.catch(() => undefined);
159
+ return run;
160
+ }
161
+ // ---- serving generation (keeper-terminal) -----------------------------------------------------------------------------------
162
+ /**
163
+ * `TerminalGenerations.picker`: an older keeper-terminal generation keeps serving new work only while the current build does not
164
+ * run, its replacement is merely `available` and nobody queued it. Otherwise (or when unsure) the current build serves.
165
+ */
166
+ async terminalServing(current, older) {
167
+ const terminal = this.options.terminal;
168
+ if (!terminal)
169
+ return undefined;
170
+ await this.load();
171
+ if ((await terminal.live()).includes(current))
172
+ return undefined;
173
+ const manifest = await terminal.manifest();
174
+ const window = windowOf(manifest, TERMINAL_PROTOCOL_VERSION);
175
+ let best;
176
+ const eligible = (gen) => {
177
+ const decision = this.decisions.get(decisionKey('terminal', gen));
178
+ return !decision?.drained && decision?.mode !== 'when-idle' && decision?.mode !== 'done';
179
+ };
180
+ for (const gen of older) {
181
+ if (!eligible(gen))
182
+ continue;
183
+ const hello = await terminal.reachable(gen).catch(() => null);
184
+ // Asked again after the await: a replacement queued meanwhile wins (its stop drains and re-probes before stopping, too).
185
+ if (!hello || !eligible(gen))
186
+ continue;
187
+ if (replacementOf({ gen, current, protocolVersion: hello.protocolVersion, window, mandatory: manifest.mandatory === true }) !== 'available')
188
+ continue;
189
+ if (!best || hello.startedAt > best.startedAt)
190
+ best = { gen, startedAt: hello.startedAt };
191
+ }
192
+ return best?.gen;
193
+ }
194
+ // ---- sampling -----------------------------------------------------------------------------------------------------------
195
+ /**
196
+ * The heartbeat's `keepers` (spec §8.1). A keeper that cannot be sampled is `unavailable`; rejects only with
197
+ * {@link KeeperSampleTimeoutError} when the whole collection hung (the heartbeat then sends `keepers:null`).
198
+ */
199
+ async statuses() {
200
+ return (await this.sampled()).map((entry) => entry.status).slice(0, KEEPER_STATUS_MAX_ENTRIES);
201
+ }
202
+ sampled(fresh = false) {
203
+ // One collection at a time: one in flight is joined, never piled up behind every heartbeat. It has its own deadline: a step with
204
+ // no bound of its own (a wedged file or /proc read) fails it — every joined caller gets KeeperSampleTimeoutError, and the next call
205
+ // starts a new collection — so no caller (heartbeat, tick, keeper_control) ever waits on it longer than the deadline.
206
+ if (this.collecting)
207
+ return this.collecting;
208
+ const cached = this.sample;
209
+ if (!fresh && cached && this.now() - cached.at < KEEPER_SAMPLE_REUSE_MS)
210
+ return cached.result;
211
+ const timeoutMs = this.options.collectTimeoutMs ?? KEEPER_COLLECT_TIMEOUT_MS;
212
+ let timer;
213
+ const collecting = this.collect();
214
+ collecting.catch(() => undefined); // settling after the deadline is nobody's business
215
+ const result = Promise.race([
216
+ collecting,
217
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new KeeperSampleTimeoutError(timeoutMs)), timeoutMs); timer.unref?.(); }),
218
+ ]).finally(() => clearTimeout(timer));
219
+ this.collecting = result;
220
+ const done = () => { if (this.collecting === result)
221
+ this.collecting = undefined; };
222
+ const failed = () => { done(); if (this.sample?.result === result)
223
+ this.sample = undefined; };
224
+ result.then(done, failed);
225
+ this.sample = { at: this.now(), result };
226
+ return result;
227
+ }
228
+ async collect() {
229
+ await this.load();
230
+ const [terminal, acp] = await Promise.all([this.collectTerminal(), this.collectAcp()]);
231
+ return [...terminal, ...acp];
232
+ }
233
+ async probe(kind, gen) {
234
+ try {
235
+ return await this.options.probe(kind, gen, this.options.sampleTimeoutMs ?? KEEPER_SAMPLE_TIMEOUT_MS);
236
+ }
237
+ catch {
238
+ return null;
239
+ }
240
+ }
241
+ async collectTerminal() {
242
+ const out = [];
243
+ const terminal = this.options.terminal;
244
+ if (terminal) {
245
+ try {
246
+ const [current, live, manifest] = await Promise.all([terminal.currentGen(), terminal.live(), terminal.manifest()]);
247
+ const window = windowOf(manifest, TERMINAL_PROTOCOL_VERSION);
248
+ const gens = live.includes(current) ? live : [current, ...live];
249
+ out.push(...await Promise.all(gens.map(async (gen) => {
250
+ const isLive = live.includes(gen);
251
+ const sample = isLive ? await this.probe('terminal', gen) : null;
252
+ const protocolVersion = sample?.hello.protocolVersion ?? (gen === current ? manifest.protocolVersion : null);
253
+ return {
254
+ live: isLive, current: gen === current,
255
+ status: {
256
+ kind: 'terminal', gen, state: !isLive ? 'stopped' : sample ? 'running' : 'unavailable',
257
+ protocolVersion: protocolVersion ?? 0,
258
+ buildVersion: sample?.hello.buildVersion ?? (gen === current ? manifest.buildVersion : ''),
259
+ replacement: replacementOf({ gen, current, protocolVersion, window, mandatory: manifest.mandatory === true }),
260
+ pending: gen === current ? null : this.pendingOf('terminal', gen),
261
+ metrics: terminalMetrics(sample?.activity),
262
+ items: terminalItems(sample?.activity),
263
+ },
264
+ };
265
+ })));
266
+ }
267
+ catch (error) {
268
+ this.log(`[keepers] keeper-terminal could not be described: ${error instanceof Error ? error.message : String(error)}`);
269
+ }
270
+ }
271
+ const legacy = this.options.legacy;
272
+ if (legacy?.live)
273
+ out.push({ live: true, current: false, status: this.legacyStatus(legacy) });
274
+ return out;
275
+ }
276
+ legacyStatus(legacy) {
277
+ const { state, open } = legacy.status();
278
+ const shell = nameLabel(this.options.legacyShell?.(), 'shell');
279
+ const sessions = legacy.lastSessions();
280
+ return {
281
+ kind: 'terminal', gen: 'legacy', legacy: true, state: state === 'present' || state === 'stopping' ? 'running' : 'unavailable', // busy = not answering
282
+ protocolVersion: 0, buildVersion: 'legacy', replacement: 'required', pending: 'when-idle',
283
+ metrics: { ptyOpen: open ?? sessions.filter((session) => !session.exited).length, ptyActive: 0, execRunning: 0 },
284
+ items: sessions.slice(0, KEEPER_STATUS_MAX_ITEMS).map((session) => ({
285
+ type: 'pty', id: clip(session.id, 256), label: shell, state: session.exited ? 'exited' : 'idle', sinceEpochMs: 0,
286
+ })),
287
+ };
288
+ }
289
+ async collectAcp() {
290
+ const acp = this.options.acp;
291
+ if (!acp)
292
+ return [];
293
+ try {
294
+ const [current, live, manifest] = await Promise.all([acp.currentGen(), acp.live(), acp.manifest()]);
295
+ const window = windowOf(manifest, ACP_PROTOCOL_VERSION);
296
+ const gens = live.includes(current) ? live : [current, ...live];
297
+ return await Promise.all(gens.map(async (gen) => {
298
+ const isLive = live.includes(gen);
299
+ const sample = isLive ? await this.probe('acp', gen) : null;
300
+ const protocolVersion = sample?.hello.protocolVersion ?? (gen === current ? manifest.protocolVersion : null);
301
+ const items = this.acpItems(sample?.activity);
302
+ return {
303
+ live: isLive, current: gen === current,
304
+ status: {
305
+ kind: 'acp', gen, state: !isLive ? 'stopped' : sample ? 'running' : 'unavailable',
306
+ protocolVersion: protocolVersion ?? 0,
307
+ buildVersion: sample?.hello.buildVersion ?? (gen === current ? manifest.buildVersion : ''),
308
+ replacement: replacementOf({ gen, current, protocolVersion, window, mandatory: manifest.mandatory === true }),
309
+ pending: gen === current ? null : this.pendingOf('acp', gen),
310
+ metrics: {
311
+ sessions: finite(sample?.activity.metrics.sessionsRunning),
312
+ busy: items.filter((item) => item.state === 'busy').length,
313
+ },
314
+ items,
315
+ },
316
+ };
317
+ }));
318
+ }
319
+ catch (error) {
320
+ this.log(`[keepers] keeper-acp could not be described: ${error instanceof Error ? error.message : String(error)}`);
321
+ return [];
322
+ }
323
+ }
324
+ /** keeper-acp's mechanical items joined with this daemon's view: busy/idle is the daemon's judgement (spec §3.5). */
325
+ acpItems(activity) {
326
+ const items = [];
327
+ for (const item of activity?.items ?? []) {
328
+ if (item.type !== 'acp' || items.length >= KEEPER_STATUS_MAX_ITEMS)
329
+ continue;
330
+ const ref = refOfSessionKey(item.id);
331
+ const view = this.options.acp?.session(ref);
332
+ const agentId = view?.agentId ?? item.label;
333
+ const running = item.state === 'running';
334
+ items.push({
335
+ type: 'session', id: clip(ref, 256), label: clip((this.options.agentName?.(agentId) ?? agentId) || 'Agent'),
336
+ state: running ? (view?.busy ? 'busy' : 'idle') : item.state === 'starting' ? 'starting' : 'exited',
337
+ sinceEpochMs: item.sinceEpochMs,
338
+ });
339
+ }
340
+ return items;
341
+ }
342
+ // ---- the policy ---------------------------------------------------------------------------------------------------------
343
+ /** One policy pass (periodic): automatic decisions, then queued replacements that can run now. Never rejects. */
344
+ tick() {
345
+ return this.serialized(async () => {
346
+ const sampled = await this.sampled(true);
347
+ await this.automatic(sampled);
348
+ await this.runQueued(sampled);
349
+ }).catch((error) => { this.log(`[keepers] policy pass failed: ${error instanceof Error ? error.message : String(error)}`); });
350
+ }
351
+ /** required/mandatory, never decided before (a cancel or a restart never brings an automatic decision back): queue it. */
352
+ async automatic(sampled) {
353
+ for (const { status, live, current } of sampled) {
354
+ if (status.legacy || current || !live || !replacesByItself(status.replacement))
355
+ continue;
356
+ if (this.decisions.has(decisionKey(status.kind, status.gen)))
357
+ continue;
358
+ const refs = status.kind === 'acp' ? await this.moveOff([status.gen]) : undefined;
359
+ await this.decide(status.kind, status.gen, { mode: 'when-idle', by: 'auto', at: this.now(), ...(refs ? { refs } : {}) });
360
+ this.log(`[keepers] ${status.kind} generation ${status.gen} is ${status.replacement}: replaced once idle`);
361
+ }
362
+ }
363
+ async moveOff(gens) {
364
+ if (gens.length === 0)
365
+ return [];
366
+ try {
367
+ return await this.options.acp.moveOff(gens);
368
+ }
369
+ catch (error) {
370
+ this.log(`[keepers] moving sessions off older keeper-acp generations failed: ${error instanceof Error ? error.message : String(error)}`);
371
+ return [];
372
+ }
373
+ }
374
+ /** Queued (`when-idle`) replacements: an idle keeper-terminal generation is stopped; keeper-acp keeps migrating and retires. */
375
+ async runQueued(sampled) {
376
+ const acpPending = [];
377
+ for (const { status, live, current } of sampled) {
378
+ if (status.legacy || current || this.pendingOf(status.kind, status.gen) !== 'when-idle')
379
+ continue;
380
+ if (status.kind === 'acp') {
381
+ if (live)
382
+ acpPending.push(status.gen);
383
+ continue;
384
+ }
385
+ if (!live || status.state !== 'running' || !terminalIdle(status.metrics))
386
+ continue;
387
+ // Asked again right before stopping: a PTY or an exec that started since the sample keeps it.
388
+ const again = await this.probe('terminal', status.gen);
389
+ if (!again || !terminalIdle(terminalMetrics(again.activity)))
390
+ continue;
391
+ // A create the picker routed there before the decision may still be on its way: drain first (keeper-terminal refuses new work
392
+ // from now on), then stop only if a probe AFTER the drain still finds it idle.
393
+ try {
394
+ await this.options.terminal.drain(status.gen);
395
+ }
396
+ catch {
397
+ continue;
398
+ }
399
+ const decision = this.decisions.get(decisionKey('terminal', status.gen));
400
+ if (decision && !decision.drained)
401
+ await this.decide('terminal', status.gen, { ...decision, drained: true });
402
+ const drained = await this.probe('terminal', status.gen);
403
+ if (!drained || !terminalIdle(terminalMetrics(drained.activity)))
404
+ continue;
405
+ await this.stopTerminal(status.gen, 'graceful', 'idle');
406
+ }
407
+ // Queued decisions of generations that are gone are done — only for a kind whose generations could be listed at all (its current
408
+ // generation is always in a successful listing), so a failed listing never ends a decision.
409
+ const listedKinds = new Set(sampled.filter((entry) => entry.current).map((entry) => entry.status.kind));
410
+ for (const [key, decision] of [...this.decisions]) {
411
+ if (decision.mode !== 'when-idle')
412
+ continue;
413
+ const [kind, gen] = key.split(':');
414
+ if (!listedKinds.has(kind))
415
+ continue;
416
+ if (!sampled.some((entry) => entry.status.kind === kind && entry.status.gen === gen && entry.live)) {
417
+ await this.decide(kind, gen, { ...decision, mode: 'done', at: this.now() });
418
+ }
419
+ }
420
+ if (acpPending.length > 0 && this.options.acp) {
421
+ // Sessions adopted on such a generation since the decision get their intent too (idempotent); their refs join the decision, so a
422
+ // cancel withdraws every intent it caused.
423
+ const refs = await this.moveOff(acpPending);
424
+ for (const gen of acpPending) {
425
+ const decision = this.decisions.get(decisionKey('acp', gen));
426
+ if (!decision || decision.mode !== 'when-idle')
427
+ continue;
428
+ const merged = [...new Set([...(decision.refs ?? []), ...refs])];
429
+ if (merged.length !== (decision.refs ?? []).length)
430
+ await this.decide('acp', gen, { ...decision, refs: merged });
431
+ }
432
+ await this.options.acp.retireIdle().catch(() => undefined);
433
+ }
434
+ }
435
+ async stopTerminal(gen, mode, why) {
436
+ const terminal = this.options.terminal;
437
+ const result = await terminal.stop(gen, mode).catch((error) => ({ stopped: false, pid: null, reason: error instanceof Error ? error.message : String(error) }));
438
+ this.log(`[keepers] keeper-terminal generation ${gen} (${why}): ${result.stopped ? 'stopped' : `not stopped${result.reason ? ` (${result.reason})` : ''}`}`);
439
+ const previous = this.decisions.get(decisionKey('terminal', gen));
440
+ if (result.stopped)
441
+ await this.decide('terminal', gen, { mode: 'done', by: previous?.by ?? 'user', at: this.now(), ...(previous?.refs ? { refs: previous.refs } : {}) });
442
+ this.sample = undefined;
443
+ return result.stopped;
444
+ }
445
+ // ---- keeper_control -------------------------------------------------------------------------------------------------------
446
+ /** Answers `keeper_control`; a repeated commandId gets the same answer (spec §8.1). Never rejects. */
447
+ control(frame) {
448
+ const known = this.results.get(frame.commandId);
449
+ if (known)
450
+ return known;
451
+ const result = this.serialized(() => this.apply(frame)).catch((error) => {
452
+ // Nothing was done when the keepers could not even be sampled: the same commandId may be tried again.
453
+ if (error instanceof KeeperSampleTimeoutError)
454
+ queueMicrotask(() => { if (this.results.get(frame.commandId) === result)
455
+ this.results.delete(frame.commandId); });
456
+ return {
457
+ t: 'keeper_control_result', commandId: frame.commandId, kind: frame.kind, action: frame.action, ok: false, affected: [],
458
+ error: error instanceof Error ? error.message : String(error),
459
+ };
460
+ });
461
+ this.results.set(frame.commandId, result);
462
+ while (this.results.size > KEEPER_CONTROL_RESULTS_LIMIT)
463
+ this.results.delete(this.results.keys().next().value);
464
+ return result;
465
+ }
466
+ async apply(frame) {
467
+ const sampled = await this.sampled(true);
468
+ const older = sampled.filter((entry) => entry.status.kind === frame.kind && !entry.current && entry.live && !entry.status.legacy);
469
+ const legacy = frame.kind === 'terminal' ? sampled.find((entry) => entry.status.legacy) : undefined;
470
+ const affected = [];
471
+ let ok = true;
472
+ let incomplete = false;
473
+ let error;
474
+ if (frame.kind === 'acp' && !this.options.acp)
475
+ throw new Error('keeper-acp is not managed by this daemon');
476
+ if (frame.kind === 'terminal' && !this.options.terminal)
477
+ throw new Error('keeper-terminal is not managed by this daemon');
478
+ switch (frame.action) {
479
+ case 'replace-when-idle': {
480
+ const refs = frame.kind === 'acp' ? await this.moveOff(older.map((entry) => entry.status.gen)) : undefined;
481
+ for (const { status } of older)
482
+ await this.decide(frame.kind, status.gen, { mode: 'when-idle', by: 'user', at: this.now(), ...(refs ? { refs } : {}) });
483
+ if (older.length > 0)
484
+ await this.runQueued(await this.sampled(true));
485
+ break;
486
+ }
487
+ case 'replace-now': {
488
+ for (const { status } of older) {
489
+ const interrupted = status.items.filter((item) => item.state !== 'exited');
490
+ const stopped = frame.kind === 'terminal'
491
+ ? await this.stopTerminal(status.gen, 'force', 'replaced now')
492
+ : await this.stopAcp(status.gen);
493
+ if (stopped) {
494
+ affected.push(...interrupted.map((item) => ({ type: item.type, id: item.id, label: item.label, gen: status.gen })));
495
+ // Not every interrupted thing is named: the keeper could not be asked, or its items were cut at the bound.
496
+ const running = frame.kind === 'terminal' ? (status.metrics.ptyOpen ?? 0) + (status.metrics.execRunning ?? 0) : (status.metrics.sessions ?? 0);
497
+ if (status.state !== 'running' || interrupted.length < running)
498
+ incomplete = true;
499
+ }
500
+ else {
501
+ ok = false;
502
+ error = `generation ${status.gen} could not be stopped`;
503
+ }
504
+ }
505
+ if (legacy && this.options.legacy) {
506
+ const shell = nameLabel(this.options.legacyShell?.(), 'shell');
507
+ for (const session of await this.options.legacy.replaceNow())
508
+ affected.push({ type: 'pty', id: session.id, label: shell, gen: 'legacy' });
509
+ if (this.options.legacy.live) {
510
+ ok = false;
511
+ error = 'the legacy tools keeper could not be stopped';
512
+ }
513
+ }
514
+ break;
515
+ }
516
+ case 'cancel': {
517
+ for (const [key, decision] of [...this.decisions]) {
518
+ if (!key.startsWith(`${frame.kind}:`) || decision.mode !== 'when-idle')
519
+ continue;
520
+ if (frame.kind === 'acp' && decision.refs?.length)
521
+ await this.options.acp.cancelMoves(decision.refs).catch(() => undefined);
522
+ await this.decide(frame.kind, key.slice(frame.kind.length + 1), { ...decision, mode: 'cancelled', by: 'user', at: this.now() });
523
+ }
524
+ break;
525
+ }
526
+ }
527
+ this.sample = undefined;
528
+ // From the decisions themselves (a sample in flight may predate them); the legacy keeper is always queued while it lives.
529
+ const queued = [...this.decisions].some(([key, decision]) => key.startsWith(`${frame.kind}:`) && decision.mode === 'when-idle')
530
+ || (frame.kind === 'terminal' && this.options.legacy?.live === true);
531
+ if (affected.length > KEEPER_AFFECTED_MAX_ITEMS)
532
+ incomplete = true;
533
+ return {
534
+ t: 'keeper_control_result', commandId: frame.commandId, kind: frame.kind, action: frame.action, ok,
535
+ affected: affected.slice(0, KEEPER_AFFECTED_MAX_ITEMS), pending: queued ? 'when-idle' : null,
536
+ ...(incomplete ? { affectedIncomplete: true } : {}), ...(error ? { error } : {}),
537
+ };
538
+ }
539
+ async stopAcp(gen) {
540
+ const acp = this.options.acp;
541
+ const result = await acp.stop(gen, 'force').catch((error) => ({ stopped: false, pid: null, reason: error instanceof Error ? error.message : String(error) }));
542
+ this.log(`[keepers] keeper-acp generation ${gen} (replaced now): ${result.stopped ? 'stopped' : `not stopped${result.reason ? ` (${result.reason})` : ''}`}`);
543
+ const previous = this.decisions.get(decisionKey('acp', gen));
544
+ if (result.stopped)
545
+ await this.decide('acp', gen, { mode: 'done', by: 'user', at: this.now(), ...(previous?.refs ? { refs: previous.refs } : {}) });
546
+ return result.stopped;
547
+ }
548
+ }
549
+ /** The window the current build's daemon drives (its manifest's `daemonProtocol`, which the release manifest carries too). */
550
+ function windowOf(manifest, protocol) {
551
+ return isProtocolWindow(manifest.daemonProtocol) ? manifest.daemonProtocol : protocolWindow(protocol);
552
+ }
553
+ const finite = (value) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0);
554
+ function terminalMetrics(activity) {
555
+ const metrics = activity?.metrics ?? {};
556
+ return { ptyOpen: finite(metrics.ptyOpen), ptyActive: finite(metrics.ptyActive), execRunning: finite(metrics.execRunning) };
557
+ }
558
+ /** keeper-terminal's items: PTYs (foreground process name) and execs (the server's label); bounded, labels cut. */
559
+ function terminalItems(activity) {
560
+ const items = [];
561
+ for (const item of activity?.items ?? []) {
562
+ if ((item.type !== 'pty' && item.type !== 'exec') || items.length >= KEEPER_STATUS_MAX_ITEMS)
563
+ continue;
564
+ items.push({
565
+ type: item.type, id: clip(item.id, 256),
566
+ // A PTY label is already a process name; an exec label is the server's description — neither is a command line.
567
+ label: clip(item.label || (item.type === 'pty' ? 'shell' : 'Command')),
568
+ state: item.state, sinceEpochMs: item.sinceEpochMs, ...(item.jobId ? { jobId: item.jobId } : {}),
569
+ });
570
+ }
571
+ return items;
572
+ }
573
+ /** The session ref a keeper-acp session key belongs to (`<ref>` or a switch successor `<ref>#s<n>`, Task 24). */
574
+ export function refOfSessionKey(key) {
575
+ return key.replace(/#s\d+$/, '');
576
+ }
@@ -0,0 +1,34 @@
1
+ /** The kit keepers a stopped workstation stops, every running generation of each. */
2
+ export const STOPPED_KEEPER_KINDS = ['acp', 'terminal'];
3
+ /** How long a graceful stop may take before it is forced (keeper-acp gives its adapters 1.5 s, the kit's grace is 3 s). */
4
+ export const KEEPER_STOP_WAIT_MS = 10_000;
5
+ /**
6
+ * Stops every running generation of keeper-acp and keeper-terminal: gracefully (`keeper.stop`: adapters, PTYs and execs are terminated
7
+ * by the keeper itself), then forced when it did not stop in time. Never throws; reports what happened per generation.
8
+ */
9
+ export async function stopKeepers(launcher, options = {}) {
10
+ const log = options.log ?? (() => { });
11
+ const reports = [];
12
+ for (const kind of STOPPED_KEEPER_KINDS) {
13
+ let gens;
14
+ try {
15
+ gens = await launcher.liveGenerations(kind);
16
+ }
17
+ catch (error) {
18
+ log(`keeper-${kind}: could not be listed: ${messageOf(error)}`);
19
+ continue;
20
+ }
21
+ for (const gen of gens) {
22
+ let forced = false;
23
+ let result = await launcher.stop(kind, gen, 'graceful', options.waitMs ?? KEEPER_STOP_WAIT_MS).catch((error) => ({ stopped: false, pid: null, reason: messageOf(error) }));
24
+ if (!result.stopped) {
25
+ forced = true;
26
+ result = await launcher.stop(kind, gen, 'force').catch((error) => ({ stopped: false, pid: null, reason: messageOf(error) }));
27
+ }
28
+ log(`keeper-${kind} ${gen}: ${result.stopped ? (forced ? 'stopped (forced)' : 'stopped') : `could not be stopped${result.reason ? ` (${result.reason})` : ''}`}`);
29
+ reports.push({ kind, gen, result, forced });
30
+ }
31
+ }
32
+ return reports;
33
+ }
34
+ const messageOf = (error) => (error instanceof Error ? error.message : String(error));