@platless/axom-cli 0.1.0

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 (90) hide show
  1. package/README.md +64 -0
  2. package/dist/args.js +29 -0
  3. package/dist/cli.js +76 -0
  4. package/dist/commands/auth.js +52 -0
  5. package/dist/commands/execute.js +40 -0
  6. package/dist/commands/guide.js +38 -0
  7. package/dist/commands/record.js +99 -0
  8. package/dist/commands/skills.js +69 -0
  9. package/dist/commands/struct.js +50 -0
  10. package/dist/commands/update.js +88 -0
  11. package/dist/commands/whoami.js +8 -0
  12. package/dist/commands/workstation.js +171 -0
  13. package/dist/config.js +131 -0
  14. package/dist/credentialBroker.js +340 -0
  15. package/dist/credentials.js +211 -0
  16. package/dist/install/agentsDir.js +35 -0
  17. package/dist/install/claude/commands.js +89 -0
  18. package/dist/install/claude/materialize.js +42 -0
  19. package/dist/install/installer.js +45 -0
  20. package/dist/install/targets/claude.js +154 -0
  21. package/dist/mcp.js +152 -0
  22. package/dist/output.js +37 -0
  23. package/dist/workstation/acp/acpAgents.js +1287 -0
  24. package/dist/workstation/acp/acpClient.js +691 -0
  25. package/dist/workstation/acp/acpSession.js +1047 -0
  26. package/dist/workstation/acp/adoption.js +396 -0
  27. package/dist/workstation/acp/auth.js +80 -0
  28. package/dist/workstation/acp/authUrlSniffer.js +52 -0
  29. package/dist/workstation/acp/binaryInstall.js +411 -0
  30. package/dist/workstation/acp/configOptions.js +257 -0
  31. package/dist/workstation/acp/contract.js +494 -0
  32. package/dist/workstation/acp/draftInterceptor.js +154 -0
  33. package/dist/workstation/acp/draftSubagents.js +38 -0
  34. package/dist/workstation/acp/installErrors.js +89 -0
  35. package/dist/workstation/acp/installProbe.js +363 -0
  36. package/dist/workstation/acp/permission.js +327 -0
  37. package/dist/workstation/acp/registry/catalog.js +29 -0
  38. package/dist/workstation/acp/registry/launch.js +128 -0
  39. package/dist/workstation/acp/registry/registryClient.js +160 -0
  40. package/dist/workstation/acp/registry/registryTypes.js +8 -0
  41. package/dist/workstation/acp/sessionUpdateMapper.js +602 -0
  42. package/dist/workstation/acp/test/fakeAgent.js +435 -0
  43. package/dist/workstation/acp/test/sessionUpdateFixtures.js +165 -0
  44. package/dist/workstation/acp/timedCommand.js +47 -0
  45. package/dist/workstation/acp/uvxInstall.js +248 -0
  46. package/dist/workstation/acp/validate.js +134 -0
  47. package/dist/workstation/agentSessions.js +1628 -0
  48. package/dist/workstation/channels/agent.js +96 -0
  49. package/dist/workstation/channels/computer.js +91 -0
  50. package/dist/workstation/channels/management.js +231 -0
  51. package/dist/workstation/channels/url.js +42 -0
  52. package/dist/workstation/config.js +47 -0
  53. package/dist/workstation/connection/encryptedChannel.js +261 -0
  54. package/dist/workstation/connection/retryPolicy.js +14 -0
  55. package/dist/workstation/connection/supervisor.js +353 -0
  56. package/dist/workstation/crypto.js +63 -0
  57. package/dist/workstation/daemon.js +225 -0
  58. package/dist/workstation/daemonctl.js +95 -0
  59. package/dist/workstation/exec.js +112 -0
  60. package/dist/workstation/journal.js +257 -0
  61. package/dist/workstation/names.js +17 -0
  62. package/dist/workstation/oauth/browser.js +24 -0
  63. package/dist/workstation/oauth/deviceAuthorization.js +198 -0
  64. package/dist/workstation/osinfo.js +4 -0
  65. package/dist/workstation/protocol/agent.js +606 -0
  66. package/dist/workstation/protocol/common.js +136 -0
  67. package/dist/workstation/protocol/computer.js +31 -0
  68. package/dist/workstation/protocol/management.js +116 -0
  69. package/dist/workstation/register.js +42 -0
  70. package/dist/workstation/service/installer.js +362 -0
  71. package/dist/workstation/service/launchd.js +46 -0
  72. package/dist/workstation/service/loginShell.js +36 -0
  73. package/dist/workstation/service/systemd.js +48 -0
  74. package/dist/workstation/service/types.js +73 -0
  75. package/dist/workstation/supervision.js +52 -0
  76. package/dist/workstation/systemMetrics.js +271 -0
  77. package/dist/workstation/update/artifact.js +61 -0
  78. package/dist/workstation/update/coordinator.js +240 -0
  79. package/dist/workstation/update/npmInstaller.js +91 -0
  80. package/dist/workstation/update/redact.js +36 -0
  81. package/dist/workstation/update/runUpdate.js +215 -0
  82. package/dist/workstation/update/stateStore.js +87 -0
  83. package/dist/workstation/update/taskRegistry.js +142 -0
  84. package/dist/workstation/update/types.js +3 -0
  85. package/dist/workstation/update/watchdog.js +311 -0
  86. package/dist/workstation/version.js +12 -0
  87. package/package.json +39 -0
  88. package/skills/axom/SKILL.md +83 -0
  89. package/skills/axom-definition/SKILL.md +74 -0
  90. package/skills/axom-platform/SKILL.md +78 -0
@@ -0,0 +1,606 @@
1
+ import { decodeChannelFrame, hasOptional, hasRequired, isBoolean, isBridgeInfoArray, isNullableAuthMethodInfoArray, isNullableString, isNumber, isObject, isString, isStringArray, isStringRecord, } from './common.js';
2
+ /** Current version and feature floors for the independently negotiated agent protocol. */
3
+ export const AGENT_WIRE_VERSION = 14;
4
+ export const AGENT_WIRE_MIN_VERSION = 1;
5
+ export const AGENT_WIRE_SESSION_LOST_VERSION = 2;
6
+ export const AGENT_WIRE_ADOPTION_VERSION = 3;
7
+ /** On-demand verification and authentication (`agent_verify` / `agent_authenticate`). */
8
+ export const AGENT_WIRE_AUTH_VERSION = 4;
9
+ /**
10
+ * Adapter installation (`agent_install` / `agent_installed`), and with it the roster's
11
+ * `cliInstalled` / `adapterInstalled`. A v4 peer answers neither, which is why the server checks
12
+ * this floor before sending rather than waiting out a request nothing will ever reply to.
13
+ */
14
+ export const AGENT_WIRE_INSTALL_VERSION = 5;
15
+ /**
16
+ * Steering (`agent_steer` / `agent_steered`). Checked before sending rather than waited out: a v5
17
+ * peer has no case for `agent_steer`, drops it, and the server's request dies at its own timeout
18
+ * with nothing said about why.
19
+ */
20
+ export const AGENT_WIRE_STEER_VERSION = 6;
21
+ /** Reconnect-time journal inventory (`agent_resume_offer`). */
22
+ export const AGENT_WIRE_RESUME_OFFER_VERSION = 7;
23
+ /**
24
+ * The same inventory carried on every `heartbeat` rather than once per connection
25
+ * (`heartbeat.sessions`).
26
+ *
27
+ * **Not a compatibility gate.** The server's `ChannelProtocolJson` sets `ignoreUnknownKeys`, so a v7
28
+ * server drops the field and acks the heartbeat exactly as it did before. The floor exists only to
29
+ * decide whether the bytes are worth sending: an inventory the peer will discard is pure overhead on
30
+ * a frame that goes out every ten seconds for the life of the daemon.
31
+ */
32
+ export const AGENT_WIRE_HEARTBEAT_INVENTORY_VERSION = 8;
33
+ /**
34
+ * Session config options: `agent_opened`/`agent_verified` carry the agent's `configOptions`
35
+ * snapshot and `agent_set_config_option` / `agent_config_option_set` round-trip
36
+ * `session/set_config_option` (`types.gen.d.ts:5102`). Checked before sending, like steering: a
37
+ * v8 peer has no branch for the request and would let it die at the server's timeout.
38
+ */
39
+ export const AGENT_WIRE_CONFIG_OPTIONS_VERSION = 9;
40
+ /**
41
+ * Orphan journals in `agent_resume_offer`: this client also lists refs whose journal is still on
42
+ * disk but whose agent is gone -- a daemon restart or a machine reboot -- as `agentAlive: false`.
43
+ *
44
+ * **A real compatibility gate, unlike AGENT_WIRE_HEARTBEAT_INVENTORY_VERSION.** That one only
45
+ * decided whether bytes were worth sending, because a peer that does not know the field ignores it.
46
+ * This one widens the meaning of an existing list whose *absence* is load-bearing: the server ends a
47
+ * live turn precisely because its ref is missing from the offer. A v9 server handed an orphan entry
48
+ * would stop ending that turn and, knowing nothing about reopening it, leave it RUNNING forever --
49
+ * strictly worse than today's honest failure. So orphans are withheld below this version.
50
+ */
51
+ export const AGENT_WIRE_ORPHAN_OFFER_VERSION = 10;
52
+ /**
53
+ * The login frames — `agent_auth_url` / `agent_auth_url_done` — and the `pending` acknowledgement on
54
+ * `agent_authenticated` that goes with them.
55
+ *
56
+ * **The hardest gate of the ladder so far, because getting it wrong does not cost one message.** A
57
+ * v10 server has no `SerialName` for `agent_auth_url`: `AgentCodec.decode` throws,
58
+ * `classifyUndecodableAgentFrame` files it as `Unattributable` (it has no `sessionRef` to attribute
59
+ * it to), and every one of those counts toward `MAX_CONSECUTIVE_UNDECODABLE_AGENT_FRAMES = 8` —
60
+ * which closes the agent channel **and every session multiplexed on it**. So the floor is checked
61
+ * before `emit`, never after, and below it `agent_authenticate` keeps its old synchronous shape:
62
+ * one reply, sent when the login is over, which is what a v10 server knows how to wait for.
63
+ */
64
+ export const AGENT_WIRE_AUTH_URL_VERSION = 11;
65
+ /**
66
+ * What the **session** path says about a refusal the agent itself produced: `authMethods` — what
67
+ * this process observed on that agent's `initialize` (`AcpAgents.observedAuthMethods`) — and
68
+ * `errorCode`, the JSON-RPC code of that refusal, on both `agent_opened` and `agent_turn_done`.
69
+ *
70
+ * A login card needs two halves — that the agent refused *for want of credentials*, and how it says
71
+ * to sign in. The probe reply (`agent_verified`) has carried both since v4. These two frames — the
72
+ * only ones by which a failed reopen and a failed prompt reach the server — carried neither, and
73
+ * neither could be reconstructed there: `errorMessage(err)` had already flattened ACP's
74
+ * `auth_required` (JSON-RPC -32000) to free text by the time a frame was built, and the
75
+ * `agent_hello` roster is a handshake snapshot that is empty for exactly the agent nobody has
76
+ * connected to yet.
77
+ *
78
+ * **Both keys ship under this one version because neither is worth a bump of its own** — not because
79
+ * they always arrive together. They do not: `refusalFields` spreads each on its own condition, so an
80
+ * agent that refused outside JSON-RPC sends the table with no code. The server requires the code
81
+ * regardless, and for a reason that has nothing to do with versioning: `auth_required` is a code, so
82
+ * a refusal carrying none is not one. ("Methods, therefore a login" was the old rule that made every
83
+ * refused prompt look like an expired token.)
84
+ *
85
+ * **The gentlest floor on the ladder, and deliberately not the shape of {@link
86
+ * AGENT_WIRE_AUTH_URL_VERSION}.** That one added whole frames, and a server without the
87
+ * `@SerialName` throws inside `AgentCodec.decode` — eight of those close the channel. This adds an
88
+ * optional key to two frames the server already decodes with `ignoreUnknownKeys`, so an older
89
+ * server reads them exactly as before. What is gated is therefore only whether the bytes are worth
90
+ * sending, like {@link AGENT_WIRE_HEARTBEAT_INVENTORY_VERSION}, and the check belongs on this side
91
+ * because both frames travel workstation -> server.
92
+ */
93
+ export const AGENT_WIRE_SESSION_AUTH_METHODS_VERSION = 12;
94
+ /**
95
+ * `agent_opened.reloaded`: whether **this open** actually performed `session/load` or
96
+ * `session/new`, as opposed to reusing a live session or piggybacking on another open's real
97
+ * result. The server needs this to settle background jobs left over from a previous agent
98
+ * process — those jobs live only in that process's memory, so a genuine reload is the one event
99
+ * that proves them gone.
100
+ *
101
+ * **Same shape as {@link AGENT_WIRE_SESSION_AUTH_METHODS_VERSION}, and for the same reason that
102
+ * one declares itself un-enforceable by anything mechanical**: this is an optional key on a frame
103
+ * the server already decodes with `ignoreUnknownKeys`, so a peer below this floor does not fail to
104
+ * decode — it just never sees the key. The floor exists to record the compatibility fact, not to
105
+ * gate a decode failure: a v12 (or older) peer never sends `reloaded` at all, and **its absence
106
+ * there must never be read as "this open did not reload"** — that peer cannot tell you either way.
107
+ * A server that settled a background job on that silence would be acting on a fact nobody
108
+ * observed. Below this floor the only correct read of "no `reloaded` key" is "unknown, do not
109
+ * settle"; at or above it, absence reverts to its ordinary meaning — `false`, like
110
+ * `supportsSteering`.
111
+ */
112
+ export const AGENT_WIRE_RELOADED_VERSION = 13;
113
+ /**
114
+ * `agent_steer` carries the **whole message** — `images` beside its `text` — and the delivery
115
+ * timing it is to be injected with (`priority`).
116
+ *
117
+ * **The criterion for "does an additive key need a floor?" is not "is it new" — it is: a key needs
118
+ * no floor only when a capability the peer must already advertise implies it can decode that key.**
119
+ * `ignoreUnknownKeys` means the question is never "will it throw"; it is "can the sender know the
120
+ * key will be understood without asking the version".
121
+ *
122
+ * `agent_prompt.images` passes that test and so has no floor: `supportsImageInput` shipped with it,
123
+ * so a peer advertising the capability necessarily understands the key. `agent_opened.reloaded` and
124
+ * the session-path `authMethods` pass a weaker version of it — they carry an *observation* a reader
125
+ * can simply do without — which is why theirs are "worth the bytes" floors rather than gates.
126
+ *
127
+ * These two fail on both counts. `supportsImageInput` predates them, so a peer can truthfully
128
+ * advertise it and still not know the keys exist; and `agent_steer` is an **RPC** — an `id`, a
129
+ * paired `agent_steered`, a server that acts on the answer — so the key is an instruction, not an
130
+ * observation. A peer below this floor decodes the frame fine, drops both keys, steers with the
131
+ * text alone and answers `injected` — so the reader's attached image never leaves their machine and
132
+ * every record on both sides says the message was delivered. Nothing observes the loss.
133
+ *
134
+ * So the server refuses to send a steer carrying either key below this floor rather than letting it
135
+ * degrade, and the engine's capability read reports no accepted timings at all below it, which
136
+ * routes the message to the queue — where it is delivered whole. A text-only steer with no priority
137
+ * is byte-for-byte the frame it always was and still goes out at {@link AGENT_WIRE_STEER_VERSION}.
138
+ */
139
+ export const AGENT_WIRE_STEER_BLOCKS_VERSION = 14;
140
+ /**
141
+ * Every agent frame's shape, as data rather than as a `switch`.
142
+ *
143
+ * Declarative on purpose: the required-field half of this table is the *only* statement anywhere of
144
+ * which fields the Kotlin twin will refuse to decode without, and the contract suite
145
+ * (`acp/contract.ts`'s `assertRequiredFieldsEnforced`) reads it directly to prove each one is really
146
+ * enforced. Left as inline `hasRequired({...})` calls inside a `switch`, that list is unreadable from
147
+ * a test, so the only way to check it would be a hand-maintained copy in the test file — which is
148
+ * precisely how `agent_authenticate.methodId` came to have no guard at all while a report still
149
+ * "decoded fine" and `authenticate(undefined)` ran all the way to the agent.
150
+ *
151
+ * `DISCRIMINATORS` is derived from the keys below for the same reason: a frame type added to the
152
+ * union and to this table can no longer be forgotten in a second, parallel list.
153
+ */
154
+ export const AGENT_FRAME_SHAPES = {
155
+ agent_hello: { required: { ticket: isString }, optional: { bridges: isBridgeInfoArray } },
156
+ agent_ready: { required: { generation: isString, agentWire: isNumber } },
157
+ agent_resume_offer: {
158
+ required: {
159
+ sessions: (sessions) => Array.isArray(sessions) && sessions.every(isAgentResumeSession),
160
+ },
161
+ },
162
+ heartbeat: {
163
+ required: { seq: isNumber, sentAtEpochMs: isNumber },
164
+ optional: { sessions: (sessions) => Array.isArray(sessions) && sessions.every(isAgentResumeSession) },
165
+ },
166
+ heartbeat_ack: { required: { seq: isNumber } },
167
+ agent_open: {
168
+ required: {
169
+ id: isString, sessionRef: isString, agentKey: isString, bridge: isString, cwd: isString, shell: isString,
170
+ },
171
+ optional: {
172
+ env: isStringRecord, pathPrepend: isStringArray, agentEnv: isStringRecord, command: isNullableString,
173
+ args: isStringArray, nativeSessionId: isNullableString,
174
+ resumeFromSeq: isNumber, backfill: isBoolean,
175
+ },
176
+ },
177
+ agent_opened: {
178
+ required: { id: isString, ok: isBoolean },
179
+ // `supportsSteering` is `true` and nothing else, for `BridgeInfo.supportsSteering`'s reason: a
180
+ // `false` would be a second spelling of what its absence already says.
181
+ optional: {
182
+ nativeSessionId: isNullableString, error: isNullableString, supportsSteering: isVerifiedTrue,
183
+ // Plain strings, not a closed vocabulary: the sender already dropped everything it did not
184
+ // recognise, and validating the values again here would make a future timing fail the frame
185
+ // rather than be ignored — the opposite of what ruling N4 asks for.
186
+ steeringTimings: isStringArray,
187
+ // `reloaded` is `true`-or-absent for the same reason as `supportsSteering`: a `false` would
188
+ // be a second spelling of what its absence already says.
189
+ reloaded: isVerifiedTrue,
190
+ agentInfo: isAgentInfo, supportsImageInput: isVerifiedTrue, configOptions: isNullableSessionConfigOptionArray,
191
+ // Same guard as `agent_verified.authMethods`, and the same absent-vs-`[]` reading: a
192
+ // half-decoded method table is worse than none, because the reader would offer a login the
193
+ // agent never named.
194
+ authMethods: isNullableAuthMethodInfoArray,
195
+ errorCode: isNullableJsonRpcErrorCode,
196
+ },
197
+ },
198
+ agent_prompt: { required: { sessionRef: isString, text: isString }, optional: { images: isImageBlockArray } },
199
+ agent_answer: { required: { sessionRef: isString, requestId: isString, outcome: isString } },
200
+ agent_interrupt: { required: { sessionRef: isString } },
201
+ agent_close: { required: { sessionRef: isString } },
202
+ agent_event: { required: { sessionRef: isString, seq: isNumber, eventName: isString, payload: isRecord } },
203
+ agent_turn_done: {
204
+ required: { sessionRef: isString, seq: isNumber, status: isString },
205
+ optional: {
206
+ stopReason: isString, nativeSessionId: isNullableString, error: isNullableString,
207
+ authMethods: isNullableAuthMethodInfoArray,
208
+ errorCode: isNullableJsonRpcErrorCode,
209
+ },
210
+ },
211
+ agent_event_ack: { required: { sessionRef: isString, throughSeq: isNumber } },
212
+ agent_session_lost: { required: { sessionRef: isString, action: isString } },
213
+ // The session manager deliberately accepts malformed native-query payloads (including null)
214
+ // to return its protocol-level `ok: false` response. The transport only requires that the
215
+ // field is present; semantic validation remains at the request handler boundary.
216
+ agent_list_native: { required: { id: isString, bridge: isString, query: () => true } },
217
+ agent_native_sessions: {
218
+ required: { id: isString, ok: isBoolean },
219
+ optional: {
220
+ sessions: (sessions) => Array.isArray(sessions) && sessions.every(isNativeSession), error: isNullableString,
221
+ },
222
+ },
223
+ // Same lenience as `agent_list_native` above, for the same reason: the session manager wants to
224
+ // answer a malformed query with its own `ok: false` rather than have the transport drop it.
225
+ agent_verify: {
226
+ required: { id: isString, bridge: isString, query: () => true },
227
+ // 与 `query` 那条**刻意的宽松**相反,这里是严格的:一份畸形的 query 还有个能回
228
+ // `ok: false` 的接收方(会话管理器),而一个畸形的 timeoutMs 谁也接不住——`"30s"` 或 `NaN`
229
+ // 一路传到 setTimeout 就是"立刻超时"或"永不超时",两种都不是任何人的意思。缺席与畸形在
230
+ // 这里必须分得开:缺席 = 没人指定预算(用本地默认),畸形 = 对方说错了话,整帧拒掉。
231
+ optional: { timeoutMs: isNullableProbeTimeoutMs },
232
+ },
233
+ agent_authenticate: {
234
+ required: { id: isString, bridge: isString, methodId: isString, query: () => true },
235
+ },
236
+ agent_verified: {
237
+ required: { id: isString, ok: isBoolean },
238
+ optional: {
239
+ authMethods: isNullableAuthMethodInfoArray,
240
+ // Not `isBoolean`: `false` is not a value this frame has. The decoder refusing it is what
241
+ // stops "we could not verify" from ever reaching a UI as "you are signed out".
242
+ authenticated: isVerifiedTrue,
243
+ sessionError: isNullableString,
244
+ error: isNullableString,
245
+ availableCommands: isNullableAvailableCommandArray,
246
+ configOptions: isNullableSessionConfigOptionArray,
247
+ },
248
+ },
249
+ agent_authenticated: {
250
+ required: { id: isString, ok: isBoolean },
251
+ // `pending` is true-or-absent, the same rule as `agent_verified.authenticated` and for the same
252
+ // reason: absence already means "this is the final word", so a `false` on the wire would be a
253
+ // second spelling of it that a reader could disagree with.
254
+ optional: { pending: isVerifiedTrue, error: isNullableString },
255
+ },
256
+ agent_auth_url: { required: { id: isString, elicitationId: isString, url: isString } },
257
+ agent_auth_url_done: { required: { id: isString, elicitationId: isString } },
258
+ agent_install: { required: { id: isString, bridge: isString } },
259
+ agent_installed: {
260
+ required: { id: isString, ok: isBoolean },
261
+ optional: {
262
+ // Not `isBoolean`: like `agent_verified.authenticated`, this field has no `false` — an
263
+ // install that did not put the adapter on the machine says so by leaving the key out.
264
+ adapterInstalled: isVerifiedTrue,
265
+ alreadyInstalled: isBoolean,
266
+ unsupported: isBoolean,
267
+ error: isNullableString,
268
+ pinnedVersionUnavailable: isPinnedVersionUnavailable,
269
+ },
270
+ },
271
+ agent_steer: {
272
+ required: { id: isString, sessionRef: isString, text: isString },
273
+ optional: { images: isImageBlockArray, priority: isString },
274
+ },
275
+ agent_steered: {
276
+ required: { id: isString, ok: isBoolean },
277
+ optional: { outcome: isSteerOutcome, error: isNullableString },
278
+ },
279
+ agent_backfill_events: {
280
+ required: { id: isString, events: (events) => Array.isArray(events) && events.every(isEvent) },
281
+ },
282
+ agent_backfill_done: {
283
+ required: { id: isString, ok: isBoolean, count: isNumber },
284
+ optional: { error: isNullableString },
285
+ },
286
+ agent_set_config_option: {
287
+ required: { id: isString, sessionRef: isString, configId: isString, value: isConfigOptionValue },
288
+ },
289
+ agent_config_option_set: {
290
+ required: { id: isString, ok: isBoolean },
291
+ optional: { configOptions: isNullableSessionConfigOptionArray, error: isNullableString },
292
+ },
293
+ };
294
+ const DISCRIMINATORS = new Set(Object.keys(AGENT_FRAME_SHAPES));
295
+ function isRecord(value) { return isObject(value); }
296
+ function isAgentResumeSession(value) {
297
+ return isObject(value)
298
+ && hasRequired(value, { sessionRef: isString, journalMaxSeq: isNumber, agentAlive: isBoolean });
299
+ }
300
+ /**
301
+ * Accepts only the two outcomes this client can produce. `startedNewTurn` is refused on purpose: it
302
+ * is the extension's third outcome, and receiving one means a peer invented a disposition for a
303
+ * message the server has already put on its timeline — see `acpSession.ts`'s `mapSteerOutcome`.
304
+ */
305
+ function isSteerOutcome(value) { return value === 'injected' || value === 'promptRequired'; }
306
+ /** Accepts `true` and nothing else — the whole point of `authenticated` having no `false`. */
307
+ function isVerifiedTrue(value) { return value === true; }
308
+ /**
309
+ * 一个能当毫秒预算用的数,或者「没人指定」。
310
+ *
311
+ * `null` 与缺席都读作后者(服务端的 `AgentVerify.timeoutMs` 是 `Long?`,虽然它配了
312
+ * `EncodeDefault.NEVER` 因而不会真把 null 写上线,但守卫不该依赖发送方的编码设置)。**拒绝**
313
+ * `NaN` / `Infinity` / 负数 / 0 / 非数:它们全都会变成一个没有人想要的 setTimeout —— 立刻超时
314
+ * 或永不超时,而不是"这次多等一会儿"。上界不在这里判,在 `clampProbeTimeout` 里夹紧:一个过大
315
+ * 但成形的数是**可以执行**的请求(夹到上限即可),而一个畸形的数不是。
316
+ */
317
+ function isNullableProbeTimeoutMs(value) {
318
+ if (value === undefined || value === null)
319
+ return true;
320
+ return typeof value === 'number' && Number.isFinite(value) && value > 0;
321
+ }
322
+ /**
323
+ * A JSON-RPC error code, or "this failure had none".
324
+ *
325
+ * `null` and absent both read as the latter — a refusal that was not a JSON-RPC one, a workstation
326
+ * failure the agent never answered, or a peer that predates the field. Anything else must be an
327
+ * integer inside the Kotlin twin's `Int`: a fractional or out-of-range number does not merely lose
328
+ * precision on the way over, it fails to decode and takes the **whole frame** with it, which is
329
+ * exactly how `stat().mtimeMs` once cost an entire agent channel. This guard is the second of the
330
+ * two that stop it — `jsonRpcErrorCode` already refuses to produce one on the way out — and it is
331
+ * the one that also covers a peer this daemon did not write.
332
+ */
333
+ function isNullableJsonRpcErrorCode(value) {
334
+ if (value === undefined || value === null)
335
+ return true;
336
+ return typeof value === 'number' && Number.isInteger(value)
337
+ && value >= -2_147_483_648 && value <= 2_147_483_647;
338
+ }
339
+ /**
340
+ * 一整份命令表,或者「没人看过」。
341
+ *
342
+ * 缺席与空表在这里必须分得开:缺席读作「问不到」,`[]` 读作「这个 agent 声明了零条命令」。
343
+ * 所以这个守卫接受 `undefined`(键不在)与 `[]`(键在、表空),但**拒绝**任何一条不成形的条目
344
+ * ——半解码的表比没有表更糟,它会让菜单列出点下去什么也不会发生的项。
345
+ */
346
+ function isNullableAvailableCommandArray(value) {
347
+ if (value === undefined || value === null)
348
+ return true;
349
+ if (!Array.isArray(value))
350
+ return false;
351
+ return value.every((entry) => typeof entry === 'object' && entry !== null &&
352
+ typeof entry.name === 'string' &&
353
+ typeof entry.description === 'string' &&
354
+ (entry.inputHint === undefined ||
355
+ entry.inputHint === null ||
356
+ typeof entry.inputHint === 'string'));
357
+ }
358
+ /** Guards {@link SelectOptionWire}: `value`/`name` required strings, `description` optional-and-nullable. */
359
+ function isSelectOption(value) {
360
+ return isObject(value) && typeof value.value === 'string'
361
+ && typeof value.name === 'string'
362
+ && hasOptional(value, { description: isNullableString });
363
+ }
364
+ /** Guards {@link SelectGroupWire}: a named cluster whose `options` are all {@link SelectOptionWire}. */
365
+ function isSelectGroup(value) {
366
+ return isObject(value) && typeof value.group === 'string'
367
+ && typeof value.name === 'string'
368
+ && Array.isArray(value.options) && value.options.every(isSelectOption);
369
+ }
370
+ /**
371
+ * Guards one {@link SessionConfigOptionWire}. `currentValue`'s required type follows the
372
+ * discriminator — a `select` option's value is the id of one of its own `options`, a `boolean`
373
+ * option's is the toggle state — so a mismatch here (a `select` with a boolean `currentValue`, or
374
+ * vice versa) is refused rather than forwarded as an option no UI can render.
375
+ */
376
+ function isSessionConfigOption(value) {
377
+ if (!isObject(value))
378
+ return false;
379
+ const o = value;
380
+ if (typeof o.id !== 'string' || typeof o.name !== 'string')
381
+ return false;
382
+ if (!hasOptional(value, { description: isNullableString, category: isNullableString }))
383
+ return false;
384
+ if (o.type === 'boolean')
385
+ return typeof o.currentValue === 'boolean';
386
+ if (o.type === 'select') {
387
+ if (typeof o.currentValue !== 'string' || !Array.isArray(o.options))
388
+ return false;
389
+ return o.options.every(isSelectOption) || o.options.every(isSelectGroup);
390
+ }
391
+ return false;
392
+ }
393
+ /**
394
+ * 一整份配置选项快照,或者「没人看过」。同 {@link isNullableAvailableCommandArray} 的缺席/空表
395
+ * 约定:缺席读作「问不到」,`[]` 读作「这个 agent 声明了零个配置项」,任何一条不成形的条目都拒绝
396
+ * ——半解码的表比没有表更糟。
397
+ */
398
+ export function isNullableSessionConfigOptionArray(value) {
399
+ if (value === undefined || value === null)
400
+ return true;
401
+ return Array.isArray(value) && value.every(isSessionConfigOption);
402
+ }
403
+ /** Guards {@link ConfigOptionValueWire}: `agent_set_config_option.value` is a select id or a boolean toggle. */
404
+ function isConfigOptionValue(value) { return typeof value === 'string' || typeof value === 'boolean'; }
405
+ /**
406
+ * Guards {@link AgentInfoWire}: `name` required, `title`/`version` optional-and-nullable.
407
+ *
408
+ * This is deliberately laxer than ACP's own `Implementation` (`types.gen.d.ts:2306-2321`), which
409
+ * declares `version` required and non-nullable (`title` is the one that is optional-and-nullable
410
+ * there). ACP requires it; this guard accepts its absence anyway, because the consequence of
411
+ * refusing it is disproportionate: `agent_opened` is a single reply, not a per-field message, so a
412
+ * decode failure here does not drop just `agentInfo` — `outboundAgentFrameRejection`'s round-trip
413
+ * check fails the whole frame, `agentSessions.ts` sends `{ok:false}` instead, and the session fails
414
+ * to open over a display-only version string. Accepting a spec-incomplete `agentInfo` costs nothing
415
+ * a consumer cares about; rejecting one costs the session.
416
+ */
417
+ function isAgentInfo(value) {
418
+ return isObject(value)
419
+ && hasRequired(value, { name: isString })
420
+ && hasOptional(value, { title: isNullableString, version: isNullableString });
421
+ }
422
+ /** Guards `agent_installed.pinnedVersionUnavailable` — see that field's own doc comment. */
423
+ function isPinnedVersionUnavailable(value) {
424
+ return isObject(value) && hasRequired(value, { pinned: isString, installedInstead: isString });
425
+ }
426
+ /**
427
+ * Guards {@link ImageBlockWire}. Strict on every field ACP declares required, and on the
428
+ * discriminator: a block missing `mimeType` reaches the agent as an undecodable blob, and one whose
429
+ * `type` is anything but `image` would be some other content variant smuggled through a field named
430
+ * for images. Both are refused here rather than half-forwarded -- the same rule the Kotlin side's
431
+ * `AcpContentBlock.fromWire` follows ("a missing required field yields null, never a partial
432
+ * block"), because half an image is worse than no image: the agent answers about something it could
433
+ * not read, and nothing on the timeline says why.
434
+ *
435
+ * `uri` is ACP's own optional-and-nullable field (`types.gen.d.ts:330-332`).
436
+ */
437
+ function isImageBlock(value) {
438
+ return isObject(value)
439
+ && value.type === 'image'
440
+ && hasRequired(value, { data: isString, mimeType: isString })
441
+ && hasOptional(value, { uri: isNullableString });
442
+ }
443
+ function isImageBlockArray(value) {
444
+ return Array.isArray(value) && value.every(isImageBlock);
445
+ }
446
+ function isNativeSession(value) {
447
+ return isObject(value) && hasRequired(value, {
448
+ nativeSessionId: isString, title: isString, updatedAtEpochMs: isNumber, cwd: isString,
449
+ });
450
+ }
451
+ function isEvent(value) {
452
+ return isObject(value) && hasRequired(value, { eventName: isString, payload: isRecord });
453
+ }
454
+ function isAgentFrame(value) {
455
+ // Cast, not a narrowed lookup: `value.t` is unvetted wire input, so it may not be one of
456
+ // `AgentFrame['t']`'s literals at all — the `!shape` check right below is what actually handles
457
+ // that, exactly as it did when this table was indexed by plain `string`.
458
+ const shape = AGENT_FRAME_SHAPES[String(value.t)];
459
+ if (!shape)
460
+ return false;
461
+ return hasRequired(value, shape.required) && hasOptional(value, shape.optional ?? {});
462
+ }
463
+ /**
464
+ * Fields the Kotlin twin declares as `Long` or `Int`, per frame type.
465
+ *
466
+ * TypeScript has one number type and the shape guards above accept any finite one, so a fractional
467
+ * value passes every check on this side and then fails to decode on the other — the field does not
468
+ * lose precision, the whole frame becomes unreadable. `stat().mtimeMs` is fractional on macOS and
469
+ * reached the wire twice, from two different bridges, before this table existed.
470
+ */
471
+ const INTEGER_FIELDS = {
472
+ agent_ready: ['agentWire'],
473
+ heartbeat: ['seq', 'sentAtEpochMs'],
474
+ heartbeat_ack: ['seq'],
475
+ agent_open: ['resumeFromSeq'],
476
+ agent_opened: ['errorCode'],
477
+ agent_event: ['seq'],
478
+ agent_turn_done: ['seq', 'errorCode'],
479
+ agent_event_ack: ['throughSeq'],
480
+ agent_backfill_done: ['count'],
481
+ };
482
+ /**
483
+ * Why an `authenticated` field must not go out, or null when it may.
484
+ *
485
+ * ACP v1 offers no way to ask an agent whether it is authenticated, and no way to be told that it is
486
+ * not: the only positive evidence is a session the agent accepted, and everything else — a
487
+ * subprocess that would not start, an agent we have never connected to, a `session/new` that failed
488
+ * for some unrelated reason — is *unknown*. Publishing unknown as `authenticated: false` would put a
489
+ * "sign in again" prompt in front of a user whose credentials are perfectly good, and they would
490
+ * have no way to tell the fabricated verdict from a real one.
491
+ *
492
+ * So the field is `true`-or-absent, and this is where that is enforced rather than merely intended.
493
+ * {@link isVerifiedTrue} already makes the decoder refuse a `false`, but a decode failure names only
494
+ * the frame; this names the field, which is what a caller needs to find the line that produced it.
495
+ */
496
+ function authenticatedViolation(frame) {
497
+ if (frame.t !== 'agent_verified')
498
+ return null;
499
+ const value = frame.authenticated;
500
+ if (value === undefined || value === true)
501
+ return null;
502
+ return `${frame.t}.authenticated may only be true or absent (absent means unknown, never ` +
503
+ `"not authenticated"), got ${String(value)}`;
504
+ }
505
+ /**
506
+ * {@link authenticatedViolation}'s twin for `agent_installed.adapterInstalled`, and it exists for
507
+ * the same reason rather than out of symmetry: the settings page turns that field into the word
508
+ * "Configured" in front of a reader, so a `false` written where a key should simply be missing
509
+ * would be a claim about their machine that nothing on this side established.
510
+ */
511
+ function adapterInstalledViolation(frame) {
512
+ if (frame.t !== 'agent_installed')
513
+ return null;
514
+ const value = frame.adapterInstalled;
515
+ if (value === undefined || value === true)
516
+ return null;
517
+ return `${frame.t}.adapterInstalled may only be true or absent (absent means the adapter did not ` +
518
+ `get onto the machine), got ${String(value)}`;
519
+ }
520
+ function integerViolation(frame) {
521
+ for (const field of INTEGER_FIELDS[frame.t] ?? []) {
522
+ const value = frame[field];
523
+ if (value !== undefined && !Number.isInteger(value)) {
524
+ return `${frame.t}.${field} must be a whole number, got ${String(value)}`;
525
+ }
526
+ }
527
+ // The one integer that does not sit at the top level, and the one that actually broke.
528
+ if (frame.t === 'agent_native_sessions' && Array.isArray(frame.sessions)) {
529
+ for (const [index, session] of frame.sessions.entries()) {
530
+ const value = session?.updatedAtEpochMs;
531
+ if (!Number.isInteger(value)) {
532
+ return `agent_native_sessions.sessions[${index}].updatedAtEpochMs must be a whole number, got ${String(value)}`;
533
+ }
534
+ }
535
+ }
536
+ // `journalMaxSeq` is a Kotlin `Long` on both carriers of the inventory, so both need the rule: a
537
+ // fractional value does not lose precision on the far side, it makes the whole frame undecodable
538
+ // -- and on the heartbeat that costs liveness, not just an event.
539
+ if (frame.t === 'agent_resume_offer' || frame.t === 'heartbeat') {
540
+ for (const [index, session] of (frame.sessions ?? []).entries()) {
541
+ if (!Number.isInteger(session.journalMaxSeq)) {
542
+ return `${frame.t}.sessions[${index}].journalMaxSeq must be a whole number, got ${String(session.journalMaxSeq)}`;
543
+ }
544
+ }
545
+ }
546
+ return null;
547
+ }
548
+ /**
549
+ * Why this frame must not be put on the wire, or null when it may go.
550
+ *
551
+ * Checked by round-tripping through our own decoder rather than against a second schema, so the
552
+ * outbound contract cannot drift from the inbound one, plus the integer rule the decoder cannot
553
+ * express. What the peer will read is exactly what gets checked: `JSON.stringify` drops `undefined`
554
+ * and applies `toJSON`, so the parsed form is the only faithful subject.
555
+ *
556
+ * Total — every failure comes back as a string, including a frame that cannot be serialized at all.
557
+ */
558
+ export function outboundAgentFrameRejection(frame) {
559
+ // Integers first, because that check names the offending field while the decoder can only name
560
+ // the frame. It is also the only one that sees `NaN`: `JSON.stringify` writes NaN as `null`, so
561
+ // by the time the round trip runs, a timestamp that was never a number looks like a missing one.
562
+ const violation = integerViolation(frame) ?? authenticatedViolation(frame) ?? adapterInstalledViolation(frame);
563
+ if (violation)
564
+ return violation;
565
+ try {
566
+ decodeAgentFrame(encodeAgentFrame(frame));
567
+ }
568
+ catch (error) {
569
+ return error instanceof Error ? error.message : String(error);
570
+ }
571
+ return null;
572
+ }
573
+ /**
574
+ * A well-formed failure to send in place of a frame that answered a server request, or null when
575
+ * the frame answered nothing and can only be dropped.
576
+ *
577
+ * Without this the requester waits out its timeout and reports the workstation as unresponsive,
578
+ * which sends whoever is debugging after the network instead of after the bridge that produced the
579
+ * bad value. `agent_backfill_events` is not itself an answer, but losing a batch makes the backfill
580
+ * incomplete, so it fails as its terminator would.
581
+ */
582
+ export function outboundFailureReply(frame, reason) {
583
+ const id = frame.id;
584
+ // The id is what routes the failure back. If that is the malformed part there is no one to tell.
585
+ if (typeof id !== 'string' || id.length === 0)
586
+ return null;
587
+ const error = `the workstation client produced a malformed ${frame.t}: ${reason}`;
588
+ switch (frame.t) {
589
+ case 'agent_opened': return { t: 'agent_opened', id, ok: false, error };
590
+ case 'agent_native_sessions': return { t: 'agent_native_sessions', id, ok: false, error };
591
+ case 'agent_verified': return { t: 'agent_verified', id, ok: false, error };
592
+ case 'agent_authenticated': return { t: 'agent_authenticated', id, ok: false, error };
593
+ case 'agent_installed': return { t: 'agent_installed', id, ok: false, error };
594
+ case 'agent_steered': return { t: 'agent_steered', id, ok: false, error };
595
+ case 'agent_config_option_set': return { t: 'agent_config_option_set', id, ok: false, error };
596
+ case 'agent_backfill_done':
597
+ case 'agent_backfill_events': return { t: 'agent_backfill_done', id, ok: false, count: 0, error };
598
+ default: return null;
599
+ }
600
+ }
601
+ export function encodeAgentFrame(frame) {
602
+ return JSON.stringify(frame);
603
+ }
604
+ export function decodeAgentFrame(text) {
605
+ return decodeChannelFrame(text, DISCRIMINATORS, isAgentFrame);
606
+ }