@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
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { AGENT_WIRE_AUTH_URL_VERSION, AGENT_WIRE_HEARTBEAT_INVENTORY_VERSION, AGENT_WIRE_SCOPED_ORIGIN_VERSION, AGENT_WIRE_ORPHAN_OFFER_VERSION, AGENT_WIRE_SESSION_AUTH_METHODS_VERSION, AGENT_WIRE_SESSION_LOST_VERSION, AGENT_WIRE_SESSION_STATE_VERSION, AGENT_WIRE_VERSION, AGENT_WIRE_DELEGATION_MCP_VERSION, AGENT_WIRE_DELEGATION_RENEW_VERSION, AGENT_WIRE_PROVIDER_TASK_VERSION, AGENT_WIRE_RUNTIME_PROFILE_VERSION, outboundAgentFrameRejection, outboundFailureReply, } from './protocol/agent.js';
|
|
4
5
|
import { DEFAULT_JOURNAL_MAX_BYTES, SessionJournal } from './journal.js';
|
|
5
6
|
import { AGENT_PROBE_TIMEOUT_MS, AgentRefusedSessionError } from './acp/acpAgents.js';
|
|
6
7
|
import { validateWireEvent } from './acp/validate.js';
|
|
8
|
+
import { reportsActivity, supportsAsyncTaskStop } from './acp/activityReporting.js';
|
|
9
|
+
import { activityIsBusy } from './acp/activityBusy.js';
|
|
10
|
+
import { buildSessionMcpServers } from './acp/delegationMcp.js';
|
|
11
|
+
function delegationLeaseId(lease) {
|
|
12
|
+
return createHash('sha256').update(JSON.stringify([
|
|
13
|
+
lease.url, lease.headers, lease.bindingId,
|
|
14
|
+
])).digest('hex');
|
|
15
|
+
}
|
|
7
16
|
/**
|
|
8
17
|
* 从线上收来的验证预算的绝对上限。
|
|
9
18
|
*
|
|
@@ -71,6 +80,20 @@ function promptBlocks(frame) {
|
|
|
71
80
|
: [];
|
|
72
81
|
return [...text, ...images];
|
|
73
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Narrows `agent_elicitation_answer.action` to the three literals this daemon itself ever sends.
|
|
85
|
+
*
|
|
86
|
+
* `AGENT_FRAME_SHAPES` only validates `action` as *a* string — ACP's own `CreateElicitationResponse`
|
|
87
|
+
* has a fourth, open `action: string` arm for an agent's own custom/future actions, so the wire
|
|
88
|
+
* layer cannot reject an arbitrary string as malformed. But this daemon is the only sender of this
|
|
89
|
+
* frame, and the server only ever offers the user accept/decline/cancel, so anything else reaching
|
|
90
|
+
* `handleFrame` is a platform bug, not a legitimate custom action — and a bare `as
|
|
91
|
+
* CreateElicitationResponse['action']` cast at the call site would launder it through unchecked,
|
|
92
|
+
* reaching the agent as a value ACP itself still accepts as protocol-valid.
|
|
93
|
+
*/
|
|
94
|
+
function isKnownElicitationAction(action) {
|
|
95
|
+
return action === 'accept' || action === 'decline' || action === 'cancel';
|
|
96
|
+
}
|
|
74
97
|
/**
|
|
75
98
|
* The server's applied-through watermark on an open frame, sanitized. Absent means "nothing yet".
|
|
76
99
|
*
|
|
@@ -94,6 +117,15 @@ function errorMessage(err) {
|
|
|
94
117
|
* arrive as one giant frame; the last batch of a stream is whatever remains below this size.
|
|
95
118
|
*/
|
|
96
119
|
export const BACKFILL_BATCH_SIZE = 64;
|
|
120
|
+
/**
|
|
121
|
+
* 一条会话自报 `running` 之后多久没有任何新推送,就值得在日志里点名。
|
|
122
|
+
*
|
|
123
|
+
* **这不是超时,不会清任何状态**——裁决 A 说 vendor 报 running 就是 running,发明一个
|
|
124
|
+
* 到点就翻成 idle 的定时器正是它禁止的猜测。这条只是把「我们完全信任了一个已经沉默 30 分钟
|
|
125
|
+
* 的信源」这件事摆到排查者面前。取 30 分钟是因为比任何正常回合都长,又短到一次排查会话里
|
|
126
|
+
* 看得见。
|
|
127
|
+
*/
|
|
128
|
+
export const STALE_ACTIVITY_WARN_MS = 30 * 60 * 1000;
|
|
97
129
|
/**
|
|
98
130
|
* Defensively turns the wire query of `agent_list_native` / `agent_verify` / `agent_authenticate`
|
|
99
131
|
* — one shape, because all three ask the same thing of an agent that has no session yet: where to
|
|
@@ -265,7 +297,10 @@ export class AgentSessionManager {
|
|
|
265
297
|
* honest failure it produces today.
|
|
266
298
|
*/
|
|
267
299
|
async resumeOfferSessions() {
|
|
268
|
-
|
|
300
|
+
// `true`: this is the once-per-connection offer, the one place `activityRaw` is allowed to
|
|
301
|
+
// ride — see {@link liveInventory}'s parameter doc for why {@link heartbeatInventory} must pass
|
|
302
|
+
// `false` at its own call site instead.
|
|
303
|
+
const live = this.liveInventory(true);
|
|
269
304
|
if (this.serverAgentWire < AGENT_WIRE_ORPHAN_OFFER_VERSION)
|
|
270
305
|
return live;
|
|
271
306
|
// Every ref this daemon still has a handle on, not merely the ones it would report as live:
|
|
@@ -327,8 +362,17 @@ export class AgentSessionManager {
|
|
|
327
362
|
* here, and lands in exactly the same server-side branch as a journal left by a previous daemon
|
|
328
363
|
* life -- which is the point: "the agent is gone" is one fact with one handling, not a special
|
|
329
364
|
* case per way of getting there.
|
|
365
|
+
*
|
|
366
|
+
* @param includeActivity Whether to carry each session's {@link LiveSession.lastActivityRaw}
|
|
367
|
+
* along as `activityRaw`. **Must be `false` from {@link heartbeatInventory} and `true` from
|
|
368
|
+
* {@link resumeOfferSessions}, never the other way round** — `activityRaw` is an unbounded vendor
|
|
369
|
+
* blob, and `AGENT_WIRE_HEARTBEAT_INVENTORY_VERSION`'s own doc comment already ruled that kind of
|
|
370
|
+
* cost off the heartbeat ("ten seconds of overhead forever is worth skipping"). Threading this as
|
|
371
|
+
* a required parameter at the one shared call site — rather than, say, a field this method reads
|
|
372
|
+
* off `this` — is what makes "never on a heartbeat" a fact about the call site instead of a fact
|
|
373
|
+
* someone has to remember to keep true.
|
|
330
374
|
*/
|
|
331
|
-
liveInventory() {
|
|
375
|
+
liveInventory(includeActivity) {
|
|
332
376
|
return [...this.sessions.values()]
|
|
333
377
|
.filter((session) => !session.closed)
|
|
334
378
|
.map((session) => {
|
|
@@ -338,6 +382,9 @@ export class AgentSessionManager {
|
|
|
338
382
|
journalMaxSeq: session.journal.lastSeq,
|
|
339
383
|
agentAlive: liveness.agentAlive,
|
|
340
384
|
turnRunning: liveness.turnRunning,
|
|
385
|
+
...(includeActivity && session.lastActivityRaw !== undefined
|
|
386
|
+
? { activityRaw: session.lastActivityRaw }
|
|
387
|
+
: {}),
|
|
341
388
|
};
|
|
342
389
|
});
|
|
343
390
|
}
|
|
@@ -363,7 +410,8 @@ export class AgentSessionManager {
|
|
|
363
410
|
heartbeatInventory() {
|
|
364
411
|
if (this.serverAgentWire < AGENT_WIRE_HEARTBEAT_INVENTORY_VERSION)
|
|
365
412
|
return undefined;
|
|
366
|
-
|
|
413
|
+
// `false`: activityRaw must stay absent on every heartbeat — see `liveInventory`'s own doc.
|
|
414
|
+
const sessions = this.liveInventory(false);
|
|
367
415
|
return sessions.length > 0 ? sessions : undefined;
|
|
368
416
|
}
|
|
369
417
|
/**
|
|
@@ -463,6 +511,30 @@ export class AgentSessionManager {
|
|
|
463
511
|
}
|
|
464
512
|
async handleFrame(frame) {
|
|
465
513
|
switch (frame.t) {
|
|
514
|
+
case 'agent_delegation_renew': {
|
|
515
|
+
if (this.serverAgentWire < AGENT_WIRE_DELEGATION_RENEW_VERSION)
|
|
516
|
+
return;
|
|
517
|
+
const live = this.liveSession(frame.sessionRef);
|
|
518
|
+
const now = Date.now();
|
|
519
|
+
if (!live || live.closed || !live.session.liveness.agentAlive || live.session.liveness.turnRunning ||
|
|
520
|
+
activityIsBusy(live.lastActivityRaw) || live.delegationRefreshPending ||
|
|
521
|
+
live.nativeSessionId !== frame.nativeSessionId || live.delegationInstanceId !== frame.instanceId ||
|
|
522
|
+
live.delegationBindingId !== frame.bindingId || !frame.bindingId ||
|
|
523
|
+
!Number.isSafeInteger(frame.renewalRevision) || frame.renewalRevision < 1 ||
|
|
524
|
+
!Number.isSafeInteger(frame.expiresAtEpochMs) || frame.expiresAtEpochMs <= now ||
|
|
525
|
+
frame.expiresAtEpochMs > now + 330_000 || frame.renewalRevision < (live.delegationRenewalRevision ?? 0) ||
|
|
526
|
+
frame.expiresAtEpochMs < (live.delegationExpiresAtEpochMs ?? 0) ||
|
|
527
|
+
(frame.renewalRevision === live.delegationRenewalRevision && frame.expiresAtEpochMs !== live.delegationExpiresAtEpochMs)) {
|
|
528
|
+
this.emit({ t: 'agent_delegation_renewed', id: frame.id, ok: false, error: 'The original managed instance cannot renew' });
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
live.delegationExpiresAtEpochMs = frame.expiresAtEpochMs;
|
|
532
|
+
live.delegationRenewalRevision = frame.renewalRevision;
|
|
533
|
+
this.emit({ t: 'agent_delegation_renewed', id: frame.id, ok: true,
|
|
534
|
+
nativeSessionId: live.nativeSessionId, instanceId: live.delegationInstanceId, bindingId: live.delegationBindingId,
|
|
535
|
+
renewalRevision: live.delegationRenewalRevision, expiresAtEpochMs: live.delegationExpiresAtEpochMs });
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
466
538
|
case 'agent_open': {
|
|
467
539
|
const admission = this.reserveOpen(frame.id, frame.sessionRef);
|
|
468
540
|
if (admission === 'rejected')
|
|
@@ -480,7 +552,7 @@ export class AgentSessionManager {
|
|
|
480
552
|
const live = this.routableSession(frame.sessionRef, 'prompt');
|
|
481
553
|
if (!live)
|
|
482
554
|
return;
|
|
483
|
-
if (this.tasks && !this.tasks.
|
|
555
|
+
if (this.tasks && !this.tasks.tryAdmitTurn(frame.sessionRef)) {
|
|
484
556
|
// No `authMethods` here, deliberately, even though this is a failed turn on a session
|
|
485
557
|
// whose agent we have certainly connected to: the prompt never reached that agent, so
|
|
486
558
|
// nothing about credentials is being reported. This frame is built here rather than in
|
|
@@ -489,10 +561,19 @@ export class AgentSessionManager {
|
|
|
489
561
|
this.record(live, (seq) => ({
|
|
490
562
|
t: 'agent_turn_done', sessionRef: frame.sessionRef, seq, status: 'failed',
|
|
491
563
|
error: 'Workstation is not accepting new work',
|
|
564
|
+
platformTurnId: frame.platformTurnId,
|
|
492
565
|
}));
|
|
493
566
|
return;
|
|
494
567
|
}
|
|
495
|
-
|
|
568
|
+
try {
|
|
569
|
+
live.session.prompt(promptBlocks(frame), frame.platformTurnId);
|
|
570
|
+
}
|
|
571
|
+
catch (error) {
|
|
572
|
+
this.record(live, (seq) => ({
|
|
573
|
+
t: 'agent_turn_done', sessionRef: frame.sessionRef, seq, status: 'failed',
|
|
574
|
+
error: error instanceof Error ? error.message : String(error), platformTurnId: frame.platformTurnId,
|
|
575
|
+
}));
|
|
576
|
+
}
|
|
496
577
|
return;
|
|
497
578
|
}
|
|
498
579
|
case 'agent_answer': {
|
|
@@ -500,6 +581,28 @@ export class AgentSessionManager {
|
|
|
500
581
|
live?.session.answer(frame.requestId, frame.outcome);
|
|
501
582
|
return;
|
|
502
583
|
}
|
|
584
|
+
case 'agent_elicitation_answer': {
|
|
585
|
+
const live = this.routableSession(frame.sessionRef, 'elicitationAnswer');
|
|
586
|
+
if (!live)
|
|
587
|
+
return;
|
|
588
|
+
if (!isKnownElicitationAction(frame.action)) {
|
|
589
|
+
// `AGENT_FRAME_SHAPES` only validates `action` as *a* string (ACP's own action union has
|
|
590
|
+
// an open `string` arm for agent-authored custom actions), but this daemon is the only
|
|
591
|
+
// sender of this frame and the server only ever offers the user accept/decline/cancel —
|
|
592
|
+
// so anything else reaching here is a platform bug (a typo, a stale enum), not a
|
|
593
|
+
// legitimate custom action. Declining rather than forwarding it verbatim keeps the agent
|
|
594
|
+
// from being handed a value ACP itself would still accept as "protocol-valid" — the exact
|
|
595
|
+
// shape `isString`-only validation cannot catch on its own.
|
|
596
|
+
console.warn(`[agent] agent_elicitation_answer for '${frame.sessionRef}' carried an unrecognised action '${frame.action}'; declining instead of forwarding it`);
|
|
597
|
+
live.session.answerElicitation(frame.requestId, { action: 'decline' });
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
live.session.answerElicitation(frame.requestId, {
|
|
601
|
+
action: frame.action,
|
|
602
|
+
...(frame.content ? { content: frame.content } : {}),
|
|
603
|
+
});
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
503
606
|
case 'agent_interrupt': {
|
|
504
607
|
const live = this.routableSession(frame.sessionRef, 'interrupt');
|
|
505
608
|
if (!live)
|
|
@@ -513,16 +616,101 @@ export class AgentSessionManager {
|
|
|
513
616
|
return;
|
|
514
617
|
}
|
|
515
618
|
/**
|
|
516
|
-
*
|
|
517
|
-
* `
|
|
518
|
-
*
|
|
519
|
-
*
|
|
619
|
+
* Stop **one** background task, leaving the running turn alone — ACP's
|
|
620
|
+
* `_session/async_task/stop`. The server sends this instead of marking its JOB row cancelled
|
|
621
|
+
* behind the agent's back, which is what it used to do: the row went terminal, the task kept
|
|
622
|
+
* running, and the UI said "stopped" while the truth was "still running".
|
|
623
|
+
*
|
|
624
|
+
* Doesn't touch `busy()`'s inputs, for the reason `agent_steer` documents at length: this is
|
|
625
|
+
* not a turn, so it must not push {@link AcpSession.inFlightTurns} — `busy()` is sampled live
|
|
626
|
+
* off that array and the agent's reported activity (2026-09-21 rewrite, no accumulator to
|
|
627
|
+
* leak any more), and `stopAsyncTask()` deliberately does none of that bookkeeping, same as
|
|
628
|
+
* `steer()` (see `AcpSession.stopAsyncTask`'s own doc).
|
|
629
|
+
*
|
|
630
|
+
* **No reply frame, so a failure can only be logged** — and that is by design for the ordinary
|
|
631
|
+
* case, not an omission (see `agent_async_task_stop`'s own doc in `protocol/agent.ts`): the
|
|
632
|
+
* question the server is waiting on is not "did the request arrive" but "did the task actually
|
|
633
|
+
* stop", and only the agent's own next `async_task_state_update` for this
|
|
634
|
+
* `(sessionRef, asyncTaskId)` answers it. That update travels the ordinary event stream and
|
|
635
|
+
* settles the JOB there.
|
|
636
|
+
*
|
|
637
|
+
* ## Known gap: a refusal here is invisible to the server
|
|
638
|
+
*
|
|
639
|
+
* A `Method not found` from an agent that advertised a name we have a collector for but does
|
|
640
|
+
* not implement `_session/async_task/stop` lands in this catch and goes no further. The server
|
|
641
|
+
* recorded a "Stop requested" note on that JOB and will now wait for a terminal update that is
|
|
642
|
+
* never coming; nothing on that side says why.
|
|
643
|
+
*
|
|
644
|
+
* **Narrow today, and deliberately not papered over.** The server only sends this frame to a
|
|
645
|
+
* session whose `agent_opened` explicitly reported `supportsAsyncTaskStop`, from the
|
|
646
|
+
* certified `InitializeResponse.agentInfo.name` compatibility map — and both adapters
|
|
647
|
+
* implement the method (verified: `acp-agent.js@0.79.0:165`,
|
|
648
|
+
* `codex-acp@1.12.0 dist/index.js:31608`). So the gap opens only if one of them drops the
|
|
649
|
+
* method in a future version, or a third-party agent adopts one of those names.
|
|
650
|
+
*
|
|
651
|
+
* **Why it is not closed here**: the only honest thing to report is "the stop request was
|
|
652
|
+
* refused; the task is still running", and this daemon has no channel that can carry it. The
|
|
653
|
+
* frame has no reply; synthesizing an `async_task_state_update` would invent a terminal state
|
|
654
|
+
* the agent never reported, which is the exact lie this whole path was built to remove; and a
|
|
655
|
+
* new event name or a reply frame is a wire-contract change that belongs in a reviewed task,
|
|
656
|
+
* not in a fix applied to one. Tracked as a Task 8 acceptance item. Until then this log line
|
|
657
|
+
* is the only evidence, so it names the consequence rather than just the error.
|
|
658
|
+
*/
|
|
659
|
+
case 'agent_async_task_stop': {
|
|
660
|
+
const live = this.routableSession(frame.sessionRef, 'async_task_stop');
|
|
661
|
+
if (!live)
|
|
662
|
+
return;
|
|
663
|
+
try {
|
|
664
|
+
if (!live.nativeSessionId || !supportsAsyncTaskStop(live.session.agentInfo?.name) || !frame.asyncTaskId.trim()) {
|
|
665
|
+
throw new Error('No certified task-stop route and exact target for this session');
|
|
666
|
+
}
|
|
667
|
+
await live.session.stopAsyncTask(frame.asyncTaskId);
|
|
668
|
+
}
|
|
669
|
+
catch (err) {
|
|
670
|
+
console.error(`[agent] stopping background task ${frame.asyncTaskId} failed for ${frame.sessionRef}: ` +
|
|
671
|
+
`${errorMessage(err)} -- the task is still running and the server will keep waiting ` +
|
|
672
|
+
'for a terminal update it will never receive');
|
|
673
|
+
}
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
case 'agent_provider_task_stop': {
|
|
677
|
+
if (this.serverAgentWire < AGENT_WIRE_PROVIDER_TASK_VERSION) {
|
|
678
|
+
this.emit({ t: 'agent_provider_task_stopped', id: frame.id, state: 'rejected', error: 'Provider task control requires a newer server' });
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
const live = this.routableSession(frame.sessionRef, 'provider_task_stop');
|
|
682
|
+
if (!live || !live.session.providerTaskAdapterSelected || !live.session.providerTaskStop ||
|
|
683
|
+
live.nativeSessionId !== frame.rootSessionId ||
|
|
684
|
+
live.session.runtimeDiagnostics?.profile?.runtimeGeneration !== frame.runtimeGeneration) {
|
|
685
|
+
this.emit({ t: 'agent_provider_task_stopped', id: frame.id, state: 'rejected', error: 'Provider task identity is not live and trusted' });
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
try {
|
|
689
|
+
const state = await live.session.providerTaskStop(frame.taskId, frame.requestId);
|
|
690
|
+
this.emit({ t: 'agent_provider_task_stopped', id: frame.id, state });
|
|
691
|
+
}
|
|
692
|
+
catch (err) {
|
|
693
|
+
this.emit({ t: 'agent_provider_task_stopped', id: frame.id, state: 'unknown', error: errorMessage(err) });
|
|
694
|
+
}
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* A steer rides a turn that is **already** running, so it must not push
|
|
699
|
+
* {@link AcpSession.inFlightTurns} the way `agent_prompt` does: `steer()` sends exactly one
|
|
700
|
+
* extension request and gets exactly one reply, not a second `agent_turn_done`, so there is
|
|
701
|
+
* no second completion to pop a second entry with.
|
|
520
702
|
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
703
|
+
* That distinction used to matter for a leak that was not hypothetical, and still explains why
|
|
704
|
+
* `steer()` pushes nothing today. The server steers whenever **it** believes a turn is
|
|
705
|
+
* running, and the agent is free to disagree and answer `promptRequired` — a steer on an idle
|
|
706
|
+
* session. Under the old bracketed `TaskRegistry.agentTasks` (removed 2026-09-21), nothing
|
|
707
|
+
* would ever send a `turn_done` to close that bracket, so the sessionRef sat there forever and
|
|
708
|
+
* `canUpdate()` stayed `false` for the rest of the daemon's life — the same shape as the
|
|
709
|
+
* hung-prompt leak `readLoopDead` exists for. `busy()` is sampled live today, off
|
|
710
|
+
* {@link AcpSession.turnRunning} and the agent's own reported activity, with no set to leave a
|
|
711
|
+
* ref in — but that safety is a property of `busy()`'s inputs, not of this call site, so
|
|
712
|
+
* `steer()` still deliberately pushes nothing to {@link AcpSession.inFlightTurns} (see its own
|
|
713
|
+
* doc): an idle-session steer must not be mistaken for the start of a turn.
|
|
526
714
|
*
|
|
527
715
|
* Every exit answers. The server is holding the steered message in its own queue table
|
|
528
716
|
* (`session_message_queue`) while this request is in flight — the message is not on the
|
|
@@ -559,10 +747,12 @@ export class AgentSessionManager {
|
|
|
559
747
|
/**
|
|
560
748
|
* Set one session config option, round-tripping ACP `session/set_config_option`. Same shape
|
|
561
749
|
* as `agent_steer` immediately above, and for the same reason: this is a real round trip the
|
|
562
|
-
* server is blocked waiting on, not a turn, so it
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
750
|
+
* server is blocked waiting on, not a turn, so it must not push
|
|
751
|
+
* {@link AcpSession.inFlightTurns} — there is no second `agent_turn_done` to pop it with, and
|
|
752
|
+
* `busy()` is sampled live off that array plus the agent's own reported activity (2026-09-21
|
|
753
|
+
* rewrite: no accumulator here any more), so a push with nothing to pop it would pin a session
|
|
754
|
+
* "busy" for the life of the daemon exactly the way the old bracketed `TaskRegistry.agentTasks`
|
|
755
|
+
* used to leak.
|
|
566
756
|
*
|
|
567
757
|
* Every exit answers: a session that is not routable, an agent that throws (its own refusal,
|
|
568
758
|
* or `Method not found` from one that never advertised the method), all send
|
|
@@ -575,11 +765,16 @@ export class AgentSessionManager {
|
|
|
575
765
|
this.emit({ t: 'agent_config_option_set', id: frame.id, ok: false, error: `no live session '${frame.sessionRef}'` });
|
|
576
766
|
return;
|
|
577
767
|
}
|
|
768
|
+
const startedAt = performance.now();
|
|
769
|
+
const operation = `request=${frame.id} session=${frame.sessionRef} config=${frame.configId}`;
|
|
770
|
+
console.info(`${new Date().toISOString()} [agent/config] applying ${operation}`);
|
|
578
771
|
try {
|
|
579
772
|
const configOptions = await live.session.setConfigOption(frame.configId, frame.value);
|
|
773
|
+
console.info(`${new Date().toISOString()} [agent/config] confirmed ${operation} elapsedMs=${Math.round(performance.now() - startedAt)}`);
|
|
580
774
|
this.emit({ t: 'agent_config_option_set', id: frame.id, ok: true, configOptions });
|
|
581
775
|
}
|
|
582
776
|
catch (err) {
|
|
777
|
+
console.warn(`${new Date().toISOString()} [agent/config] failed ${operation} elapsedMs=${Math.round(performance.now() - startedAt)}: ${errorMessage(err)}`);
|
|
583
778
|
// Includes the agent's own refusal ("Invalid value…", "Unknown config option…") and a
|
|
584
779
|
// `Method not found` from an agent without the method — both are the server's to show.
|
|
585
780
|
this.emit({ t: 'agent_config_option_set', id: frame.id, ok: false, error: errorMessage(err) });
|
|
@@ -596,7 +791,13 @@ export class AgentSessionManager {
|
|
|
596
791
|
if (!live)
|
|
597
792
|
return;
|
|
598
793
|
this.touch(live);
|
|
599
|
-
await this.enqueue(live, () =>
|
|
794
|
+
await this.enqueue(live, async () => {
|
|
795
|
+
if (live.scopedReplayBlocked || this.blockIncompatibleJournal(live))
|
|
796
|
+
return;
|
|
797
|
+
await live.journal.truncateThrough(frame.throughSeq);
|
|
798
|
+
if (frame.throughSeq >= live.highestScopedJournalSeq)
|
|
799
|
+
live.highestScopedJournalSeq = 0;
|
|
800
|
+
});
|
|
600
801
|
return;
|
|
601
802
|
}
|
|
602
803
|
case 'agent_list_native':
|
|
@@ -606,6 +807,13 @@ export class AgentSessionManager {
|
|
|
606
807
|
}
|
|
607
808
|
await this.onListNative(frame);
|
|
608
809
|
return;
|
|
810
|
+
case 'agent_delete_native':
|
|
811
|
+
if (this.tasks && !this.tasks.accepting) {
|
|
812
|
+
this.emit({ t: 'agent_native_deleted', id: frame.id, ok: false, error: 'Workstation is not accepting new work' });
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
await this.onDeleteNative(frame);
|
|
816
|
+
return;
|
|
609
817
|
// Both of these spawn an agent subprocess, exactly as `agent_list_native` does, so they answer
|
|
610
818
|
// a draining workstation the same way rather than starting work it is trying to finish.
|
|
611
819
|
case 'agent_verify':
|
|
@@ -632,10 +840,39 @@ export class AgentSessionManager {
|
|
|
632
840
|
await this.onInstall(frame);
|
|
633
841
|
return;
|
|
634
842
|
default:
|
|
635
|
-
|
|
843
|
+
this.warnUnhandledFrame(frame);
|
|
636
844
|
return;
|
|
637
845
|
}
|
|
638
846
|
}
|
|
847
|
+
/** Frame types already reported as having no case on this daemon; see {@link warnUnhandledFrame}. */
|
|
848
|
+
warnedUnhandledFrameTypes = new Set();
|
|
849
|
+
/**
|
|
850
|
+
* Say once, per frame type, that a frame reached this dispatcher and nothing acted on it.
|
|
851
|
+
*
|
|
852
|
+
* **This used to be a bare `return` with a comment.** That silence is the exact half of the wire
|
|
853
|
+
* this side had no alarm for: `agent_async_task_stop` is the first server->client request frame
|
|
854
|
+
* whose only consumer is the `switch` above, so a version of this daemon that shipped the sending
|
|
855
|
+
* half without the receiving one would have dropped every stop request with no type error, no log
|
|
856
|
+
* line and no timeout — "shipped inert", and invisible. The Kotlin twin
|
|
857
|
+
* (`AgentChannelSession.warnUnhandledFrame`) has been loud here since the `ec341b8e4` incident,
|
|
858
|
+
* where three reply frames were dropped before their handler and three features were inert on
|
|
859
|
+
* every workstation from the day they shipped; this is the same alarm on the other end.
|
|
860
|
+
*
|
|
861
|
+
* Deduplicated by type rather than logged per frame, exactly as the Kotlin twin does it: the
|
|
862
|
+
* diagnosis is "this type has no case", and repeating it per frame would bury it.
|
|
863
|
+
*
|
|
864
|
+
* One benign arrival is possible and is deliberately **not** exempted: a `heartbeat_ack` for a seq
|
|
865
|
+
* the channel no longer has outstanding slips past `connectEncryptedChannel`'s ack filter and
|
|
866
|
+
* lands here. That costs one line for the life of the process, and an exemption list would be a
|
|
867
|
+
* second, parallel statement of which frames this daemon handles — precisely the hand-maintained
|
|
868
|
+
* copy `AGENT_FRAME_SHAPES`'s own doc exists to avoid.
|
|
869
|
+
*/
|
|
870
|
+
warnUnhandledFrame(frame) {
|
|
871
|
+
if (this.warnedUnhandledFrameTypes.has(frame.t))
|
|
872
|
+
return;
|
|
873
|
+
this.warnedUnhandledFrameTypes.add(frame.t);
|
|
874
|
+
console.warn(`[agent] no case for an inbound '${frame.t}'; it reached this daemon and nothing consumed it`);
|
|
875
|
+
}
|
|
639
876
|
/**
|
|
640
877
|
* Classifies an open before its first await. A live or already-opening ref is continuation
|
|
641
878
|
* traffic, so graceful drain lets it replay/piggyback without acquiring (and later releasing)
|
|
@@ -823,6 +1060,21 @@ export class AgentSessionManager {
|
|
|
823
1060
|
* daemon's side of the divergence; the notice heals the server's.
|
|
824
1061
|
*/
|
|
825
1062
|
async sweepIdle(ttlMs, now) {
|
|
1063
|
+
// Observation, not a decision: a session the agent still claims is `running` is never swept
|
|
1064
|
+
// for that alone (ruling A forbids inventing a timeout that overrides the vendor), but one
|
|
1065
|
+
// that has gone this long without another push is worth naming in the log so whoever is
|
|
1066
|
+
// debugging a stuck workstation can see it instead of just trusting a RUNNING that never moves.
|
|
1067
|
+
for (const live of this.sessions.values()) {
|
|
1068
|
+
if (!live.staleActivityWarned &&
|
|
1069
|
+
activityIsBusy(live.lastActivityRaw) &&
|
|
1070
|
+
live.lastActivityAtMs !== undefined &&
|
|
1071
|
+
now - live.lastActivityAtMs > STALE_ACTIVITY_WARN_MS) {
|
|
1072
|
+
live.staleActivityWarned = true;
|
|
1073
|
+
console.warn(`[acp:${live.agentId} agent:${live.agentKey}] session ${live.sessionRef} has reported \`running\` ` +
|
|
1074
|
+
`for ${Math.round((now - live.lastActivityAtMs) / 60000)} minutes with no further push; ` +
|
|
1075
|
+
'the platform is reporting it as RUNNING because that is what the agent said.');
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
826
1078
|
const idle = [...this.sessions.values()].filter((live) => now - live.lastActivityAt >= ttlMs);
|
|
827
1079
|
// Snapshot first: retire() mutates `sessions`, and it awaits.
|
|
828
1080
|
for (const live of idle) {
|
|
@@ -835,28 +1087,77 @@ export class AgentSessionManager {
|
|
|
835
1087
|
return idle.map((live) => live.sessionRef);
|
|
836
1088
|
}
|
|
837
1089
|
async onOpen(frame) {
|
|
838
|
-
|
|
839
|
-
|
|
1090
|
+
if (frame.delegationMcp && (this.serverAgentWire < AGENT_WIRE_DELEGATION_MCP_VERSION ||
|
|
1091
|
+
(frame.delegationMcp.bindingId && this.serverAgentWire < AGENT_WIRE_DELEGATION_RENEW_VERSION))) {
|
|
1092
|
+
this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Managed delegation requires a newer server' });
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
if (frame.delegationMcp) {
|
|
1096
|
+
try {
|
|
1097
|
+
buildSessionMcpServers([], frame.delegationMcp);
|
|
1098
|
+
}
|
|
1099
|
+
catch {
|
|
1100
|
+
this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Delegation MCP configuration is unavailable' });
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
// Rotation is server-driven. A busy reconnect replays the original process's journal.
|
|
840
1105
|
const existing = this.liveSession(frame.sessionRef);
|
|
1106
|
+
let openFrame = frame;
|
|
1107
|
+
let retireForRefresh;
|
|
841
1108
|
if (existing) {
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
1109
|
+
if (existing.delegationExpiresAtEpochMs && !frame.delegationMcp) {
|
|
1110
|
+
this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Managed delegation lease is required' });
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
if (frame.delegationMcp && frame.nativeSessionId && frame.nativeSessionId !== existing.nativeSessionId) {
|
|
1114
|
+
this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Managed delegation cannot reload the original session' });
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
const refresh = !!frame.delegationMcp &&
|
|
1118
|
+
(existing.delegationLeaseId !== delegationLeaseId(frame.delegationMcp) || existing.delegationRefreshPending === true);
|
|
1119
|
+
if (refresh) {
|
|
1120
|
+
if (!existing.nativeSessionId || (frame.nativeSessionId && frame.nativeSessionId !== existing.nativeSessionId)) {
|
|
1121
|
+
this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Managed delegation cannot reload the original session' });
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
if (existing.session.liveness.turnRunning || activityIsBusy(existing.lastActivityRaw)) {
|
|
1125
|
+
existing.delegationRefreshPending = true;
|
|
1126
|
+
if (!await this.resumeAndReply(existing, frame, false))
|
|
1127
|
+
return;
|
|
1128
|
+
this.replyNoReplayNeeded(frame);
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
openFrame = { ...frame, nativeSessionId: existing.nativeSessionId, requireSessionLoad: true };
|
|
1132
|
+
retireForRefresh = existing;
|
|
1133
|
+
}
|
|
1134
|
+
else {
|
|
1135
|
+
// Reusing a live session: neither the process nor the native session moved.
|
|
1136
|
+
if (!await this.resumeAndReply(existing, frame, false))
|
|
1137
|
+
return;
|
|
1138
|
+
// The session already exists, so any history it has was already streamed under whichever
|
|
1139
|
+
// open first established it (or never will be, if that open didn't ask for it) — replaying it
|
|
1140
|
+
// again here would duplicate what the platform already holds. But if THIS frame asked for
|
|
1141
|
+
// backfill, the server is waiting on exactly one `agent_backfill_done` for THIS open's id:
|
|
1142
|
+
// silence is not "no", it is a wait that never resolves. Answer it without replaying.
|
|
1143
|
+
this.replyNoReplayNeeded(frame);
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
851
1146
|
}
|
|
852
1147
|
const inFlight = this.opening.get(frame.sessionRef);
|
|
853
1148
|
if (inFlight) {
|
|
854
1149
|
// Piggyback on the attempt already running, and report its real failure — not a generic one.
|
|
855
1150
|
const { live, error, agentRefused } = await inFlight;
|
|
856
1151
|
if (live) {
|
|
1152
|
+
if (!!frame.delegationMcp !== !!live.delegationExpiresAtEpochMs ||
|
|
1153
|
+
(frame.delegationMcp && live.delegationLeaseId !== delegationLeaseId(frame.delegationMcp))) {
|
|
1154
|
+
this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'Managed delegation lease does not match the live session' });
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
857
1157
|
// Piggybacking on the attempt already running: a non-null `live` means that attempt was a
|
|
858
1158
|
// real open (`session/load` or `session/new`), which this frame is reporting secondhand.
|
|
859
|
-
await this.resumeAndReply(live, frame, true)
|
|
1159
|
+
if (!await this.resumeAndReply(live, frame, true))
|
|
1160
|
+
return;
|
|
860
1161
|
// Same reasoning as the reconnect branch above: whichever frame actually drove openSession
|
|
861
1162
|
// already streamed this session's history (if it asked to); this frame is a second, racing
|
|
862
1163
|
// open of the same ref and must not trigger a second replay — but still owes its own
|
|
@@ -876,8 +1177,14 @@ export class AgentSessionManager {
|
|
|
876
1177
|
}
|
|
877
1178
|
return;
|
|
878
1179
|
}
|
|
879
|
-
const attempt =
|
|
880
|
-
|
|
1180
|
+
const attempt = (async () => {
|
|
1181
|
+
if (retireForRefresh)
|
|
1182
|
+
await this.retire(retireForRefresh, 'keep');
|
|
1183
|
+
return this.openSession(openFrame);
|
|
1184
|
+
})()
|
|
1185
|
+
.catch((err) => ({
|
|
1186
|
+
live: null, error: openFrame.delegationMcp ? 'Managed delegation session could not open' : errorMessage(err),
|
|
1187
|
+
}));
|
|
881
1188
|
this.opening.set(frame.sessionRef, attempt);
|
|
882
1189
|
let outcome;
|
|
883
1190
|
try {
|
|
@@ -900,7 +1207,8 @@ export class AgentSessionManager {
|
|
|
900
1207
|
// resolving from the finally, and microtasks always drain before the next inbound frame.
|
|
901
1208
|
if (outcome.live) {
|
|
902
1209
|
// openSession returned successfully, having just done session/load or session/new.
|
|
903
|
-
await this.resumeAndReply(outcome.live, frame, true)
|
|
1210
|
+
if (!await this.resumeAndReply(outcome.live, frame, true))
|
|
1211
|
+
return;
|
|
904
1212
|
// Only the direct-attempt path: the existing-session and in-flight-piggyback branches above
|
|
905
1213
|
// return before reaching here, so a racing duplicate `agent_open` (or a reconnect of an
|
|
906
1214
|
// already-open ref) can never trigger a second replay of the same history.
|
|
@@ -975,6 +1283,9 @@ export class AgentSessionManager {
|
|
|
975
1283
|
// legitimately rely on `this`, and a bare local `const backfill = session.backfill` would call
|
|
976
1284
|
// it unbound.
|
|
977
1285
|
for await (const event of session.backfill()) {
|
|
1286
|
+
if (event.acpSessionId !== undefined && this.serverAgentWire < AGENT_WIRE_SCOPED_ORIGIN_VERSION) {
|
|
1287
|
+
throw new Error(`Scoped backfill requires agent wire ${AGENT_WIRE_SCOPED_ORIGIN_VERSION}; native origin cannot be discarded`);
|
|
1288
|
+
}
|
|
978
1289
|
batch.push(event);
|
|
979
1290
|
count += 1;
|
|
980
1291
|
if (batch.length >= BACKFILL_BATCH_SIZE) {
|
|
@@ -1015,8 +1326,16 @@ export class AgentSessionManager {
|
|
|
1015
1326
|
const resumeFrom = resumeFromSeqOf(frame);
|
|
1016
1327
|
// On the session's own queue so an agent event produced mid-replay is sent after it, not into
|
|
1017
1328
|
// the middle of it.
|
|
1329
|
+
let incompatible = false;
|
|
1018
1330
|
await this.enqueue(live, async () => {
|
|
1331
|
+
if (live.closed)
|
|
1332
|
+
return;
|
|
1333
|
+
incompatible = this.blockIncompatibleJournal(live);
|
|
1334
|
+
if (incompatible)
|
|
1335
|
+
return;
|
|
1019
1336
|
const entries = await live.journal.entriesAfter(resumeFrom);
|
|
1337
|
+
if (live.closed)
|
|
1338
|
+
return;
|
|
1020
1339
|
if (this.hasGap(live, entries, resumeFrom)) {
|
|
1021
1340
|
this.emit({
|
|
1022
1341
|
t: 'agent_event',
|
|
@@ -1033,7 +1352,19 @@ export class AgentSessionManager {
|
|
|
1033
1352
|
// de-duplication does the deciding.
|
|
1034
1353
|
for (const entry of entries)
|
|
1035
1354
|
this.emit(entry.frame);
|
|
1355
|
+
live.scopedReplayBlocked = false;
|
|
1036
1356
|
});
|
|
1357
|
+
if (incompatible) {
|
|
1358
|
+
const error = `Scoped journal replay requires agent wire ${AGENT_WIRE_SCOPED_ORIGIN_VERSION}; retained history was not delivered or acknowledged`;
|
|
1359
|
+
this.emit({ t: 'agent_opened', id: frame.id, ok: false, error });
|
|
1360
|
+
if (frame.backfill)
|
|
1361
|
+
this.emit({ t: 'agent_backfill_done', id: frame.id, ok: false, count: 0, error });
|
|
1362
|
+
return false;
|
|
1363
|
+
}
|
|
1364
|
+
if (live.closed) {
|
|
1365
|
+
this.emit({ t: 'agent_opened', id: frame.id, ok: false, error: 'session was closed while opening' });
|
|
1366
|
+
return false;
|
|
1367
|
+
}
|
|
1037
1368
|
this.emit({
|
|
1038
1369
|
t: 'agent_opened',
|
|
1039
1370
|
id: frame.id,
|
|
@@ -1044,6 +1375,12 @@ export class AgentSessionManager {
|
|
|
1044
1375
|
// Emitted only when true, the same `supportsSteering` convention as below: absent is the
|
|
1045
1376
|
// field's only "no", and it is also what an older peer sends.
|
|
1046
1377
|
...(reloaded ? { reloaded: true } : {}),
|
|
1378
|
+
...(live.delegationExpiresAtEpochMs ? { delegationExpiresAtEpochMs: live.delegationExpiresAtEpochMs } : {}),
|
|
1379
|
+
...(live.delegationRefreshPending ? { delegationRefreshPending: true } : {}),
|
|
1380
|
+
...(this.serverAgentWire >= AGENT_WIRE_DELEGATION_RENEW_VERSION && live.delegationInstanceId ? {
|
|
1381
|
+
delegationInstanceId: live.delegationInstanceId, delegationBindingId: live.delegationBindingId,
|
|
1382
|
+
delegationRenewalRevision: live.delegationRenewalRevision,
|
|
1383
|
+
} : {}),
|
|
1047
1384
|
// The server learns this here and nowhere else. It used to be published only on the roster in
|
|
1048
1385
|
// the `agent_hello` handshake, which is a snapshot taken *before* any agent was connected to —
|
|
1049
1386
|
// and the capability can only be observed on a live `initialize`, whose record
|
|
@@ -1078,6 +1415,7 @@ export class AgentSessionManager {
|
|
|
1078
1415
|
// ruling R1: `agent_opened` is assembled from `live.session.*`, not from the roster, so a
|
|
1079
1416
|
// field added to `BridgeInfo` would have no reader here).
|
|
1080
1417
|
...(live.session.agentInfo ? { agentInfo: live.session.agentInfo } : {}),
|
|
1418
|
+
...(live.session.runtimeDiagnostics ? { runtimeDiagnostics: live.session.runtimeDiagnostics } : {}),
|
|
1081
1419
|
// ACP `PromptCapabilities.image`, learned at the same `initialize` and for the same
|
|
1082
1420
|
// session-not-roster reason as `supportsSteering` above: `true`-or-absent is this field's
|
|
1083
1421
|
// whole vocabulary, so an agent that said nothing (or a peer too old to answer) reads the
|
|
@@ -1094,7 +1432,22 @@ export class AgentSessionManager {
|
|
|
1094
1432
|
// would report the pre-change values to the server after every config change. See
|
|
1095
1433
|
// {@link AcpAgentSession.configOptions} for what that costs.
|
|
1096
1434
|
configOptions: live.session.configOptions,
|
|
1435
|
+
// Whether this daemon wired up an activity-state collector for this session's agent (design
|
|
1436
|
+
// §5.3) — decided from the same `agentInfo.name` just read above, never from whether a push
|
|
1437
|
+
// has actually arrived: those are different facts (see the field's own doc in
|
|
1438
|
+
// `protocol/agent.ts`). No version floor: an additive key on a frame the peer already decodes
|
|
1439
|
+
// with `ignoreUnknownKeys`, the same `supportsSteering`/`reloaded` shape, not the
|
|
1440
|
+
// new-discriminator shape `agent_session_state` itself is.
|
|
1441
|
+
...(reportsActivity(live.session.agentInfo?.name) ? { reportsActivity: true } : {}),
|
|
1442
|
+
...(live.nativeSessionId && supportsAsyncTaskStop(live.session.agentInfo?.name)
|
|
1443
|
+
? { supportsAsyncTaskStop: true } : {}),
|
|
1444
|
+
// This session's activity-state snapshot as of right now, resent here because
|
|
1445
|
+
// `agent_session_state` is never journaled and a reopen would otherwise have nothing to
|
|
1446
|
+
// recover it from — see `LiveSession.lastActivityRaw`'s own doc. Absent when nothing has ever
|
|
1447
|
+
// arrived (including "no collector for this agent"), never a synthesized default.
|
|
1448
|
+
...(live.lastActivityRaw !== undefined ? { activityRaw: live.lastActivityRaw } : {}),
|
|
1097
1449
|
});
|
|
1450
|
+
return true;
|
|
1098
1451
|
}
|
|
1099
1452
|
/**
|
|
1100
1453
|
* Whether the overflow drop actually took something the server still needs. `droppedOldest` alone
|
|
@@ -1132,8 +1485,16 @@ export class AgentSessionManager {
|
|
|
1132
1485
|
// init() only reads — the file appears on the first append — so an open that fails here leaves
|
|
1133
1486
|
// nothing behind.
|
|
1134
1487
|
const journal = new SessionJournal(this.sessionsDir, frame.sessionRef, this.maxJournalBytes);
|
|
1488
|
+
let highestScopedJournalSeq = 0;
|
|
1135
1489
|
try {
|
|
1136
1490
|
await journal.init(resumeFromSeqOf(frame));
|
|
1491
|
+
// Recovery is the only full scan needed for compatibility. Appends and ACKs below maintain
|
|
1492
|
+
// the high watermark incrementally, including while a v17 connection is offline.
|
|
1493
|
+
for (const entry of await journal.entriesAfter(0)) {
|
|
1494
|
+
if (entry.frame.t === 'agent_event' && entry.frame.acpSessionId != null) {
|
|
1495
|
+
highestScopedJournalSeq = Math.max(highestScopedJournalSeq, entry.seq);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1137
1498
|
}
|
|
1138
1499
|
catch (err) {
|
|
1139
1500
|
return { live: null, error: `journal init failed: ${errorMessage(err)}` };
|
|
@@ -1149,6 +1510,7 @@ export class AgentSessionManager {
|
|
|
1149
1510
|
let nativeSessionId;
|
|
1150
1511
|
try {
|
|
1151
1512
|
const result = await this.agents.open({
|
|
1513
|
+
openRequestId: frame.id,
|
|
1152
1514
|
// `frame.bridge` is the registry agent id now — the wire field keeps its name because Plan A
|
|
1153
1515
|
// renames nothing (Global Constraint 3); ruling R2 replaced what it *means*.
|
|
1154
1516
|
agentId: frame.bridge,
|
|
@@ -1158,12 +1520,18 @@ export class AgentSessionManager {
|
|
|
1158
1520
|
...(frame.command ? { command: frame.command } : {}),
|
|
1159
1521
|
args: frame.args ?? [],
|
|
1160
1522
|
...(frame.nativeSessionId ? { nativeSessionId: frame.nativeSessionId } : {}),
|
|
1523
|
+
...(frame.requireSessionLoad ? { requireSessionLoad: true } : {}),
|
|
1524
|
+
...(frame.delegationMcp ? { delegationMcp: frame.delegationMcp } : {}),
|
|
1161
1525
|
// Retaining a whole adopted history costs memory for the session's lifetime, so it is only
|
|
1162
1526
|
// kept when this open is the one that will stream it back.
|
|
1163
1527
|
backfill: frame.backfill === true,
|
|
1164
1528
|
});
|
|
1165
1529
|
session = result.session;
|
|
1166
1530
|
nativeSessionId = result.nativeSessionId;
|
|
1531
|
+
if (frame.delegationMcp && !nativeSessionId) {
|
|
1532
|
+
await session.close();
|
|
1533
|
+
return { live: null, error: 'Managed delegation requires a native session ID' };
|
|
1534
|
+
}
|
|
1167
1535
|
}
|
|
1168
1536
|
catch (err) {
|
|
1169
1537
|
// `open` throws on both sides of the agent's `initialize` — nothing to launch and a spawn
|
|
@@ -1172,7 +1540,7 @@ export class AgentSessionManager {
|
|
|
1172
1540
|
// on `session/new` arrives as; see {@link OpenOutcome.agentRefused}.
|
|
1173
1541
|
return {
|
|
1174
1542
|
live: null,
|
|
1175
|
-
error: errorMessage(err),
|
|
1543
|
+
error: frame.delegationMcp ? 'Managed delegation session could not open' : errorMessage(err),
|
|
1176
1544
|
// The code comes off the marker, not off `errorMessage(err)` above: the wrapper captured it
|
|
1177
1545
|
// from the original rejection at the moment that object still existed.
|
|
1178
1546
|
...(err instanceof AgentRefusedSessionError
|
|
@@ -1217,15 +1585,38 @@ export class AgentSessionManager {
|
|
|
1217
1585
|
agentKey: frame.agentKey,
|
|
1218
1586
|
session,
|
|
1219
1587
|
nativeSessionId,
|
|
1588
|
+
...(frame.delegationMcp ? { delegationExpiresAtEpochMs: frame.delegationMcp.expiresAtEpochMs } : {}),
|
|
1589
|
+
...(frame.delegationMcp ? { delegationLeaseId: delegationLeaseId(frame.delegationMcp) } : {}),
|
|
1590
|
+
...(frame.delegationMcp?.bindingId ? { delegationBindingId: frame.delegationMcp.bindingId,
|
|
1591
|
+
delegationInstanceId: randomUUID(), delegationRenewalRevision: 0 } : {}),
|
|
1220
1592
|
journal,
|
|
1221
1593
|
writes: Promise.resolve(),
|
|
1594
|
+
scopedReplayBlocked: false,
|
|
1595
|
+
highestScopedJournalSeq,
|
|
1222
1596
|
closed: false,
|
|
1223
1597
|
lastActivityAt: Date.now(),
|
|
1224
1598
|
};
|
|
1225
1599
|
this.sessions.set(frame.sessionRef, live);
|
|
1226
|
-
this.tasks?.registerAgentSession(frame.sessionRef, () => this.retire(live, 'destroy')
|
|
1600
|
+
this.tasks?.registerAgentSession(frame.sessionRef, () => this.retire(live, 'destroy'),
|
|
1601
|
+
// 两个层级量取或:本进程发出的提示词是否在途(agent 自己也许在续跑一个我们没发起的回合,
|
|
1602
|
+
// 那一种由第二项回答),以及 agent 自报的状态。两者都不累积,所以没有可泄漏的状态。
|
|
1603
|
+
() => live.session.liveness.turnRunning || activityIsBusy(live.lastActivityRaw));
|
|
1227
1604
|
session.onEvent((e) => this.onAgentEvent(live, e));
|
|
1228
1605
|
session.onTurnDone((r) => this.onAgentTurnDone(live, r));
|
|
1606
|
+
session.onActivity((raw) => this.onAgentActivity(live, raw));
|
|
1607
|
+
session.onRuntimeDiagnostics?.((diagnostics) => {
|
|
1608
|
+
if (live.closed || this.sessions.get(live.sessionRef) !== live ||
|
|
1609
|
+
this.serverAgentWire < AGENT_WIRE_RUNTIME_PROFILE_VERSION)
|
|
1610
|
+
return;
|
|
1611
|
+
this.emit({ t: 'agent_runtime_profile', sessionRef: live.sessionRef, diagnostics });
|
|
1612
|
+
});
|
|
1613
|
+
session.onProviderTaskEvent?.((event) => {
|
|
1614
|
+
if (live.closed || this.sessions.get(live.sessionRef) !== live || this.serverAgentWire < AGENT_WIRE_PROVIDER_TASK_VERSION ||
|
|
1615
|
+
!live.session.providerTaskAdapterSelected || event.rootSessionId !== live.nativeSessionId ||
|
|
1616
|
+
event.runtimeGeneration !== live.session.runtimeDiagnostics?.profile?.runtimeGeneration)
|
|
1617
|
+
return;
|
|
1618
|
+
this.emit({ t: 'agent_provider_task_event', sessionRef: live.sessionRef, ...event });
|
|
1619
|
+
});
|
|
1229
1620
|
return { live };
|
|
1230
1621
|
}
|
|
1231
1622
|
/**
|
|
@@ -1268,6 +1659,25 @@ export class AgentSessionManager {
|
|
|
1268
1659
|
this.emit({ t: 'agent_native_sessions', id: frame.id, ok: false, error: errorMessage(err) });
|
|
1269
1660
|
}
|
|
1270
1661
|
}
|
|
1662
|
+
/**
|
|
1663
|
+
* Answers `agent_delete_native`: `session/delete` per id over a throwaway connection, like
|
|
1664
|
+
* {@link onListNative}. Per-id outcomes ride in `results` (`ok:true`); `ok:false` means the
|
|
1665
|
+
* deletion could not be attempted at all (no launchable agent, bad query). Never throws.
|
|
1666
|
+
*/
|
|
1667
|
+
async onDeleteNative(frame) {
|
|
1668
|
+
const spec = sanitizeNativeSessionQuery(frame.bridge, frame.query);
|
|
1669
|
+
if (!spec) {
|
|
1670
|
+
this.emit({ t: 'agent_native_deleted', id: frame.id, ok: false, error: 'native session query is missing cwd' });
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
try {
|
|
1674
|
+
const results = await this.agents.deleteNativeSessions(spec, frame.nativeSessionIds);
|
|
1675
|
+
this.emit({ t: 'agent_native_deleted', id: frame.id, ok: true, results });
|
|
1676
|
+
}
|
|
1677
|
+
catch (err) {
|
|
1678
|
+
this.emit({ t: 'agent_native_deleted', id: frame.id, ok: false, error: errorMessage(err) });
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1271
1681
|
/**
|
|
1272
1682
|
* Answers `agent_verify` by actually starting the agent and seeing what it says — the only way the
|
|
1273
1683
|
* roster's auth fields can be populated for an agent no session has ever run (see
|
|
@@ -1443,11 +1853,11 @@ export class AgentSessionManager {
|
|
|
1443
1853
|
onAgentEvent(live, e) {
|
|
1444
1854
|
if (live.closed)
|
|
1445
1855
|
return;
|
|
1446
|
-
//
|
|
1447
|
-
//
|
|
1448
|
-
//
|
|
1449
|
-
|
|
1450
|
-
|
|
1856
|
+
// 这里曾经有一行:`tool_call` / `permission_request` 就登记一次「这条会话在忙」。它是在
|
|
1857
|
+
// 没有 vendor 状态信号的年代用来猜「agent 是不是在续跑一个本进程没发起的回合」的启发式,
|
|
1858
|
+
// 而它开的口子只有 `turn_done` 能关——任何一条没有配套 `turn_done` 的事件都会把这个 ref
|
|
1859
|
+
// 永久钉成忙碌。那个猜测现在有了真信号(`lastActivityRaw`,见本类的注册点),所以这一行
|
|
1860
|
+
// 不是被删掉了事,是被它当初代理的那个事实取代了。
|
|
1451
1861
|
// Before the contract check: an agent emitting even non-conforming events is a live process,
|
|
1452
1862
|
// and the idle sweep is about liveness, not correctness.
|
|
1453
1863
|
this.touch(live);
|
|
@@ -1461,6 +1871,17 @@ export class AgentSessionManager {
|
|
|
1461
1871
|
`${rejection.reason}${rejection.detail ? ` (${rejection.detail})` : ''}`);
|
|
1462
1872
|
return;
|
|
1463
1873
|
}
|
|
1874
|
+
if (e.acpSessionId !== undefined && this.serverAgentWire < AGENT_WIRE_SCOPED_ORIGIN_VERSION) {
|
|
1875
|
+
if (e.eventName === 'permission_request' && typeof e.payload.requestId === 'string') {
|
|
1876
|
+
const options = e.payload.options;
|
|
1877
|
+
let cancelled = '__axom_undeliverable_permission__';
|
|
1878
|
+
while (options.some((option) => option.optionId === cancelled))
|
|
1879
|
+
cancelled += '_';
|
|
1880
|
+
live.session.answer(e.payload.requestId, cancelled);
|
|
1881
|
+
}
|
|
1882
|
+
console.warn(`[acp:${live.agentId}] scoped live event '${e.eventName}' for '${e.acpSessionId}' disabled: requires agent wire ${AGENT_WIRE_SCOPED_ORIGIN_VERSION}`);
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1464
1885
|
this.record(live, (seq) => ({
|
|
1465
1886
|
t: 'agent_event',
|
|
1466
1887
|
sessionRef: live.sessionRef,
|
|
@@ -1473,10 +1894,55 @@ export class AgentSessionManager {
|
|
|
1473
1894
|
...(e.acpSessionId ? { acpSessionId: e.acpSessionId } : {}),
|
|
1474
1895
|
}));
|
|
1475
1896
|
}
|
|
1897
|
+
/**
|
|
1898
|
+
* `session.onActivity`'s handler: one agent-reported activity-state push. Deliberately not
|
|
1899
|
+
* routed through {@link record} — an activity push is a level, not an edge (the
|
|
1900
|
+
* `agent_session_state` frame's own doc comment in `protocol/agent.ts`), so it must never be
|
|
1901
|
+
* journaled and must never advance {@link SessionJournal}'s seq the way `onAgentEvent`'s
|
|
1902
|
+
* `agent_event`s do.
|
|
1903
|
+
*
|
|
1904
|
+
* `live.lastActivityRaw` is updated **before** the version check, unconditionally: it feeds
|
|
1905
|
+
* `agent_opened.activityRaw` and `AgentResumeSessionWire.activityRaw`, neither of which is gated
|
|
1906
|
+
* on {@link AGENT_WIRE_SESSION_STATE_VERSION} (they are additive keys on frames the peer already
|
|
1907
|
+
* decodes, the `supportsSteering`/`reloaded` shape — see those fields' own doc comments), so the
|
|
1908
|
+
* cache has to be current even for a peer too old to receive the push itself.
|
|
1909
|
+
*
|
|
1910
|
+
* The push itself **is** gated, and the check has to live here — the client side of the
|
|
1911
|
+
* compatibility matrix `AGENT_WIRE_SESSION_STATE_VERSION`'s own doc comment describes as the
|
|
1912
|
+
* dangerous direction: a v15 client talking to a v14 server. `agent_session_state` is a new
|
|
1913
|
+
* discriminator, not an additive key, so an unrecognised one throws inside `AgentCodec.decode` on
|
|
1914
|
+
* the server and gets charged to `FailSession` — and this frame exists to be sent over and over,
|
|
1915
|
+
* so a run of them on an unupgraded server would close the whole agent channel. Below the floor
|
|
1916
|
+
* the push is silently dropped rather than queued or converted to anything else: a level nobody
|
|
1917
|
+
* downstream can read is no worse than one that was never collected.
|
|
1918
|
+
*/
|
|
1919
|
+
onAgentActivity(live, raw) {
|
|
1920
|
+
if (live.closed)
|
|
1921
|
+
return;
|
|
1922
|
+
live.lastActivityRaw = raw;
|
|
1923
|
+
live.lastActivityAtMs = Date.now();
|
|
1924
|
+
live.staleActivityWarned = false;
|
|
1925
|
+
// 采样式判据没有「变空闲」事件;一次状态推送正是它可能翻转的时刻,喊一声让
|
|
1926
|
+
// waitForIdle 重新判一次。漏喊只会推迟到下次判定,多喊无害。
|
|
1927
|
+
this.tasks?.sessionBusyChanged();
|
|
1928
|
+
if (this.serverAgentWire < AGENT_WIRE_SESSION_STATE_VERSION)
|
|
1929
|
+
return;
|
|
1930
|
+
this.emit({
|
|
1931
|
+
t: 'agent_session_state',
|
|
1932
|
+
sessionRef: live.sessionRef,
|
|
1933
|
+
vendor: live.session.agentInfo?.name ?? '',
|
|
1934
|
+
raw,
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1476
1937
|
onAgentTurnDone(live, r) {
|
|
1477
1938
|
if (live.closed)
|
|
1478
1939
|
return;
|
|
1479
|
-
this.
|
|
1940
|
+
// `agentTurnFinished` used to clear this ref's bracket entry here. There is no entry to clear
|
|
1941
|
+
// any more — busy is sampled straight off `live.session.liveness.turnRunning` (already false
|
|
1942
|
+
// by the time this fires; see `AcpSession.turnRunning`'s own doc) — but a session that was the
|
|
1943
|
+
// registry's *only* busy one may have just gone idle, and sampling has no event of its own to
|
|
1944
|
+
// announce that, so this is where the registry is told to look again.
|
|
1945
|
+
this.tasks?.sessionBusyChanged();
|
|
1480
1946
|
this.touch(live);
|
|
1481
1947
|
if (r.nativeSessionId)
|
|
1482
1948
|
live.nativeSessionId = r.nativeSessionId;
|
|
@@ -1485,6 +1951,8 @@ export class AgentSessionManager {
|
|
|
1485
1951
|
sessionRef: live.sessionRef,
|
|
1486
1952
|
seq,
|
|
1487
1953
|
status: r.status,
|
|
1954
|
+
platformTurnId: r.platformTurnId,
|
|
1955
|
+
executionEnded: r.executionEnded,
|
|
1488
1956
|
stopReason: r.stopReason,
|
|
1489
1957
|
nativeSessionId: r.nativeSessionId ?? null,
|
|
1490
1958
|
error: r.error ?? null,
|
|
@@ -1516,6 +1984,9 @@ export class AgentSessionManager {
|
|
|
1516
1984
|
const seq = live.journal.nextSeq();
|
|
1517
1985
|
const frame = build(seq);
|
|
1518
1986
|
void this.enqueue(live, async () => {
|
|
1987
|
+
if (frame.t === 'agent_event' && frame.acpSessionId != null) {
|
|
1988
|
+
live.highestScopedJournalSeq = Math.max(live.highestScopedJournalSeq, seq);
|
|
1989
|
+
}
|
|
1519
1990
|
try {
|
|
1520
1991
|
await live.journal.append({ seq, frame });
|
|
1521
1992
|
}
|
|
@@ -1524,9 +1995,26 @@ export class AgentSessionManager {
|
|
|
1524
1995
|
// will not survive a reconnect.
|
|
1525
1996
|
console.error(`[agent] journal append failed for ${live.sessionRef}: ${errorMessage(err)}`);
|
|
1526
1997
|
}
|
|
1998
|
+
// Keep subsequent root/turn frames durable but withheld too: sending a higher sequence
|
|
1999
|
+
// would let the old peer ACK past the child event it could not interpret.
|
|
2000
|
+
if (live.scopedReplayBlocked || this.blockIncompatibleJournal(live))
|
|
2001
|
+
return;
|
|
1527
2002
|
this.emit(frame);
|
|
1528
2003
|
});
|
|
1529
2004
|
}
|
|
2005
|
+
/** Must run on the session queue, before replay, delivery or ACK truncation. */
|
|
2006
|
+
blockIncompatibleJournal(live) {
|
|
2007
|
+
if (this.serverAgentWire >= AGENT_WIRE_SCOPED_ORIGIN_VERSION)
|
|
2008
|
+
return false;
|
|
2009
|
+
if (live.scopedReplayBlocked)
|
|
2010
|
+
return true;
|
|
2011
|
+
// Conservatively retain this requirement through journal overflow: only a compatible ACK
|
|
2012
|
+
// proves the peer has applied (or explicitly accepted a gap through) that scoped sequence.
|
|
2013
|
+
const incompatible = live.highestScopedJournalSeq > 0;
|
|
2014
|
+
if (incompatible)
|
|
2015
|
+
live.scopedReplayBlocked = true;
|
|
2016
|
+
return incompatible;
|
|
2017
|
+
}
|
|
1530
2018
|
/** Appends `op` to the session's serialized queue. The returned promise never rejects. */
|
|
1531
2019
|
enqueue(live, op) {
|
|
1532
2020
|
const next = live.writes.then(op).catch((err) => {
|