@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
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { connectEncryptedChannel } from '../connection/encryptedChannel.js';
3
3
  import { AGENT_WIRE_MIN_VERSION, AGENT_WIRE_RESUME_OFFER_VERSION, AGENT_WIRE_VERSION, decodeAgentFrame, encodeAgentFrame, outboundAgentFrameRejection, } from '../protocol/agent.js';
4
- import { bridgesForWire } from '../protocol/common.js';
4
+ import { bridgesForWire, helloRoster } from '../protocol/common.js';
5
5
  import { workstationChannelUrl } from './url.js';
6
6
  /** Binds daemon-scoped sessions only to the encrypted agent channel's sender. */
7
7
  export class AgentChannel {
@@ -25,7 +25,9 @@ export class AgentChannel {
25
25
  // Hoisted rather than inlined twice: `closeAll` compares it by identity to decide whether the
26
26
  // binding it is being asked to drop is still this connection's. See AgentSessionManager.closeAll.
27
27
  const send = (frame) => channel.send(frame);
28
- void dispatchAgentChannelFrames(channel.messages, send, this.options.agents, () => channel.close());
28
+ // The socket's unsent bytes: the manager's socket gate holds session frames back above 1 MiB so the heartbeat (written by the
29
+ // channel itself) never queues behind a backlog (spec §5.6).
30
+ void dispatchAgentChannelFrames(channel.messages, send, this.options.agents, () => channel.close(), () => channel.bufferedAmount);
29
31
  void channel.closed.then(() => this.options.agents.closeAll(send));
30
32
  return { closed: channel.closed, close: () => channel.close() };
31
33
  }
@@ -58,10 +60,10 @@ export function agentHeartbeat(seq, sentAtEpochMs, agents) {
58
60
  }
59
61
  /** Exported so the roster this hello carries can be asserted without opening a socket. */
60
62
  export function buildAgentHelloFrame(ticket, bridges) {
61
- return { t: 'agent_hello', ticket, bridges: bridgesForWire(bridges(), 'agent_hello') };
63
+ return { t: 'agent_hello', ticket, bridges: bridgesForWire(helloRoster(bridges()), 'agent_hello') };
62
64
  }
63
65
  /** Negotiates one agent connection and emits exactly one reconnect offer when the server supports it. */
64
- export async function dispatchAgentChannelFrames(messages, send, agents, close = () => { }) {
66
+ export async function dispatchAgentChannelFrames(messages, send, agents, close = () => { }, bufferedAmount) {
65
67
  let offered = false;
66
68
  try {
67
69
  for await (const frame of messages) {
@@ -71,14 +73,20 @@ export async function dispatchAgentChannelFrames(messages, send, agents, close =
71
73
  return;
72
74
  }
73
75
  const negotiated = Math.min(frame.agentWire, AGENT_WIRE_VERSION);
74
- agents.bindSend(send, negotiated);
76
+ agents.bindSend(send, negotiated, bufferedAmount);
75
77
  if (!offered && negotiated >= AGENT_WIRE_RESUME_OFFER_VERSION) {
76
78
  offered = true;
77
79
  send({ t: 'agent_resume_offer', sessions: await agents.resumeOfferSessions() });
78
80
  }
79
81
  continue;
80
82
  }
81
- await dispatchAgentFrame(agents, frame);
83
+ // Start handlers in wire order, but keep receiving while their RPCs / disk I/O wait.
84
+ // A probe can take longer than an open's 30s or a config change's 10s deadline; awaiting it
85
+ // here makes every unrelated session time out while heartbeats still report a healthy socket.
86
+ // The manager owns open deduplication, close-during-open tombstones and per-session journal
87
+ // ordering. Answers, interrupts and closes must also reach it while an earlier RPC is pending.
88
+ // dispatchAgentFrame catches rejections, including for these detached handlers.
89
+ void dispatchAgentFrame(agents, frame);
82
90
  }
83
91
  }
84
92
  catch (error) {
@@ -1,20 +1,35 @@
1
+ import { DesktopBridge } from '../desktop/bridge.js';
2
+ import { resolveBrowserBinding } from '../desktop/binding.js';
1
3
  import { createHash } from 'node:crypto';
2
4
  import { connectEncryptedChannel } from '../connection/encryptedChannel.js';
3
5
  import { runExec } from '../exec.js';
6
+ import { ExecPolicy, keeperExecsOutliveTheChannel } from '../exec/policy.js';
7
+ import { ComputerExecWire } from './computerExecs.js';
4
8
  import { decodeComputerFrame, encodeComputerFrame } from '../protocol/computer.js';
5
9
  import { workstationChannelUrl } from './url.js';
6
10
  import { TaskRegistry } from '../update/taskRegistry.js';
11
+ import { toFileError } from '../files/errors.js';
12
+ import { object } from '../tools/validation.js';
13
+ import { isUnaccountedTool } from '../tools/router.js';
14
+ import { requestRootFileExecutors } from '../files/adapters/computerFileFrames.js';
7
15
  /**
8
16
  * Consumes computer frames without serializing long-running commands behind one another.
9
17
  * All command failures and post-close sends are contained here because this is called detached
10
18
  * from the socket connection loop.
11
19
  */
12
- export async function dispatchComputerFrames(messages, send, execute, tasks = new TaskRegistry()) {
20
+ export async function dispatchComputerFrames(messages, send, execute, tasks = new TaskRegistry(), files = {}, tools, desktop, durable = {}) {
13
21
  const active = new Map();
14
- const safeSend = (frame) => { try {
22
+ const activeTools = new Set();
23
+ let open = true;
24
+ let wireVersion = 1;
25
+ const safeSend = (frame) => { if (!open)
26
+ return; try {
15
27
  send(frame);
16
28
  }
17
29
  catch { /* socket already closed */ } };
30
+ // Wire ≥ 5 (keeperExecsOutliveTheChannel): exec frames go to the exec policy, which owns the execs (spec §4.3).
31
+ const execWire = new ComputerExecWire(durable.host ?? new ExecPolicy(), tasks, { send: safeSend, bufferedAmount: durable.bufferedAmount ?? (() => 0) }, durable.log);
32
+ const durableWire = () => keeperExecsOutliveTheChannel(wireVersion);
18
33
  const run = (request) => {
19
34
  if (!tasks.accepting) {
20
35
  safeSend({ t: 'exec_exit', id: request.id, exitCode: null, stdout: '', stderr: 'Workstation is not accepting new work', timedOut: false, truncated: false });
@@ -25,6 +40,8 @@ export async function dispatchComputerFrames(messages, send, execute, tasks = ne
25
40
  // the most recently accepted request while the older promise still cleans up its own entry.
26
41
  active.get(request.id)?.abort();
27
42
  active.set(request.id, controller);
43
+ // Wire ≤ 4 (spec §4.3 "compat", §8.2): every exec, in keeper-terminal or not, is a shell task (counted, drained, aborted by a
44
+ // force update) and is killed when the channel closes: the server reports it ended and an agent may run it again.
28
45
  void (async () => {
29
46
  try {
30
47
  const exit = await tasks.withShellTask(async (shutdown) => {
@@ -33,10 +50,19 @@ export async function dispatchComputerFrames(messages, send, execute, tasks = ne
33
50
  controller.abort();
34
51
  else
35
52
  shutdown.addEventListener('abort', onShutdown, { once: true });
53
+ // A keeper-terminal exec is a shell task here AND in keeper-terminal's execRunning: counted once on the page (minor 7).
54
+ let uncount;
36
55
  try {
37
- return await execute(request, request.streaming ? (stream, chunk) => safeSend({ t: 'exec_output', id: request.id, stream, chunk }) : undefined, controller.signal);
56
+ return await execute(request, request.streaming ? (stream, chunk) => safeSend({ t: 'exec_output', id: request.id, stream, chunk }) : undefined, controller.signal, {
57
+ outlivesChannel: false,
58
+ onStarted: (info) => { if (info.durable) {
59
+ uncount?.();
60
+ uncount = tasks.countKeeperExec();
61
+ } },
62
+ });
38
63
  }
39
64
  finally {
65
+ uncount?.();
40
66
  shutdown.removeEventListener('abort', onShutdown);
41
67
  }
42
68
  });
@@ -51,15 +77,175 @@ export async function dispatchComputerFrames(messages, send, execute, tasks = ne
51
77
  }
52
78
  })();
53
79
  };
80
+ const runFile = (request) => {
81
+ // Read/write landed in wire v2; directory listing and creation arrived with v3.
82
+ const minWireVersion = request.t === 'file_ls_request' || request.t === 'file_mkdir_request' ? 3 : 2;
83
+ const fail = (code, message) => {
84
+ if (request.t === 'file_read_request')
85
+ safeSend({ t: 'file_read_result', id: request.id, error: { code, message } });
86
+ else if (request.t === 'file_write_request')
87
+ safeSend({ t: 'file_write_result', id: request.id, error: { code, message } });
88
+ else if (request.t === 'file_ls_request')
89
+ safeSend({ t: 'file_ls_result', id: request.id, error: { code, message } });
90
+ else
91
+ safeSend({ t: 'file_mkdir_result', id: request.id, error: { code, message } });
92
+ };
93
+ if (wireVersion < minWireVersion) {
94
+ fail('unsupported_protocol', `Computer file operations require wire v${minWireVersion}`);
95
+ return;
96
+ }
97
+ if (!tasks.accepting) {
98
+ fail('workstation_offline', 'Workstation is not accepting new work');
99
+ return;
100
+ }
101
+ active.get(request.id)?.abort();
102
+ const controller = new AbortController();
103
+ active.set(request.id, controller);
104
+ void (async () => {
105
+ try {
106
+ const data = await tasks.withShellTask(async (shutdown) => {
107
+ const onShutdown = () => controller.abort();
108
+ if (shutdown.aborted)
109
+ controller.abort();
110
+ else
111
+ shutdown.addEventListener('abort', onShutdown, { once: true });
112
+ try {
113
+ if (files.runFrame)
114
+ return await files.runFrame(request, controller.signal);
115
+ if (request.t === 'file_read_request') {
116
+ return await (files.readFile ?? requestRootFileExecutors.readFile)({ root: request.root, path: request.path, startLine: request.startLine, maxLines: request.maxLines }, controller.signal);
117
+ }
118
+ if (request.t === 'file_write_request') {
119
+ return await (files.writeFile ?? requestRootFileExecutors.writeFile)({ root: request.root, path: request.path, content: request.content, mode: request.mode, expectedSha256: request.expectedSha256 }, controller.signal);
120
+ }
121
+ if (request.t === 'file_ls_request') {
122
+ return await (files.listDir ?? requestRootFileExecutors.listDir)({ root: request.root, path: request.path, startAfter: request.startAfter, maxEntries: request.maxEntries }, controller.signal);
123
+ }
124
+ return await (files.createDir ?? requestRootFileExecutors.createDir)({ root: request.root, path: request.path, parents: request.parents }, controller.signal);
125
+ }
126
+ finally {
127
+ shutdown.removeEventListener('abort', onShutdown);
128
+ }
129
+ });
130
+ if (active.get(request.id) !== controller)
131
+ return;
132
+ if (files.runFrame) {
133
+ safeSend(data);
134
+ return;
135
+ }
136
+ if (request.t === 'file_read_request')
137
+ safeSend({ t: 'file_read_result', id: request.id, data: data });
138
+ else if (request.t === 'file_write_request')
139
+ safeSend({ t: 'file_write_result', id: request.id, data: data });
140
+ else if (request.t === 'file_ls_request')
141
+ safeSend({ t: 'file_ls_result', id: request.id, data: data });
142
+ else
143
+ safeSend({ t: 'file_mkdir_result', id: request.id, data: data });
144
+ }
145
+ catch (error) {
146
+ if (active.get(request.id) !== controller)
147
+ return;
148
+ const mapped = toFileError(error);
149
+ if (controller.signal.aborted) {
150
+ const mutation = request.t === 'file_write_request' || request.t === 'file_mkdir_request';
151
+ fail(mutation ? 'outcome_unknown' : 'workstation_offline', request.t === 'file_write_request' ? 'Write outcome is unknown after cancellation'
152
+ : request.t === 'file_mkdir_request' ? 'Directory creation outcome is unknown after cancellation'
153
+ : 'Read was cancelled');
154
+ }
155
+ else
156
+ fail(mapped.code, mapped.message);
157
+ }
158
+ finally {
159
+ if (active.get(request.id) === controller)
160
+ active.delete(request.id);
161
+ }
162
+ })();
163
+ };
164
+ const runTool = (request) => {
165
+ const fail = (error) => safeSend({ t: 'tool_result', id: request.id, ok: false, result: {}, error: (error || 'Workstation tool failed').slice(0, 1024) });
166
+ if (wireVersion < 3) {
167
+ fail('Workstation tools require computer wire v3');
168
+ return;
169
+ }
170
+ // tools.info (a probe) and terminal.* (PTYs live in their keeper) answer while draining and are not work a restart
171
+ // would interrupt: no admission gate, no shell-task accounting.
172
+ const probe = isUnaccountedTool(request.method);
173
+ if (!probe && !tasks.accepting) {
174
+ fail('Workstation is not accepting new work');
175
+ return;
176
+ }
177
+ if (!tools && request.method !== 'browser.sharedText') {
178
+ fail('Workstation tools are not configured');
179
+ return;
180
+ }
181
+ if (activeTools.has(request.id)) {
182
+ fail('Duplicate tool request id');
183
+ return;
184
+ }
185
+ if (activeTools.size >= 16) {
186
+ fail('Too many simultaneous tool requests');
187
+ return;
188
+ }
189
+ activeTools.add(request.id);
190
+ void (async () => {
191
+ let timer;
192
+ try {
193
+ const call = () => Promise.race([
194
+ request.method === 'browser.sharedText' ? (desktop ? desktop.insertText(request.params) : Promise.reject(Error('Shared desktop unavailable'))) : tools(request.method, request.params),
195
+ new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error('Workstation tool timed out; mutation outcome may be unknown')), 7500); }),
196
+ ]);
197
+ const result = probe ? await call() : await tasks.withShellTask(call);
198
+ if (!object(result))
199
+ throw new Error('Invalid workstation tool result');
200
+ safeSend({ t: 'tool_result', id: request.id, ok: true, result, error: null });
201
+ }
202
+ catch (error) {
203
+ fail(error instanceof Error ? error.message : 'Workstation tool failed');
204
+ }
205
+ finally {
206
+ if (timer)
207
+ clearTimeout(timer);
208
+ activeTools.delete(request.id);
209
+ }
210
+ })();
211
+ };
54
212
  try {
55
213
  for await (const frame of messages) {
56
- if (frame.t === 'exec_request')
57
- run(frame);
58
- else if (frame.t === 'exec_cancel')
214
+ if (frame.t.startsWith('desktop_'))
215
+ desktop?.onFrame(frame);
216
+ else if (frame.t === 'tool_request')
217
+ runTool(frame);
218
+ else if (frame.t === 'exec_request') {
219
+ if (durableWire())
220
+ execWire.request(frame);
221
+ else
222
+ run(frame);
223
+ }
224
+ else if (frame.t === 'exec_cancel') {
59
225
  active.get(frame.id)?.abort();
226
+ if (durableWire())
227
+ execWire.cancel(frame);
228
+ }
229
+ else if (frame.t === 'exec_attach') {
230
+ if (durableWire())
231
+ execWire.attachFrame(frame);
232
+ }
233
+ else if (frame.t === 'exec_ack') {
234
+ if (durableWire())
235
+ execWire.ack(frame);
236
+ }
237
+ else if (frame.t === 'computer_ready') {
238
+ wireVersion = frame.wireVersion;
239
+ desktop?.ready(frame.generation, frame.wireVersion);
240
+ }
241
+ else if (frame.t === 'file_read_request' || frame.t === 'file_write_request' || frame.t === 'file_ls_request' || frame.t === 'file_mkdir_request')
242
+ runFile(frame);
60
243
  }
61
244
  }
62
245
  finally {
246
+ open = false;
247
+ desktop?.close();
248
+ execWire.close();
63
249
  for (const controller of active.values())
64
250
  controller.abort();
65
251
  }
@@ -68,9 +254,16 @@ export async function dispatchComputerFrames(messages, send, execute, tasks = ne
68
254
  export class ComputerChannel {
69
255
  options;
70
256
  tasks;
257
+ tools;
258
+ /** One exec host for every channel this object opens: execs outlive a channel and are re-attached on the next one. */
259
+ execs;
71
260
  constructor(options) {
72
261
  this.options = options;
73
262
  this.tasks = options.taskRegistry ?? new TaskRegistry();
263
+ this.execs = options.execs ?? new ExecPolicy();
264
+ // Tools only through the caller's router (daemon.ts): no fallback to a terminal-kind ToolsClient, whose request() would start
265
+ // (or reach) the legacy tools keeper behind the TerminalRouter's back.
266
+ this.tools = options.tools;
74
267
  }
75
268
  activeTaskCount() { return this.tasks.snapshot().activeShellTasks; }
76
269
  async connect(ticket, signal) {
@@ -86,6 +279,9 @@ export class ComputerChannel {
86
279
  return { closed: channel.closed, close: () => channel.close() };
87
280
  }
88
281
  async dispatch(channel) {
89
- await dispatchComputerFrames(channel.messages, (frame) => channel.send(frame), this.options.runExec ?? runExec, this.tasks);
282
+ const desktop = new DesktopBridge(resolveBrowserBinding(this.options.browserBinding), frame => channel.send(frame), () => channel.bufferedAmount);
283
+ await dispatchComputerFrames(channel.messages, (frame) => channel.send(frame), this.options.runExec ?? ((request, onOutput, signal) => runExec(request, onOutput, signal)), this.tasks, this.options, this.tools, desktop, {
284
+ host: this.execs, bufferedAmount: () => channel.bufferedAmount, log: this.options.log,
285
+ });
90
286
  }
91
287
  }
@@ -0,0 +1,200 @@
1
+ // COMPUTER wire v5 exec frames of ONE computer channel (spec §4.3, §8.2): exec_request / exec_attach / exec_ack / exec_cancel in,
2
+ // exec_started / exec_output (with offsets) / exec_status / exec_exit out. The execs themselves belong to the exec policy
3
+ // (exec/policy.ts `DurableExecHost`), not to this channel: a channel close only stops this channel's attachments.
4
+ //
5
+ // - A keeper exec (durable) is never a shell task: not counted in `activeShellTasks`, admitted while an update drains, untouched by
6
+ // a force update (`stopAll`) and by a channel close.
7
+ // - A fallback exec (in this process, `durable:false`) keeps today's semantics: a shell task, refused while draining, killed by a
8
+ // force update and by a channel close.
9
+ // - An exec_request for an id the policy already knows is an attach (exec_started again, then its output from offset 0): never a
10
+ // second run.
11
+ import { ExecAttachment, wireExitFrame } from '../exec/attach.js';
12
+ import { durableExitFrame } from '../exec/policy.js';
13
+ export const NOT_ACCEPTING_MESSAGE = 'Workstation is not accepting new work';
14
+ /** How soon an `exec_attach` whose exec's state could not be decided asks the policy again. */
15
+ export const ATTACH_UNDECIDED_RETRY_MS = 2000;
16
+ /** A v5 `exec_exit` that is not the outcome of a durable exec (refused, limit, fallback): today's fields plus `cancelled`. */
17
+ function plainExit(exit, cancelled) {
18
+ return { ...exit, cancelled };
19
+ }
20
+ export class ComputerExecWire {
21
+ host;
22
+ tasks;
23
+ channel;
24
+ log;
25
+ options;
26
+ /** This channel's attachment per server exec id (one at a time: a newer attach replaces the older). */
27
+ attachments = new Map();
28
+ /** In-process (fallback) execs started by this channel: they die with it. */
29
+ fallbacks = new Map();
30
+ /** Requests between receipt and knowing where they run, with an exec_cancel received meanwhile. */
31
+ pending = new Map();
32
+ closed = false;
33
+ constructor(host, tasks, channel, log = () => { }, options = {}) {
34
+ this.host = host;
35
+ this.tasks = tasks;
36
+ this.channel = channel;
37
+ this.log = log;
38
+ this.options = options;
39
+ }
40
+ send(frame) { if (!this.closed)
41
+ this.channel.send(frame); }
42
+ request(frame) {
43
+ if (this.fallbacks.has(frame.id) || this.pending.has(frame.id))
44
+ return; // the same request again on this channel: already running
45
+ const pending = { cancelled: false };
46
+ this.pending.set(frame.id, pending);
47
+ // Counted (not gated, not stoppable) from here until the exec has its first checkpoint: a drain admits a v5 exec, but a restart
48
+ // inside this window would leave keeper-terminal with an exec the next daemon can only identify by its id (spec §8.2).
49
+ const release = this.tasks.holdShellTask();
50
+ void (async () => {
51
+ try {
52
+ const started = await this.host.startOrFind(frame);
53
+ if (started.kind !== 'durable')
54
+ release();
55
+ if (started.kind === 'exit') {
56
+ this.send(plainExit(started.exit, false));
57
+ return;
58
+ }
59
+ if (started.kind === 'durable') {
60
+ const { exec } = started;
61
+ const uncount = this.tasks.countKeeperExec(); // held in activeShellTasks AND running in keeper-terminal (minor 7)
62
+ void exec.firstCheckpoint.finally(() => { uncount(); release(); });
63
+ this.send({ t: 'exec_started', id: frame.id, durable: true, ...(exec.pid !== null ? { pid: exec.pid } : {}), startedAtMs: exec.startedAtMs });
64
+ this.attach(frame.id, exec, 0);
65
+ if (pending.cancelled)
66
+ void this.host.cancel(frame.id).catch(() => { }); // cancelled before the policy knew the id
67
+ return;
68
+ }
69
+ // Not durable: today's rules (spec §8.2): refused while an update drains, counted, killed by a force update or a close.
70
+ if (!this.tasks.accepting || this.closed) {
71
+ this.send(plainExit({ t: 'exec_exit', id: frame.id, exitCode: null, stdout: '', stderr: NOT_ACCEPTING_MESSAGE, timedOut: false, truncated: false }, false));
72
+ return;
73
+ }
74
+ await this.runFallback(frame, started.run, pending.cancelled);
75
+ }
76
+ catch (error) {
77
+ release();
78
+ this.send(plainExit({ t: 'exec_exit', id: frame.id, exitCode: null, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: false, truncated: false }, false));
79
+ }
80
+ finally {
81
+ if (this.pending.get(frame.id) === pending)
82
+ this.pending.delete(frame.id);
83
+ }
84
+ })();
85
+ }
86
+ async runFallback(frame, run, cancelledEarly) {
87
+ const entry = { controller: new AbortController(), cancelled: cancelledEarly };
88
+ if (cancelledEarly)
89
+ entry.controller.abort();
90
+ this.fallbacks.set(frame.id, entry);
91
+ let offset = 0;
92
+ const onOutput = frame.streaming
93
+ ? (stream, chunk) => this.send({ t: 'exec_output', id: frame.id, stream, chunk, offset: offset++, recvAtMs: Date.now() })
94
+ : undefined;
95
+ try {
96
+ const exit = await this.tasks.withShellTask(async (shutdown) => {
97
+ const onShutdown = () => entry.controller.abort();
98
+ if (shutdown.aborted)
99
+ onShutdown();
100
+ else
101
+ shutdown.addEventListener('abort', onShutdown, { once: true });
102
+ try {
103
+ return await run(onOutput, entry.controller.signal, (info) => {
104
+ this.send({ t: 'exec_started', id: frame.id, durable: false, ...(info.pid !== undefined ? { pid: info.pid } : {}), startedAtMs: info.startedAtMs });
105
+ });
106
+ }
107
+ finally {
108
+ shutdown.removeEventListener('abort', onShutdown);
109
+ }
110
+ });
111
+ this.send(plainExit(exit, entry.cancelled));
112
+ }
113
+ catch (error) {
114
+ this.send(plainExit({ t: 'exec_exit', id: frame.id, exitCode: null, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: false, truncated: false }, entry.cancelled));
115
+ }
116
+ finally {
117
+ if (this.fallbacks.get(frame.id) === entry)
118
+ this.fallbacks.delete(frame.id);
119
+ }
120
+ }
121
+ /** Streams `exec` to this channel from `fromOffset` (replacing an older attachment of the same id). */
122
+ attach(id, exec, fromOffset) {
123
+ if (this.closed)
124
+ return;
125
+ this.attachments.get(id)?.stop();
126
+ const attachment = new ExecAttachment(exec, { send: (frame) => this.send(frame), bufferedAmount: () => this.channel.bufferedAmount() }, {
127
+ id, fromOffset, log: this.log,
128
+ exitFrame: (outcome) => wireExitFrame(durableExitFrame(id, outcome), outcome, exec.gapBytes),
129
+ });
130
+ this.attachments.set(id, attachment);
131
+ void attachment.done.finally(() => { if (this.attachments.get(id) === attachment)
132
+ this.attachments.delete(id); });
133
+ }
134
+ /** `exec_attach`: `exec_status` now, then the output from `fromOffset` and, once ended, the final status and the exit. */
135
+ attachFrame(frame) {
136
+ void (async () => {
137
+ if (this.fallbacks.has(frame.id)) { // in this process on this very channel: nothing to re-attach, it is still streaming here
138
+ this.send({ t: 'exec_status', id: frame.id, state: 'running', headOffset: -1, committedOffset: -1 });
139
+ return;
140
+ }
141
+ // It ran in this process for another channel (its exec_started{durable:false} may never have reached the server): `running` /
142
+ // `exited` and, once it ended, its exit — never `unknown`, which would make the server send the request again (Task 26).
143
+ const fallback = this.host.fallbackOf?.(frame.id);
144
+ if (fallback) {
145
+ this.send({ t: 'exec_status', id: frame.id, state: fallback.done ? 'exited' : 'running', headOffset: -1, committedOffset: -1 });
146
+ const exit = await fallback.exit;
147
+ this.send({ t: 'exec_status', id: frame.id, state: 'exited', headOffset: -1, committedOffset: -1 });
148
+ this.send({ ...exit, id: frame.id });
149
+ return;
150
+ }
151
+ // `unknown` only when the policy PROVED nobody runs it (the server then gives the exec up, or sends its request again). When
152
+ // keeper-terminal could not be asked (slow after a restart, not answering) the answer waits: asked again until it is decided or
153
+ // this channel closes (the next channel's attach asks anew). The server's own wait limit bounds it.
154
+ let exec;
155
+ while (exec === undefined) {
156
+ try {
157
+ exec = await this.host.find(frame.id);
158
+ }
159
+ catch (error) {
160
+ if (this.closed)
161
+ return;
162
+ this.log(`exec ${frame.id}: attach deferred: ${error instanceof Error ? error.message : String(error)}`);
163
+ await new Promise((resolve) => setTimeout(resolve, this.options.undecidedRetryMs ?? ATTACH_UNDECIDED_RETRY_MS));
164
+ if (this.closed)
165
+ return;
166
+ }
167
+ }
168
+ if (!exec) {
169
+ this.send({ t: 'exec_status', id: frame.id, state: 'unknown', headOffset: -1, committedOffset: -1 });
170
+ return;
171
+ }
172
+ const status = await exec.wireStatus();
173
+ this.send({ t: 'exec_status', id: frame.id, ...status });
174
+ this.attach(frame.id, exec, frame.fromOffset);
175
+ })().catch((error) => this.log(`exec ${frame.id}: attach failed: ${error instanceof Error ? error.message : String(error)}`));
176
+ }
177
+ ack(frame) { this.host.acknowledge(frame.id, frame.throughOffset); }
178
+ /** `exec_cancel`: idempotent, silent for an unknown or ended exec. */
179
+ cancel(frame) {
180
+ const pending = this.pending.get(frame.id);
181
+ if (pending)
182
+ pending.cancelled = true;
183
+ const fallback = this.fallbacks.get(frame.id);
184
+ if (fallback) {
185
+ fallback.cancelled = true;
186
+ fallback.controller.abort();
187
+ return;
188
+ }
189
+ void this.host.cancel(frame.id, frame.signal).catch((error) => this.log(`exec ${frame.id}: cancel failed: ${error instanceof Error ? error.message : String(error)}`));
190
+ }
191
+ /** The channel closed: stop streaming (durable execs keep running for the next channel) and kill this channel's fallback execs. */
192
+ close() {
193
+ this.closed = true;
194
+ for (const attachment of this.attachments.values())
195
+ attachment.stop();
196
+ this.attachments.clear();
197
+ for (const fallback of this.fallbacks.values())
198
+ fallback.controller.abort();
199
+ }
200
+ }