@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
@@ -2,6 +2,8 @@ import WebSocket from 'ws';
2
2
  import { RetryPolicy } from '../connection/retryPolicy.js';
3
3
  import { tryOpenAuthorizationUrl } from './browser.js';
4
4
  import { loadDeviceId } from '../../config.js';
5
+ import { DEFAULT_CLI_SCOPES, grantedCliScopes } from '../../authScopes.js';
6
+ import { revokeRefreshToken } from '../../credentials.js';
5
7
  export const DEVICE_CODE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
6
8
  export class DeviceAuthorizationError extends Error {
7
9
  }
@@ -87,7 +89,7 @@ function waitForNotification(socket, sessionId, notificationTicket, deadline, no
87
89
  socket.on('error', () => finish('reconnect'));
88
90
  });
89
91
  }
90
- async function exchangeDeviceCode(server, clientId, deviceCode, fetch_) {
92
+ async function exchangeDeviceCode(server, clientId, deviceCode, fetch_, requestedScopes) {
91
93
  const response = await requestJson(fetch_, new URL('/oauth/token', server), {
92
94
  method: 'POST',
93
95
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
@@ -98,14 +100,25 @@ async function exchangeDeviceCode(server, clientId, deviceCode, fetch_) {
98
100
  device_id: await loadDeviceId(),
99
101
  }),
100
102
  }, 200, 'Device token exchange');
103
+ return validateTokenResponse(server, response, requestedScopes, fetch_);
104
+ }
105
+ async function validateTokenResponse(server, response, requestedScopes, fetch_) {
101
106
  const accessToken = requireString(response.access_token, 'access_token', 'Device token response');
102
107
  const refreshToken = requireString(response.refresh_token, 'refresh_token', 'Device token response');
103
108
  if (!Number.isFinite(response.expires_in) || response.expires_in <= 0) {
104
109
  throw new DeviceAuthorizationError('Device token response has invalid expires_in');
105
110
  }
106
- return { accessToken, refreshToken, expiresInSeconds: response.expires_in };
111
+ let scopes;
112
+ try {
113
+ scopes = grantedCliScopes(response.scope, requestedScopes);
114
+ }
115
+ catch (error) {
116
+ await revokeRefreshToken(server, refreshToken, fetch_);
117
+ throw new DeviceAuthorizationError(error instanceof Error ? error.message : 'Device token response has invalid scopes');
118
+ }
119
+ return { accessToken, refreshToken, expiresInSeconds: response.expires_in, scopes };
107
120
  }
108
- async function pollDeviceCode(server, clientId, deviceCode, fetch_) {
121
+ async function pollDeviceCode(server, clientId, deviceCode, fetch_, requestedScopes) {
109
122
  const response = await fetch_(new URL('/oauth/token', server), {
110
123
  method: 'POST',
111
124
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
@@ -128,17 +141,13 @@ async function pollDeviceCode(server, clientId, deviceCode, fetch_) {
128
141
  if (response.status !== 200)
129
142
  throw new DeviceAuthorizationError(`Device token polling failed: ${response.status}`);
130
143
  const token = await response.json();
131
- return {
132
- accessToken: requireString(token.access_token, 'access_token', 'Device token response'),
133
- refreshToken: requireString(token.refresh_token, 'refresh_token', 'Device token response'),
134
- expiresInSeconds: token.expires_in,
135
- };
144
+ return validateTokenResponse(server, token, requestedScopes, fetch_);
136
145
  }
137
146
  /**
138
147
  * Uses OAuth Device Authorization for both desktop and headless clients. The notification socket
139
148
  * only carries state; the tokens exist only in this call's return value.
140
149
  */
141
- export async function authorizeDevice(server, clientName, hostname, deps = {}) {
150
+ export async function authorizeDevice(server, clientName, hostname, deps = {}, requestedScopes = DEFAULT_CLI_SCOPES) {
142
151
  const baseUrl = normalizedServer(server);
143
152
  const fetch_ = deps.fetch ?? globalThis.fetch;
144
153
  const print = deps.print ?? console.log;
@@ -151,7 +160,7 @@ export async function authorizeDevice(server, clientName, hostname, deps = {}) {
151
160
  const authorization = await requestJson(fetch_, new URL('/oauth/device_authorization', baseUrl), {
152
161
  method: 'POST',
153
162
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
154
- body: new URLSearchParams({ client_id: clientId, hostname }),
163
+ body: new URLSearchParams({ client_id: clientId, hostname, scope: requestedScopes.join(' ') }),
155
164
  }, 200, 'Device authorization request');
156
165
  const deviceCode = requireString(authorization.device_code, 'device_code', 'Device authorization response');
157
166
  const verificationUrl = requireString(authorization.verification_uri_complete, 'verification_uri_complete', 'Device authorization response');
@@ -178,14 +187,14 @@ export async function authorizeDevice(server, clientName, hostname, deps = {}) {
178
187
  }
179
188
  const status = await waitForNotification(socket, sessionId, notificationTicket, deadline, now, () => retry.recordSuccess());
180
189
  if (status === 'approved') {
181
- const tokens = await exchangeDeviceCode(baseUrl, clientId, deviceCode, fetch_);
190
+ const tokens = await exchangeDeviceCode(baseUrl, clientId, deviceCode, fetch_, requestedScopes);
182
191
  return { clientId, ...tokens };
183
192
  }
184
193
  if (status === 'denied')
185
194
  throw new DeviceAuthorizationError('Device authorization was denied');
186
195
  if (status === 'expired')
187
196
  break;
188
- const polled = await pollDeviceCode(baseUrl, clientId, deviceCode, fetch_);
197
+ const polled = await pollDeviceCode(baseUrl, clientId, deviceCode, fetch_, requestedScopes);
189
198
  if (polled)
190
199
  return { clientId, ...polled };
191
200
  const pollingFloor = Math.max(1, authorization.interval ?? 5) * 1_000;
@@ -1,6 +1,18 @@
1
+ import { isRuntimeDiagnostics } from '../qoderAdapter/profile.js';
1
2
  import { decodeChannelFrame, hasOptional, hasRequired, isBoolean, isBridgeInfoArray, isNullableAuthMethodInfoArray, isNullableString, isNumber, isObject, isString, isStringArray, isStringRecord, } from './common.js';
2
3
  /** Current version and feature floors for the independently negotiated agent protocol. */
3
- export const AGENT_WIRE_VERSION = 14;
4
+ export const AGENT_WIRE_VERSION = 24;
5
+ /** `agent_delete_native` / `agent_native_deleted`: purge a session Axom created on an external agent. */
6
+ export const AGENT_WIRE_NATIVE_DELETE_VERSION = 24;
7
+ export const AGENT_WIRE_PROVIDER_TASK_VERSION = 23;
8
+ export const AGENT_WIRE_RUNTIME_PROFILE_VERSION = 22;
9
+ export const AGENT_WIRE_DELEGATION_RENEW_VERSION = 21;
10
+ export const AGENT_WIRE_TURN_IDENTITY_VERSION = 20;
11
+ export const AGENT_WIRE_DELEGATION_MCP_VERSION = 19;
12
+ /** Scoped replay/history is enabled only when both peers preserve native origin. */
13
+ export const AGENT_WIRE_SCOPED_ORIGIN_VERSION = 18;
14
+ /** agent_open.requireSessionLoad: reject unsupported/failed load without session/new fallback. */
15
+ export const AGENT_WIRE_STRICT_LOAD_VERSION = 17;
4
16
  export const AGENT_WIRE_MIN_VERSION = 1;
5
17
  export const AGENT_WIRE_SESSION_LOST_VERSION = 2;
6
18
  export const AGENT_WIRE_ADOPTION_VERSION = 3;
@@ -137,6 +149,59 @@ export const AGENT_WIRE_RELOADED_VERSION = 13;
137
149
  * is byte-for-byte the frame it always was and still goes out at {@link AGENT_WIRE_STEER_VERSION}.
138
150
  */
139
151
  export const AGENT_WIRE_STEER_BLOCKS_VERSION = 14;
152
+ /**
153
+ * The session activity-state push (`agent_session_state`) and the single-background-task stop
154
+ * request (`agent_async_task_stop`). **Both must be checked before sending — the check just lives
155
+ * in a different process for each, because the two frames do not share a sender.**
156
+ *
157
+ * ## Why both need a floor, not just one
158
+ *
159
+ * A version floor answers one question: *before sending, does the peer understand this frame?*
160
+ * These two frames travel opposite directions, so the check lives on opposite sides:
161
+ *
162
+ * - `agent_async_task_stop` is the **server's** request to this client. A v14 *client* has no case
163
+ * for it in `AGENT_FRAME_SHAPES` — the same shape as {@link AGENT_WIRE_STEER_VERSION} and
164
+ * {@link AGENT_WIRE_INSTALL_VERSION} — so the check lives on the Kotlin server (this constant's
165
+ * twin, `AgentChannelSession`, a later task), before every `agent_async_task_stop` it sends. There
166
+ * is nothing for this file to check on receipt: this daemon only ever handles the frame, never
167
+ * sends it.
168
+ * - `agent_session_state` is the **client's** push to the server, and getting this half wrong is
169
+ * what an earlier revision of this comment did — it argued "an old server just never sees the
170
+ * frame" was enough, which is only half the compatibility matrix and not the dangerous half. The
171
+ * dangerous direction is **a newer client talking to an older server**: this daemon is a
172
+ * separately-installed npm package on the user's machine, so the client upgrading ahead of the
173
+ * server is the ordinary update order here, not an edge case. A v15 client sending
174
+ * `agent_session_state` to a v14 server hits a decoder that throws on the unrecognised
175
+ * discriminator, and because the frame carries a top-level `sessionRef` and no `id`, the server's
176
+ * own recovery (`classifyUndecodableAgentFrame`) charges it to `FailSession` — declaring that
177
+ * session lost and forcing a reopen. This frame exists to be sent over and over, so on an
178
+ * unupgraded server every single push would force a session loss, and a run of them closes the
179
+ * whole agent channel — worse than the `agent_auth_url` incident this file already documents,
180
+ * since that frame carries no `sessionRef` and only ever reaches `Unattributable`.
181
+ *
182
+ * The correct precedent is {@link AGENT_WIRE_AUTH_URL_VERSION}, not
183
+ * {@link AGENT_WIRE_RELOADED_VERSION}: `agent_opened.reloaded` is an **optional key on a frame the
184
+ * peer already decodes**, safely absorbed by `ignoreUnknownKeys`, so nothing on the sending side
185
+ * needs to check anything. `agent_session_state` is a **new discriminator**, and a decoder throws
186
+ * on an unrecognised one before `ignoreUnknownKeys` ever gets a say — the same shape
187
+ * `agent_auth_url` is, and that constant's own floor is checked client-side (`serverAgentWire <
188
+ * AGENT_WIRE_AUTH_URL_VERSION`, see `agentSessions.ts`'s `onAuthenticate`/`present`/`dismiss`) for
189
+ * exactly this reason. `agent_session_state` follows the same rule: the client checks
190
+ * `this.serverAgentWire` before sending and **discards** the push below this floor, rather than
191
+ * erroring — a level nobody downstream can read is no worse than one that was never collected.
192
+ *
193
+ * So both frames are gated, on opposite sides: this file's own send path (a later task) must check
194
+ * `serverAgentWire` before every `agent_session_state`, and the Kotlin twin must check
195
+ * `clientAgentWire` before every `agent_async_task_stop`.
196
+ */
197
+ export const AGENT_WIRE_SESSION_STATE_VERSION = 15;
198
+ /**
199
+ * Elicitation answers (`agent_elicitation_answer`). Checked before sending, like
200
+ * {@link AGENT_WIRE_INSTALL_VERSION} and {@link AGENT_WIRE_CONFIG_OPTIONS_VERSION}: a peer below
201
+ * this floor has no case for the frame at all and would drop it, leaving the agent process blocked
202
+ * inside `elicitation/create` rather than ever reaching the user's answer.
203
+ */
204
+ export const AGENT_WIRE_ELICITATION_VERSION = 16;
140
205
  /**
141
206
  * Every agent frame's shape, as data rather than as a `switch`.
142
207
  *
@@ -171,7 +236,10 @@ export const AGENT_FRAME_SHAPES = {
171
236
  optional: {
172
237
  env: isStringRecord, pathPrepend: isStringArray, agentEnv: isStringRecord, command: isNullableString,
173
238
  args: isStringArray, nativeSessionId: isNullableString,
174
- resumeFromSeq: isNumber, backfill: isBoolean,
239
+ resumeFromSeq: isNumber, backfill: isBoolean, requireSessionLoad: isBoolean,
240
+ delegationMcp: (value) => isObject(value) &&
241
+ isString(value.url) && isStringRecord(value.headers) && isNumber(value.expiresAtEpochMs) &&
242
+ (value.bindingId === undefined || isString(value.bindingId)),
175
243
  },
176
244
  },
177
245
  agent_opened: {
@@ -180,6 +248,7 @@ export const AGENT_FRAME_SHAPES = {
180
248
  // `false` would be a second spelling of what its absence already says.
181
249
  optional: {
182
250
  nativeSessionId: isNullableString, error: isNullableString, supportsSteering: isVerifiedTrue,
251
+ runtimeDiagnostics: isRuntimeDiagnostics,
183
252
  // Plain strings, not a closed vocabulary: the sender already dropped everything it did not
184
253
  // recognise, and validating the values again here would make a future timing fail the frame
185
254
  // rather than be ignored — the opposite of what ruling N4 asks for.
@@ -187,16 +256,36 @@ export const AGENT_FRAME_SHAPES = {
187
256
  // `reloaded` is `true`-or-absent for the same reason as `supportsSteering`: a `false` would
188
257
  // be a second spelling of what its absence already says.
189
258
  reloaded: isVerifiedTrue,
259
+ delegationExpiresAtEpochMs: isNumber,
260
+ delegationRefreshPending: isVerifiedTrue,
261
+ delegationInstanceId: isString, delegationBindingId: isString, delegationRenewalRevision: isNumber,
190
262
  agentInfo: isAgentInfo, supportsImageInput: isVerifiedTrue, configOptions: isNullableSessionConfigOptionArray,
191
263
  // Same guard as `agent_verified.authMethods`, and the same absent-vs-`[]` reading: a
192
264
  // half-decoded method table is worse than none, because the reader would offer a login the
193
265
  // agent never named.
194
266
  authMethods: isNullableAuthMethodInfoArray,
195
267
  errorCode: isNullableJsonRpcErrorCode,
268
+ // `true`-or-absent, the same rule as `supportsSteering` above: absence already means "no
269
+ // collector wired up", so a `false` would be a second spelling of it.
270
+ reportsActivity: isVerifiedTrue,
271
+ supportsAsyncTaskStop: isVerifiedTrue,
272
+ // Vendor-verbatim and uninterpreted here (decision L1) -- any object shape is accepted, the
273
+ // same lenience `agent_event.payload` gets via `isRecord`.
274
+ activityRaw: isRecord,
196
275
  },
197
276
  },
198
- agent_prompt: { required: { sessionRef: isString, text: isString }, optional: { images: isImageBlockArray } },
277
+ agent_prompt: { required: { sessionRef: isString, text: isString }, optional: { images: isImageBlockArray, platformTurnId: isString } },
278
+ agent_delegation_renew: { required: { id: isString, sessionRef: isString, nativeSessionId: isString,
279
+ instanceId: isString, bindingId: isString, renewalRevision: isNumber, expiresAtEpochMs: isNumber } },
280
+ agent_delegation_renewed: { required: { id: isString, ok: isBoolean }, optional: { error: isString,
281
+ nativeSessionId: isString, instanceId: isString, bindingId: isString, renewalRevision: isNumber, expiresAtEpochMs: isNumber } },
199
282
  agent_answer: { required: { sessionRef: isString, requestId: isString, outcome: isString } },
283
+ // `content`'s interior is never validated here -- it is ACP's free-form elicitation payload, and
284
+ // this table is transport, not translator. `isRecord` only rejects a non-object.
285
+ agent_elicitation_answer: {
286
+ required: { sessionRef: isString, requestId: isString, action: isString },
287
+ optional: { content: isRecord },
288
+ },
200
289
  agent_interrupt: { required: { sessionRef: isString } },
201
290
  agent_close: { required: { sessionRef: isString } },
202
291
  agent_event: { required: { sessionRef: isString, seq: isNumber, eventName: isString, payload: isRecord } },
@@ -206,6 +295,8 @@ export const AGENT_FRAME_SHAPES = {
206
295
  stopReason: isString, nativeSessionId: isNullableString, error: isNullableString,
207
296
  authMethods: isNullableAuthMethodInfoArray,
208
297
  errorCode: isNullableJsonRpcErrorCode,
298
+ platformTurnId: isString,
299
+ executionEnded: isVerifiedTrue,
209
300
  },
210
301
  },
211
302
  agent_event_ack: { required: { sessionRef: isString, throughSeq: isNumber } },
@@ -214,6 +305,12 @@ export const AGENT_FRAME_SHAPES = {
214
305
  // to return its protocol-level `ok: false` response. The transport only requires that the
215
306
  // field is present; semantic validation remains at the request handler boundary.
216
307
  agent_list_native: { required: { id: isString, bridge: isString, query: () => true } },
308
+ // `query` is as lenient as `agent_list_native`'s, for the same reason.
309
+ agent_delete_native: { required: { id: isString, bridge: isString, query: () => true, nativeSessionIds: isStringArray } },
310
+ agent_native_deleted: {
311
+ required: { id: isString, ok: isBoolean },
312
+ optional: { results: isNativeDeleteResults, error: isNullableString },
313
+ },
217
314
  agent_native_sessions: {
218
315
  required: { id: isString, ok: isBoolean },
219
316
  optional: {
@@ -290,9 +387,39 @@ export const AGENT_FRAME_SHAPES = {
290
387
  required: { id: isString, ok: isBoolean },
291
388
  optional: { configOptions: isNullableSessionConfigOptionArray, error: isNullableString },
292
389
  },
390
+ agent_session_state: { required: { sessionRef: isString, vendor: isString, raw: isRecord } },
391
+ agent_runtime_profile: { required: { sessionRef: isString, diagnostics: isRuntimeDiagnostics } },
392
+ agent_provider_task_event: { required: { sessionRef: isString, rootSessionId: isTaskId,
393
+ runtimeGeneration: isTaskId, taskId: isTaskId, uuid: isTaskId, state: isProviderTaskState,
394
+ live: isBoolean, stale: isBoolean }, optional: { taskType: isTaskType, background: isBoolean,
395
+ agentId: isTaskId, launchingToolUseId: isTaskId, status: isProviderTerminalState, summary: isTaskSummary } },
396
+ agent_provider_task_stop: { required: { id: isTaskId, sessionRef: isTaskId, rootSessionId: isTaskId,
397
+ runtimeGeneration: isTaskId, taskId: isTaskId, requestId: isTaskId } },
398
+ agent_provider_task_stopped: { required: { id: isTaskId, state: isProviderStopState },
399
+ optional: { error: isTaskSummary } },
400
+ agent_async_task_stop: { required: { sessionRef: isString, asyncTaskId: isString } },
293
401
  };
294
402
  const DISCRIMINATORS = new Set(Object.keys(AGENT_FRAME_SHAPES));
295
403
  function isRecord(value) { return isObject(value); }
404
+ function isTaskId(value) {
405
+ return typeof value === 'string' && value.length > 0 &&
406
+ Buffer.byteLength(value, 'utf8') <= 256 && !/[\x00-\x1f\x7f]/.test(value);
407
+ }
408
+ function isTaskType(value) { return isTaskId(value) && Buffer.byteLength(value, 'utf8') <= 128; }
409
+ function isTaskSummary(value) {
410
+ return typeof value === 'string' &&
411
+ Buffer.byteLength(value, 'utf8') <= 8192;
412
+ }
413
+ function isProviderTaskState(value) {
414
+ return value === 'pending' || value === 'paused' || value === 'running' || value === 'completed' ||
415
+ value === 'stopped' || value === 'failed' || value === 'unknown';
416
+ }
417
+ function isProviderTerminalState(value) {
418
+ return value === 'completed' || value === 'failed' || value === 'stopped';
419
+ }
420
+ function isProviderStopState(value) {
421
+ return value === 'accepted' || value === 'unknown' || value === 'rejected';
422
+ }
296
423
  function isAgentResumeSession(value) {
297
424
  return isObject(value)
298
425
  && hasRequired(value, { sessionRef: isString, journalMaxSeq: isNumber, agentAlive: isBoolean });
@@ -448,8 +575,17 @@ function isNativeSession(value) {
448
575
  nativeSessionId: isString, title: isString, updatedAtEpochMs: isNumber, cwd: isString,
449
576
  });
450
577
  }
578
+ function isNativeDeleteResult(value) {
579
+ return isObject(value)
580
+ && hasRequired(value, { nativeSessionId: isString, outcome: (o) => o === 'deleted' || o === 'unsupported' || o === 'failed' })
581
+ && hasOptional(value, { error: isNullableString });
582
+ }
583
+ function isNativeDeleteResults(value) {
584
+ return Array.isArray(value) && value.every(isNativeDeleteResult);
585
+ }
451
586
  function isEvent(value) {
452
- return isObject(value) && hasRequired(value, { eventName: isString, payload: isRecord });
587
+ return isObject(value) && hasRequired(value, { eventName: isString, payload: isRecord })
588
+ && hasOptional(value, { acpSessionId: isNullableString });
453
589
  }
454
590
  function isAgentFrame(value) {
455
591
  // Cast, not a narrowed lookup: `value.t` is unvetted wire input, so it may not be one of
@@ -588,11 +724,14 @@ export function outboundFailureReply(frame, reason) {
588
724
  switch (frame.t) {
589
725
  case 'agent_opened': return { t: 'agent_opened', id, ok: false, error };
590
726
  case 'agent_native_sessions': return { t: 'agent_native_sessions', id, ok: false, error };
727
+ case 'agent_native_deleted': return { t: 'agent_native_deleted', id, ok: false, error };
591
728
  case 'agent_verified': return { t: 'agent_verified', id, ok: false, error };
592
729
  case 'agent_authenticated': return { t: 'agent_authenticated', id, ok: false, error };
593
730
  case 'agent_installed': return { t: 'agent_installed', id, ok: false, error };
594
731
  case 'agent_steered': return { t: 'agent_steered', id, ok: false, error };
595
732
  case 'agent_config_option_set': return { t: 'agent_config_option_set', id, ok: false, error };
733
+ case 'agent_delegation_renewed': return { t: 'agent_delegation_renewed', id, ok: false, error };
734
+ case 'agent_provider_task_stopped': return { t: 'agent_provider_task_stopped', id, state: 'unknown', error };
596
735
  case 'agent_backfill_done':
597
736
  case 'agent_backfill_events': return { t: 'agent_backfill_done', id, ok: false, count: 0, error };
598
737
  default: return null;
@@ -602,5 +741,15 @@ export function encodeAgentFrame(frame) {
602
741
  return JSON.stringify(frame);
603
742
  }
604
743
  export function decodeAgentFrame(text) {
605
- return decodeChannelFrame(text, DISCRIMINATORS, isAgentFrame);
744
+ const frame = decodeChannelFrame(text, DISCRIMINATORS, isAgentFrame);
745
+ if (frame.t === 'agent_provider_task_event') {
746
+ if ((frame.state === 'completed' || frame.state === 'failed' || frame.state === 'stopped') && frame.live)
747
+ throw new TypeError('Invalid provider task terminal live state');
748
+ if (frame.status !== undefined &&
749
+ (frame.status !== frame.state || !['completed', 'failed', 'stopped'].includes(frame.status)))
750
+ throw new TypeError('Invalid provider task terminal status');
751
+ if (frame.summary !== undefined && frame.status === undefined)
752
+ throw new TypeError('Provider task summary requires terminal status');
753
+ }
754
+ return frame;
606
755
  }
@@ -23,6 +23,9 @@ export function isAuthMethodInfo(value) {
23
23
  && hasOptional(value, { type: isNullableString, description: isNullableString, args: isNullableStringArray });
24
24
  }
25
25
  export const isNullableAuthMethodInfoArray = (value) => value === null || (Array.isArray(value) && value.every(isAuthMethodInfo));
26
+ export function isMissingPrerequisite(value) {
27
+ return isObject(value) && hasRequired(value, { label: isString }) && hasOptional(value, { hint: isString });
28
+ }
26
29
  export function isBridgeInfo(value) {
27
30
  return isObject(value)
28
31
  && hasRequired(value, { type: isString })
@@ -49,7 +52,10 @@ export function isBridgeInfo(value) {
49
52
  // string is all a peer sending it without the flag is asked to satisfy.
50
53
  platformTriple: isString,
51
54
  // `true` and nothing else, like `supportsSteering`: see `BridgeInfo.recommended`.
55
+ curated: isTrueOnly,
52
56
  recommended: isTrueOnly,
57
+ detected: isTrueOnly,
58
+ missingPrerequisite: isMissingPrerequisite,
53
59
  defaultName: isNullableString,
54
60
  defaultLabel: isNullableString,
55
61
  // Optional on purpose: see BridgeInfo's doc comment — absent is "unknown", and a guard that
@@ -1,9 +1,63 @@
1
- import { decodeChannelFrame, hasOptional, hasRequired, isBoolean, isNumber, isString, isStringArray, isStringRecord } from './common.js';
1
+ import { validDesktopFrame } from '../desktop/protocol.js';
2
+ import { decodeChannelFrame, hasOptional, hasRequired, isBoolean, isNumber, isObject, isString, isStringArray, isStringRecord } from './common.js';
3
+ import { TOOL_REQUEST_BYTES, TOOL_RESULT_BYTES } from '../tools/validation.js';
4
+ export const MAX_COMPUTER_FRAME_BYTES = 8 * 1024 * 1024;
2
5
  const DISCRIMINATORS = new Set([
6
+ 'desktop_capability', 'desktop_open', 'desktop_opened', 'desktop_data', 'desktop_credit', 'desktop_close',
3
7
  'computer_hello', 'computer_ready', 'heartbeat', 'heartbeat_ack',
4
- 'exec_request', 'exec_output', 'exec_exit', 'exec_cancel',
8
+ 'exec_request', 'exec_output', 'exec_exit', 'exec_cancel', 'tool_request', 'tool_result',
9
+ 'file_read_request', 'file_read_result', 'file_write_request', 'file_write_result',
10
+ 'file_ls_request', 'file_ls_result', 'file_mkdir_request', 'file_mkdir_result',
5
11
  ]);
12
+ const fileErrorCodes = new Set([
13
+ 'invalid_argument', 'invalid_path', 'unsupported_workstation', 'unsupported_protocol',
14
+ 'workstation_offline', 'not_found', 'not_regular_file', 'invalid_encoding', 'line_too_long',
15
+ 'too_large', 'conflict', 'permission_denied', 'io_error', 'outcome_unknown',
16
+ ]);
17
+ const isNonnegativeInteger = (value) => Number.isSafeInteger(value) && value >= 0;
18
+ const isPositiveInteger = (value) => Number.isSafeInteger(value) && value >= 1;
19
+ const isSha256 = (value) => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
20
+ const onlyKeys = (value, keys) => Object.keys(value).every(key => keys.includes(key));
21
+ function isFileError(value) {
22
+ return isObject(value) && hasRequired(value, { code: isString, message: isString })
23
+ && fileErrorCodes.has(value.code) && onlyKeys(value, ['code', 'message']);
24
+ }
25
+ function isReadData(value) {
26
+ return isObject(value) && hasRequired(value, {
27
+ path: isString, content: isString, startLine: isPositiveInteger, endLine: isNonnegativeInteger,
28
+ truncated: isBoolean, sizeBytes: isNonnegativeInteger, sha256: isSha256,
29
+ }) && hasOptional(value, { nextLine: isPositiveInteger })
30
+ && onlyKeys(value, ['path', 'content', 'startLine', 'endLine', 'nextLine', 'truncated', 'sizeBytes', 'sha256']);
31
+ }
32
+ function isWriteData(value) {
33
+ return isObject(value) && hasRequired(value, { path: isString, sizeBytes: isNonnegativeInteger, sha256: isSha256, created: isBoolean })
34
+ && onlyKeys(value, ['path', 'sizeBytes', 'sha256', 'created']);
35
+ }
36
+ const dirEntryKinds = new Set(['file', 'directory', 'symlink', 'other']);
37
+ function isLsData(value) {
38
+ return isObject(value) && hasRequired(value, { path: isString, truncated: isBoolean })
39
+ && hasOptional(value, { nextAfter: isString })
40
+ && onlyKeys(value, ['path', 'entries', 'truncated', 'nextAfter'])
41
+ && Array.isArray(value.entries)
42
+ && value.entries.every((entry) => isObject(entry) && hasRequired(entry, { name: isString, kind: isString })
43
+ && dirEntryKinds.has(entry.kind)
44
+ && hasOptional(entry, { sizeBytes: isNonnegativeInteger })
45
+ && onlyKeys(entry, ['name', 'kind', 'sizeBytes']));
46
+ }
47
+ function isMkdirData(value) {
48
+ return isObject(value) && hasRequired(value, { path: isString, created: isBoolean })
49
+ && onlyKeys(value, ['path', 'created']);
50
+ }
51
+ function isFileResult(value, validData) {
52
+ if (!hasRequired(value, { id: isString }) || !onlyKeys(value, ['t', 'id', 'data', 'error']))
53
+ return false;
54
+ const hasData = Object.hasOwn(value, 'data');
55
+ const hasError = Object.hasOwn(value, 'error');
56
+ return hasData !== hasError && (hasData ? validData(value.data) : isFileError(value.error));
57
+ }
6
58
  function isComputerFrame(value) {
59
+ if (typeof value.t === 'string' && value.t.startsWith('desktop_'))
60
+ return validDesktopFrame(value);
7
61
  switch (value.t) {
8
62
  case 'computer_hello': return hasRequired(value, { ticket: isString });
9
63
  case 'computer_ready': return hasRequired(value, { generation: isString, wireVersion: isNumber });
@@ -18,14 +72,49 @@ function isComputerFrame(value) {
18
72
  id: isString, exitCode: isNullableNumber, stdout: isString, stderr: isString,
19
73
  timedOut: isBoolean, truncated: isBoolean,
20
74
  });
75
+ case 'tool_request': return hasRequired(value, { id: isString, method: isString, params: isObject })
76
+ && value.id.length > 0 && value.id.length <= 128
77
+ && value.method.length > 0 && value.method.length <= 64
78
+ && Buffer.byteLength(JSON.stringify(value)) <= TOOL_REQUEST_BYTES
79
+ && onlyKeys(value, ['t', 'id', 'method', 'params']);
80
+ case 'tool_result': return hasRequired(value, { id: isString, ok: isBoolean, result: isObject, error: value => value === null || isString(value) })
81
+ && value.id.length > 0 && value.id.length <= 128
82
+ && (value.ok ? value.error === null : typeof value.error === 'string' && value.error.length > 0)
83
+ && Buffer.byteLength(JSON.stringify(value)) <= TOOL_RESULT_BYTES
84
+ && onlyKeys(value, ['t', 'id', 'ok', 'result', 'error']);
21
85
  case 'exec_cancel': return hasRequired(value, { id: isString });
86
+ case 'file_read_request': return hasRequired(value, {
87
+ id: isString, root: isString, path: isString, startLine: isPositiveInteger, maxLines: isPositiveInteger,
88
+ }) && value.maxLines <= 2000 && onlyKeys(value, ['t', 'id', 'root', 'path', 'startLine', 'maxLines']);
89
+ case 'file_write_request': return hasRequired(value, {
90
+ id: isString, root: isString, path: isString, content: isString, mode: isString,
91
+ }) && (value.mode === 'create' || value.mode === 'replace')
92
+ && hasOptional(value, { expectedSha256: isSha256 })
93
+ && onlyKeys(value, ['t', 'id', 'root', 'path', 'content', 'mode', 'expectedSha256']);
94
+ case 'file_ls_request': return hasRequired(value, {
95
+ id: isString, root: isString, path: isString, maxEntries: isPositiveInteger,
96
+ }) && value.maxEntries <= 2000
97
+ && hasOptional(value, { startAfter: isString })
98
+ && onlyKeys(value, ['t', 'id', 'root', 'path', 'startAfter', 'maxEntries']);
99
+ case 'file_mkdir_request': return hasRequired(value, {
100
+ id: isString, root: isString, path: isString, parents: isBoolean,
101
+ }) && onlyKeys(value, ['t', 'id', 'root', 'path', 'parents']);
102
+ case 'file_read_result': return isFileResult(value, isReadData);
103
+ case 'file_write_result': return isFileResult(value, isWriteData);
104
+ case 'file_ls_result': return isFileResult(value, isLsData);
105
+ case 'file_mkdir_result': return isFileResult(value, isMkdirData);
22
106
  default: return false;
23
107
  }
24
108
  }
25
109
  function isNullableNumber(value) { return value === null || isNumber(value); }
26
110
  export function encodeComputerFrame(frame) {
27
- return JSON.stringify(frame);
111
+ const encoded = JSON.stringify(frame);
112
+ if (Buffer.byteLength(encoded, 'utf8') > MAX_COMPUTER_FRAME_BYTES)
113
+ throw new TypeError('Computer frame too large');
114
+ return encoded;
28
115
  }
29
116
  export function decodeComputerFrame(text) {
117
+ if (Buffer.byteLength(text, 'utf8') > MAX_COMPUTER_FRAME_BYTES)
118
+ throw new TypeError('Computer frame too large');
30
119
  return decodeChannelFrame(text, DISCRIMINATORS, isComputerFrame);
31
120
  }
@@ -0,0 +1,106 @@
1
+ /** Native Qoder does not forward background child SDK messages through ACP. Read only the
2
+ * exact transcript named by a validated root launch receipt; never scan or infer ownership. */
3
+ import { constants } from 'node:fs';
4
+ import { open } from 'node:fs/promises';
5
+ const object = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
6
+ const id = (value) => typeof value === 'string' && /^[a-zA-Z0-9_-]{1,160}$/.test(value);
7
+ const LIMIT = 2 * 1024 * 1024;
8
+ export class BackgroundProcess {
9
+ options;
10
+ offset = 0;
11
+ tail = Buffer.alloc(0);
12
+ skipLine = false;
13
+ seen = new Set();
14
+ tools = new Map();
15
+ constructor(options) {
16
+ this.options = options;
17
+ }
18
+ async poll() {
19
+ const file = await open(this.options.path, constants.O_RDONLY | constants.O_NOFOLLOW);
20
+ try {
21
+ const stat = await file.stat();
22
+ if (!stat.isFile())
23
+ return true;
24
+ if (stat.size < this.offset) {
25
+ this.offset = 0;
26
+ this.tail = Buffer.alloc(0);
27
+ this.skipLine = false;
28
+ }
29
+ const end = Math.min(stat.size, this.offset + 4 * 1024 * 1024);
30
+ while (this.offset < end) {
31
+ const chunk = Buffer.alloc(Math.min(64 * 1024, end - this.offset));
32
+ const { bytesRead } = await file.read(chunk, 0, chunk.length, this.offset);
33
+ if (!bytesRead)
34
+ break;
35
+ this.offset += bytesRead;
36
+ this.consumeBytes(chunk.subarray(0, bytesRead));
37
+ }
38
+ return this.offset >= stat.size;
39
+ }
40
+ finally {
41
+ await file.close();
42
+ }
43
+ }
44
+ consumeBytes(bytes) {
45
+ let start = 0;
46
+ for (let end = bytes.indexOf(10); end >= 0; end = bytes.indexOf(10, start)) {
47
+ const part = bytes.subarray(start, end);
48
+ if (!this.skipLine && this.tail.length + part.length <= LIMIT) {
49
+ try {
50
+ this.consume(object(JSON.parse(Buffer.concat([this.tail, part]).toString('utf8'))));
51
+ }
52
+ catch { /* not evidence */ }
53
+ }
54
+ this.tail = Buffer.alloc(0);
55
+ this.skipLine = false;
56
+ start = end + 1;
57
+ }
58
+ const part = bytes.subarray(start);
59
+ if (!this.skipLine && this.tail.length + part.length <= LIMIT)
60
+ this.tail = Buffer.concat([this.tail, part]);
61
+ else {
62
+ this.tail = Buffer.alloc(0);
63
+ this.skipLine = true;
64
+ }
65
+ }
66
+ consume(row) {
67
+ const o = this.options;
68
+ if (row.sessionId !== o.sessionId || row.cwd !== o.cwd || row.agentId !== o.agentId ||
69
+ row.parent_tool_use_id !== o.parentToolUseId || row.isSidechain !== true || row.version !== '1.1.61' ||
70
+ !id(row.uuid) || this.seen.has(row.uuid) || this.seen.size >= 4096)
71
+ return;
72
+ this.seen.add(row.uuid);
73
+ const content = object(row.message).content;
74
+ if (!Array.isArray(content))
75
+ return;
76
+ for (const block of content.map(object)) {
77
+ if (row.type === 'assistant' && block.type === 'tool_use' && id(block.id) && typeof block.name === 'string') {
78
+ if (this.tools.has(block.id) || this.tools.size >= 4096)
79
+ continue;
80
+ const input = object(block.input);
81
+ this.tools.set(block.id, { name: block.name, input });
82
+ this.emit('tool_call', { toolCallId: block.id, name: block.name, title: block.name,
83
+ kind: block.name === 'Read' ? 'read' : block.name === 'Bash' ? 'execute' : block.name === 'Agent' ? 'think' : 'other',
84
+ rawInput: input, status: 'pending' }, block.name);
85
+ }
86
+ else if (row.type === 'user' && block.type === 'tool_result' && id(block.tool_use_id)) {
87
+ const tool = this.tools.get(block.tool_use_id);
88
+ if (!tool)
89
+ continue;
90
+ const raw = row.toolUseResult ?? block.content;
91
+ const text = typeof block.content === 'string' ? block.content : JSON.stringify(block.content ?? '');
92
+ this.emit('tool_call_update', { toolCallId: block.tool_use_id, name: tool.name,
93
+ status: block.is_error === true ? 'failed' : 'completed', rawOutput: raw,
94
+ content: [{ type: 'content', content: { type: 'text', text } }] }, tool.name);
95
+ }
96
+ else if (row.type === 'assistant' && ((block.type === 'text' && typeof block.text === 'string') ||
97
+ (block.type === 'thinking' && typeof block.thinking === 'string'))) {
98
+ this.emit(block.type === 'text' ? 'agent_message_chunk' : 'agent_thought_chunk', { content: { type: 'text', text: block.type === 'text' ? block.text : block.thinking } });
99
+ }
100
+ }
101
+ }
102
+ emit(eventName, payload, toolName) {
103
+ this.options.emit({ eventName, payload: { ...payload, _meta: { qoder: { version: 1, source: 'correction',
104
+ parentToolUseId: this.options.parentToolUseId, route: 'child', ...(toolName ? { toolName } : {}) } } } });
105
+ }
106
+ }