@platless/axom-cli 0.1.0 → 0.3.13

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 (109) hide show
  1. package/README.md +1 -1
  2. package/dist/authScopes.js +19 -0
  3. package/dist/axom-patch/cli/README.md +110 -0
  4. package/dist/axom-patch/cli/pi-steering.json +37 -0
  5. package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +34 -0
  6. package/dist/axom-patch/cli/probes/pi-steering.mjs +80 -0
  7. package/dist/axom-patch/cli/probes/qoder-steering.mjs +89 -0
  8. package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +49 -0
  9. package/dist/axom-patch/cli/qoder-background-terminal.md +81 -0
  10. package/dist/axom-patch/cli/qoder-foreground-output.md +41 -0
  11. package/dist/axom-patch/cli/qoder-steering.json +52 -0
  12. package/dist/axom-patch/cli/qoder-steering.md +30 -0
  13. package/dist/axom-patch/cli/qoder-subagent-origin.json +73 -0
  14. package/dist/axom-patch/cli/qoder-subagent-subwork.md +73 -0
  15. package/dist/axom-patch/cli/runtime.json +37 -0
  16. package/dist/axom-patch/cli/steering/claude-steering-js.patch +89 -0
  17. package/dist/axom-patch/cli/steering/claude-steering-types.patch +28 -0
  18. package/dist/axom-patch/cli/steering/codex-steering.patch +57 -0
  19. package/dist/axom-patch/cli/steering/pi-steering.js +79 -0
  20. package/dist/cli.js +9 -1
  21. package/dist/commands/auth.js +4 -13
  22. package/dist/commands/execute.js +4 -4
  23. package/dist/commands/record.js +6 -6
  24. package/dist/commands/struct.js +4 -4
  25. package/dist/commands/workstation.js +2 -2
  26. package/dist/credentials.js +2 -2
  27. package/dist/install/claude/commands.js +13 -13
  28. package/dist/mcp.js +32 -14
  29. package/dist/workstation/acp/acpAgents.js +447 -166
  30. package/dist/workstation/acp/acpClient.js +232 -18
  31. package/dist/workstation/acp/acpSession.js +502 -74
  32. package/dist/workstation/acp/activityBusy.js +43 -0
  33. package/dist/workstation/acp/activityReporting.js +54 -0
  34. package/dist/workstation/acp/adoption.js +107 -49
  35. package/dist/workstation/acp/authUrlSniffer.js +36 -0
  36. package/dist/workstation/acp/contract.js +11 -6
  37. package/dist/workstation/acp/delegationMcp.js +24 -0
  38. package/dist/workstation/acp/draftInterceptor.js +9 -0
  39. package/dist/workstation/acp/managedPatches.js +228 -0
  40. package/dist/workstation/acp/patchAssets.js +63 -0
  41. package/dist/workstation/acp/permission.js +300 -34
  42. package/dist/workstation/acp/qoderStartup.js +45 -0
  43. package/dist/workstation/acp/registry/catalog.js +263 -4
  44. package/dist/workstation/acp/registry/detect.js +55 -0
  45. package/dist/workstation/acp/sessionScope.js +19 -0
  46. package/dist/workstation/acp/sessionUpdateMapper.js +39 -21
  47. package/dist/workstation/acp/terminalLogin.js +197 -0
  48. package/dist/workstation/acp/test/fakeAgent.js +185 -9
  49. package/dist/workstation/acp/validate.js +33 -5
  50. package/dist/workstation/agentSessions.js +532 -44
  51. package/dist/workstation/channels/agent.js +7 -1
  52. package/dist/workstation/channels/computer.js +152 -4
  53. package/dist/workstation/channels/management.js +3 -3
  54. package/dist/workstation/config.js +3 -1
  55. package/dist/workstation/connection/encryptedChannel.js +88 -21
  56. package/dist/workstation/daemon.js +25 -3
  57. package/dist/workstation/desktop/binding.js +36 -0
  58. package/dist/workstation/desktop/bridge.js +212 -0
  59. package/dist/workstation/desktop/flow-control.js +52 -0
  60. package/dist/workstation/desktop/focused-text.js +130 -0
  61. package/dist/workstation/desktop/protocol.js +35 -0
  62. package/dist/workstation/fileOps.js +277 -0
  63. package/dist/workstation/filePaths.js +174 -0
  64. package/dist/workstation/fileWorker.js +81 -0
  65. package/dist/workstation/fileWorkerClient.js +121 -0
  66. package/dist/workstation/oauth/deviceAuthorization.js +21 -12
  67. package/dist/workstation/protocol/agent.js +154 -5
  68. package/dist/workstation/protocol/common.js +6 -0
  69. package/dist/workstation/protocol/computer.js +92 -3
  70. package/dist/workstation/qoderAdapter/backgroundProcess.js +106 -0
  71. package/dist/workstation/qoderAdapter/backgroundTerminal.js +262 -0
  72. package/dist/workstation/qoderAdapter/completedContinuation.js +139 -0
  73. package/dist/workstation/qoderAdapter/contract.js +78 -0
  74. package/dist/workstation/qoderAdapter/foregroundOutput.js +30 -0
  75. package/dist/workstation/qoderAdapter/history.js +87 -0
  76. package/dist/workstation/qoderAdapter/identityIndex.js +191 -0
  77. package/dist/workstation/qoderAdapter/inputQueue.js +42 -0
  78. package/dist/workstation/qoderAdapter/main.js +93 -0
  79. package/dist/workstation/qoderAdapter/mapper.js +117 -0
  80. package/dist/workstation/qoderAdapter/observerPlugin.js +245 -0
  81. package/dist/workstation/qoderAdapter/observerReceiver.js +187 -0
  82. package/dist/workstation/qoderAdapter/permissions.js +61 -0
  83. package/dist/workstation/qoderAdapter/profile.js +93 -0
  84. package/dist/workstation/qoderAdapter/sdkDriver.js +49 -0
  85. package/dist/workstation/qoderAdapter/session.js +319 -0
  86. package/dist/workstation/qoderAdapter/subagentMeta.js +28 -0
  87. package/dist/workstation/qoderAdapter/subwork.js +77 -0
  88. package/dist/workstation/qoderAdapter/taskControl.js +38 -0
  89. package/dist/workstation/qoderAdapter/taskEvent.js +26 -0
  90. package/dist/workstation/qoderAdapter/taskLedger.js +163 -0
  91. package/dist/workstation/qoderAdapter/types.js +1 -0
  92. package/dist/workstation/qoderAdapter/usage.js +37 -0
  93. package/dist/workstation/register.js +4 -0
  94. package/dist/workstation/tools/browser.js +394 -0
  95. package/dist/workstation/tools/client.js +269 -0
  96. package/dist/workstation/tools/core.js +50 -0
  97. package/dist/workstation/tools/files.js +525 -0
  98. package/dist/workstation/tools/keeperProcess.js +114 -0
  99. package/dist/workstation/tools/terminal.js +214 -0
  100. package/dist/workstation/tools/validation.js +50 -0
  101. package/dist/workstation/update/coordinator.js +29 -0
  102. package/dist/workstation/update/npmInstaller.js +2 -0
  103. package/dist/workstation/update/ptyPreflight.js +24 -0
  104. package/dist/workstation/update/taskRegistry.js +44 -20
  105. package/dist/workstation/update/watchdog.js +66 -0
  106. package/package.json +14 -4
  107. package/skills/axom/SKILL.md +55 -17
  108. package/skills/axom-definition/SKILL.md +32 -32
  109. package/skills/axom-platform/SKILL.md +39 -39
@@ -78,7 +78,13 @@ export async function dispatchAgentChannelFrames(messages, send, agents, close =
78
78
  }
79
79
  continue;
80
80
  }
81
- await dispatchAgentFrame(agents, frame);
81
+ // Start handlers in wire order, but keep receiving while their RPCs / disk I/O wait.
82
+ // A probe can take longer than an open's 30s or a config change's 10s deadline; awaiting it
83
+ // here makes every unrelated session time out while heartbeats still report a healthy socket.
84
+ // The manager owns open deduplication, close-during-open tombstones and per-session journal
85
+ // ordering. Answers, interrupts and closes must also reach it while an earlier RPC is pending.
86
+ // dispatchAgentFrame catches rejections, including for these detached handlers.
87
+ void dispatchAgentFrame(agents, frame);
82
88
  }
83
89
  }
84
90
  catch (error) {
@@ -1,17 +1,27 @@
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';
4
6
  import { decodeComputerFrame, encodeComputerFrame } from '../protocol/computer.js';
5
7
  import { workstationChannelUrl } from './url.js';
6
8
  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';
12
+ import { object } from '../tools/validation.js';
7
13
  /**
8
14
  * Consumes computer frames without serializing long-running commands behind one another.
9
15
  * All command failures and post-close sends are contained here because this is called detached
10
16
  * from the socket connection loop.
11
17
  */
12
- export async function dispatchComputerFrames(messages, send, execute, tasks = new TaskRegistry()) {
18
+ export async function dispatchComputerFrames(messages, send, execute, tasks = new TaskRegistry(), files = {}, tools, desktop) {
13
19
  const active = new Map();
14
- const safeSend = (frame) => { try {
20
+ const activeTools = new Set();
21
+ let open = true;
22
+ let wireVersion = 1;
23
+ const safeSend = (frame) => { if (!open)
24
+ return; try {
15
25
  send(frame);
16
26
  }
17
27
  catch { /* socket already closed */ } };
@@ -51,15 +61,149 @@ export async function dispatchComputerFrames(messages, send, execute, tasks = ne
51
61
  }
52
62
  })();
53
63
  };
64
+ const runFile = (request) => {
65
+ // Read/write landed in wire v2; directory listing and creation arrived with v3.
66
+ const minWireVersion = request.t === 'file_ls_request' || request.t === 'file_mkdir_request' ? 3 : 2;
67
+ const fail = (code, message) => {
68
+ if (request.t === 'file_read_request')
69
+ safeSend({ t: 'file_read_result', id: request.id, error: { code, message } });
70
+ else if (request.t === 'file_write_request')
71
+ safeSend({ t: 'file_write_result', id: request.id, error: { code, message } });
72
+ else if (request.t === 'file_ls_request')
73
+ safeSend({ t: 'file_ls_result', id: request.id, error: { code, message } });
74
+ else
75
+ safeSend({ t: 'file_mkdir_result', id: request.id, error: { code, message } });
76
+ };
77
+ if (wireVersion < minWireVersion) {
78
+ fail('unsupported_protocol', `Computer file operations require wire v${minWireVersion}`);
79
+ return;
80
+ }
81
+ if (!tasks.accepting) {
82
+ fail('workstation_offline', 'Workstation is not accepting new work');
83
+ return;
84
+ }
85
+ active.get(request.id)?.abort();
86
+ const controller = new AbortController();
87
+ active.set(request.id, controller);
88
+ void (async () => {
89
+ try {
90
+ const data = await tasks.withShellTask(async (shutdown) => {
91
+ const onShutdown = () => controller.abort();
92
+ if (shutdown.aborted)
93
+ controller.abort();
94
+ else
95
+ shutdown.addEventListener('abort', onShutdown, { once: true });
96
+ try {
97
+ 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);
99
+ }
100
+ 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);
102
+ }
103
+ 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);
105
+ }
106
+ return await (files.createDir ?? createComputerDirSafely)({ root: request.root, path: request.path, parents: request.parents }, controller.signal);
107
+ }
108
+ finally {
109
+ shutdown.removeEventListener('abort', onShutdown);
110
+ }
111
+ });
112
+ if (active.get(request.id) !== controller)
113
+ return;
114
+ if (request.t === 'file_read_request')
115
+ safeSend({ t: 'file_read_result', id: request.id, data: data });
116
+ else if (request.t === 'file_write_request')
117
+ safeSend({ t: 'file_write_result', id: request.id, data: data });
118
+ else if (request.t === 'file_ls_request')
119
+ safeSend({ t: 'file_ls_result', id: request.id, data: data });
120
+ else
121
+ safeSend({ t: 'file_mkdir_result', id: request.id, data: data });
122
+ }
123
+ catch (error) {
124
+ if (active.get(request.id) !== controller)
125
+ return;
126
+ const mapped = computerFileError(error);
127
+ if (controller.signal.aborted) {
128
+ const mutation = request.t === 'file_write_request' || request.t === 'file_mkdir_request';
129
+ fail(mutation ? 'outcome_unknown' : 'workstation_offline', request.t === 'file_write_request' ? 'Write outcome is unknown after cancellation'
130
+ : request.t === 'file_mkdir_request' ? 'Directory creation outcome is unknown after cancellation'
131
+ : 'Read was cancelled');
132
+ }
133
+ else
134
+ fail(mapped.code, mapped.message);
135
+ }
136
+ finally {
137
+ if (active.get(request.id) === controller)
138
+ active.delete(request.id);
139
+ }
140
+ })();
141
+ };
142
+ const runTool = (request) => {
143
+ const fail = (error) => safeSend({ t: 'tool_result', id: request.id, ok: false, result: {}, error: (error || 'Workstation tool failed').slice(0, 1024) });
144
+ if (wireVersion < 3) {
145
+ fail('Workstation tools require computer wire v3');
146
+ return;
147
+ }
148
+ if (!tasks.accepting) {
149
+ fail('Workstation is not accepting new work');
150
+ return;
151
+ }
152
+ if (!tools && request.method !== 'browser.sharedText') {
153
+ fail('Workstation tools keeper is not configured');
154
+ return;
155
+ }
156
+ if (activeTools.has(request.id)) {
157
+ fail('Duplicate tool request id');
158
+ return;
159
+ }
160
+ if (activeTools.size >= 16) {
161
+ fail('Too many simultaneous tool requests');
162
+ return;
163
+ }
164
+ activeTools.add(request.id);
165
+ void (async () => {
166
+ let timer;
167
+ try {
168
+ const result = await tasks.withShellTask(() => Promise.race([
169
+ request.method === 'browser.sharedText' ? (desktop ? desktop.insertText(request.params) : Promise.reject(Error('Shared desktop unavailable'))) : tools(request.method, request.params),
170
+ new Promise((_resolve, reject) => { timer = setTimeout(() => reject(new Error('Workstation tool timed out; mutation outcome may be unknown')), 7500); }),
171
+ ]));
172
+ if (!object(result))
173
+ throw new Error('Invalid workstation tool result');
174
+ safeSend({ t: 'tool_result', id: request.id, ok: true, result, error: null });
175
+ }
176
+ catch (error) {
177
+ fail(error instanceof Error ? error.message : 'Workstation tool failed');
178
+ }
179
+ finally {
180
+ if (timer)
181
+ clearTimeout(timer);
182
+ activeTools.delete(request.id);
183
+ }
184
+ })();
185
+ };
54
186
  try {
55
187
  for await (const frame of messages) {
56
- if (frame.t === 'exec_request')
188
+ if (frame.t.startsWith('desktop_'))
189
+ desktop?.onFrame(frame);
190
+ else if (frame.t === 'tool_request')
191
+ runTool(frame);
192
+ else if (frame.t === 'exec_request')
57
193
  run(frame);
58
194
  else if (frame.t === 'exec_cancel')
59
195
  active.get(frame.id)?.abort();
196
+ else if (frame.t === 'computer_ready') {
197
+ wireVersion = frame.wireVersion;
198
+ desktop?.ready(frame.generation, frame.wireVersion);
199
+ }
200
+ else if (frame.t === 'file_read_request' || frame.t === 'file_write_request' || frame.t === 'file_ls_request' || frame.t === 'file_mkdir_request')
201
+ runFile(frame);
60
202
  }
61
203
  }
62
204
  finally {
205
+ open = false;
206
+ desktop?.close();
63
207
  for (const controller of active.values())
64
208
  controller.abort();
65
209
  }
@@ -68,9 +212,12 @@ export async function dispatchComputerFrames(messages, send, execute, tasks = ne
68
212
  export class ComputerChannel {
69
213
  options;
70
214
  tasks;
215
+ tools;
71
216
  constructor(options) {
72
217
  this.options = options;
73
218
  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);
74
221
  }
75
222
  activeTaskCount() { return this.tasks.snapshot().activeShellTasks; }
76
223
  async connect(ticket, signal) {
@@ -86,6 +233,7 @@ export class ComputerChannel {
86
233
  return { closed: channel.closed, close: () => channel.close() };
87
234
  }
88
235
  async dispatch(channel) {
89
- await dispatchComputerFrames(channel.messages, (frame) => channel.send(frame), this.options.runExec ?? runExec, this.tasks);
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);
90
238
  }
91
239
  }
@@ -8,7 +8,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: 1 }, AGENT: { min: AGENT_WIRE_MIN_VERSION, max: AGENT_WIRE_VERSION },
11
+ COMPUTER: { min: 1, max: 4 }, AGENT: { min: AGENT_WIRE_MIN_VERSION, max: AGENT_WIRE_VERSION },
12
12
  };
13
13
  /** Exported so tests can assert the wire frame's `clientPackage` without opening a socket. */
14
14
  export function buildHelloFrame(options, clientNonce) {
@@ -180,8 +180,8 @@ export class ManagementChannel {
180
180
  }),
181
181
  };
182
182
  }
183
- heartbeat(seq, sentAtEpochMs) {
184
- const snapshot = this.options.snapshot();
183
+ async heartbeat(seq, sentAtEpochMs) {
184
+ const snapshot = await this.options.snapshot();
185
185
  return {
186
186
  t: 'heartbeat', seq, sentAtEpochMs, clientVersion: CLIENT_VERSION,
187
187
  channels: {
@@ -1,3 +1,4 @@
1
+ import { resolveBrowserBinding } from './desktop/binding.js';
1
2
  import { promises as fs } from 'node:fs';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
@@ -33,7 +34,8 @@ export async function loadClientConfig(dir) {
33
34
  if (!parsed?.server || !parsed.workstationId) {
34
35
  throw new Error('Invalid workstation config: server and workstationId are required; run axom auth again');
35
36
  }
36
- return { server: parsed.server, workstationId: parsed.workstationId, ...(Array.isArray(parsed.workspaces) ? { workspaces: parsed.workspaces } : {}) };
37
+ const browserBinding = parsed.browserBinding ? resolveBrowserBinding(parsed.browserBinding, {}) : undefined;
38
+ return { ...(browserBinding ? { browserBinding } : {}), server: parsed.server, workstationId: parsed.workstationId, ...(Array.isArray(parsed.workspaces) ? { workspaces: parsed.workspaces } : {}) };
37
39
  }
38
40
  export async function writeKeys(dir, clientKeyB64, serverPubB64) {
39
41
  await fs.mkdir(dir, { recursive: true });
@@ -67,6 +67,8 @@ export function connectEncryptedChannel(options) {
67
67
  let openTimer;
68
68
  let nextSequence = 1;
69
69
  const outstandingAcks = new Set();
70
+ let lastReceivedAt = timers.now();
71
+ let maxHeartbeatLagMs = 0;
70
72
  let resolveClosed;
71
73
  let removeAbortListener = () => { };
72
74
  const closed = new Promise((resolve) => { resolveClosed = resolve; });
@@ -103,30 +105,87 @@ export function connectEncryptedChannel(options) {
103
105
  socket.send(crypto.seal(sessionKey, Buffer.from(options.encode(frame))));
104
106
  };
105
107
  const scheduleHeartbeat = () => {
108
+ const dueAt = timers.now() + heartbeatMs;
106
109
  heartbeatTimer = timers.setTimeout(() => {
107
110
  if (settled)
108
111
  return;
112
+ maxHeartbeatLagMs = Math.max(maxHeartbeatLagMs, timers.now() - dueAt);
109
113
  const sequence = nextSequence++;
110
114
  // Register before send: a synchronous server acknowledgement/work frame emitted from
111
- // `send` must observe and satisfy this precise liveness period.
115
+ // `send` must observe and satisfy this precise liveness period. This happens synchronously,
116
+ // before `options.heartbeat` is even awaited below, so that guarantee holds regardless of
117
+ // how long building the frame takes.
112
118
  outstandingAcks.add(sequence);
113
- try {
114
- send(options.heartbeat(sequence, timers.now()));
115
- }
116
- catch {
117
- outstandingAcks.delete(sequence);
118
- close();
119
- return;
120
- }
121
- // `ws.send` can synchronously deliver close/error through a test double or a transport
122
- // implementation. Never resurrect liveness state after that terminal callback.
123
- if (settled)
124
- return;
125
- if (outstandingAcks.size >= missedAcks) {
126
- close();
127
- return;
128
- }
129
- scheduleHeartbeat();
119
+ // `options.heartbeat` may be async (management's reconciles on-disk update state first), so
120
+ // the rest of this tick's work moves into a promise chain. There is deliberately no separate
121
+ // "did we close while the frame was being built" guard between the await and `send`: `settled`
122
+ // and `state` are only ever flipped together, synchronously, inside `finish()`, so `send`'s own
123
+ // `state !== 'OPEN'` check already covers every case such a guard could -- a second copy of the
124
+ // same condition here would be unreachable, untestable dead code, not defense in depth.
125
+ void (async () => {
126
+ let frame;
127
+ try {
128
+ frame = await options.heartbeat(sequence, timers.now());
129
+ }
130
+ catch (error) {
131
+ // Unlike a `send` failure (below), this can come from `options.heartbeat` itself doing
132
+ // real work before it returns a frame -- management's reconciles on-disk update state
133
+ // first -- so a rejection here can be the *only* sign a whole heartbeat cycle silently
134
+ // gave up. Log it rather than let the channel simply close with no trace of why.
135
+ console.error(`[encrypted-channel] heartbeat frame build failed, closing: ${error instanceof Error ? error.message : String(error)}`);
136
+ outstandingAcks.delete(sequence);
137
+ close();
138
+ return;
139
+ }
140
+ try {
141
+ send(frame);
142
+ }
143
+ catch (error) {
144
+ console.error(`[encrypted-channel] heartbeat send failed, closing: ${error instanceof Error ? error.message : String(error)}`);
145
+ outstandingAcks.delete(sequence);
146
+ close();
147
+ return;
148
+ }
149
+ // `ws.send` can synchronously deliver close/error through a test double or a transport
150
+ // implementation. Never resurrect liveness state after that terminal callback.
151
+ if (settled)
152
+ return;
153
+ if (outstandingAcks.size >= missedAcks) {
154
+ console.warn(`${new Date(timers.now()).toISOString()} [encrypted-channel] ${options.url.pathname} closing after ` +
155
+ `${outstandingAcks.size} unconfirmed heartbeats; silentMs=${Math.max(0, timers.now() - lastReceivedAt)}, ` +
156
+ `timerLagMs=${maxHeartbeatLagMs}`);
157
+ close();
158
+ return;
159
+ }
160
+ scheduleHeartbeat();
161
+ })().catch((error) => {
162
+ // Nothing above is expected to throw past its own try/catch except `close()`'s
163
+ // `socket.close()` (a caller-supplied `WebSocketLike`) and `scheduleHeartbeat`'s
164
+ // `timers.setTimeout` -- both call into code this module does not control. Before
165
+ // `options.heartbeat` could be async, the equivalent synchronous throw escaped this
166
+ // `setTimeout` callback as an uncaught exception, which Node turns into a process exit that
167
+ // the supervisor restarts: loud and recoverable. Now it is a promise rejection instead, and
168
+ // `daemon.ts` registers a process-wide `unhandledRejection` handler that deliberately logs
169
+ // and keeps the daemon alive -- exactly wrong here, because an uncaught throw from
170
+ // `scheduleHeartbeat` in particular leaves `settled` false and `state` stuck at `'OPEN'`
171
+ // with nothing left to ever arm another heartbeat: the channel reports healthy forever while
172
+ // it has silently stopped. Force it terminal by hand instead of trusting whichever call
173
+ // above almost got there. `finish()` alone is synchronous and safe to call unconditionally
174
+ // (and idempotent if `close()` already ran before throwing). It does call two
175
+ // caller-supplied functions -- `timers.clearTimeout` and `removeAbortListener` (which wraps
176
+ // `AbortSignal.removeEventListener` when `options.signal` was given) -- so it is not
177
+ // literally incapable of throwing; it is safe here because production wiring for both is a
178
+ // real timer API and a real `AbortSignal`, neither of which throws from clearing/removing.
179
+ // The socket close is only ever best-effort from here, specifically so
180
+ // this handler's own failure stays impossible even if the very thing that just threw was
181
+ // `socket.close()` itself.
182
+ console.error(`[encrypted-channel] heartbeat cycle failed unrecoverably: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
183
+ finish();
184
+ try {
185
+ socket.close();
186
+ }
187
+ catch { /* best-effort; already reported above */ }
188
+ });
130
189
  }, heartbeatMs);
131
190
  };
132
191
  return new Promise((resolve, reject) => {
@@ -183,7 +242,7 @@ export function connectEncryptedChannel(options) {
183
242
  throw new Error('Encrypted channel needs a session key or handshake');
184
243
  opened = true;
185
244
  if (options.initialFrame)
186
- send(options.initialFrame());
245
+ send(await options.initialFrame());
187
246
  // An initial proof may synchronously trigger a socket close/error. `opened` permits the
188
247
  // proof frame itself, but the public connection is not published until this guard passes.
189
248
  if (settled || connectionSettled)
@@ -193,6 +252,7 @@ export function connectEncryptedChannel(options) {
193
252
  disarmOpenTimer();
194
253
  resolve({
195
254
  send,
255
+ get bufferedAmount() { return socket.bufferedAmount ?? 0; },
196
256
  messages,
197
257
  close,
198
258
  closed,
@@ -226,14 +286,20 @@ export function connectEncryptedChannel(options) {
226
286
  // be the only server traffic while a task runs. Invalid frames take the catch path and never
227
287
  // get to satisfy liveness.
228
288
  outstandingAcks.clear();
289
+ lastReceivedAt = timers.now();
290
+ maxHeartbeatLagMs = 0;
229
291
  if (!acknowledged)
230
292
  messages.push(frame);
231
293
  }
232
294
  catch {
295
+ console.warn(`${new Date(timers.now()).toISOString()} [encrypted-channel] ${options.url.pathname} closing after an invalid encrypted frame`);
233
296
  close();
234
297
  }
235
298
  });
236
- socket.on('close', () => {
299
+ socket.on('close', (code, reason) => {
300
+ if (!settled)
301
+ console.warn(`${new Date(timers.now()).toISOString()} [encrypted-channel] ${options.url.pathname} peer closed; ` +
302
+ `code=${code ?? 'unknown'}, reason=${reason?.toString() || 'none'}`);
237
303
  if (!connectionSettled) {
238
304
  connectionSettled = true;
239
305
  finish();
@@ -242,9 +308,10 @@ export function connectEncryptedChannel(options) {
242
308
  }
243
309
  finish();
244
310
  });
245
- socket.on('error', () => {
311
+ socket.on('error', (error) => {
246
312
  if (settled)
247
313
  return;
314
+ console.warn(`${new Date(timers.now()).toISOString()} [encrypted-channel] ${options.url.pathname} socket error: ${error?.message ?? 'unknown'}`);
248
315
  const rejectOpening = !connectionSettled;
249
316
  // Mark this before close(): ws and deterministic fakes can emit close synchronously.
250
317
  if (rejectOpening)
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { AGENT_WIRE_MIN_VERSION } from './protocol/agent.js';
4
- import { clientDir, loadClientConfig, readKeys } from './config.js';
4
+ import { clientDir, loadClientConfig, readKeys, updateServiceContext } from './config.js';
5
5
  import { AgentSessionManager } from './agentSessions.js';
6
6
  import { AcpAgents } from './acp/acpAgents.js';
7
7
  import { ConnectionSupervisor } from './connection/supervisor.js';
@@ -12,6 +12,7 @@ import { TaskRegistry } from './update/taskRegistry.js';
12
12
  import { createUpdateCoordinator } from './update/runUpdate.js';
13
13
  import { resolveCliPath } from './daemonctl.js';
14
14
  import { SystemMetricsSampler } from './systemMetrics.js';
15
+ import { preflightOptionalPty } from './update/ptyPreflight.js';
15
16
  /**
16
17
  * The path `runDaemon` hands `createUpdateCoordinator` as `cliPath`, used to re-provision the OS
17
18
  * service after an update (see runUpdate.ts's `provisionService`).
@@ -140,6 +141,22 @@ export function resolveServiceLifecycle(options) {
140
141
  // provision-and-arm would hand that mode to a one-shot, unsupervised debugging invocation.
141
142
  return options.supervision === 'container' ? 'container' : 'none';
142
143
  }
144
+ /**
145
+ * Builds the object `ManagementChannelOptions.snapshot` reports on every management heartbeat.
146
+ *
147
+ * Lifted out of `runDaemon` into its own function -- rather than staying the closure literal
148
+ * passed inline to `new ManagementChannel({ snapshot: ... })` -- specifically so a test can drive
149
+ * it directly. `runDaemon` boots a whole daemon (real config, real keys, a real `ManagementChannel`
150
+ * with no seam for injecting a socket), so before this extraction the *only* thing that ever
151
+ * verified `update:` here was the boot-*reconciled* phase (`updates.snapshotAsync()`), rather than
152
+ * the raw on-disk one (`updates.snapshot()`), was `tsc` accepting the `await`. `tsc` cannot tell
153
+ * `snapshotAsync()` and `snapshot()` apart by behavior -- both type-check -- so a future edit that
154
+ * quietly swaps one for the other would compile clean and wedge the server's view of every
155
+ * workstation the next time one restarts mid-update.
156
+ */
157
+ export async function buildManagementSnapshot(supervisor, tasks, updates) {
158
+ return { channels: supervisor.channelSnapshot(), ...tasks.snapshot(), update: await updates.snapshotAsync() };
159
+ }
143
160
  export async function runDaemon(options = {}) {
144
161
  // Last line of defence for the whole process. Node's default for an unhandled rejection is to
145
162
  // *exit*, and this daemon is the only thing keeping every session on this workstation alive —
@@ -152,6 +169,9 @@ export async function runDaemon(options = {}) {
152
169
  const dir = clientDir();
153
170
  const cfg = await loadClientConfig(dir);
154
171
  const { clientKey, serverPub } = await readKeys(dir);
172
+ // Repair the first fixed release even when it was installed by an older ignore-scripts updater,
173
+ // before a newly started terminal keeper snapshots native dependency availability.
174
+ await preflightOptionalPty(updateServiceContext(dir));
155
175
  const tasks = new TaskRegistry();
156
176
  // Attaches both the server-driven update path (onUpdate below) and axom update's manual path to
157
177
  // the same coordinator assembly — see runUpdate.ts.
@@ -183,7 +203,9 @@ export async function runDaemon(options = {}) {
183
203
  // Plaintext WebSockets are only for an explicitly opted-in local development fixture. Production
184
204
  // ticket handshakes must be WSS because the ticket is the data-channel key derivation material.
185
205
  const allowInsecureWs = process.env.AXOM_WORKSTATION_ALLOW_INSECURE_WS === '1';
186
- const computer = new ComputerChannel({ server: cfg.server, allowInsecureWs, taskRegistry: tasks });
206
+ const computer = new ComputerChannel({ server: cfg.server, allowInsecureWs, taskRegistry: tasks,
207
+ toolsConfig: { configDir: dir, workstationId: cfg.workstationId, server: cfg.server, roots: (cfg.workspaces ?? []).map(workspace => workspace.cwd), browserBinding: cfg.browserBinding },
208
+ });
187
209
  const metrics = new SystemMetricsSampler(cfg.workspaces ?? []);
188
210
  await metrics.start();
189
211
  const management = new ManagementChannel({
@@ -198,7 +220,7 @@ export async function runDaemon(options = {}) {
198
220
  bridges = await acp.roster();
199
221
  return bridges;
200
222
  },
201
- snapshot: () => ({ channels: supervisor.channelSnapshot(), ...tasks.snapshot(), update: updates.snapshot() }),
223
+ snapshot: () => buildManagementSnapshot(supervisor, tasks, updates),
202
224
  });
203
225
  const agent = new AgentChannel({ server: cfg.server, agents, bridges: () => bridges, allowInsecureWs });
204
226
  supervisor = new ConnectionSupervisor({
@@ -0,0 +1,36 @@
1
+ import path from 'node:path';
2
+ function endpoint(text, kind) {
3
+ let url;
4
+ try {
5
+ url = new URL(text);
6
+ }
7
+ catch {
8
+ throw new Error('Invalid browser endpoint');
9
+ }
10
+ // Require literal loopback authorities, before WHATWG canonicalizes short/hex IPv4.
11
+ if (!/^(?:tcp|ws|wss|http|https):\/\/(?:127\.0\.0\.1|\[::1\]):\d+(?:\/|$)/.test(text) || url.username || url.password || url.search || url.hash || !url.port || Number(url.port) < 1 || Number(url.port) > 65535)
12
+ throw new Error('Browser endpoints require an explicit loopback host and port without credentials');
13
+ if (kind === 'vnc' ? !['tcp:', 'ws:', 'wss:'].includes(url.protocol) : !['http:', 'https:'].includes(url.protocol))
14
+ throw new Error('Unsupported browser endpoint transport');
15
+ if (url.protocol === 'tcp:' && url.pathname && url.pathname !== '/')
16
+ throw new Error('TCP endpoint cannot have a path');
17
+ return url.toString();
18
+ }
19
+ export function resolveBrowserBinding(config, env = process.env) {
20
+ if (config && (typeof config !== 'object' || typeof config.vncEndpoint !== 'string' || !config.vncEndpoint))
21
+ throw new Error('Invalid browser binding config');
22
+ const vnc = env.AXOM_BROWSER_VNC_URL ?? config?.vncEndpoint;
23
+ if (!vnc)
24
+ return undefined;
25
+ const binding = { bindingId: config?.bindingId ?? 'default', displayName: config?.displayName ?? 'Shared browser', vncEndpoint: endpoint(vnc, 'vnc'), cdpEndpoint: env.AXOM_BROWSER_CDP_URL ?? config?.cdpEndpoint, downloadsRoot: config?.downloadsRoot };
26
+ if (typeof binding.bindingId !== 'string' || !binding.bindingId || binding.bindingId.length > 128 || typeof binding.displayName !== 'string' || !binding.displayName || binding.displayName.length > 128)
27
+ throw new Error('Invalid browser binding metadata');
28
+ if (binding.cdpEndpoint)
29
+ binding.cdpEndpoint = endpoint(binding.cdpEndpoint, 'cdp');
30
+ if (binding.downloadsRoot !== undefined && (typeof binding.downloadsRoot !== 'string' || !path.isAbsolute(binding.downloadsRoot) || binding.downloadsRoot.includes('\0')))
31
+ throw new Error('Browser downloadsRoot must be an explicit absolute directory');
32
+ return binding;
33
+ }
34
+ export function browserCapability(binding) {
35
+ return { bindingId: binding?.bindingId ?? null, displayName: binding?.displayName ?? null, configured: !!binding, cdp: !!binding?.cdpEndpoint };
36
+ }