@platless/axom-cli 0.3.13 → 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 (235) hide show
  1. package/dist/axom-patch/cli/README.md +30 -8
  2. package/dist/axom-patch/cli/account-limits/README.md +27 -0
  3. package/dist/axom-patch/cli/account-limits/antigravity-proxy.mjs +47 -0
  4. package/dist/axom-patch/cli/account-limits/antigravity-read.mjs +90 -0
  5. package/dist/axom-patch/cli/account-limits/antigravity.mjs +46 -0
  6. package/dist/axom-patch/cli/account-limits/generate.py +120 -0
  7. package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +64 -0
  8. package/dist/axom-patch/cli/account-limits/normalize.mjs +151 -0
  9. package/dist/axom-patch/cli/claude-account-limits.json +37 -0
  10. package/dist/axom-patch/cli/codex-account-limits.json +37 -0
  11. package/dist/axom-patch/cli/commandcode-account-limits.json +30 -0
  12. package/dist/axom-patch/cli/nightly-task.md +152 -0
  13. package/dist/axom-patch/cli/pi-subagent-subwork.json +23 -0
  14. package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +5 -1
  15. package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
  16. package/dist/axom-patch/cli/probes/qoder-steering.mjs +1 -1
  17. package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +20 -8
  18. package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
  19. package/dist/axom-patch/cli/qoder-background-terminal.md +2 -1
  20. package/dist/axom-patch/cli/qoder-foreground-output.md +4 -3
  21. package/dist/axom-patch/cli/qoder-steering.json +15 -15
  22. package/dist/axom-patch/cli/qoder-subagent-origin.json +21 -21
  23. package/dist/axom-patch/cli/runtime.json +34 -8
  24. package/dist/axom-patch/cli/zcode-account-limits.json +31 -0
  25. package/dist/axom-patch/cli/zcode-models.json +38 -0
  26. package/dist/axom-patch/cli/zcode-subagent-subwork.json +73 -0
  27. package/dist/cli.js +33 -6
  28. package/dist/keepers/acp/keeper.mjs +3083 -0
  29. package/dist/keepers/acp/manifest.json +9 -0
  30. package/dist/keepers/terminal/keeper.mjs +8469 -0
  31. package/dist/keepers/terminal/manifest.json +9 -0
  32. package/dist/keepers/terminal/node_modules/node-pty/lib/conpty_console_list_agent.js +25 -0
  33. package/dist/keepers/terminal/node_modules/node-pty/lib/eventEmitter2.js +47 -0
  34. package/dist/keepers/terminal/node_modules/node-pty/lib/index.js +52 -0
  35. package/dist/keepers/terminal/node_modules/node-pty/lib/interfaces.js +7 -0
  36. package/dist/keepers/terminal/node_modules/node-pty/lib/shared/conout.js +10 -0
  37. package/dist/keepers/terminal/node_modules/node-pty/lib/terminal.js +190 -0
  38. package/dist/keepers/terminal/node_modules/node-pty/lib/types.js +7 -0
  39. package/dist/keepers/terminal/node_modules/node-pty/lib/unixTerminal.js +351 -0
  40. package/dist/keepers/terminal/node_modules/node-pty/lib/utils.js +38 -0
  41. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsConoutConnection.js +131 -0
  42. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsPtyAgent.js +344 -0
  43. package/dist/keepers/terminal/node_modules/node-pty/lib/windowsTerminal.js +209 -0
  44. package/dist/keepers/terminal/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
  45. package/dist/keepers/terminal/node_modules/node-pty/package.json +66 -0
  46. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
  47. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
  48. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/pty.node +0 -0
  49. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
  50. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-arm64/pty.node +0 -0
  51. package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-x64/pty.node +0 -0
  52. package/dist/runtime-deps/qoder-sdk/package-lock.json +1189 -0
  53. package/dist/runtime-deps/qoder-sdk/package.json +8 -0
  54. package/dist/test/keeperKit.js +67 -0
  55. package/dist/workstation/acp/acpAgents.js +2163 -611
  56. package/dist/workstation/acp/acpClient.js +60 -497
  57. package/dist/workstation/acp/adoption.js +10 -396
  58. package/dist/workstation/acp/binaryInstall.js +100 -27
  59. package/dist/workstation/acp/builds/gc.js +245 -0
  60. package/dist/workstation/acp/builds/install/binary.js +28 -0
  61. package/dist/workstation/acp/builds/install/npm.js +235 -0
  62. package/dist/workstation/acp/builds/install/uv.js +254 -0
  63. package/dist/workstation/acp/builds/migrate.js +195 -0
  64. package/dist/workstation/acp/builds/model.js +128 -0
  65. package/dist/workstation/acp/builds/nodeGate.js +100 -0
  66. package/dist/workstation/acp/builds/store.js +352 -0
  67. package/dist/workstation/acp/commandTableProbe.js +68 -0
  68. package/dist/workstation/acp/configOptions.js +103 -1
  69. package/dist/workstation/acp/envSecrets.js +27 -0
  70. package/dist/workstation/acp/initializeResponse.js +285 -0
  71. package/dist/workstation/acp/installErrors.js +12 -2
  72. package/dist/workstation/acp/installProbe.js +1 -1
  73. package/dist/workstation/acp/managedPatches.js +76 -160
  74. package/dist/workstation/acp/outbox.js +330 -0
  75. package/dist/workstation/acp/patchAssets.js +20 -3
  76. package/dist/workstation/acp/permission.js +42 -10
  77. package/dist/workstation/acp/processor/checkpoint.js +87 -0
  78. package/dist/workstation/acp/processor/commands.js +35 -0
  79. package/dist/workstation/acp/processor/control.js +355 -0
  80. package/dist/workstation/acp/processor/emit.js +26 -0
  81. package/dist/workstation/acp/processor/jsonrpc.js +172 -0
  82. package/dist/workstation/acp/processor/memoryLog.js +49 -0
  83. package/dist/workstation/acp/processor/origins.js +48 -0
  84. package/dist/workstation/acp/processor/output.js +67 -0
  85. package/dist/workstation/acp/processor/projection.js +42 -0
  86. package/dist/workstation/acp/processor/record.js +128 -0
  87. package/dist/workstation/acp/processor/runtime.js +181 -0
  88. package/dist/workstation/acp/processor/sdkSchemas.js +61 -0
  89. package/dist/workstation/acp/processor/session.js +525 -0
  90. package/dist/workstation/acp/processor/sideChannel.js +80 -0
  91. package/dist/workstation/acp/processor/state.js +74 -0
  92. package/dist/workstation/acp/processor/step.js +459 -0
  93. package/dist/workstation/acp/processor/test/fixtureLog.js +65 -0
  94. package/dist/workstation/acp/processor/test/harness.js +120 -0
  95. package/dist/workstation/acp/processor/test/largeIdEcho.js +50 -0
  96. package/dist/workstation/acp/processor/test/rawAgent.js +70 -0
  97. package/dist/workstation/acp/processor/transport/generations.js +76 -0
  98. package/dist/workstation/acp/processor/transport/keeper.js +130 -0
  99. package/dist/workstation/acp/processor/transport/local.js +269 -0
  100. package/dist/workstation/acp/processor/transport/types.js +78 -0
  101. package/dist/workstation/acp/processor/updates.js +364 -0
  102. package/dist/workstation/acp/processorSession.js +1512 -0
  103. package/dist/workstation/acp/qoderStartup.js +1 -1
  104. package/dist/workstation/acp/registry/catalog.js +154 -32
  105. package/dist/workstation/acp/registry/launch.js +61 -119
  106. package/dist/workstation/acp/sessionUpdateMapper.js +18 -7
  107. package/dist/workstation/acp/steering.js +58 -0
  108. package/dist/workstation/acp/switching.js +114 -0
  109. package/dist/workstation/acp/terminalLogin.js +16 -42
  110. package/dist/workstation/acp/terminalLoginSpec.js +41 -0
  111. package/dist/workstation/acp/test/adapterFixtures.js +256 -0
  112. package/dist/workstation/acp/test/daemonLife.js +106 -0
  113. package/dist/workstation/acp/test/keeperWorld.js +267 -0
  114. package/dist/workstation/acp/test/memoryOutbox.js +55 -0
  115. package/dist/workstation/acp/test/raw/fixtures.js +196 -0
  116. package/dist/workstation/acp/test/raw/qoderCaptures.js +125 -0
  117. package/dist/workstation/acp/test/raw/sanitize.js +344 -0
  118. package/dist/workstation/acp/test/raw/sources.js +249 -0
  119. package/dist/workstation/acp/test/realAdapter.js +377 -0
  120. package/dist/workstation/acp/test/scriptAgent.js +206 -0
  121. package/dist/workstation/acp/test/sessionFrames.js +42 -0
  122. package/dist/workstation/acp/timedCommand.js +4 -2
  123. package/dist/workstation/acp/uvxInstall.js +72 -122
  124. package/dist/workstation/agentFlow.js +103 -0
  125. package/dist/workstation/agentSessions.js +1324 -474
  126. package/dist/workstation/channels/agent.js +7 -5
  127. package/dist/workstation/channels/computer.js +69 -21
  128. package/dist/workstation/channels/computerExecs.js +200 -0
  129. package/dist/workstation/channels/management.js +84 -4
  130. package/dist/workstation/daemon.js +288 -7
  131. package/dist/workstation/daemonctl.js +28 -2
  132. package/dist/workstation/exec/attach.js +126 -0
  133. package/dist/workstation/exec/command.js +40 -0
  134. package/dist/workstation/exec/durable.js +517 -0
  135. package/dist/workstation/exec/fallback.js +106 -0
  136. package/dist/workstation/exec/policy.js +837 -0
  137. package/dist/workstation/exec/shaping.js +211 -0
  138. package/dist/workstation/exec.js +3 -112
  139. package/dist/workstation/files/adapters/computerFileFrames.js +51 -0
  140. package/dist/workstation/files/adapters/toolRequests.js +121 -0
  141. package/dist/workstation/files/client.js +455 -0
  142. package/dist/workstation/files/errors.js +28 -0
  143. package/dist/workstation/files/fence.js +350 -0
  144. package/dist/workstation/files/find/fileIndex.js +153 -0
  145. package/dist/workstation/files/find/fuzzy.js +155 -0
  146. package/dist/workstation/files/host.js +122 -0
  147. package/dist/workstation/files/hostProtocol.js +2 -0
  148. package/dist/workstation/files/media/cache.js +620 -0
  149. package/dist/workstation/files/media/encoding.js +308 -0
  150. package/dist/workstation/files/media/estimator.js +100 -0
  151. package/dist/workstation/files/media/media.js +325 -0
  152. package/dist/workstation/files/media/packedAudio.js +23 -0
  153. package/dist/workstation/files/media/scheduler.js +551 -0
  154. package/dist/workstation/files/media/types.js +5 -0
  155. package/dist/workstation/files/scope.js +45 -0
  156. package/dist/workstation/files/structured.js +284 -0
  157. package/dist/workstation/{fileOps.js → files/text.js} +117 -38
  158. package/dist/workstation/files/uploads.js +351 -0
  159. package/dist/workstation/journal.js +65 -236
  160. package/dist/workstation/keepers/acp/client.js +101 -0
  161. package/dist/workstation/keepers/acp/envelope.js +62 -0
  162. package/dist/workstation/keepers/acp/host.js +419 -0
  163. package/dist/workstation/keepers/acp/main.js +18 -0
  164. package/dist/workstation/keepers/acp/memoryStream.js +121 -0
  165. package/dist/workstation/keepers/acp/params.js +137 -0
  166. package/dist/workstation/keepers/acp/protocol.js +66 -0
  167. package/dist/workstation/keepers/acp/recover.js +120 -0
  168. package/dist/workstation/keepers/acp/session.js +584 -0
  169. package/dist/workstation/keepers/acp/stderr.js +154 -0
  170. package/dist/workstation/keepers/kit/activity.js +103 -0
  171. package/dist/workstation/keepers/kit/bootstrap.js +164 -0
  172. package/dist/workstation/keepers/kit/buildVersion.js +51 -0
  173. package/dist/workstation/keepers/kit/bundle.js +120 -0
  174. package/dist/workstation/keepers/kit/client.js +432 -0
  175. package/dist/workstation/keepers/kit/identity.js +21 -0
  176. package/dist/workstation/keepers/kit/ipc.js +112 -0
  177. package/dist/workstation/keepers/kit/launchConfig.js +8 -0
  178. package/dist/workstation/keepers/kit/launcher.js +249 -0
  179. package/dist/workstation/keepers/kit/lock.js +123 -0
  180. package/dist/workstation/keepers/kit/orphanSweep.js +86 -0
  181. package/dist/workstation/keepers/kit/paths.js +75 -0
  182. package/dist/workstation/keepers/kit/protocol.js +43 -0
  183. package/dist/workstation/keepers/kit/server.js +518 -0
  184. package/dist/workstation/keepers/kit/streamLog.js +697 -0
  185. package/dist/workstation/keepers/legacy.js +296 -0
  186. package/dist/workstation/keepers/policy.js +576 -0
  187. package/dist/workstation/keepers/stop.js +34 -0
  188. package/dist/workstation/keepers/terminal/client.js +213 -0
  189. package/dist/workstation/keepers/terminal/exec.js +981 -0
  190. package/dist/workstation/keepers/terminal/generations.js +129 -0
  191. package/dist/workstation/keepers/terminal/keeper.js +62 -0
  192. package/dist/workstation/keepers/terminal/main.js +43 -0
  193. package/dist/workstation/keepers/terminal/orphanSweep.js +3 -0
  194. package/dist/workstation/keepers/terminal/params.js +27 -0
  195. package/dist/workstation/keepers/terminal/protocol.js +56 -0
  196. package/dist/workstation/keepers/terminal/pty.js +330 -0
  197. package/dist/workstation/protocol/accountLimits.js +47 -0
  198. package/dist/workstation/protocol/agent.js +48 -4
  199. package/dist/workstation/protocol/common.js +30 -0
  200. package/dist/workstation/protocol/computer.js +15 -4
  201. package/dist/workstation/protocol/management.js +50 -2
  202. package/dist/workstation/qoderAdapter/backgroundProcess.js +18 -6
  203. package/dist/workstation/qoderAdapter/backgroundTerminal.js +78 -147
  204. package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +372 -0
  205. package/dist/workstation/qoderAdapter/callbackContext.js +76 -0
  206. package/dist/workstation/qoderAdapter/foregroundOutput.js +15 -9
  207. package/dist/workstation/qoderAdapter/history.js +1 -3
  208. package/dist/workstation/qoderAdapter/main.js +4 -2
  209. package/dist/workstation/qoderAdapter/sdkDriver.js +1 -2
  210. package/dist/workstation/qoderAdapter/sdkRuntime.js +93 -0
  211. package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +145 -0
  212. package/dist/workstation/qoderAdapter/session.js +1 -1
  213. package/dist/workstation/qoderAdapter/subwork.js +15 -7
  214. package/dist/workstation/systemMetrics.js +110 -1
  215. package/dist/workstation/tools/client.js +9 -1
  216. package/dist/workstation/tools/info.js +50 -0
  217. package/dist/workstation/tools/keeperProcess.js +15 -5
  218. package/dist/workstation/tools/router.js +55 -0
  219. package/dist/workstation/tools/terminalRouter.js +222 -0
  220. package/dist/workstation/tools/validation.js +37 -1
  221. package/dist/workstation/update/coordinator.js +3 -0
  222. package/dist/workstation/update/ptyPreflight.js +21 -0
  223. package/dist/workstation/update/taskRegistry.js +105 -11
  224. package/package.json +10 -7
  225. package/dist/workstation/acp/acpSession.js +0 -1475
  226. package/dist/workstation/acp/contract.js +0 -499
  227. package/dist/workstation/acp/draftInterceptor.js +0 -163
  228. package/dist/workstation/acp/sessionScope.js +0 -19
  229. package/dist/workstation/filePaths.js +0 -174
  230. package/dist/workstation/fileWorker.js +0 -81
  231. package/dist/workstation/fileWorkerClient.js +0 -121
  232. package/dist/workstation/qoderAdapter/taskEvent.js +0 -26
  233. package/dist/workstation/tools/core.js +0 -50
  234. package/dist/workstation/tools/files.js +0 -525
  235. package/dist/workstation/tools/terminal.js +0 -214
@@ -1,3 +1,5 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import os from 'node:os';
1
3
  import path from 'node:path';
2
4
  import { fileURLToPath } from 'node:url';
3
5
  import { AGENT_WIRE_MIN_VERSION } from './protocol/agent.js';
@@ -12,7 +14,21 @@ import { TaskRegistry } from './update/taskRegistry.js';
12
14
  import { createUpdateCoordinator } from './update/runUpdate.js';
13
15
  import { resolveCliPath } from './daemonctl.js';
14
16
  import { SystemMetricsSampler } from './systemMetrics.js';
15
- import { preflightOptionalPty } from './update/ptyPreflight.js';
17
+ import { cachedPtyStatus, preflightOptionalPty } from './update/ptyPreflight.js';
18
+ import { FilesHostClient } from './files/client.js';
19
+ import { ToolsClient } from './tools/client.js';
20
+ import { WorkstationTools } from './tools/router.js';
21
+ import { aggregateToolsInfo, computeFilesInfo } from './tools/info.js';
22
+ import { defaultDistDir, KeeperLauncher } from './keepers/kit/launcher.js';
23
+ import { readKeeperManifest } from './keepers/kit/bundle.js';
24
+ import { KEEPER_POLICY_TICK_MS, KeeperPolicy, oneShotDrain, oneShotProbe } from './keepers/policy.js';
25
+ import { TerminalGenerations } from './keepers/terminal/generations.js';
26
+ import { AcpKeeperClient } from './keepers/acp/client.js';
27
+ import { KeeperTransport } from './acp/processor/transport/keeper.js';
28
+ import { keeperGenerations } from './acp/processor/transport/generations.js';
29
+ import { ExecPolicy } from './exec/policy.js';
30
+ import { LegacyToolsKeeper } from './keepers/legacy.js';
31
+ import { TerminalRouter, terminalEnv, terminalShell } from './tools/terminalRouter.js';
16
32
  /**
17
33
  * The path `runDaemon` hands `createUpdateCoordinator` as `cliPath`, used to re-provision the OS
18
34
  * service after an update (see runUpdate.ts's `provisionService`).
@@ -106,6 +122,45 @@ export const IDLE_SWEEP_INTERVAL_MS = 10 * 60 * 1000;
106
122
  * nobody awaits a timer callback, and an escaping rejection is an unhandled rejection, which Node
107
123
  * answers by exiting.
108
124
  */
125
+ /**
126
+ * The daemon's start-time adapter build upkeep: legacy migration, then the certified reconcile; calls
127
+ * `onRosterChanged` once if either changed any agent's current build. Never rejects.
128
+ */
129
+ export async function maintainAdapterBuilds(acp, onRosterChanged) {
130
+ try {
131
+ const migrated = await acp.migrateLegacyAdapters();
132
+ const moves = await acp.reconcileCertifiedBuilds();
133
+ if (migrated.length || moves.length)
134
+ await onRosterChanged();
135
+ }
136
+ catch (error) {
137
+ console.warn(`[acp-builds] adapter build upkeep failed: ${String(error)}`);
138
+ }
139
+ }
140
+ /** How often held sessions are looked at for a pending switch (spec §6.3 step 7) or a lost adapter to reopen (Task 24). */
141
+ export const SWITCH_POLL_INTERVAL_MS = 5_000;
142
+ /** How often unused adapter builds are collected (spec §6.3 "GC"; the first run is one grace period after start). */
143
+ export const BUILD_GC_INTERVAL_MS = 10 * 60_000;
144
+ /**
145
+ * Task 24: the switch scheduler (every {@link SWITCH_POLL_INTERVAL_MS}: `agent_reopen_request` for idle sessions with a pending
146
+ * switch, and for keeper-hosted sessions whose adapter keeper-acp lost) and build GC (every {@link BUILD_GC_INTERVAL_MS}). Both
147
+ * timers are `unref`'d, and neither callback can reject. Returns the stop.
148
+ */
149
+ export function startSwitchUpkeep(agents, acp, pollMs = SWITCH_POLL_INTERVAL_MS, gcMs = BUILD_GC_INTERVAL_MS) {
150
+ let polling = false;
151
+ const poll = setInterval(() => {
152
+ if (polling)
153
+ return;
154
+ polling = true;
155
+ void Promise.resolve().then(() => agents.pollSwitches()).catch((err) => {
156
+ console.error(`[agent] switch poll failed: ${err instanceof Error ? err.message : String(err)}`);
157
+ }).finally(() => { polling = false; });
158
+ }, pollMs);
159
+ poll.unref();
160
+ const gc = setInterval(() => { void Promise.resolve().then(() => acp.collectBuilds()).catch(() => undefined); }, gcMs);
161
+ gc.unref();
162
+ return () => { clearInterval(poll); clearInterval(gc); };
163
+ }
109
164
  export function startIdleSweep(agents, ttlMs = IDLE_SESSION_TTL_MS, intervalMs = IDLE_SWEEP_INTERVAL_MS) {
110
165
  const timer = setInterval(() => {
111
166
  void (async () => {
@@ -157,6 +212,74 @@ export function resolveServiceLifecycle(options) {
157
212
  export async function buildManagementSnapshot(supervisor, tasks, updates) {
158
213
  return { channels: supervisor.channelSnapshot(), ...tasks.snapshot(), update: await updates.snapshotAsync() };
159
214
  }
215
+ /**
216
+ * The files host's configured roots: every workspace cwd, absolute (a relative one is taken relative to the home
217
+ * directory; FileTools refuses relative roots because they would resolve against whatever directory a pinned section
218
+ * holds).
219
+ */
220
+ export function workspaceRoots(cfg, home = os.homedir()) {
221
+ return [path.join(home, '.axom', 'playground'), ...(cfg.workspaces ?? []).filter(workspace => typeof workspace?.cwd === 'string' && workspace.cwd.length > 0).map(workspace => path.resolve(home, workspace.cwd))];
222
+ }
223
+ /** The roots that exist as directories right now. A missing workspace must not keep the host (all of Files) down. */
224
+ export async function existingDirectories(roots) {
225
+ const existing = [];
226
+ for (const root of roots) {
227
+ try {
228
+ if ((await fs.stat(root)).isDirectory())
229
+ existing.push(root);
230
+ }
231
+ catch { /* left out until it exists */ }
232
+ }
233
+ return existing;
234
+ }
235
+ /** How often the daemon re-reads the workspaces config (and root existence) to restart the files host on a change. */
236
+ export const FILES_ROOTS_POLL_MS = 10_000;
237
+ /** How often the legacy tools keeper is re-probed and checked for retirement (keepers/legacy.ts). */
238
+ export const LEGACY_TICK_MS = 30_000;
239
+ /**
240
+ * Builds the object `ManagementChannelOptions.keepers` reports (Task 26): the policy's statuses, never a rejection (a failure is
241
+ * `null`, which the channel also guarantees). `countedExecs` (final review minor 7): keeper-terminal execs `activeShellTasks` also
242
+ * counts right now — reported as the current terminal keeper's `execCounted` metric (sampled with this heartbeat), so the
243
+ * Workstations page counts `activeShellTasks + execRunning − execCounted` commands, each once.
244
+ */
245
+ export async function buildKeeperStatuses(policy, countedExecs = () => 0) {
246
+ let statuses;
247
+ try {
248
+ statuses = await policy.statuses();
249
+ }
250
+ catch {
251
+ return null;
252
+ }
253
+ const terminals = statuses.filter((status) => status.kind === 'terminal' && !status.legacy);
254
+ const target = terminals.find((status) => status.replacement === 'none') ?? terminals[0];
255
+ if (!target)
256
+ return statuses;
257
+ let counted = 0;
258
+ try {
259
+ const value = countedExecs();
260
+ counted = Number.isSafeInteger(value) && value > 0 ? value : 0;
261
+ }
262
+ catch {
263
+ counted = 0;
264
+ }
265
+ // A copy: the policy reuses its sample for a while.
266
+ return statuses.map((status) => (status === target ? { ...status, metrics: { ...status.metrics, execCounted: counted } } : status));
267
+ }
268
+ /**
269
+ * Runs `poll` every `intervalMs`, skipping a tick while the previous run is still in flight (on a hung mount a stat
270
+ * may never return; overlapping runs would pile up behind it). The timer is unref'd. Returns the stop function.
271
+ */
272
+ export function startExclusivePoll(poll, intervalMs) {
273
+ let running = false;
274
+ const timer = setInterval(() => {
275
+ if (running)
276
+ return;
277
+ running = true;
278
+ void poll().catch(() => { }).finally(() => { running = false; });
279
+ }, intervalMs);
280
+ timer.unref();
281
+ return () => clearInterval(timer);
282
+ }
160
283
  export async function runDaemon(options = {}) {
161
284
  // Last line of defence for the whole process. Node's default for an unhandled rejection is to
162
285
  // *exit*, and this daemon is the only thing keeping every session on this workstation alive —
@@ -170,7 +293,7 @@ export async function runDaemon(options = {}) {
170
293
  const cfg = await loadClientConfig(dir);
171
294
  const { clientKey, serverPub } = await readKeys(dir);
172
295
  // Repair the first fixed release even when it was installed by an older ignore-scripts updater,
173
- // before a newly started terminal keeper snapshots native dependency availability.
296
+ // before a newly started keeper-terminal snapshots native dependency availability.
174
297
  await preflightOptionalPty(updateServiceContext(dir));
175
298
  const tasks = new TaskRegistry();
176
299
  // Attaches both the server-driven update path (onUpdate below) and axom update's manual path to
@@ -196,15 +319,132 @@ export async function runDaemon(options = {}) {
196
319
  });
197
320
  // One ACP client for every external agent; the roster it reports is the ACP agent registry, keyed
198
321
  // by registry agent id (ruling R2) — the same key `agent_open.bridge` now carries.
199
- const acp = new AcpAgents({ cacheDir: dir });
200
- const agents = new AgentSessionManager(acp, null, path.join(dir, 'agents'), undefined, tasks);
322
+ // Live sessions run in keeper-acp (spec §5.3) — the adapters outlive the daemon there — or, when it cannot be reached, in the
323
+ // daemon itself through the local fallback (§5.7). Short-lived connections (list, delete, verify, login) stay direct children.
324
+ const acpLauncher = new KeeperLauncher({ configDir: dir, workstationId: cfg.workstationId, server: cfg.server });
325
+ const acpTransport = new KeeperTransport(new AcpKeeperClient({ launcher: acpLauncher }));
326
+ // Sessions keeper-acp still runs from a previous daemon (a restart, an upgrade, a crash) are adopted where they are — across
327
+ // keeper-acp generations (spec §5.5, §6.3) — before the agent channel's reconnect offer is answered.
328
+ const acpGenerations = keeperGenerations({ launcher: acpLauncher, current: acpTransport });
329
+ const acp = new AcpAgents({ cacheDir: dir, transports: { keeper: acpTransport }, keeperGenerations: acpGenerations });
330
+ const agents = new AgentSessionManager(acp, null, path.join(dir, 'agents'), tasks);
331
+ void agents.adoptSessions();
201
332
  let bridges = await acp.roster();
333
+ // A scan's roster (and a pushed one) also carries the adapter builds live sessions run and how many could move now (spec §6.4,
334
+ // Task 25): keeper-acp's process labels joined with this daemon's sessions. The hello's roster above does not — it is not a scan.
335
+ const scanRoster = () => acp.roster({ sessions: (session) => agents.rosterSessionState(session) });
202
336
  let supervisor;
203
337
  // Plaintext WebSockets are only for an explicitly opted-in local development fixture. Production
204
338
  // ticket handshakes must be WSS because the ticket is the data-channel key derivation material.
205
339
  const allowInsecureWs = process.env.AXOM_WORKSTATION_ALLOW_INSECURE_WS === '1';
206
- const computer = new ComputerChannel({ server: cfg.server, allowInsecureWs, taskRegistry: tasks,
207
- toolsConfig: { configDir: dir, workstationId: cfg.workstationId, server: cfg.server, roots: (cfg.workspaces ?? []).map(workspace => workspace.cwd), browserBinding: cfg.browserBinding },
340
+ // Files: one daemon-owned host process (files/host.ts) serves file_*_request frames and files.* tool requests.
341
+ const home = os.homedir();
342
+ const playgroundCwd = path.join(home, '.axom', 'playground');
343
+ await fs.mkdir(playgroundCwd, { recursive: true });
344
+ let roots = workspaceRoots(cfg, home);
345
+ let hostRoots = [];
346
+ const filesHost = new FilesHostClient({
347
+ configDir: dir,
348
+ options: async () => { hostRoots = await existingDirectories(roots); return { home, roots: hostRoots }; },
349
+ onActivityChanged: () => tasks.externalActivityChanged(),
350
+ log: (line) => console.error(`${new Date().toISOString()} ${line}`),
351
+ });
352
+ tasks.externalActivity = () => filesHost.activity();
353
+ filesHost.start();
354
+ const stopRootsPoll = startExclusivePoll(async () => {
355
+ try {
356
+ roots = workspaceRoots(await loadClientConfig(dir), home);
357
+ const current = await existingDirectories(roots);
358
+ if (JSON.stringify(current) !== JSON.stringify(hostRoots))
359
+ filesHost.reconfigure();
360
+ }
361
+ catch { /* an unreadable config keeps the current host */ }
362
+ }, FILES_ROOTS_POLL_MS);
363
+ // tools.info is aggregated here and starts no keeper: its terminal fields come from keeper-terminal's hello when that keeper
364
+ // already runs, else from the cached node-pty check. terminal.* go through the TerminalRouter (keeper-terminal, or a legacy
365
+ // tools keeper an older CLI left running with live PTYs: served until they end, then stopped). browser.* keep the
366
+ // browser-kind ToolsClient path. Nothing here constructs a terminal-kind ToolsClient: its request() would start the legacy keeper.
367
+ const toolsConfig = { configDir: dir, workstationId: cfg.workstationId, server: cfg.server, roots, browserBinding: cfg.browserBinding };
368
+ const browserTools = new ToolsClient(toolsConfig, 'browser');
369
+ const ptyStatus = cachedPtyStatus();
370
+ // keeper-terminal generations (Task 26): new work goes to the serving generation; PTYs and execs an older generation still runs
371
+ // after an upgrade are routed to it by owner until the replacement policy retires it.
372
+ const terminalLauncher = new KeeperLauncher({ configDir: dir, workstationId: cfg.workstationId, server: cfg.server });
373
+ const terminalKeepers = new TerminalGenerations({ launcher: terminalLauncher });
374
+ const legacyKeeper = new LegacyToolsKeeper({
375
+ tools: toolsConfig,
376
+ recoverUploads: (uploads) => filesHost.recoverUploads(uploads),
377
+ log: (line) => console.error(`${new Date().toISOString()} ${line}`),
378
+ });
379
+ void legacyKeeper.detect();
380
+ const stopLegacyPoll = startExclusivePoll(() => legacyKeeper.tick(), LEGACY_TICK_MS);
381
+ const terminals = new TerminalRouter({
382
+ kits: terminalKeepers,
383
+ legacy: legacyKeeper,
384
+ launch: async (cwd) => ({ cwd: await filesHost.terminalCwd(cwd), shell: terminalShell(), env: terminalEnv() }),
385
+ });
386
+ const workstationTools = new WorkstationTools({
387
+ files: filesHost,
388
+ terminal: (method, params) => terminals.request(method, params),
389
+ browser: (method, params) => browserTools.request(method, params),
390
+ info: () => aggregateToolsInfo({
391
+ files: async () => filesHost.info() ?? computeFilesInfo(home, roots),
392
+ terminal: () => terminalKeepers.terminalStatus(ptyStatus),
393
+ browser: (method, params) => browserTools.request(method, params),
394
+ }),
395
+ });
396
+ // exec_request: keeper-terminal (durable) when available, else in this process; argv/env/deadline/shaping decided here.
397
+ // Execs a previous daemon left in keeper-terminal are recovered first: wire ≤ 4 ones are stopped (their channel is gone), wire v5
398
+ // ones are followed for the server to re-attach (exec_attach on the next computer channel).
399
+ // Keeper replacement (spec §8.3, Task 26): what the heartbeat reports of each keeper generation, and what happens to an older one.
400
+ const drainKeeper = oneShotDrain((kind, gen) => (kind === 'acp' ? acpLauncher : terminalLauncher).pathsFor(kind, gen));
401
+ const keeperPolicy = new KeeperPolicy({
402
+ dir: path.join(dir, 'keepers'),
403
+ probe: oneShotProbe((kind, gen) => (kind === 'acp' ? acpLauncher : terminalLauncher).pathsFor(kind, gen)),
404
+ terminal: {
405
+ currentGen: () => terminalKeepers.currentGen(),
406
+ live: async () => (await terminalKeepers.running()).map((generation) => generation.gen),
407
+ manifest: () => terminalKeepers.manifest(),
408
+ stop: async (gen, mode) => {
409
+ const result = await terminalLauncher.stop('terminal', gen, mode);
410
+ if (result.stopped)
411
+ terminalKeepers.forget(gen);
412
+ return result;
413
+ },
414
+ reachable: async (gen) => (await terminalKeepers.client(gen)).runningHello(),
415
+ drain: (gen) => drainKeeper('terminal', gen),
416
+ },
417
+ legacy: legacyKeeper,
418
+ acp: {
419
+ currentGen: () => acpGenerations.current(),
420
+ live: () => acpGenerations.live(),
421
+ manifest: () => readKeeperManifest(path.join(defaultDistDir(), 'acp')),
422
+ stop: (gen, mode) => acpLauncher.stop('acp', gen, mode),
423
+ moveOff: (gens) => agents.moveOffOlderKeeperGenerations(undefined, gens),
424
+ cancelMoves: async (refs) => { for (const sessionRef of refs)
425
+ await agents.cancelSwitch({ sessionRef }); },
426
+ retireIdle: () => acp.retireIdleGenerations(),
427
+ session: (ref) => agents.sessionView(ref),
428
+ },
429
+ agentName: (agentId) => bridges.find((bridge) => bridge.type === agentId)?.displayName || agentId,
430
+ legacyShell: () => terminalShell(),
431
+ log: (line) => console.error(`${new Date().toISOString()} ${line}`),
432
+ });
433
+ terminalKeepers.picker = (current, older) => keeperPolicy.terminalServing(current, older);
434
+ const stopKeeperPolicy = startExclusivePoll(() => keeperPolicy.tick(), KEEPER_POLICY_TICK_MS);
435
+ // Spec §3.1: keeper sockets that fell back to /tmp (a long configDir path) are touched so a /tmp cleaner never reaps them.
436
+ const stopSocketTouch = startExclusivePoll(async () => {
437
+ await acpLauncher.touchSockets('acp');
438
+ await terminalLauncher.touchSockets('terminal');
439
+ }, KEEPER_POLICY_TICK_MS);
440
+ void keeperPolicy.tick();
441
+ const execPolicy = new ExecPolicy({ generations: terminalKeepers, log: (line) => console.error(`${new Date().toISOString()} ${line}`) });
442
+ void execPolicy.recover();
443
+ const computer = new ComputerChannel({ server: cfg.server, allowInsecureWs, taskRegistry: tasks, browserBinding: cfg.browserBinding,
444
+ runExec: (request, onOutput, signal, hooks) => execPolicy.run(request, onOutput, signal, hooks),
445
+ execs: execPolicy, log: (line) => console.error(`${new Date().toISOString()} ${line}`),
446
+ tools: (method, params) => workstationTools.request(method, params),
447
+ runFrame: (request, signal) => workstationTools.runFrame(request, signal),
208
448
  });
209
449
  const metrics = new SystemMetricsSampler(cfg.workspaces ?? []);
210
450
  await metrics.start();
@@ -212,17 +452,46 @@ export async function runDaemon(options = {}) {
212
452
  server: cfg.server, workstationId: cfg.workstationId, workstation: cfg.workstationId, clientKey, serverPub,
213
453
  bridges: () => bridges,
214
454
  workspaces: cfg.workspaces,
455
+ playgroundCwd,
215
456
  metrics: () => metrics.snapshot(),
216
457
  allowInsecureWs,
217
458
  onReady: (ready) => updates.managementReady(ready.release, ready.generation),
218
459
  onUpdate: (command) => updates.handle(command),
219
460
  onExternalAgentsScan: async () => {
220
- bridges = await acp.roster();
461
+ bridges = await scanRoster();
221
462
  return bridges;
222
463
  },
223
464
  snapshot: () => buildManagementSnapshot(supervisor, tasks, updates),
465
+ keepers: () => buildKeeperStatuses(keeperPolicy, () => tasks.countedKeeperExecs),
466
+ onKeeperControl: (frame) => keeperPolicy.control(frame),
224
467
  });
225
468
  const agent = new AgentChannel({ server: cfg.server, agents, bridges: () => bridges, allowInsecureWs });
469
+ // Spec §6.2: first take over what an older CLI left (`_npx` copies, pre-build binary dirs) so agents
470
+ // that worked keep working; then §6.1: a version this CLI certifies (runtime.json/catalog pin) or a
471
+ // changed patch set moves an installed agent's current build forward by itself ("Updated by Axom").
472
+ // In the background: either may copy or download, an open of an agent not done yet waits on that
473
+ // agent's lock, and running sessions keep the build they were spawned from either way. A changed
474
+ // roster reaches the server the way a scan's does; offline, the next hello carries it.
475
+ void maintainAdapterBuilds(acp, async () => {
476
+ bridges = await scanRoster();
477
+ management.pushRoster(bridges);
478
+ });
479
+ // A quota capability learned mid-flight (this process's first spawn of an agent) changes what the
480
+ // roster would say; push it the way a build change does. Coalesced: several agents flipping
481
+ // around one burst of opens share a single scan, and a flip during that scan queues one more.
482
+ let rosterPushQueued = false;
483
+ acp.onRosterChanged(() => {
484
+ if (rosterPushQueued)
485
+ return;
486
+ rosterPushQueued = true;
487
+ void Promise.resolve().then(async () => {
488
+ rosterPushQueued = false;
489
+ bridges = await scanRoster();
490
+ management.pushRoster(bridges);
491
+ }).catch((error) => {
492
+ console.warn(`[acp-builds] roster push after capability change failed: ${String(error)}`);
493
+ });
494
+ });
226
495
  supervisor = new ConnectionSupervisor({
227
496
  management, computer, agent,
228
497
  // Timestamped here rather than inside the supervisor: the daemon owns this log's shape. The
@@ -231,6 +500,7 @@ export async function runDaemon(options = {}) {
231
500
  log: (message) => console.log(`${new Date().toISOString()} ${message}`),
232
501
  });
233
502
  const stopIdleSweep = startIdleSweep(agents);
503
+ const stopSwitchUpkeep = startSwitchUpkeep(agents, acp);
234
504
  const shutdown = new AbortController();
235
505
  const stop = () => shutdown.abort();
236
506
  process.once('SIGINT', stop);
@@ -242,6 +512,17 @@ export async function runDaemon(options = {}) {
242
512
  process.removeListener('SIGINT', stop);
243
513
  process.removeListener('SIGTERM', stop);
244
514
  stopIdleSweep();
515
+ stopSwitchUpkeep();
245
516
  metrics.stop();
517
+ stopRootsPoll();
518
+ stopLegacyPoll();
519
+ stopKeeperPolicy();
520
+ stopSocketTouch();
521
+ terminalKeepers.disconnect(); // only the connections: keeper-terminal and its PTYs outlive the daemon
522
+ // keeper-hosted sessions are detached (head checkpoint, adapter untouched, no failed turn); the others checkpoint (spec §5.5.5)
523
+ await agents.detachAll();
524
+ acp.closeTransports(); // keeper-acp's adapters outlive the daemon; local-fallback adapters die with it (spec §5.7)
525
+ // Bounded: a graceful host shutdown, then SIGKILL (a hung IO cannot hold the daemon's exit).
526
+ await filesHost.stop();
246
527
  }
247
528
  }
@@ -5,6 +5,8 @@ import { clientDir, loadClientConfig } from './config.js';
5
5
  import { createServiceManager } from './service/installer.js';
6
6
  import { buildServicePath } from './service/types.js';
7
7
  import { loginShellPath } from './service/loginShell.js';
8
+ import { KeeperLauncher } from './keepers/kit/launcher.js';
9
+ import { stopKeepers } from './keepers/stop.js';
8
10
  const __filename = fileURLToPath(import.meta.url);
9
11
  /**
10
12
  * The package's real npm bin entry (`package.json`'s `bin.axom`), given the directory a compiled
@@ -49,9 +51,20 @@ export function serviceContext() {
49
51
  pathEnv: buildServicePath(process.env.PATH, nodeDirectory),
50
52
  };
51
53
  }
54
+ /** The workstation's keepers stopped (see {@link DaemonControllerDeps.stopKeepers}); an unregistered client has none. */
55
+ async function stopRegisteredKeepers() {
56
+ const configDir = clientDir();
57
+ const config = await loadClientConfig(configDir).catch(() => null);
58
+ if (!config?.workstationId || !config.server)
59
+ return;
60
+ await stopKeepers(new KeeperLauncher({ configDir, workstationId: config.workstationId, server: config.server }), {
61
+ log: (line) => console.error(line),
62
+ });
63
+ }
52
64
  const defaultDeps = {
53
65
  service: (context) => createServiceManager(context ?? serviceContext()),
54
66
  config: () => loadClientConfig(clientDir()),
67
+ stopKeepers: stopRegisteredKeepers,
55
68
  };
56
69
  /**
57
70
  * User services own the daemon lifecycle. Deliberately no PID-file fallback remains: a stale
@@ -73,9 +86,22 @@ export function createDaemonController(overrides = {}) {
73
86
  : base;
74
87
  await deps.service(context).install({ interactive: true });
75
88
  },
76
- async uninstall() { await deps.service().uninstall(); },
89
+ // The workstation is stopped by the user: what its keepers host ends too (the daemon first, so nothing relaunches them).
90
+ // In a `finally`: a service that is not installed (or fails to stop) must not leave the keepers running.
91
+ async uninstall() { try {
92
+ await deps.service().uninstall();
93
+ }
94
+ finally {
95
+ await deps.stopKeepers();
96
+ } },
77
97
  async start() { await deps.service().start(); },
78
- async stop() { await deps.service().stop(); },
98
+ async stop() { try {
99
+ await deps.service().stop();
100
+ }
101
+ finally {
102
+ await deps.stopKeepers();
103
+ } },
104
+ // A restart keeps the keepers: terminals, commands and agent sessions go on under the next daemon.
79
105
  async restart() { await deps.service().restart(); },
80
106
  async status() {
81
107
  const state = await deps.service().status();
@@ -0,0 +1,126 @@
1
+ import { ExecWireText } from './shaping.js';
2
+ /** Spec §4.3: `exec_output` waits while the socket holds more than the high mark, until it drains below the low mark. */
3
+ export const EXEC_WINDOW_HIGH_BYTES = 1024 * 1024;
4
+ export const EXEC_WINDOW_LOW_BYTES = 256 * 1024;
5
+ const WINDOW_POLL_MS = 20;
6
+ /** Records per `exec.read` page (the keeper also caps a page at 1.5 MiB). */
7
+ const PAGE_RECORDS = 1024;
8
+ /** Re-read cadence when nothing new arrived and no progress was signalled. */
9
+ const IDLE_POLL_MS = 500;
10
+ const RETRY_MS = 1000;
11
+ /** The v5 `exec_exit` of an ended durable exec (`exitFrame` is the today-shaped frame, exec/policy.ts `durableExitFrame`). */
12
+ export function wireExitFrame(exitFrame, outcome, gapBytes) {
13
+ const signal = outcome.state === 'exited' ? outcome.exit.signal : null;
14
+ return { ...exitFrame, cancelled: outcome.cancelled, ...(signal ? { signal } : {}), ...(gapBytes > 0 ? { gaps: gapBytes } : {}) };
15
+ }
16
+ const sleep = (ms, signal) => new Promise((resolve) => {
17
+ if (signal.aborted) {
18
+ resolve();
19
+ return;
20
+ }
21
+ const done = () => { clearTimeout(timer); signal.removeEventListener('abort', done); resolve(); };
22
+ const timer = setTimeout(done, ms);
23
+ signal.addEventListener('abort', done, { once: true });
24
+ });
25
+ /** Resolves once the socket has room for more output: at once below the high mark, else when it drained below the low mark. */
26
+ export async function waitForWindow(bufferedAmount, signal, pollMs = WINDOW_POLL_MS) {
27
+ if (bufferedAmount() <= EXEC_WINDOW_HIGH_BYTES)
28
+ return;
29
+ while (!signal.aborted && bufferedAmount() > EXEC_WINDOW_LOW_BYTES)
30
+ await sleep(pollMs, signal);
31
+ }
32
+ /**
33
+ * Streams `exec` to `channel`: reads its records from keeper-terminal from the commit point on (decoding silently up to
34
+ * `fromOffset`), sends one `exec_output` per chunk at or after `fromOffset`, honouring the socket window, then the final
35
+ * `exec_status` and `exec_exit` once the exec ended. A non-streaming exec sends only the final two. `stop()` ends it silently
36
+ * (the channel closed, or a newer attach replaced it); the exec itself is untouched.
37
+ */
38
+ export class ExecAttachment {
39
+ exec;
40
+ channel;
41
+ options;
42
+ abort = new AbortController();
43
+ done;
44
+ constructor(exec, channel, options) {
45
+ this.exec = exec;
46
+ this.channel = channel;
47
+ this.options = options;
48
+ this.done = this.run().catch((error) => { this.options.log?.(`exec ${options.id}: attachment failed: ${error instanceof Error ? error.message : String(error)}`); });
49
+ }
50
+ stop() { this.abort.abort(); }
51
+ get stopped() { return this.abort.signal.aborted; }
52
+ async run() {
53
+ const { exec } = this;
54
+ if (exec.wire?.streaming !== false)
55
+ await this.stream();
56
+ if (this.stopped)
57
+ return;
58
+ const outcome = await Promise.race([exec.outcome, new Promise((resolve) => this.abort.signal.addEventListener('abort', () => resolve(null), { once: true }))]);
59
+ if (outcome === null || this.stopped)
60
+ return;
61
+ const id = this.options.id;
62
+ this.channel.send({ t: 'exec_status', id, state: 'exited', headOffset: exec.finalHead, committedOffset: exec.committedOffset });
63
+ this.channel.send(this.options.exitFrame(outcome));
64
+ }
65
+ /** Waits for more records: a progress signal from the exec's own follower, a stop, or the idle poll. */
66
+ wait(ms) {
67
+ return new Promise((resolve) => {
68
+ const unsubscribe = this.exec.onProgress(() => { unsubscribe(); resolve(); });
69
+ void sleep(ms, this.abort.signal).then(() => { unsubscribe(); resolve(); });
70
+ });
71
+ }
72
+ async stream() {
73
+ const { exec, channel } = this;
74
+ const { id, fromOffset } = this.options;
75
+ const text = new ExecWireText();
76
+ let cursor = exec.commitFloor + 1;
77
+ const hold = exec.hold(cursor);
78
+ try {
79
+ while (!this.stopped) {
80
+ const exitAt = exec.exitRecordOffset;
81
+ if (exitAt !== undefined && cursor > exitAt)
82
+ return;
83
+ let page;
84
+ try {
85
+ page = await exec.read(cursor, PAGE_RECORDS);
86
+ }
87
+ catch (error) {
88
+ if (exec.finished)
89
+ return; // deleted, reclaimed or unreachable: the outcome says so
90
+ this.options.log?.(`exec ${id}: read failed: ${error instanceof Error ? error.message : String(error)}`);
91
+ await this.wait(RETRY_MS);
92
+ continue;
93
+ }
94
+ if (this.stopped)
95
+ return;
96
+ if (page.records.length === 0) {
97
+ if (exec.finished)
98
+ return;
99
+ await this.wait(this.options.idleMs ?? IDLE_POLL_MS);
100
+ continue;
101
+ }
102
+ for (const record of page.records) {
103
+ if (record.o < cursor)
104
+ continue;
105
+ cursor = record.o + 1;
106
+ hold.at = cursor;
107
+ if (record.k === 'exit')
108
+ return;
109
+ if (record.k !== 'out' && record.k !== 'err')
110
+ continue;
111
+ const stream = record.k === 'out' ? 'stdout' : 'stderr';
112
+ const chunk = text.push(stream, record.so, Buffer.from(record.b, 'base64'));
113
+ if (!chunk || record.o < fromOffset)
114
+ continue;
115
+ await waitForWindow(() => channel.bufferedAmount(), this.abort.signal);
116
+ if (this.stopped)
117
+ return;
118
+ channel.send({ t: 'exec_output', id, stream, chunk, offset: record.o, recvAtMs: record.t });
119
+ }
120
+ }
121
+ }
122
+ finally {
123
+ exec.unhold(hold);
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,40 @@
1
+ // What the daemon decides about one `exec_request` before anything runs (spec §4.2 "daemon (policy)" column; analysis E §6.3–6.4):
2
+ // the exact argv, the complete environment and the absolute deadline. Shared by the durable (keeper-terminal) path and the
3
+ // in-process fallback, so both run the same command line in the same environment.
4
+ import path from 'node:path';
5
+ /**
6
+ * `[shell, '-lc', command]`, element by element what the daemon has always spawned (`spawn(req.shell, ['-lc', req.command])`):
7
+ * a login, non-interactive shell of the user's choice reading the command string exactly. Never wrapped (`sh -c`, `; echo $?`, …):
8
+ * any wrapping would go through the user's shell dialect (zsh EQUALS, fish quoting).
9
+ */
10
+ export function execArgv(req) {
11
+ return [req.shell, '-lc', req.command];
12
+ }
13
+ /** The daemon's credential for its own server: never visible to a command (commands/workstation.ts drops it already; again here). */
14
+ export const ACCESS_TOKEN_ENV = 'AXOM_ACCESS_TOKEN';
15
+ /**
16
+ * The complete environment of the command: the daemon's environment without the access token, then the request's `env`, then
17
+ * `pathPrepend` in front of PATH (exactly as the daemon always did). keeper-terminal gets this whole map and never falls back to its
18
+ * own environment (under a systemd transient unit that would be the user manager's, frozen at the keeper's start).
19
+ */
20
+ export function execEnvironment(daemonEnv, req) {
21
+ const env = {};
22
+ for (const [name, value] of Object.entries(daemonEnv))
23
+ if (value !== undefined && name !== ACCESS_TOKEN_ENV)
24
+ env[name] = value;
25
+ Object.assign(env, req.env);
26
+ if (req.pathPrepend.length > 0)
27
+ env.PATH = [...req.pathPrepend, env.PATH ?? ''].filter(Boolean).join(path.delimiter);
28
+ return env;
29
+ }
30
+ /**
31
+ * The absolute deadline, from `timeoutSeconds` and the DAEMON's clock at the moment it first received the request (never the
32
+ * server's clock). No timeout (absent, null, 0 or negative: today's rule) means no deadline.
33
+ */
34
+ export function execDeadline(req, receivedAtMs) {
35
+ return req.timeoutSeconds && req.timeoutSeconds > 0 ? receivedAtMs + req.timeoutSeconds * 1000 : undefined;
36
+ }
37
+ /** keeper-terminal wants an absolute cwd; a relative one resolves against the daemon's cwd, as `spawn` always resolved it. */
38
+ export function execCwd(req) {
39
+ return path.resolve(req.cwd || '.');
40
+ }