@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.
- package/README.md +1 -1
- package/dist/authScopes.js +19 -0
- package/dist/axom-patch/cli/README.md +110 -0
- package/dist/axom-patch/cli/pi-steering.json +37 -0
- package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +34 -0
- package/dist/axom-patch/cli/probes/pi-steering.mjs +80 -0
- package/dist/axom-patch/cli/probes/qoder-steering.mjs +89 -0
- package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +49 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +81 -0
- package/dist/axom-patch/cli/qoder-foreground-output.md +41 -0
- package/dist/axom-patch/cli/qoder-steering.json +52 -0
- package/dist/axom-patch/cli/qoder-steering.md +30 -0
- package/dist/axom-patch/cli/qoder-subagent-origin.json +73 -0
- package/dist/axom-patch/cli/qoder-subagent-subwork.md +73 -0
- package/dist/axom-patch/cli/runtime.json +37 -0
- package/dist/axom-patch/cli/steering/claude-steering-js.patch +89 -0
- package/dist/axom-patch/cli/steering/claude-steering-types.patch +28 -0
- package/dist/axom-patch/cli/steering/codex-steering.patch +57 -0
- package/dist/axom-patch/cli/steering/pi-steering.js +79 -0
- package/dist/cli.js +9 -1
- package/dist/commands/auth.js +4 -13
- package/dist/commands/execute.js +4 -4
- package/dist/commands/record.js +6 -6
- package/dist/commands/struct.js +4 -4
- package/dist/commands/workstation.js +2 -2
- package/dist/credentials.js +2 -2
- package/dist/install/claude/commands.js +13 -13
- package/dist/mcp.js +32 -14
- package/dist/workstation/acp/acpAgents.js +447 -166
- package/dist/workstation/acp/acpClient.js +232 -18
- package/dist/workstation/acp/acpSession.js +502 -74
- package/dist/workstation/acp/activityBusy.js +43 -0
- package/dist/workstation/acp/activityReporting.js +54 -0
- package/dist/workstation/acp/adoption.js +107 -49
- package/dist/workstation/acp/authUrlSniffer.js +36 -0
- package/dist/workstation/acp/contract.js +11 -6
- package/dist/workstation/acp/delegationMcp.js +24 -0
- package/dist/workstation/acp/draftInterceptor.js +9 -0
- package/dist/workstation/acp/managedPatches.js +228 -0
- package/dist/workstation/acp/patchAssets.js +63 -0
- package/dist/workstation/acp/permission.js +300 -34
- package/dist/workstation/acp/qoderStartup.js +45 -0
- package/dist/workstation/acp/registry/catalog.js +263 -4
- package/dist/workstation/acp/registry/detect.js +55 -0
- package/dist/workstation/acp/sessionScope.js +19 -0
- package/dist/workstation/acp/sessionUpdateMapper.js +39 -21
- package/dist/workstation/acp/terminalLogin.js +197 -0
- package/dist/workstation/acp/test/fakeAgent.js +185 -9
- package/dist/workstation/acp/validate.js +33 -5
- package/dist/workstation/agentSessions.js +532 -44
- package/dist/workstation/channels/agent.js +7 -1
- package/dist/workstation/channels/computer.js +152 -4
- package/dist/workstation/channels/management.js +3 -3
- package/dist/workstation/config.js +3 -1
- package/dist/workstation/connection/encryptedChannel.js +88 -21
- package/dist/workstation/daemon.js +25 -3
- package/dist/workstation/desktop/binding.js +36 -0
- package/dist/workstation/desktop/bridge.js +212 -0
- package/dist/workstation/desktop/flow-control.js +52 -0
- package/dist/workstation/desktop/focused-text.js +130 -0
- package/dist/workstation/desktop/protocol.js +35 -0
- package/dist/workstation/fileOps.js +277 -0
- package/dist/workstation/filePaths.js +174 -0
- package/dist/workstation/fileWorker.js +81 -0
- package/dist/workstation/fileWorkerClient.js +121 -0
- package/dist/workstation/oauth/deviceAuthorization.js +21 -12
- package/dist/workstation/protocol/agent.js +154 -5
- package/dist/workstation/protocol/common.js +6 -0
- package/dist/workstation/protocol/computer.js +92 -3
- package/dist/workstation/qoderAdapter/backgroundProcess.js +106 -0
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +262 -0
- package/dist/workstation/qoderAdapter/completedContinuation.js +139 -0
- package/dist/workstation/qoderAdapter/contract.js +78 -0
- package/dist/workstation/qoderAdapter/foregroundOutput.js +30 -0
- package/dist/workstation/qoderAdapter/history.js +87 -0
- package/dist/workstation/qoderAdapter/identityIndex.js +191 -0
- package/dist/workstation/qoderAdapter/inputQueue.js +42 -0
- package/dist/workstation/qoderAdapter/main.js +93 -0
- package/dist/workstation/qoderAdapter/mapper.js +117 -0
- package/dist/workstation/qoderAdapter/observerPlugin.js +245 -0
- package/dist/workstation/qoderAdapter/observerReceiver.js +187 -0
- package/dist/workstation/qoderAdapter/permissions.js +61 -0
- package/dist/workstation/qoderAdapter/profile.js +93 -0
- package/dist/workstation/qoderAdapter/sdkDriver.js +49 -0
- package/dist/workstation/qoderAdapter/session.js +319 -0
- package/dist/workstation/qoderAdapter/subagentMeta.js +28 -0
- package/dist/workstation/qoderAdapter/subwork.js +77 -0
- package/dist/workstation/qoderAdapter/taskControl.js +38 -0
- package/dist/workstation/qoderAdapter/taskEvent.js +26 -0
- package/dist/workstation/qoderAdapter/taskLedger.js +163 -0
- package/dist/workstation/qoderAdapter/types.js +1 -0
- package/dist/workstation/qoderAdapter/usage.js +37 -0
- package/dist/workstation/register.js +4 -0
- package/dist/workstation/tools/browser.js +394 -0
- package/dist/workstation/tools/client.js +269 -0
- package/dist/workstation/tools/core.js +50 -0
- package/dist/workstation/tools/files.js +525 -0
- package/dist/workstation/tools/keeperProcess.js +114 -0
- package/dist/workstation/tools/terminal.js +214 -0
- package/dist/workstation/tools/validation.js +50 -0
- package/dist/workstation/update/coordinator.js +29 -0
- package/dist/workstation/update/npmInstaller.js +2 -0
- package/dist/workstation/update/ptyPreflight.js +24 -0
- package/dist/workstation/update/taskRegistry.js +44 -20
- package/dist/workstation/update/watchdog.js +66 -0
- package/package.json +14 -4
- package/skills/axom/SKILL.md +55 -17
- package/skills/axom-definition/SKILL.md +32 -32
- package/skills/axom-platform/SKILL.md +39 -39
|
@@ -36,18 +36,17 @@
|
|
|
36
36
|
* the server's turn-end / replay-end flush is what closes a message.
|
|
37
37
|
*/
|
|
38
38
|
import { randomUUID } from 'node:crypto';
|
|
39
|
+
import { certifiedRuntime, ForegroundOutputBoundary } from '../qoderAdapter/foregroundOutput.js';
|
|
40
|
+
import { BackgroundTerminalRepair, nativeTerminalHistoryPath } from '../qoderAdapter/backgroundTerminal.js';
|
|
41
|
+
import { SubworkBoundary, supportsSubworkMeta } from '../qoderAdapter/subwork.js';
|
|
39
42
|
import { methods } from '@agentclientprotocol/sdk';
|
|
40
43
|
import { jsonRpcErrorCode } from './acpClient.js';
|
|
41
44
|
import { settleUpdates } from './adoption.js';
|
|
42
45
|
import { mapSessionUpdate, SessionMapperState } from './sessionUpdateMapper.js';
|
|
46
|
+
import { SessionMapperScopes } from './sessionScope.js';
|
|
47
|
+
import { newSessionMetaFor } from './activityReporting.js';
|
|
48
|
+
import { buildSessionMcpServers } from './delegationMcp.js';
|
|
43
49
|
import { flatChoicesOf, modesAlreadyRepresented, normalizeConfigOptions, SYNTHESIZED_MODES_CONFIG_ID } from './configOptions.js';
|
|
44
|
-
/**
|
|
45
|
-
* A few ACP adapters reply to `session/prompt` before their last `session/update` notifications
|
|
46
|
-
* have arrived. Those trailing chunks have no following turn boundary to synthesize
|
|
47
|
-
* `agent_message`, so the server would keep them buffered until the reader sent another message.
|
|
48
|
-
* This short quiet period lets a burst remain one message while ensuring it is rendered on its own.
|
|
49
|
-
*/
|
|
50
|
-
const LATE_MESSAGE_SETTLE_MS = 250;
|
|
51
50
|
/**
|
|
52
51
|
* Points the connection's diverted-update stream at the session that owns it.
|
|
53
52
|
*
|
|
@@ -76,6 +75,15 @@ function bindDivertedUpdates(connection, ownSessionId, session) {
|
|
|
76
75
|
* {@link AcpConnection.supportsSteering} first.
|
|
77
76
|
*/
|
|
78
77
|
const STEER_METHOD = '_session/steering';
|
|
78
|
+
/**
|
|
79
|
+
* The adapter's *stop one background task* extension method. **Not an ACP method** either — it is
|
|
80
|
+
* `claude-agent-acp`'s own `ASYNC_TASK_STOP_METHOD` (`acp-agent.js@0.79.0:165`), whose own comment
|
|
81
|
+
* reads *"Stops one Claude background task without cancelling the parent prompt turn."*
|
|
82
|
+
* `codex-acp@1.12.0` registers the same literal name with the same params
|
|
83
|
+
* (`dist/index.js:31608`, `:35126-35129`, `:32395-32406`). Reached through the untyped `request`
|
|
84
|
+
* overload, for the reason {@link STEER_METHOD} documents.
|
|
85
|
+
*/
|
|
86
|
+
const ASYNC_TASK_STOP_METHOD = '_session/async_task/stop';
|
|
79
87
|
/**
|
|
80
88
|
* Narrow a `_session/steering` response onto {@link SteerOutcome}, or throw.
|
|
81
89
|
*
|
|
@@ -119,6 +127,14 @@ export function mapSteerOutcome(result) {
|
|
|
119
127
|
* sent over the wire to any agent, so no real option id can ever equal it in practice.
|
|
120
128
|
*/
|
|
121
129
|
const ABANDONED_ANSWER = `__acp_session_abandoned_${randomUUID()}__`;
|
|
130
|
+
/**
|
|
131
|
+
* The value `registerPendingElicitation`'s resolver receives when nobody is ever going to answer.
|
|
132
|
+
* Unlike {@link ABANDONED_ANSWER}, this needs no randomized sentinel: `CreateElicitationResponse`'s
|
|
133
|
+
* `action` union already has `'cancel'` as a real ACP outcome meaning exactly this ("the client
|
|
134
|
+
* will not provide a response"), so there is no risk of it colliding with a real user answer the
|
|
135
|
+
* way a fixed permission-option-id literal would.
|
|
136
|
+
*/
|
|
137
|
+
const ABANDONED_ELICITATION_ANSWER = { action: 'cancel' };
|
|
122
138
|
/** ACP's `StopReason` (`end_turn | max_tokens | max_turn_requests | refusal | cancelled`) → the wire's `TurnStopReason`. `'cancelled'` is handled by the caller (it forces `status: 'interrupted'` instead, which carries no `stopReason` — see {@link AcpSession.finishTurn}), so it never actually reaches this function; the arm is kept so the switch stays exhaustive over every literal `StopReason` can hold. */
|
|
123
139
|
function mapStopReason(reason) {
|
|
124
140
|
switch (reason) {
|
|
@@ -169,6 +185,47 @@ async function steerRequest(connection, sessionId, blocks, priority) {
|
|
|
169
185
|
});
|
|
170
186
|
return mapSteerOutcome(result);
|
|
171
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* One `_session/async_task/stop` round trip, shared by {@link AcpSession.open} and
|
|
190
|
+
* {@link AcpSession.attach} exactly as {@link steerRequest} is — the two differ only in where the
|
|
191
|
+
* session id comes from.
|
|
192
|
+
*
|
|
193
|
+
* The request shape is the adapter's own `parseAsyncTaskStopRequest`
|
|
194
|
+
* (`claude-agent-acp@0.79.0` `dist/acp-agent.js:166-173`): a non-empty `sessionId` and a non-empty
|
|
195
|
+
* `asyncTaskId`, both trimmed strings, and nothing else. `codex-acp@1.12.0` parses the identical
|
|
196
|
+
* pair (`dist/index.js:35126-35129`, a zod object with `.trim().min(1)` on both), so there is one
|
|
197
|
+
* shape here, not a per-vendor branch. The response is `{ stopped: boolean }` on both
|
|
198
|
+
* (`acp-agent.js:1784-1798`, `index.js:32395-32406`), which this layer deliberately does **not**
|
|
199
|
+
* turn into a verdict: `stopped: false` only means the adapter had no live task under that id
|
|
200
|
+
* right now, and the platform's real answer
|
|
201
|
+
* — did the background task actually end — arrives as the agent's own next `async_task_state_update`
|
|
202
|
+
* for this `(sessionId, asyncTaskId)`. Reporting `false` as a failure would make the server show an
|
|
203
|
+
* error for a task that is, by then, already settled.
|
|
204
|
+
*
|
|
205
|
+
* Reached through the untyped `request` overload for the same reason {@link steerRequest} is: this
|
|
206
|
+
* is an adapter extension in ACP's `_`-prefixed namespace, not an ACP method — `grep async_task`
|
|
207
|
+
* over the pinned SDK's `types.gen.d.ts` returns nothing.
|
|
208
|
+
*/
|
|
209
|
+
async function asyncTaskStopRequest(connection, sessionId, asyncTaskId) {
|
|
210
|
+
const result = await connection.connection.agent.request(ASYNC_TASK_STOP_METHOD, {
|
|
211
|
+
sessionId,
|
|
212
|
+
asyncTaskId,
|
|
213
|
+
});
|
|
214
|
+
if (result?.stopped !== true) {
|
|
215
|
+
// Logged, never thrown: see this function's own doc for why `stopped: false` is not a failure.
|
|
216
|
+
console.warn(`[acp] ${ASYNC_TASK_STOP_METHOD} reported no live task '${asyncTaskId}' on session ${sessionId}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function providerTaskStopRequest(connection, sessionId, taskId, requestId) {
|
|
220
|
+
const result = await connection.connection.agent.request('_axom/task/stop', {
|
|
221
|
+
sessionId, taskId, requestId,
|
|
222
|
+
});
|
|
223
|
+
if (result?.state === 'accepted')
|
|
224
|
+
return 'accepted';
|
|
225
|
+
if (result?.state === 'unknown')
|
|
226
|
+
return 'unknown';
|
|
227
|
+
throw new Error('Provider task stop was rejected');
|
|
228
|
+
}
|
|
172
229
|
/**
|
|
173
230
|
* One `session/set_config_option` (`SetSessionConfigOptionRequest`, `types.gen.d.ts:5102-5132`) or
|
|
174
231
|
* — for the daemon's synthesized {@link SYNTHESIZED_MODES_CONFIG_ID} — `session/set_mode`
|
|
@@ -286,6 +343,7 @@ class AttachedQueue {
|
|
|
286
343
|
}
|
|
287
344
|
export class AcpSession {
|
|
288
345
|
session;
|
|
346
|
+
connection;
|
|
289
347
|
outbox = [];
|
|
290
348
|
/**
|
|
291
349
|
* This session's `toolCallId → name` memory, for the `tool_call_update`s ACP lets arrive with
|
|
@@ -295,9 +353,23 @@ export class AcpSession {
|
|
|
295
353
|
* is: one `AcpSession` is one ACP session, and a registry shared between two of them would let
|
|
296
354
|
* one session's tool call name a different session's update.
|
|
297
355
|
*/
|
|
298
|
-
|
|
356
|
+
rootRuntimeId = randomUUID();
|
|
357
|
+
scopes = new SessionMapperScopes((scope) => new SessionMapperState((raw) => { if (scope.nativeSessionId === this.session.sessionId)
|
|
358
|
+
this.reportActivity(raw); }));
|
|
359
|
+
mapperFor(nativeSessionId) {
|
|
360
|
+
return this.scopes.get({ rootRuntimeId: this.rootRuntimeId, nativeSessionId });
|
|
361
|
+
}
|
|
299
362
|
eventSink = null;
|
|
300
363
|
turnSink = null;
|
|
364
|
+
backgroundWriterUnconfirmed = false;
|
|
365
|
+
exactExecutionUnconfirmed = false;
|
|
366
|
+
/**
|
|
367
|
+
* This session's latest agent-reported activity-state consumer — `agentSessions.ts`, via
|
|
368
|
+
* `AcpAgentSession.onActivity`. Kept apart from {@link eventSink} on purpose: an activity push is
|
|
369
|
+
* a level, not an edge (see the `agent_session_state` frame's own doc comment), so it must never
|
|
370
|
+
* pass through the `WireEvent`/journal/seq machinery {@link eventSink} feeds.
|
|
371
|
+
*/
|
|
372
|
+
activitySink = null;
|
|
301
373
|
draining = false;
|
|
302
374
|
/**
|
|
303
375
|
* Every turn this session has started and not yet reported a result for, oldest first.
|
|
@@ -319,15 +391,19 @@ export class AcpSession {
|
|
|
319
391
|
* per-prompt attribution would need an id ACP does not put on `session/update` either.
|
|
320
392
|
*/
|
|
321
393
|
inFlightTurns = [];
|
|
322
|
-
/** Timer which closes assistant text that an adapter sent after it had already ended the turn. */
|
|
323
|
-
lateMessageFlushTimer = null;
|
|
324
394
|
/**
|
|
325
395
|
* Whether a `session/prompt` round trip is outstanding on the agent **right now**.
|
|
326
396
|
*
|
|
327
397
|
* This is the turn's ground truth: {@link inFlightTurns} is pushed when the prompt goes out and
|
|
328
398
|
* shifted when the agent answers, so it says what the agent is doing rather than what anything
|
|
329
|
-
* else recorded about it.
|
|
330
|
-
*
|
|
399
|
+
* else recorded about it. `TaskRegistry.snapshot()` reads this getter directly, live, on every
|
|
400
|
+
* call -- there is no bracket of started/finished events kept elsewhere on the daemon side that
|
|
401
|
+
* could go stale or outlive it (that bookkeeping set is gone; see the design doc's §4.1/§4.2).
|
|
402
|
+
* The signal that *can* now outlive the events it describes is the agent's own self-reported
|
|
403
|
+
* activity (`lastActivityRaw`): a vendor that says `running` and then falls silent stays
|
|
404
|
+
* "running" until it says otherwise, by deliberate policy (no invented timeout; §4.4). This
|
|
405
|
+
* getter has no equivalent failure mode -- it cannot outlive the round trip it is asking about,
|
|
406
|
+
* because it *is* that round trip, sampled live rather than recorded.
|
|
331
407
|
*/
|
|
332
408
|
get turnRunning() {
|
|
333
409
|
return this.inFlightTurns.length > 0;
|
|
@@ -336,6 +412,9 @@ export class AcpSession {
|
|
|
336
412
|
get readable() {
|
|
337
413
|
return this.readLoopDead === null;
|
|
338
414
|
}
|
|
415
|
+
nativeTerminalRepair;
|
|
416
|
+
foregroundOutput;
|
|
417
|
+
subworkBoundary;
|
|
339
418
|
closed = false;
|
|
340
419
|
connectionAborted = false;
|
|
341
420
|
/**
|
|
@@ -348,10 +427,18 @@ export class AcpSession {
|
|
|
348
427
|
* joins the same silence. Two of the consequences live outside this file, which is why no
|
|
349
428
|
* per-file review caught them:
|
|
350
429
|
*
|
|
351
|
-
* - `
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
430
|
+
* - `TaskRegistry.canUpdate()` samples {@link turnRunning} live off {@link inFlightTurns} rather
|
|
431
|
+
* than accumulating a bracketed ref (2026-09-21 rewrite — the old `agentTasks` Set could go
|
|
432
|
+
* stale on its own, holding a ref no real condition still justified; live sampling means a mark
|
|
433
|
+
* can no longer outlive the condition that set it). That does not make this concern go away,
|
|
434
|
+
* because the condition here would be real, not stale bookkeeping: {@link inFlightTurns} is
|
|
435
|
+
* still pushed unconditionally at the bottom of this method, and only {@link finishTurn} pops
|
|
436
|
+
* it. A prompt sent into a connection nobody is reading gets no `session/update`, so nothing
|
|
437
|
+
* would ever call {@link finishTurn} for that entry — the turn is genuinely, permanently
|
|
438
|
+
* unfinished, so live sampling faithfully reports it and pins `turnRunning` to `true`, and with
|
|
439
|
+
* it `canUpdate()` to `false`, for the rest of this process's life. Same failure mode as the old
|
|
440
|
+
* leak, one call frame earlier: a busy mark nothing will ever clear. This flag's early return is
|
|
441
|
+
* what pairs that push with an immediate {@link finishTurn} instead of leaving it stuck.
|
|
355
442
|
* - Each retry `touch()`es the session, so retrying keeps `lastActivityAt` fresh and `sweepIdle`
|
|
356
443
|
* never reclaims it either.
|
|
357
444
|
*
|
|
@@ -371,16 +458,62 @@ export class AcpSession {
|
|
|
371
458
|
* process forever. `permission.ts` is the only thing that populates it, via
|
|
372
459
|
* {@link registerPendingApproval}; nothing in this file does.
|
|
373
460
|
*/
|
|
461
|
+
connectionGeneration = randomUUID();
|
|
462
|
+
permissionsInterrupted = false;
|
|
463
|
+
permissionOrigins = new Set();
|
|
464
|
+
// One parent per exact native ID, including edges whose parent has not arrived yet.
|
|
465
|
+
permissionParents = new Map();
|
|
466
|
+
unresolvedPermissionOrigins = new Set();
|
|
374
467
|
pendingApprovals = new Map();
|
|
468
|
+
settledApprovals = new Map();
|
|
469
|
+
callbacksComplete = true;
|
|
470
|
+
publishCallbacks() {
|
|
471
|
+
this.connection.publishCallbacks?.({ rootSessionId: this.session.sessionId,
|
|
472
|
+
connectionGeneration: this.connectionGeneration, complete: this.callbacksComplete,
|
|
473
|
+
pending: this.callbacksComplete ? [...this.pendingApprovals].map(([requestId, entry]) => ({ requestId,
|
|
474
|
+
originNativeSessionId: entry.originNativeSessionId, rpcRequestId: entry.rpcRequestId })) : [],
|
|
475
|
+
settled: this.callbacksComplete ? [...this.settledApprovals].map(([requestId, reason]) => ({ requestId, reason })) : [] });
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* `requestId` → the resolver for one outstanding `elicitation/create` round trip. Parallel to
|
|
479
|
+
* {@link pendingApprovals}, and drained the same way for the same reason — `close()` and the
|
|
480
|
+
* connection-abort branch of `runReadLoop` must not leave the agent process blocked inside
|
|
481
|
+
* `elicitation/create` forever. A separate map rather than reusing `pendingApprovals`, because the
|
|
482
|
+
* resolved value differs in kind: a `CreateElicitationResponse` object, not a bare option-id
|
|
483
|
+
* string — see `AgentFrame.AgentElicitationAnswer`'s KDoc for why the two frames (and so the two
|
|
484
|
+
* maps) do not collapse into one.
|
|
485
|
+
*/
|
|
486
|
+
pendingElicitations = new Map();
|
|
375
487
|
readLoopDone;
|
|
376
|
-
constructor(session, connectionSignal) {
|
|
488
|
+
constructor(session, connectionSignal, connection) {
|
|
377
489
|
this.session = session;
|
|
490
|
+
this.connection = connection;
|
|
378
491
|
if (connectionSignal.aborted)
|
|
379
492
|
this.connectionAborted = true;
|
|
380
493
|
else
|
|
381
|
-
connectionSignal.addEventListener('abort', () =>
|
|
494
|
+
connectionSignal.addEventListener('abort', () => {
|
|
495
|
+
this.connectionAborted = true;
|
|
496
|
+
this.nativeTerminalRepair?.close();
|
|
497
|
+
}, { once: true });
|
|
498
|
+
if (certifiedRuntime(connection.agentInfo))
|
|
499
|
+
this.foregroundOutput = new ForegroundOutputBoundary(this.session.sessionId);
|
|
500
|
+
if (supportsSubworkMeta(connection.agentInfo))
|
|
501
|
+
this.subworkBoundary = new SubworkBoundary(this.session.sessionId);
|
|
382
502
|
this.readLoopDone = this.runReadLoop();
|
|
383
503
|
}
|
|
504
|
+
/** Only actual hosted sessions enable the certified Qoder boundary; probes never read history. */
|
|
505
|
+
enableBackgroundTerminalRepair(cwd, historyHome, restoreCalls = false) {
|
|
506
|
+
if (this.closed || this.connectionAborted || this.readLoopDead || this.nativeTerminalRepair)
|
|
507
|
+
return;
|
|
508
|
+
const path = nativeTerminalHistoryPath(this.connection.agentInfo, this.session.sessionId, cwd, historyHome);
|
|
509
|
+
if (!path)
|
|
510
|
+
return;
|
|
511
|
+
this.nativeTerminalRepair = new BackgroundTerminalRepair({
|
|
512
|
+
includeProcess: this.subworkBoundary !== undefined,
|
|
513
|
+
path, cwd, restoreCalls, nativeSessionId: this.session.sessionId, emit: event => this.emitEvent(event),
|
|
514
|
+
});
|
|
515
|
+
this.nativeTerminalRepair.start();
|
|
516
|
+
}
|
|
384
517
|
/**
|
|
385
518
|
* Open a new ACP session via `session/new` and start driving it.
|
|
386
519
|
*
|
|
@@ -390,7 +523,18 @@ export class AcpSession {
|
|
|
390
523
|
* degraded to `null` here rather than trusted at face value.
|
|
391
524
|
*/
|
|
392
525
|
static async open(connection, options) {
|
|
393
|
-
|
|
526
|
+
// Opts into claude-agent-acp's raw-SDK-message channel (design §5.1/§2.1) for exactly the
|
|
527
|
+
// session that needs it — `undefined` for every other agent, in which case `buildSession` keeps
|
|
528
|
+
// taking the plain `cwd` string it always has when no MCP server is configured. The explicit
|
|
529
|
+
// request path carries the same MCP list as session/load, including any scoped delegation lease.
|
|
530
|
+
const meta = newSessionMetaFor(connection.agentInfo?.name);
|
|
531
|
+
const mcpServers = buildSessionMcpServers(options.mcpServers, options.delegationMcp);
|
|
532
|
+
if (options.delegationMcp)
|
|
533
|
+
connection.redactStderrSecret(Object.values(options.delegationMcp.headers)[0]);
|
|
534
|
+
const builder = meta || mcpServers.length > 0
|
|
535
|
+
? connection.connection.agent.buildSession({ cwd: options.cwd, mcpServers, ...(meta ? { _meta: meta } : {}) })
|
|
536
|
+
: connection.connection.agent.buildSession(options.cwd);
|
|
537
|
+
const activeSession = await builder.start();
|
|
394
538
|
// `NewSessionResponse` (`acp.d.ts:329`'s `newSessionResponse` getter) carries both halves
|
|
395
539
|
// `normalizeConfigOptions` needs: `configOptions` (`types.gen.d.ts:2546`) and `modes` (`:2542`).
|
|
396
540
|
const response = activeSession.newSessionResponse;
|
|
@@ -408,8 +552,16 @@ export class AcpSession {
|
|
|
408
552
|
cancel: () => connection.connection.agent.notify(methods.agent.session.cancel, { sessionId: activeSession.sessionId }),
|
|
409
553
|
steer: (blocks, priority) => steerRequest(connection, activeSession.sessionId, blocks, priority),
|
|
410
554
|
setConfigOption: setConfigOptionRequest(connection, activeSession.sessionId, () => snapshot, (next) => (snapshot = next)),
|
|
555
|
+
stopAsyncTask: (asyncTaskId) => asyncTaskStopRequest(connection, activeSession.sessionId, asyncTaskId),
|
|
556
|
+
providerTaskStop: (taskId, requestId) => providerTaskStopRequest(connection, activeSession.sessionId, taskId, requestId),
|
|
411
557
|
};
|
|
412
|
-
const session = new AcpSession(drivenSession, connection.connection.signal);
|
|
558
|
+
const session = new AcpSession(drivenSession, connection.connection.signal, connection);
|
|
559
|
+
// Claude's activity-state notification arrives on the connection, not on this session's own
|
|
560
|
+
// `session/update` stream (see `activityReporting.ts` and `AcpConnection.bindActivity`'s doc
|
|
561
|
+
// comment for why `extNotification` cannot be routed the way `session/update` is). Bound
|
|
562
|
+
// unconditionally: a connection whose agent never sends `_claude/sdkMessage` simply never
|
|
563
|
+
// invokes this sink.
|
|
564
|
+
connection.bindActivity((raw) => session.reportActivity(raw));
|
|
413
565
|
const nativeSessionId = activeSession.sessionId.length > 0 ? activeSession.sessionId : null;
|
|
414
566
|
bindDivertedUpdates(connection, activeSession.sessionId, session);
|
|
415
567
|
return { session, nativeSessionId, configOptions: snapshot };
|
|
@@ -453,6 +605,22 @@ export class AcpSession {
|
|
|
453
605
|
* array forever. `current()` instead re-reads `initial.configOptions` every time, until a real
|
|
454
606
|
* `setConfigOption` call produces a snapshot of its own — so a caller that populates `initial`
|
|
455
607
|
* (a mutable object) after construction is still seen.
|
|
608
|
+
*
|
|
609
|
+
* ### Opts claude into activity reporting exactly as {@link AcpSession.open} does
|
|
610
|
+
*
|
|
611
|
+
* `AcpSession.open` sends `_meta.claudeCode.emitRawSDKMessages` on `session/new` and calls
|
|
612
|
+
* `connection.bindActivity(...)` right after constructing its session (`:596`, `:631`). This method
|
|
613
|
+
* used to do neither, permanently losing activity reporting for every reconnect and every
|
|
614
|
+
* adoption of a claude session, even though `agentInfo.name` still made it report
|
|
615
|
+
* `reportsActivity: true` on `agent_opened` — see `protocol/agent.ts`'s `reportsActivity` field doc
|
|
616
|
+
* for how that gap was measured and closed. The `_meta` half is now sent by the caller: this
|
|
617
|
+
* method does not issue `session/load` itself (see this method's own opening paragraph — the
|
|
618
|
+
* caller already did, before calling this), but `adoption.ts`'s `replayNativeSession` — the
|
|
619
|
+
* function that does issue it — sends the exact same opt-in `AcpSession.open` sends, via the same
|
|
620
|
+
* `newSessionMetaFor` filter, so there is exactly one place that decides which agents opt in. This
|
|
621
|
+
* method's own half is `bindActivity` below, called unconditionally right after construction, the
|
|
622
|
+
* same as `open` — a connection whose agent never sends `_claude/sdkMessage` simply never invokes
|
|
623
|
+
* the sink.
|
|
456
624
|
*/
|
|
457
625
|
static attach(connection, sessionId, updates, initial) {
|
|
458
626
|
const agent = connection.connection.agent;
|
|
@@ -508,12 +676,17 @@ export class AcpSession {
|
|
|
508
676
|
cancel: () => agent.notify(methods.agent.session.cancel, { sessionId }),
|
|
509
677
|
steer: (blocks, priority) => steerRequest(connection, sessionId, blocks, priority),
|
|
510
678
|
setConfigOption: setConfigOptionRequest(connection, sessionId, current, (next) => (snapshot = next)),
|
|
679
|
+
stopAsyncTask: (asyncTaskId) => asyncTaskStopRequest(connection, sessionId, asyncTaskId),
|
|
511
680
|
dispose: () => {
|
|
512
681
|
unsubscribe();
|
|
513
682
|
queue.fail(new Error(`[acp] session ${sessionId} was disposed`));
|
|
514
683
|
},
|
|
515
684
|
};
|
|
516
|
-
const attached = new AcpSession(driven, connection.connection.signal);
|
|
685
|
+
const attached = new AcpSession(driven, connection.connection.signal, connection);
|
|
686
|
+
// Same wiring `AcpSession.open` does right after constructing its session (`:631`) — see this
|
|
687
|
+
// method's own doc comment ("Opts claude into activity reporting exactly as `AcpSession.open`
|
|
688
|
+
// does") for why this half was missing and what closes it.
|
|
689
|
+
connection.bindActivity((raw) => attached.reportActivity(raw));
|
|
517
690
|
bindDivertedUpdates(connection, sessionId, attached);
|
|
518
691
|
return attached;
|
|
519
692
|
}
|
|
@@ -527,7 +700,7 @@ export class AcpSession {
|
|
|
527
700
|
* `PromptCapabilities.image` (`AcpConnection.capabilities.promptImage`, surfaced to callers as
|
|
528
701
|
* `AcpAgentSession.supportsImageInput`), and enforcing that is the caller's job, not this method's.
|
|
529
702
|
*/
|
|
530
|
-
prompt(blocks) {
|
|
703
|
+
prompt(blocks, platformTurnId) {
|
|
531
704
|
// Checked ahead of `closed`, and the reason this branch exists at all: a session can outlive its
|
|
532
705
|
// read loop without anyone having closed it (a dead connection, an unmappable update), and then
|
|
533
706
|
// nothing but this call is left to end the turn. Registered as a turn and finished on the spot,
|
|
@@ -535,7 +708,7 @@ export class AcpSession {
|
|
|
535
708
|
// path exactly as it does on the live one — `agentSessions.ts` unblocks `TaskRegistry` and the
|
|
536
709
|
// server closes the round off `onTurnDone` and nothing else. See {@link readLoopDead}.
|
|
537
710
|
if (this.readLoopDead !== null) {
|
|
538
|
-
this.inFlightTurns.push({ interrupted: false });
|
|
711
|
+
this.inFlightTurns.push({ interrupted: false, platformTurnId });
|
|
539
712
|
this.finishTurn('failed', undefined, this.readLoopDead);
|
|
540
713
|
return;
|
|
541
714
|
}
|
|
@@ -543,13 +716,16 @@ export class AcpSession {
|
|
|
543
716
|
console.warn('[acp] dropping prompt for a session that is no longer running');
|
|
544
717
|
return;
|
|
545
718
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
719
|
+
if (this.exactExecutionUnconfirmed || (this.inFlightTurns.length > 0 && (platformTurnId !== undefined ||
|
|
720
|
+
this.inFlightTurns.some((turn) => turn.platformTurnId !== undefined)))) {
|
|
721
|
+
throw new Error('An exact platform turn is still running; overlapping prompts are unavailable');
|
|
722
|
+
}
|
|
723
|
+
this.permissionsInterrupted = false;
|
|
550
724
|
// One turn per prompt, always — see {@link inFlightTurns} for why an overlapping prompt must not
|
|
551
725
|
// fold into the turn already running.
|
|
552
|
-
this.inFlightTurns.push({ interrupted: false });
|
|
726
|
+
this.inFlightTurns.push({ interrupted: false, platformTurnId });
|
|
727
|
+
if (platformTurnId !== undefined)
|
|
728
|
+
this.exactExecutionUnconfirmed = true;
|
|
553
729
|
// The `.catch(() => {})` is not error handling — `ActiveSession.prompt()` already routes this
|
|
554
730
|
// same rejection onto the `nextUpdate()` queue internally (see the file doc comment), and
|
|
555
731
|
// `runReadLoop`'s catch branch is what turns it into a `TurnResult`. It exists only so the
|
|
@@ -590,25 +766,65 @@ export class AcpSession {
|
|
|
590
766
|
async setConfigOption(configId, value) {
|
|
591
767
|
return this.session.setConfigOption(configId, value);
|
|
592
768
|
}
|
|
769
|
+
/**
|
|
770
|
+
* Stop **one** background task on this session, leaving the running turn alone — the
|
|
771
|
+
* `_session/async_task/stop` extension; see {@link asyncTaskStopRequest}.
|
|
772
|
+
*
|
|
773
|
+
* No {@link inFlightTurns} bookkeeping, for the same reason {@link steer} does none: this does not
|
|
774
|
+
* create a turn and, unlike {@link interrupt}, it does not end one either. The adapter's own
|
|
775
|
+
* comment is explicit that the parent prompt turn keeps running.
|
|
776
|
+
*
|
|
777
|
+
* Throws on a closed session rather than warning-and-returning the way {@link interrupt} does: a
|
|
778
|
+
* dropped interrupt still leaves the turn marked interrupted locally, so the caller's picture
|
|
779
|
+
* stays true, whereas a dropped stop leaves a background task running with the server told
|
|
780
|
+
* nothing — the exact silence this whole path exists to remove.
|
|
781
|
+
*/
|
|
782
|
+
async stopAsyncTask(asyncTaskId) {
|
|
783
|
+
if (this.closed)
|
|
784
|
+
throw new Error('[acp] cannot stop a background task on a session that is no longer running');
|
|
785
|
+
return this.session.stopAsyncTask(asyncTaskId);
|
|
786
|
+
}
|
|
787
|
+
async providerTaskStop(taskId, requestId) {
|
|
788
|
+
if (this.closed)
|
|
789
|
+
throw new Error('[acp] cannot stop a provider task on a closed session');
|
|
790
|
+
return this.session.providerTaskStop?.(taskId, requestId) ?? Promise.reject(new Error('Provider task stop unavailable'));
|
|
791
|
+
}
|
|
593
792
|
/**
|
|
594
793
|
* Resolve one pending `session/request_permission` round trip. Unknown/already-answered
|
|
595
794
|
* `requestId`s are silently ignored, matching the deleted Claude Code bridge's `answer()` — a
|
|
596
795
|
* platform relaying a stale answer must not throw.
|
|
597
796
|
*/
|
|
598
797
|
answer(requestId, outcome) {
|
|
599
|
-
|
|
600
|
-
|
|
798
|
+
this.settleApproval(requestId, outcome, 'answered');
|
|
799
|
+
}
|
|
800
|
+
cancelPendingApproval(requestId, outcome) {
|
|
801
|
+
this.settleApproval(requestId, outcome, 'canceled');
|
|
802
|
+
}
|
|
803
|
+
settleApproval(requestId, outcome, reason) {
|
|
804
|
+
const entry = this.pendingApprovals.get(requestId);
|
|
805
|
+
if (!entry || entry.connectionGeneration !== this.connectionGeneration)
|
|
601
806
|
return;
|
|
602
807
|
this.pendingApprovals.delete(requestId);
|
|
603
|
-
|
|
808
|
+
if (this.settledApprovals.size >= 64)
|
|
809
|
+
this.callbacksComplete = false;
|
|
810
|
+
else
|
|
811
|
+
this.settledApprovals.set(requestId, reason);
|
|
812
|
+
this.publishCallbacks();
|
|
813
|
+
entry.resolve(outcome);
|
|
604
814
|
}
|
|
605
815
|
/**
|
|
606
816
|
* Register a pending `session/request_permission` round trip. `permission.ts` calls this from its
|
|
607
817
|
* `session/request_permission` handler and resolves the registered callback once the user (or
|
|
608
818
|
* `close()`) answers it; nothing in this file calls it.
|
|
609
819
|
*/
|
|
610
|
-
registerPendingApproval(requestId, resolve) {
|
|
611
|
-
|
|
820
|
+
registerPendingApproval(requestId, resolve, address) {
|
|
821
|
+
const origin = address?.originNativeSessionId ?? this.session.sessionId;
|
|
822
|
+
if (!origin.trim() || (origin !== this.session.sessionId && !this.permissionOrigins.has(origin))) {
|
|
823
|
+
console.error(JSON.stringify({ component: 'acp', error: 'invalid_permission_origin', origin, rootRuntimeId: this.rootRuntimeId }));
|
|
824
|
+
resolve(ABANDONED_ANSWER);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (this.closed || this.readLoopDead !== null || this.connectionAborted || this.permissionsInterrupted) {
|
|
612
828
|
// A permission request that reaches us after our own close() has nobody left to answer it —
|
|
613
829
|
// review finding (task-5, item 2): registering it anyway would park a card `close()`'s own
|
|
614
830
|
// drain already ran past, and the agent would wait forever for a response nothing will send.
|
|
@@ -617,7 +833,44 @@ export class AcpSession {
|
|
|
617
833
|
resolve(ABANDONED_ANSWER);
|
|
618
834
|
return;
|
|
619
835
|
}
|
|
620
|
-
|
|
836
|
+
if ([requestId, origin, address?.rpcRequestId ?? requestId].some((value) => !value.trim() || Buffer.byteLength(value, 'utf8') > 256 || /[\x00-\x1f\x7f]/.test(value)) ||
|
|
837
|
+
this.pendingApprovals.size >= 64 || this.pendingApprovals.has(requestId) || this.settledApprovals.has(requestId)) {
|
|
838
|
+
this.callbacksComplete = false;
|
|
839
|
+
this.publishCallbacks();
|
|
840
|
+
resolve(ABANDONED_ANSWER);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
this.pendingApprovals.set(requestId, {
|
|
844
|
+
connectionGeneration: this.connectionGeneration, rootRuntimeId: this.rootRuntimeId,
|
|
845
|
+
originNativeSessionId: origin, rpcRequestId: address?.rpcRequestId ?? requestId, resolve,
|
|
846
|
+
});
|
|
847
|
+
this.publishCallbacks();
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Resolve one pending `elicitation/create` round trip. Same contract as {@link answer}: an
|
|
851
|
+
* unknown or already-answered `requestId` is silently ignored, because a stale elicitation answer
|
|
852
|
+
* for a request that already settled must not throw.
|
|
853
|
+
*/
|
|
854
|
+
answerElicitation(requestId, response) {
|
|
855
|
+
const resolve = this.pendingElicitations.get(requestId);
|
|
856
|
+
if (!resolve)
|
|
857
|
+
return;
|
|
858
|
+
this.pendingElicitations.delete(requestId);
|
|
859
|
+
resolve(response);
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Register a pending `elicitation/create` round trip. Same contract as
|
|
863
|
+
* {@link registerPendingApproval}: once this session is closed, there is nobody left to answer,
|
|
864
|
+
* so the resolver settles immediately (`{action: 'cancel'}`, ACP's own real cancellation outcome
|
|
865
|
+
* — unlike a permission option id, there is no collision to guard against here) instead of ever
|
|
866
|
+
* being stored.
|
|
867
|
+
*/
|
|
868
|
+
registerPendingElicitation(requestId, resolve) {
|
|
869
|
+
if (this.closed) {
|
|
870
|
+
resolve(ABANDONED_ELICITATION_ANSWER);
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
this.pendingElicitations.set(requestId, resolve);
|
|
621
874
|
}
|
|
622
875
|
/**
|
|
623
876
|
* Emit a wire event that did not come from `session/update` — `permission.ts` calls this from its
|
|
@@ -629,12 +882,78 @@ export class AcpSession {
|
|
|
629
882
|
* comment) instead of racing a second, independent path a consumer would have to merge itself.
|
|
630
883
|
*/
|
|
631
884
|
emitEvent(event) {
|
|
885
|
+
this.nativeTerminalRepair?.observe(event);
|
|
632
886
|
if (this.closed) {
|
|
633
887
|
// Same reasoning as `registerPendingApproval` above: once closed, nothing is left reading the
|
|
634
888
|
// outbox on the other end that could still make sense of a fresh card.
|
|
635
889
|
return;
|
|
636
890
|
}
|
|
637
|
-
|
|
891
|
+
const payload = { ...event.payload };
|
|
892
|
+
if (event.eventName === 'permission_request') {
|
|
893
|
+
delete payload.callbackContext;
|
|
894
|
+
const callbackContext = this.callbackContextFor(event);
|
|
895
|
+
if (callbackContext)
|
|
896
|
+
payload.callbackContext = callbackContext;
|
|
897
|
+
const call = payload.toolCall;
|
|
898
|
+
const meta = call && typeof call === 'object' && !Array.isArray(call)
|
|
899
|
+
? call._meta : undefined;
|
|
900
|
+
const sdkCallback = meta && typeof meta === 'object' && !Array.isArray(meta) &&
|
|
901
|
+
Object.prototype.hasOwnProperty.call(meta, 'qoderSdk');
|
|
902
|
+
if (sdkCallback && !callbackContext) {
|
|
903
|
+
if (typeof payload.requestId === 'string')
|
|
904
|
+
this.cancelPendingApproval(payload.requestId, ABANDONED_ANSWER);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
const normalized = { ...event, payload,
|
|
909
|
+
...(event.acpSessionId === this.session.sessionId ? { acpSessionId: undefined } : {}) };
|
|
910
|
+
for (const mapped of this.subworkBoundary?.map(normalized) ?? [normalized]) {
|
|
911
|
+
this.observePermissionOrigin(mapped);
|
|
912
|
+
this.enqueue({ kind: 'event', event: mapped });
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
callbackContextFor(event) {
|
|
916
|
+
if (event.eventName !== 'permission_request' || event.acpSessionId !== this.session.sessionId)
|
|
917
|
+
return;
|
|
918
|
+
const diagnostics = this.connection.runtimeDiagnostics;
|
|
919
|
+
const profile = diagnostics.profile;
|
|
920
|
+
const callbacks = diagnostics.callbacks;
|
|
921
|
+
const requestId = event.payload.requestId;
|
|
922
|
+
const toolCall = event.payload.toolCall;
|
|
923
|
+
if (!diagnostics.available || profile?.mode !== 'sdk-adapter' || profile.initialized !== true ||
|
|
924
|
+
profile.rootConfirmed !== true || profile.rootSessionId !== this.session.sessionId ||
|
|
925
|
+
!profile.runtimeGeneration || callbacks?.complete !== true ||
|
|
926
|
+
callbacks.rootSessionId !== this.session.sessionId ||
|
|
927
|
+
typeof requestId !== 'string' || !this.pendingApprovals.has(requestId) ||
|
|
928
|
+
!toolCall || typeof toolCall !== 'object' || Array.isArray(toolCall))
|
|
929
|
+
return;
|
|
930
|
+
const call = toolCall;
|
|
931
|
+
const sdkMeta = call._meta && typeof call._meta === 'object' && !Array.isArray(call._meta)
|
|
932
|
+
? call._meta.qoderSdk : undefined;
|
|
933
|
+
if (!sdkMeta || typeof sdkMeta !== 'object' || Array.isArray(sdkMeta))
|
|
934
|
+
return;
|
|
935
|
+
const sdk = sdkMeta;
|
|
936
|
+
const origin = sdk.origin;
|
|
937
|
+
if (sdk.version !== 1 || sdk.blockingScope !== 'root' || sdk.callbackOnly !== true ||
|
|
938
|
+
typeof sdk.requestId !== 'string' || !/^[0-9a-f-]{36}$/i.test(sdk.requestId) ||
|
|
939
|
+
!origin || typeof origin !== 'object' || Array.isArray(origin))
|
|
940
|
+
return;
|
|
941
|
+
const source = origin;
|
|
942
|
+
const toolUseId = call.toolCallId;
|
|
943
|
+
const agentId = source.agentId;
|
|
944
|
+
if (source.rootSessionId !== this.session.sessionId || source.runtimeGeneration !== profile.runtimeGeneration ||
|
|
945
|
+
typeof toolUseId !== 'string' || source.toolUseId !== toolUseId ||
|
|
946
|
+
!toolUseId || Buffer.byteLength(toolUseId, 'utf8') > 256 ||
|
|
947
|
+
(agentId !== undefined && (typeof agentId !== 'string' || !agentId || Buffer.byteLength(agentId, 'utf8') > 256)))
|
|
948
|
+
return;
|
|
949
|
+
const pending = callbacks.pending.find((entry) => entry.requestId === requestId &&
|
|
950
|
+
entry.originNativeSessionId === this.session.sessionId);
|
|
951
|
+
if (!pending || callbacks.pending.length > 64 || this.pendingApprovals.get(requestId)?.rpcRequestId !== pending.rpcRequestId)
|
|
952
|
+
return;
|
|
953
|
+
return { rootSessionId: this.session.sessionId, hostGeneration: diagnostics.generation,
|
|
954
|
+
connectionGeneration: callbacks.connectionGeneration, requestId,
|
|
955
|
+
runtimeGeneration: profile.runtimeGeneration, toolUseId,
|
|
956
|
+
...(agentId ? { agentId } : {}), independent: false };
|
|
638
957
|
}
|
|
639
958
|
/**
|
|
640
959
|
* Applies one `session/update` the transport diverted before the SDK could destroy it.
|
|
@@ -654,10 +973,10 @@ export class AcpSession {
|
|
|
654
973
|
* on the connection, not just this one.
|
|
655
974
|
*/
|
|
656
975
|
applyDivertedUpdate(diverted) {
|
|
657
|
-
const isChild = diverted.
|
|
976
|
+
const isChild = diverted.sessionId !== this.session.sessionId;
|
|
658
977
|
let events;
|
|
659
978
|
try {
|
|
660
|
-
events = mapSessionUpdate(diverted.update, this.
|
|
979
|
+
events = mapSessionUpdate(diverted.update, this.mapperFor(diverted.sessionId));
|
|
661
980
|
}
|
|
662
981
|
catch (err) {
|
|
663
982
|
// An unmapped variant is a contract gap worth naming, not worth killing a session over. The
|
|
@@ -688,6 +1007,9 @@ export class AcpSession {
|
|
|
688
1007
|
// after the user pressed Stop.
|
|
689
1008
|
for (const turn of this.inFlightTurns)
|
|
690
1009
|
turn.interrupted = true;
|
|
1010
|
+
this.permissionsInterrupted = true;
|
|
1011
|
+
this.drainPendingApprovals();
|
|
1012
|
+
this.drainPendingElicitations();
|
|
691
1013
|
try {
|
|
692
1014
|
await this.session.cancel();
|
|
693
1015
|
}
|
|
@@ -714,8 +1036,9 @@ export class AcpSession {
|
|
|
714
1036
|
async close() {
|
|
715
1037
|
if (!this.closed) {
|
|
716
1038
|
this.closed = true;
|
|
717
|
-
this.
|
|
1039
|
+
this.nativeTerminalRepair?.close();
|
|
718
1040
|
this.drainPendingApprovals();
|
|
1041
|
+
this.drainPendingElicitations();
|
|
719
1042
|
try {
|
|
720
1043
|
await this.session.cancel();
|
|
721
1044
|
}
|
|
@@ -725,6 +1048,10 @@ export class AcpSession {
|
|
|
725
1048
|
this.session.dispose();
|
|
726
1049
|
}
|
|
727
1050
|
await this.readLoopDone;
|
|
1051
|
+
this.scopes.clear();
|
|
1052
|
+
this.permissionOrigins.clear();
|
|
1053
|
+
this.permissionParents.clear();
|
|
1054
|
+
this.unresolvedPermissionOrigins.clear();
|
|
728
1055
|
}
|
|
729
1056
|
/**
|
|
730
1057
|
* Resolve every still-pending permission request with {@link ABANDONED_ANSWER} and clear the map.
|
|
@@ -735,9 +1062,18 @@ export class AcpSession {
|
|
|
735
1062
|
* connection ever calls `close()`.
|
|
736
1063
|
*/
|
|
737
1064
|
drainPendingApprovals() {
|
|
738
|
-
|
|
739
|
-
resolve(ABANDONED_ANSWER);
|
|
1065
|
+
const pending = [...this.pendingApprovals.values()];
|
|
740
1066
|
this.pendingApprovals.clear();
|
|
1067
|
+
this.callbacksComplete = false;
|
|
1068
|
+
this.publishCallbacks();
|
|
1069
|
+
for (const entry of pending)
|
|
1070
|
+
entry.resolve(ABANDONED_ANSWER);
|
|
1071
|
+
}
|
|
1072
|
+
/** {@link pendingElicitations}' half of {@link drainPendingApprovals} — same callers, same reason. */
|
|
1073
|
+
drainPendingElicitations() {
|
|
1074
|
+
for (const resolve of this.pendingElicitations.values())
|
|
1075
|
+
resolve(ABANDONED_ELICITATION_ANSWER);
|
|
1076
|
+
this.pendingElicitations.clear();
|
|
741
1077
|
}
|
|
742
1078
|
onEvent(cb) {
|
|
743
1079
|
this.eventSink = cb;
|
|
@@ -747,6 +1083,21 @@ export class AcpSession {
|
|
|
747
1083
|
this.turnSink = cb;
|
|
748
1084
|
this.drain();
|
|
749
1085
|
}
|
|
1086
|
+
/** Registers this session's activity-state consumer. See {@link activitySink}. */
|
|
1087
|
+
onActivity(cb) {
|
|
1088
|
+
this.activitySink = cb;
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Feeds one agent-reported activity-state push into this session's sink — the join point for both
|
|
1092
|
+
* collection paths: codex-acp's `session_info_update._meta.codex.threadStatus`, via
|
|
1093
|
+
* {@link scopes}' injected callback, and claude-agent-acp's `_claude/sdkMessage`
|
|
1094
|
+
* extNotification, wired directly onto the connection in `AcpSession.open` (see there for why
|
|
1095
|
+
* that registration cannot live inside `mapSessionUpdate`'s ordinary flow: the notification is not
|
|
1096
|
+
* a `session/update` at all).
|
|
1097
|
+
*/
|
|
1098
|
+
reportActivity(raw) {
|
|
1099
|
+
this.activitySink?.(raw);
|
|
1100
|
+
}
|
|
750
1101
|
// `backfill?()` is deliberately not implemented here: replaying an existing native session's
|
|
751
1102
|
// history is `session/load`, which is `adoption.ts`'s job ("领养"), not this file's. Not
|
|
752
1103
|
// `session/resume(replayFrom)`, as the plan text and an earlier version of this comment said —
|
|
@@ -777,6 +1128,7 @@ export class AcpSession {
|
|
|
777
1128
|
// the manager's onTurnDone would never fire for it. All of them, not just the oldest: the
|
|
778
1129
|
// queue is permanently failed, so no further stop message is ever coming for the rest.
|
|
779
1130
|
this.readLoopDead = errorMessage(err);
|
|
1131
|
+
this.nativeTerminalRepair?.close();
|
|
780
1132
|
this.finishAllTurns('interrupted', errorMessage(err));
|
|
781
1133
|
return;
|
|
782
1134
|
}
|
|
@@ -792,7 +1144,8 @@ export class AcpSession {
|
|
|
792
1144
|
// branch below has always depended on that).
|
|
793
1145
|
// `err` itself, not its message: this is the only frame in which the agent's JSON-RPC code
|
|
794
1146
|
// still exists. `errorMessage(err)` on the same line is what destroyed it before.
|
|
795
|
-
|
|
1147
|
+
const peerErrorCode = jsonRpcErrorCode(err);
|
|
1148
|
+
this.finishTurn('failed', undefined, errorMessage(err), !this.connectionAborted, peerErrorCode, !this.connectionAborted && peerErrorCode !== undefined);
|
|
796
1149
|
if (this.connectionAborted) {
|
|
797
1150
|
// The connection died out from under us without our own close() ever running. Same
|
|
798
1151
|
// reasoning as the `this.closed` branch above: the queue is now permanently failed, so no
|
|
@@ -803,16 +1156,18 @@ export class AcpSession {
|
|
|
803
1156
|
// which is what {@link readLoopDead} gives it — finishing the turns open right now says
|
|
804
1157
|
// nothing about the retry the user sends ten seconds from now.
|
|
805
1158
|
this.readLoopDead = errorMessage(err);
|
|
1159
|
+
this.nativeTerminalRepair?.close();
|
|
806
1160
|
this.finishAllTurns('failed', errorMessage(err));
|
|
807
1161
|
// Nothing will drain pendingApprovals otherwise (review finding, task-5 item 5).
|
|
808
1162
|
this.drainPendingApprovals();
|
|
1163
|
+
this.drainPendingElicitations();
|
|
809
1164
|
return; // the connection itself is gone; further reads would just spin
|
|
810
1165
|
}
|
|
811
1166
|
continue;
|
|
812
1167
|
}
|
|
813
1168
|
if (message.kind === 'session_update') {
|
|
814
1169
|
try {
|
|
815
|
-
for (const event of mapSessionUpdate(message.update, this.
|
|
1170
|
+
for (const event of mapSessionUpdate(message.update, this.mapperFor(this.session.sessionId)))
|
|
816
1171
|
this.routeMappedEvent(event);
|
|
817
1172
|
}
|
|
818
1173
|
catch (err) {
|
|
@@ -848,6 +1203,7 @@ export class AcpSession {
|
|
|
848
1203
|
// turn with the shared cause, drain the approvals, stop reading.
|
|
849
1204
|
console.error(`[acp] unmappable session/update; ending this session's turns: ${errorMessage(err)}`);
|
|
850
1205
|
this.readLoopDead = errorMessage(err);
|
|
1206
|
+
this.nativeTerminalRepair?.close();
|
|
851
1207
|
this.finishAllTurns('failed', errorMessage(err));
|
|
852
1208
|
this.drainPendingApprovals();
|
|
853
1209
|
return;
|
|
@@ -858,10 +1214,88 @@ export class AcpSession {
|
|
|
858
1214
|
// own interrupt() was never called (a defensive backstop, not the primary path — see
|
|
859
1215
|
// `finishTurn`'s interrupted-flag handling for that).
|
|
860
1216
|
const status = message.stopReason === 'cancelled' ? 'interrupted' : 'completed';
|
|
861
|
-
this.finishTurn(status, message.stopReason);
|
|
1217
|
+
this.finishTurn(status, message.stopReason, undefined, false, undefined, true);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
/** Host-owned load history restores addresses, never actionable interactions or live events. */
|
|
1222
|
+
observeReplayEvent(event) {
|
|
1223
|
+
this.nativeTerminalRepair?.observe(event);
|
|
1224
|
+
this.observePermissionOrigin(event);
|
|
1225
|
+
this.observeBackgroundWriter(event);
|
|
1226
|
+
}
|
|
1227
|
+
observeBackgroundWriter(event) {
|
|
1228
|
+
const input = event.payload.rawInput;
|
|
1229
|
+
if (input && typeof input === 'object' && !Array.isArray(input) &&
|
|
1230
|
+
input.run_in_background === true) {
|
|
1231
|
+
// An explicitly requested detached writer has no authenticated termination signal here.
|
|
1232
|
+
this.backgroundWriterUnconfirmed = true;
|
|
1233
|
+
}
|
|
1234
|
+
const output = event.payload.rawOutput;
|
|
1235
|
+
if (output && typeof output === 'object' && !Array.isArray(output)) {
|
|
1236
|
+
const evidence = output;
|
|
1237
|
+
if (evidence.backgrounded === true && (typeof evidence.taskId === 'string' || typeof evidence.pid === 'number')) {
|
|
1238
|
+
this.backgroundWriterUnconfirmed = true;
|
|
862
1239
|
}
|
|
863
1240
|
}
|
|
864
1241
|
}
|
|
1242
|
+
/** Connection-local relations grant approval eligibility only after a complete path to our root. */
|
|
1243
|
+
observePermissionOrigin(event) {
|
|
1244
|
+
if (event.eventName !== 'subagent_spawned')
|
|
1245
|
+
return;
|
|
1246
|
+
const parent = event.acpSessionId ?? this.session.sessionId;
|
|
1247
|
+
const child = event.payload.subagentSessionId;
|
|
1248
|
+
const reject = (reason) => console.warn(JSON.stringify({
|
|
1249
|
+
component: 'acp', error: 'invalid_permission_relation', reason,
|
|
1250
|
+
parent, child, rootRuntimeId: this.rootRuntimeId,
|
|
1251
|
+
}));
|
|
1252
|
+
if (typeof parent !== 'string' || !parent.trim() || typeof child !== 'string' || !child.trim()) {
|
|
1253
|
+
reject('invalid_native_id');
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
if (child === parent || child === this.session.sessionId) {
|
|
1257
|
+
reject(child === parent ? 'self_parent' : 'root_reparent');
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
const existing = this.permissionParents.get(child);
|
|
1261
|
+
if (existing !== undefined) {
|
|
1262
|
+
if (existing !== parent)
|
|
1263
|
+
reject('conflicting_parent');
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
// Include unresolved edges: otherwise two untrusted parents could first form a cycle and
|
|
1267
|
+
// become eligible later when one is named by the root. Rejected edges never enter the graph.
|
|
1268
|
+
for (let ancestor = parent; ancestor !== undefined; ancestor = this.permissionParents.get(ancestor)) {
|
|
1269
|
+
if (ancestor === child) {
|
|
1270
|
+
reject('cycle');
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
const trusted = parent === this.session.sessionId || this.permissionOrigins.has(parent);
|
|
1275
|
+
if (!trusted && this.unresolvedPermissionOrigins.size >= 1024) {
|
|
1276
|
+
reject('unresolved_capacity');
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
this.permissionParents.set(child, parent);
|
|
1280
|
+
if (!trusted) {
|
|
1281
|
+
this.unresolvedPermissionOrigins.add(child);
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
this.permissionOrigins.add(child);
|
|
1285
|
+
// A bounded fixed-point walk handles arbitrarily reversed arrival order, including replay.
|
|
1286
|
+
// Only relation events run it; ordinary event/approval traffic never scans these edges.
|
|
1287
|
+
let promoted;
|
|
1288
|
+
do {
|
|
1289
|
+
promoted = false;
|
|
1290
|
+
for (const candidate of this.unresolvedPermissionOrigins) {
|
|
1291
|
+
if (this.permissionOrigins.has(this.permissionParents.get(candidate))) {
|
|
1292
|
+
this.unresolvedPermissionOrigins.delete(candidate);
|
|
1293
|
+
this.permissionOrigins.add(candidate);
|
|
1294
|
+
promoted = true;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
} while (promoted);
|
|
1298
|
+
}
|
|
865
1299
|
/**
|
|
866
1300
|
* Routes one mapped `WireEvent` to the outbox, tracking `agent_message_chunk`/`agent_thought_chunk`
|
|
867
1301
|
* on the way through so `agent_message` can be synthesized at the right moments — the one per-turn
|
|
@@ -871,17 +1305,18 @@ export class AcpSession {
|
|
|
871
1305
|
* event, and the server folds it.
|
|
872
1306
|
*/
|
|
873
1307
|
routeMappedEvent(event) {
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
1308
|
+
if (this.foregroundOutput && !this.foregroundOutput.accept(event))
|
|
1309
|
+
return;
|
|
1310
|
+
this.nativeTerminalRepair?.observe(event);
|
|
1311
|
+
for (const mapped of this.subworkBoundary?.map(event) ?? [event]) {
|
|
1312
|
+
this.observePermissionOrigin(mapped);
|
|
1313
|
+
this.observeBackgroundWriter(mapped);
|
|
1314
|
+
if ((!this.subworkBoundary || !mapped.acpSessionId || mapped.acpSessionId === this.session.sessionId) &&
|
|
1315
|
+
(mapped.eventName === 'agent_message_chunk' || mapped.eventName === 'agent_thought_chunk')) {
|
|
1316
|
+
this.trackMessageBoundary(mapped);
|
|
1317
|
+
}
|
|
1318
|
+
this.enqueue({ kind: 'event', event: mapped });
|
|
877
1319
|
}
|
|
878
|
-
this.enqueue({ kind: 'event', event });
|
|
879
|
-
// `finishTurn` has already consumed the normal boundary. If an adapter emits content after it,
|
|
880
|
-
// give the rest of that burst a short chance to arrive, then synthesize the missing boundary
|
|
881
|
-
// ourselves. Without this, CoworkEngine correctly buffers the delta but can only flush it when
|
|
882
|
-
// an unrelated later user message happens to append an item.
|
|
883
|
-
if (isAssistantChunk && !this.turnRunning)
|
|
884
|
-
this.scheduleLateMessageCompletion();
|
|
885
1320
|
}
|
|
886
1321
|
/**
|
|
887
1322
|
* Notes that assistant content/thought is streaming, and — if a *different* `messageId` from the
|
|
@@ -912,26 +1347,6 @@ export class AcpSession {
|
|
|
912
1347
|
this.sawMessageContent = false;
|
|
913
1348
|
this.currentMessageId = null;
|
|
914
1349
|
}
|
|
915
|
-
/** Debounce a late chunk burst into one synthetic `agent_message` marker. */
|
|
916
|
-
scheduleLateMessageCompletion() {
|
|
917
|
-
this.cancelLateMessageCompletion();
|
|
918
|
-
this.lateMessageFlushTimer = setTimeout(() => {
|
|
919
|
-
this.lateMessageFlushTimer = null;
|
|
920
|
-
if (!this.closed && !this.turnRunning)
|
|
921
|
-
this.flushMessageCompleted();
|
|
922
|
-
}, LATE_MESSAGE_SETTLE_MS);
|
|
923
|
-
}
|
|
924
|
-
/** Flush a late tail before its contents can be mistaken for a later prompt's answer. */
|
|
925
|
-
flushLateMessageCompletion() {
|
|
926
|
-
this.cancelLateMessageCompletion();
|
|
927
|
-
this.flushMessageCompleted();
|
|
928
|
-
}
|
|
929
|
-
cancelLateMessageCompletion() {
|
|
930
|
-
if (this.lateMessageFlushTimer === null)
|
|
931
|
-
return;
|
|
932
|
-
clearTimeout(this.lateMessageFlushTimer);
|
|
933
|
-
this.lateMessageFlushTimer = null;
|
|
934
|
-
}
|
|
935
1350
|
/**
|
|
936
1351
|
* Terminate every turn still in flight — for the one path (a permanently failed read queue) where
|
|
937
1352
|
* no further stop message can arrive for any of them.
|
|
@@ -956,12 +1371,14 @@ export class AcpSession {
|
|
|
956
1371
|
* `agentRefused` — the single `if` below is what keeps the code and the attribution from ever
|
|
957
1372
|
* disagreeing, rather than two conditions someone can later edit apart.
|
|
958
1373
|
*/
|
|
959
|
-
finishTurn(status, stopReason, error, agentRefused = false, errorCode) {
|
|
1374
|
+
finishTurn(status, stopReason, error, agentRefused = false, errorCode, executionEnded = false) {
|
|
960
1375
|
// The oldest unfinished turn, popped: one stop message (or one prompt rejection) ends exactly
|
|
961
1376
|
// one turn, so a second overlapping turn keeps its own result instead of being swallowed.
|
|
962
1377
|
const turn = this.inFlightTurns.shift();
|
|
963
1378
|
if (!turn)
|
|
964
1379
|
return;
|
|
1380
|
+
if (this.inFlightTurns.length === 0)
|
|
1381
|
+
this.foregroundOutput?.endTurn();
|
|
965
1382
|
// Must land before the TurnResult below. Unconditionally safe to call — a no-op when nothing
|
|
966
1383
|
// was accumulated — so every path that can end a turn (a normal stop, an interrupt, a failed
|
|
967
1384
|
// request, or close() mid-turn) goes through exactly this one place rather than each needing to
|
|
@@ -971,7 +1388,18 @@ export class AcpSession {
|
|
|
971
1388
|
// A turn this session itself interrupted always reports 'interrupted', regardless of what the
|
|
972
1389
|
// agent's own response claims — mirrors the deleted Claude Code bridge's finishTurn.
|
|
973
1390
|
const resolved = turn.interrupted ? 'interrupted' : status;
|
|
1391
|
+
if (resolved === 'interrupted') {
|
|
1392
|
+
this.permissionsInterrupted = true;
|
|
1393
|
+
this.drainPendingApprovals();
|
|
1394
|
+
this.drainPendingElicitations();
|
|
1395
|
+
}
|
|
974
1396
|
const result = { status: resolved };
|
|
1397
|
+
if (turn.platformTurnId !== undefined)
|
|
1398
|
+
result.platformTurnId = turn.platformTurnId;
|
|
1399
|
+
if (executionEnded && this.inFlightTurns.length === 0 && !this.backgroundWriterUnconfirmed) {
|
|
1400
|
+
result.executionEnded = true;
|
|
1401
|
+
this.exactExecutionUnconfirmed = false;
|
|
1402
|
+
}
|
|
975
1403
|
if (resolved === 'completed' && stopReason && stopReason !== 'cancelled') {
|
|
976
1404
|
result.stopReason = mapStopReason(stopReason);
|
|
977
1405
|
}
|