@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,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,7 +73,7 @@ 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() });
@@ -3,19 +3,21 @@ import { resolveBrowserBinding } from '../desktop/binding.js';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { connectEncryptedChannel } from '../connection/encryptedChannel.js';
5
5
  import { runExec } from '../exec.js';
6
+ import { ExecPolicy, keeperExecsOutliveTheChannel } from '../exec/policy.js';
7
+ import { ComputerExecWire } from './computerExecs.js';
6
8
  import { decodeComputerFrame, encodeComputerFrame } from '../protocol/computer.js';
7
9
  import { workstationChannelUrl } from './url.js';
8
10
  import { TaskRegistry } from '../update/taskRegistry.js';
9
- import { createComputerDirSafely, listComputerDirSafely, readComputerFileSafely, writeComputerFileSafely } from '../fileWorkerClient.js';
10
- import { computerFileError } from '../filePaths.js';
11
- import { ToolsClient } from '../tools/client.js';
11
+ import { toFileError } from '../files/errors.js';
12
12
  import { object } from '../tools/validation.js';
13
+ import { isUnaccountedTool } from '../tools/router.js';
14
+ import { requestRootFileExecutors } from '../files/adapters/computerFileFrames.js';
13
15
  /**
14
16
  * Consumes computer frames without serializing long-running commands behind one another.
15
17
  * All command failures and post-close sends are contained here because this is called detached
16
18
  * from the socket connection loop.
17
19
  */
18
- export async function dispatchComputerFrames(messages, send, execute, tasks = new TaskRegistry(), files = {}, tools, desktop) {
20
+ export async function dispatchComputerFrames(messages, send, execute, tasks = new TaskRegistry(), files = {}, tools, desktop, durable = {}) {
19
21
  const active = new Map();
20
22
  const activeTools = new Set();
21
23
  let open = true;
@@ -25,6 +27,9 @@ export async function dispatchComputerFrames(messages, send, execute, tasks = ne
25
27
  send(frame);
26
28
  }
27
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);
28
33
  const run = (request) => {
29
34
  if (!tasks.accepting) {
30
35
  safeSend({ t: 'exec_exit', id: request.id, exitCode: null, stdout: '', stderr: 'Workstation is not accepting new work', timedOut: false, truncated: false });
@@ -35,6 +40,8 @@ export async function dispatchComputerFrames(messages, send, execute, tasks = ne
35
40
  // the most recently accepted request while the older promise still cleans up its own entry.
36
41
  active.get(request.id)?.abort();
37
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.
38
45
  void (async () => {
39
46
  try {
40
47
  const exit = await tasks.withShellTask(async (shutdown) => {
@@ -43,10 +50,19 @@ export async function dispatchComputerFrames(messages, send, execute, tasks = ne
43
50
  controller.abort();
44
51
  else
45
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;
46
55
  try {
47
- 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
+ });
48
63
  }
49
64
  finally {
65
+ uncount?.();
50
66
  shutdown.removeEventListener('abort', onShutdown);
51
67
  }
52
68
  });
@@ -94,16 +110,18 @@ export async function dispatchComputerFrames(messages, send, execute, tasks = ne
94
110
  else
95
111
  shutdown.addEventListener('abort', onShutdown, { once: true });
96
112
  try {
113
+ if (files.runFrame)
114
+ return await files.runFrame(request, controller.signal);
97
115
  if (request.t === 'file_read_request') {
98
- return await (files.readFile ?? readComputerFileSafely)({ root: request.root, path: request.path, startLine: request.startLine, maxLines: request.maxLines }, controller.signal);
116
+ return await (files.readFile ?? requestRootFileExecutors.readFile)({ root: request.root, path: request.path, startLine: request.startLine, maxLines: request.maxLines }, controller.signal);
99
117
  }
100
118
  if (request.t === 'file_write_request') {
101
- return await (files.writeFile ?? writeComputerFileSafely)({ root: request.root, path: request.path, content: request.content, mode: request.mode, expectedSha256: request.expectedSha256 }, controller.signal);
119
+ return await (files.writeFile ?? requestRootFileExecutors.writeFile)({ root: request.root, path: request.path, content: request.content, mode: request.mode, expectedSha256: request.expectedSha256 }, controller.signal);
102
120
  }
103
121
  if (request.t === 'file_ls_request') {
104
- return await (files.listDir ?? listComputerDirSafely)({ root: request.root, path: request.path, startAfter: request.startAfter, maxEntries: request.maxEntries }, controller.signal);
122
+ return await (files.listDir ?? requestRootFileExecutors.listDir)({ root: request.root, path: request.path, startAfter: request.startAfter, maxEntries: request.maxEntries }, controller.signal);
105
123
  }
106
- return await (files.createDir ?? createComputerDirSafely)({ root: request.root, path: request.path, parents: request.parents }, controller.signal);
124
+ return await (files.createDir ?? requestRootFileExecutors.createDir)({ root: request.root, path: request.path, parents: request.parents }, controller.signal);
107
125
  }
108
126
  finally {
109
127
  shutdown.removeEventListener('abort', onShutdown);
@@ -111,6 +129,10 @@ export async function dispatchComputerFrames(messages, send, execute, tasks = ne
111
129
  });
112
130
  if (active.get(request.id) !== controller)
113
131
  return;
132
+ if (files.runFrame) {
133
+ safeSend(data);
134
+ return;
135
+ }
114
136
  if (request.t === 'file_read_request')
115
137
  safeSend({ t: 'file_read_result', id: request.id, data: data });
116
138
  else if (request.t === 'file_write_request')
@@ -123,7 +145,7 @@ export async function dispatchComputerFrames(messages, send, execute, tasks = ne
123
145
  catch (error) {
124
146
  if (active.get(request.id) !== controller)
125
147
  return;
126
- const mapped = computerFileError(error);
148
+ const mapped = toFileError(error);
127
149
  if (controller.signal.aborted) {
128
150
  const mutation = request.t === 'file_write_request' || request.t === 'file_mkdir_request';
129
151
  fail(mutation ? 'outcome_unknown' : 'workstation_offline', request.t === 'file_write_request' ? 'Write outcome is unknown after cancellation'
@@ -145,12 +167,15 @@ export async function dispatchComputerFrames(messages, send, execute, tasks = ne
145
167
  fail('Workstation tools require computer wire v3');
146
168
  return;
147
169
  }
148
- if (!tasks.accepting) {
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) {
149
174
  fail('Workstation is not accepting new work');
150
175
  return;
151
176
  }
152
177
  if (!tools && request.method !== 'browser.sharedText') {
153
- fail('Workstation tools keeper is not configured');
178
+ fail('Workstation tools are not configured');
154
179
  return;
155
180
  }
156
181
  if (activeTools.has(request.id)) {
@@ -165,10 +190,11 @@ export async function dispatchComputerFrames(messages, send, execute, tasks = ne
165
190
  void (async () => {
166
191
  let timer;
167
192
  try {
168
- const result = await tasks.withShellTask(() => Promise.race([
193
+ const call = () => Promise.race([
169
194
  request.method === 'browser.sharedText' ? (desktop ? desktop.insertText(request.params) : Promise.reject(Error('Shared desktop unavailable'))) : tools(request.method, request.params),
170
195
  new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error('Workstation tool timed out; mutation outcome may be unknown')), 7500); }),
171
- ]));
196
+ ]);
197
+ const result = probe ? await call() : await tasks.withShellTask(call);
172
198
  if (!object(result))
173
199
  throw new Error('Invalid workstation tool result');
174
200
  safeSend({ t: 'tool_result', id: request.id, ok: true, result, error: null });
@@ -189,10 +215,25 @@ export async function dispatchComputerFrames(messages, send, execute, tasks = ne
189
215
  desktop?.onFrame(frame);
190
216
  else if (frame.t === 'tool_request')
191
217
  runTool(frame);
192
- else if (frame.t === 'exec_request')
193
- run(frame);
194
- else if (frame.t === 'exec_cancel')
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') {
195
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
+ }
196
237
  else if (frame.t === 'computer_ready') {
197
238
  wireVersion = frame.wireVersion;
198
239
  desktop?.ready(frame.generation, frame.wireVersion);
@@ -204,6 +245,7 @@ export async function dispatchComputerFrames(messages, send, execute, tasks = ne
204
245
  finally {
205
246
  open = false;
206
247
  desktop?.close();
248
+ execWire.close();
207
249
  for (const controller of active.values())
208
250
  controller.abort();
209
251
  }
@@ -213,11 +255,15 @@ export class ComputerChannel {
213
255
  options;
214
256
  tasks;
215
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;
216
260
  constructor(options) {
217
261
  this.options = options;
218
262
  this.tasks = options.taskRegistry ?? new TaskRegistry();
219
- const client = options.toolsConfig ? new ToolsClient(options.toolsConfig) : undefined;
220
- this.tools = options.tools ?? (client ? (method, params) => client.request(method, params) : undefined);
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;
221
267
  }
222
268
  activeTaskCount() { return this.tasks.snapshot().activeShellTasks; }
223
269
  async connect(ticket, signal) {
@@ -233,7 +279,9 @@ export class ComputerChannel {
233
279
  return { closed: channel.closed, close: () => channel.close() };
234
280
  }
235
281
  async dispatch(channel) {
236
- const desktop = new DesktopBridge(resolveBrowserBinding(this.options.browserBinding ?? this.options.toolsConfig?.browserBinding), frame => channel.send(frame), () => channel.bufferedAmount);
237
- await dispatchComputerFrames(channel.messages, (frame) => channel.send(frame), this.options.runExec ?? runExec, this.tasks, this.options, this.tools, desktop);
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
+ });
238
286
  }
239
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
+ }
@@ -1,14 +1,15 @@
1
1
  import { deriveSessionKey, randomBytes } from '../crypto.js';
2
2
  import { connectEncryptedChannel } from '../connection/encryptedChannel.js';
3
3
  import { CLIENT_PACKAGE_NAME, CLIENT_VERSION } from '../version.js';
4
- import { decodeManagementFrame, encodeManagementFrame, MANAGEMENT_WIRE_DRAINING_VERSION, MANAGEMENT_WIRE_LOAD_AVERAGES_VERSION, MANAGEMENT_WIRE_METRICS_VERSION, MANAGEMENT_WIRE_MIN_VERSION, MANAGEMENT_WIRE_PRESSURE_VERSION, MANAGEMENT_WIRE_VERSION, } from '../protocol/management.js';
5
- import { bridgesForWire } from '../protocol/common.js';
4
+ import { decodeManagementFrame, encodeManagementFrame, MANAGEMENT_WIRE_DRAINING_VERSION, MANAGEMENT_WIRE_GPU_VERSION, MANAGEMENT_WIRE_KEEPERS_VERSION, MANAGEMENT_WIRE_LOAD_AVERAGES_VERSION, MANAGEMENT_WIRE_METRICS_VERSION, MANAGEMENT_WIRE_MIN_VERSION, MANAGEMENT_WIRE_PRESSURE_VERSION, MANAGEMENT_WIRE_VERSION, } from '../protocol/management.js';
5
+ import { bridgesForWire, helloRoster } from '../protocol/common.js';
6
6
  import { AGENT_WIRE_MIN_VERSION, AGENT_WIRE_VERSION } from '../protocol/agent.js';
7
7
  import { osInfo } from '../osinfo.js';
8
8
  import { workstationChannelUrl } from './url.js';
9
9
  const protocols = {
10
10
  MANAGEMENT: { min: MANAGEMENT_WIRE_MIN_VERSION, max: MANAGEMENT_WIRE_VERSION },
11
- COMPUTER: { min: 1, max: 4 }, AGENT: { min: AGENT_WIRE_MIN_VERSION, max: AGENT_WIRE_VERSION },
11
+ // COMPUTER v5: durable exec frames (exec_started/attach/status/ack, offsets on exec_output; spec §4.3).
12
+ COMPUTER: { min: 1, max: 5 }, AGENT: { min: AGENT_WIRE_MIN_VERSION, max: AGENT_WIRE_VERSION },
12
13
  };
13
14
  /** Exported so tests can assert the wire frame's `clientPackage` without opening a socket. */
14
15
  export function buildHelloFrame(options, clientNonce) {
@@ -24,7 +25,7 @@ export function buildHelloFrame(options, clientNonce) {
24
25
  */
25
26
  function helloBridges(bridges) {
26
27
  const roster = bridges?.();
27
- return roster === undefined ? undefined : bridgesForWire(roster, 'management_hello');
28
+ return roster === undefined ? undefined : bridgesForWire(helloRoster(roster), 'management_hello');
28
29
  }
29
30
  /** Exported so the roster this answer carries can be asserted without driving a whole channel. */
30
31
  export function buildScanResultFrame(commandId, bridges) {
@@ -57,6 +58,10 @@ export function metricsForManagementWire(metrics, serverManagementWire) {
57
58
  if (!metrics)
58
59
  return metrics;
59
60
  const selectedWire = serverManagementWire ?? 0;
61
+ if (selectedWire < MANAGEMENT_WIRE_GPU_VERSION && metrics.gpus !== undefined) {
62
+ const { gpus: _gpus, ...withoutGpus } = metrics;
63
+ return metricsForManagementWire(withoutGpus, selectedWire);
64
+ }
60
65
  if (selectedWire >= MANAGEMENT_WIRE_LOAD_AVERAGES_VERSION) {
61
66
  const { cpuLoadNormalized: _cpuLoadNormalized, ...rawLoad } = metrics;
62
67
  return rawLoad;
@@ -98,6 +103,51 @@ export function dispatchUpdateCommand(channel, command, onUpdate, serverManageme
98
103
  })
99
104
  .catch(() => { });
100
105
  }
106
+ /**
107
+ * The most a heartbeat waits for its `keepers` sample. The channel builds the next heartbeat only after this one went out, so a sample
108
+ * that hangs (a wedged `ps`, a stuck file read) must never hold the frame: past this it goes out with `keepers: null`.
109
+ */
110
+ export const KEEPERS_SAMPLE_CAP_MS = 1500;
111
+ /**
112
+ * Heartbeat `keepers` for the negotiated wire: absent below 8 (an older server does not know the field), the sampled list from 8 on,
113
+ * `null` when sampling failed or did not answer within `capMs`. Never rejects, never waits longer than `capMs`. Exported for tests.
114
+ */
115
+ export async function keepersForManagementWire(sample, serverManagementWire, capMs = KEEPERS_SAMPLE_CAP_MS) {
116
+ if (!sample || (serverManagementWire ?? 0) < MANAGEMENT_WIRE_KEEPERS_VERSION)
117
+ return {};
118
+ let timer;
119
+ const capped = new Promise((resolve) => { timer = setTimeout(() => resolve(null), capMs); timer.unref?.(); });
120
+ try {
121
+ const sampled = Promise.resolve().then(sample);
122
+ sampled.catch(() => undefined); // a late rejection after the cap must not be unhandled
123
+ return { keepers: await Promise.race([sampled, capped]) };
124
+ }
125
+ catch {
126
+ return { keepers: null };
127
+ }
128
+ finally {
129
+ clearTimeout(timer);
130
+ }
131
+ }
132
+ /**
133
+ * Runs one `keeper_control` and sends its result if the channel is still open. Detached from the frame loop (a replace-now stops
134
+ * keeper processes, which takes seconds). Exported for tests.
135
+ */
136
+ export function dispatchKeeperControl(channel, frame, onKeeperControl) {
137
+ return onKeeperControl(frame)
138
+ .catch((error) => ({
139
+ t: 'keeper_control_result', commandId: frame.commandId, kind: frame.kind, action: frame.action, ok: false, affected: [],
140
+ error: error instanceof Error ? error.message : String(error),
141
+ }))
142
+ .then((result) => {
143
+ if (channel.state !== 'OPEN')
144
+ return;
145
+ try {
146
+ channel.send(result);
147
+ }
148
+ catch { /* the channel closed meanwhile; the server asks again with the same commandId */ }
149
+ });
150
+ }
101
151
  /**
102
152
  * The server nonce to derive this session's key from.
103
153
  *
@@ -125,9 +175,29 @@ export class ManagementChannel {
125
175
  options;
126
176
  /** Undefined until this connection receives management_ready; reset for every reconnect. */
127
177
  serverManagementWire;
178
+ /** The open channel of the current connection, for frames this client sends unprompted ({@link pushRoster}). */
179
+ channel;
128
180
  constructor(options) {
129
181
  this.options = options;
130
182
  }
183
+ /**
184
+ * Tell the server about a roster that changed without a scan asking (adapter builds moved by Axom,
185
+ * spec §6.1): the same `external_agents_scan_result` frame a scan answers with, under a client-made
186
+ * command id no scan is waiting on — the server records the roster and completes nothing. `false`
187
+ * when there is no open connection; the next `management_hello` carries {@link ManagementChannelOptions.bridges} then.
188
+ */
189
+ pushRoster(bridges) {
190
+ const channel = this.channel;
191
+ if (!channel || channel.state !== 'OPEN')
192
+ return false;
193
+ try {
194
+ channel.send(buildScanResultFrame(`roster-push-${randomBytes(8).toString('hex')}`, bridges));
195
+ return true;
196
+ }
197
+ catch {
198
+ return false;
199
+ }
200
+ }
131
201
  async connect(signal) {
132
202
  this.serverManagementWire = undefined;
133
203
  const nonce = randomBytes(16);
@@ -161,6 +231,10 @@ export class ManagementChannel {
161
231
  // is never driven by the local browser/device-authorization flow.
162
232
  await this.options.onReady?.({ generation: ready.generation, release: ready.release });
163
233
  const pending = new Map();
234
+ this.channel = channel;
235
+ void channel.closed.then(() => { if (this.channel === channel)
236
+ this.channel = undefined; }, () => { if (this.channel === channel)
237
+ this.channel = undefined; });
164
238
  void this.routeMessages(channel, pending);
165
239
  return {
166
240
  generation: ready.generation,
@@ -184,6 +258,7 @@ export class ManagementChannel {
184
258
  const snapshot = await this.options.snapshot();
185
259
  return {
186
260
  t: 'heartbeat', seq, sentAtEpochMs, clientVersion: CLIENT_VERSION,
261
+ ...(this.options.playgroundCwd ? { playgroundCwd: this.options.playgroundCwd } : {}),
187
262
  channels: {
188
263
  MANAGEMENT: snapshot.channels.management,
189
264
  COMPUTER: snapshot.channels.computer,
@@ -195,6 +270,7 @@ export class ManagementChannel {
195
270
  ? { metrics: metricsForManagementWire(this.options.metrics(), this.serverManagementWire) }
196
271
  : {}),
197
272
  ...(snapshot.update ? { update: statusForManagementWire(snapshot.update, this.serverManagementWire) } : {}),
273
+ ...await keepersForManagementWire(this.options.keepers, this.serverManagementWire, this.options.keepersCapMs),
198
274
  };
199
275
  }
200
276
  async nextReady(channel) {
@@ -217,6 +293,10 @@ export class ManagementChannel {
217
293
  void dispatchUpdateCommand(channel, frame, this.options.onUpdate, this.serverManagementWire);
218
294
  continue;
219
295
  }
296
+ if (frame.t === 'keeper_control' && this.options.onKeeperControl) {
297
+ void dispatchKeeperControl(channel, frame, this.options.onKeeperControl);
298
+ continue;
299
+ }
220
300
  if (frame.t === 'external_agents_scan' && this.options.onExternalAgentsScan) {
221
301
  channel.send(buildScanResultFrame(frame.commandId, await this.options.onExternalAgentsScan()));
222
302
  }