dsh-ntr 0.2.1 → 0.3.1
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.en.md +4 -4
- package/README.md +4 -4
- package/cordis.patch.yml +12 -0
- package/lib/bridge.js +646 -68
- package/lib/client/panel.js +162 -4
- package/lib/client/store.js +188 -4
- package/lib/client.js +431 -9
- package/lib/client.js.map +1 -1
- package/lib/impl.js +47 -0
- package/lib/index.js +15 -1
- package/lib/types/bridge.d.ts +206 -4
- package/lib/types/client/store.d.ts +87 -1
- package/lib/types/impl.d.ts +26 -1
- package/lib/types/protocol.d.ts +80 -0
- package/lib/types/update.d.ts +186 -0
- package/lib/update.js +696 -0
- package/package.json +1 -1
- package/src/bridge.ts +691 -68
- package/src/client/panel.tsx +288 -5
- package/src/client/store.ts +234 -8
- package/src/impl.ts +66 -1
- package/src/index.ts +16 -1
- package/src/protocol.ts +84 -0
- package/src/update.ts +712 -0
package/lib/bridge.js
CHANGED
|
@@ -14,11 +14,33 @@ import { accessSync, constants, existsSync, mkdirSync, readFileSync, realpathSyn
|
|
|
14
14
|
import { client, methods, ndJsonStream, PROTOCOL_VERSION, RequestError, } from '@agentclientprotocol/sdk';
|
|
15
15
|
import { AcpError, EventHub, PermissionLedger, Transcript, acpToolCallDelta, acpToolCallFromUpdate, buildAdapterModelId, childEnv, isAcpToolContinuation, isUnderPath, mergeCatalogShape, parseCatalogOptions, usableConfigOptions, permissionRequestFrame, pickAutoOption, quoteCommandToken, randomUUID, resolveFsPath, resolveSessionCwd, selectForwardPrompt, acpPresetStatusFromRoster, splitAdapterModelId, updateContentToText, describeUnhandledUpdate, BOOKKEEPING_UPDATES, validateAgentDef, validateTagHalo, validateApprovalDeadlines, validatePermissionTimeoutMode, normalizeApprovalWindow, } from "./impl.js";
|
|
16
16
|
import { DEFAULT_TAG_HALO } from "./protocol.js";
|
|
17
|
+
/**
|
|
18
|
+
* Version of this installed package, read from the shipped `package.json` —
|
|
19
|
+
* one level above both `src/` at test time and `lib/` at runtime. The settings
|
|
20
|
+
* badge and the ACP handshake's `clientInfo` both report it; a copied literal
|
|
21
|
+
* would go stale on every release.
|
|
22
|
+
*/
|
|
23
|
+
export const PLUGIN_VERSION = (() => {
|
|
24
|
+
try {
|
|
25
|
+
const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
|
|
26
|
+
return typeof manifest.version === 'string' ? manifest.version : '';
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
})();
|
|
17
32
|
const STDERR_TAIL_LINES = 40;
|
|
18
33
|
/** Single fs/read_text_file answer cap — agents reading huge files must page via `line`/`limit`. */
|
|
19
34
|
const FS_MAX_READ_BYTES = 2 * 1024 * 1024;
|
|
20
35
|
/** Single fs/write_text_file payload cap. */
|
|
21
36
|
const FS_MAX_WRITE_BYTES = 2 * 1024 * 1024;
|
|
37
|
+
/**
|
|
38
|
+
* How long a stop waits for the agent to answer `session/close` before killing
|
|
39
|
+
* the child anyway. The capability is optional and the LOCAL teardown is what
|
|
40
|
+
* actually frees the session, so a wedged agent must not be able to hang a
|
|
41
|
+
* stop — and with it the stall reclaim that calls it — forever.
|
|
42
|
+
*/
|
|
43
|
+
const SESSION_CLOSE_TIMEOUT_MS = 5_000;
|
|
22
44
|
/**
|
|
23
45
|
* The plugin's normalized MCP server shape → the ACP wire shape.
|
|
24
46
|
*
|
|
@@ -79,6 +101,9 @@ const DEFAULT_TUNING = {
|
|
|
79
101
|
staleCancelRetryDelayMs: 600,
|
|
80
102
|
staleCancelMaxRetries: 2,
|
|
81
103
|
turnSettleMs: 5_000,
|
|
104
|
+
turnStallMs: 600_000,
|
|
105
|
+
turnStallToolMs: 1_800_000,
|
|
106
|
+
turnStallTickMs: 30_000,
|
|
82
107
|
idleTimeoutMs: 1_800_000,
|
|
83
108
|
probeIdleTimeoutMs: 300_000,
|
|
84
109
|
};
|
|
@@ -128,8 +153,19 @@ export class AgentBridge {
|
|
|
128
153
|
lastActivityAt = 0;
|
|
129
154
|
/** Throwaway catalog probes in flight — they hold no live session of their own. */
|
|
130
155
|
pendingProbes = 0;
|
|
131
|
-
/**
|
|
132
|
-
|
|
156
|
+
/**
|
|
157
|
+
* Host→agent session commands in flight (mode / config); not turns. Counted
|
|
158
|
+
* PER SESSION: a command the wedged agent never answers must not pin the
|
|
159
|
+
* bridge against reclaiming THAT session (it is exactly the state the
|
|
160
|
+
* watchdog is recovering from), while another session's command still does.
|
|
161
|
+
*/
|
|
162
|
+
pendingCommands = new Map();
|
|
163
|
+
/**
|
|
164
|
+
* `session/new` requests in flight. The session does not exist in `sessions`
|
|
165
|
+
* yet, so nothing else would tell a teardown (`releaseAll` / idle release)
|
|
166
|
+
* that a caller is waiting on the child it is about to kill.
|
|
167
|
+
*/
|
|
168
|
+
pendingCreates = 0;
|
|
133
169
|
/**
|
|
134
170
|
* Whether a REAL session has ever been registered on this bridge. Probes
|
|
135
171
|
* bypass session bookkeeping, so a bridge that has never hosted anything was
|
|
@@ -293,7 +329,7 @@ export class AgentBridge {
|
|
|
293
329
|
this.touch();
|
|
294
330
|
return;
|
|
295
331
|
}
|
|
296
|
-
this.releaseAll(`agent "${this.def.id}" was released after ${String(Math.round(this.currentIdleTimeoutMs / 1000))}s idle
|
|
332
|
+
this.releaseAll(`agent "${this.def.id}" was released after ${String(Math.round(this.currentIdleTimeoutMs / 1000))}s idle`, 'idle');
|
|
297
333
|
}
|
|
298
334
|
/**
|
|
299
335
|
* Claim this bridge's single prompt slot for one turn, waiting (bounded) for
|
|
@@ -360,6 +396,17 @@ export class AgentBridge {
|
|
|
360
396
|
this.touch();
|
|
361
397
|
};
|
|
362
398
|
}
|
|
399
|
+
/**
|
|
400
|
+
* Open a session's settle gate without owning its turn stream: the turn's
|
|
401
|
+
* stream is gone (a resume, a release) but the next prompt still waits on
|
|
402
|
+
* this promise, and nothing is left that could ever resolve it.
|
|
403
|
+
*/
|
|
404
|
+
openSettleGate(session) {
|
|
405
|
+
const settle = session.settledResolve;
|
|
406
|
+
session.settled = undefined;
|
|
407
|
+
session.settledResolve = undefined;
|
|
408
|
+
settle?.();
|
|
409
|
+
}
|
|
363
410
|
view() {
|
|
364
411
|
return {
|
|
365
412
|
id: this.def.id,
|
|
@@ -389,7 +436,8 @@ export class AgentBridge {
|
|
|
389
436
|
*/
|
|
390
437
|
hasActiveWork() {
|
|
391
438
|
if (this.pendingProbes > 0
|
|
392
|
-
|| this.
|
|
439
|
+
|| this.pendingCreates > 0
|
|
440
|
+
|| this.pendingCommands.size > 0
|
|
393
441
|
|| this.resuming.size > 0
|
|
394
442
|
|| this.permissions.sessionIds().length > 0
|
|
395
443
|
|| this.starting !== undefined)
|
|
@@ -400,6 +448,37 @@ export class AgentBridge {
|
|
|
400
448
|
}
|
|
401
449
|
return false;
|
|
402
450
|
}
|
|
451
|
+
/**
|
|
452
|
+
* Active work on this bridge EXCEPT `session`'s own — what a restart-carrying
|
|
453
|
+
* reclaim (the stall watchdog's follow-up) would destroy. Killing this child
|
|
454
|
+
* restarts every ACP session on the connection, so a live conversation that
|
|
455
|
+
* is mid-turn, mid-resume, or waiting on a human pins the process: its
|
|
456
|
+
* context is the one thing a restart cannot give back.
|
|
457
|
+
*
|
|
458
|
+
* `session`'s OWN mode/config command is deliberately not counted: an agent
|
|
459
|
+
* that ignores `session/set_mode` would otherwise make its own stall
|
|
460
|
+
* unrecoverable — the command's counter is exactly the evidence of the wedge.
|
|
461
|
+
*/
|
|
462
|
+
hasOtherActiveWork(session) {
|
|
463
|
+
if (this.pendingProbes > 0
|
|
464
|
+
|| this.pendingCreates > 0
|
|
465
|
+
|| this.resuming.size > 0
|
|
466
|
+
|| this.starting !== undefined)
|
|
467
|
+
return true;
|
|
468
|
+
for (const sessionId of this.pendingCommands.keys()) {
|
|
469
|
+
if (sessionId !== session.sessionId)
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
if (this.permissions.sessionIds().some(entry => entry.sessionId !== session.sessionId))
|
|
473
|
+
return true;
|
|
474
|
+
for (const other of this.sessions.values()) {
|
|
475
|
+
if (other === session)
|
|
476
|
+
continue;
|
|
477
|
+
if (other.running || other.settled !== undefined)
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
403
482
|
/**
|
|
404
483
|
* Nothing depends on this child's identity right now: no live session and no
|
|
405
484
|
* work. This is the WORKSPACE MOVE's predicate. It stays stricter than the
|
|
@@ -410,6 +489,19 @@ export class AgentBridge {
|
|
|
410
489
|
isQuiet() {
|
|
411
490
|
return this.sessions.size === 0 && !this.hasActiveWork();
|
|
412
491
|
}
|
|
492
|
+
/**
|
|
493
|
+
* A turn is in flight (or the agent has not settled its response yet) on any
|
|
494
|
+
* of this bridge's sessions. `settled` only stays non-undefined while the
|
|
495
|
+
* agent's answer is outstanding — the settler clears it on completion — so
|
|
496
|
+
* this is real work, not an idle session.
|
|
497
|
+
*/
|
|
498
|
+
hasActiveTurn() {
|
|
499
|
+
for (const session of this.sessions.values()) {
|
|
500
|
+
if (session.running || session.settled !== undefined)
|
|
501
|
+
return true;
|
|
502
|
+
}
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
413
505
|
/**
|
|
414
506
|
* An idle teardown is safe when nothing is WORKING. Idle live sessions do not
|
|
415
507
|
* block it: `releaseAll` parks them as `released` (never closed), and the next
|
|
@@ -440,7 +532,7 @@ export class AgentBridge {
|
|
|
440
532
|
if (this.state === 'ready' && this.conn !== undefined && this.spawnCwd !== spawnCwd && this.isQuiet()) {
|
|
441
533
|
const reason = `agent "${this.def.id}" restarts in ${spawnCwd} (the workspace of the session using it)`;
|
|
442
534
|
this.hub.broadcast({ type: 'agent/notice', agentId: this.def.id, level: 'info', text: `acp: ${reason}` });
|
|
443
|
-
this.releaseAll(reason);
|
|
535
|
+
this.releaseAll(reason, 'workspace');
|
|
444
536
|
}
|
|
445
537
|
}
|
|
446
538
|
if (this.state === 'ready' && this.conn !== undefined)
|
|
@@ -545,7 +637,7 @@ export class AgentBridge {
|
|
|
545
637
|
terminal: false,
|
|
546
638
|
session: { configOptions: { boolean: {} } },
|
|
547
639
|
},
|
|
548
|
-
clientInfo: { name: 'dsh-ntr', title: 'DSH NTR', version:
|
|
640
|
+
clientInfo: { name: 'dsh-ntr', title: 'DSH NTR', version: PLUGIN_VERSION },
|
|
549
641
|
}), 'initialize');
|
|
550
642
|
this.initResult = init;
|
|
551
643
|
}
|
|
@@ -590,7 +682,26 @@ export class AgentBridge {
|
|
|
590
682
|
* @param spawnCwd - where the agent PROCESS should run; see `ensureReady`.
|
|
591
683
|
*/
|
|
592
684
|
async createSession(cwd, extraRoots = [], spawnCwd) {
|
|
685
|
+
// Readiness (and its possible restart into `spawnCwd`, see `ensureReady`)
|
|
686
|
+
// comes FIRST: `isQuiet` must not count THIS call's in-flight `session/new`
|
|
687
|
+
// against that move, or a fresh conversation could never bring a quiet
|
|
688
|
+
// process into its workspace. The counter is bumped in the same microtask
|
|
689
|
+
// that resumes here, so no idle-release timer can slip into the gap.
|
|
593
690
|
await this.ensureReady(spawnCwd);
|
|
691
|
+
// Counted while the request is in flight: until `sessions.set` runs, the
|
|
692
|
+
// session is invisible to every teardown predicate, and a release that
|
|
693
|
+
// killed the child here would strand this promise on a dead connection.
|
|
694
|
+
this.pendingCreates += 1;
|
|
695
|
+
try {
|
|
696
|
+
return await this.openSession(cwd, extraRoots);
|
|
697
|
+
}
|
|
698
|
+
finally {
|
|
699
|
+
this.pendingCreates -= 1;
|
|
700
|
+
this.touch();
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
/** The body of {@link createSession}; the caller keeps `pendingCreates` for it. */
|
|
704
|
+
async openSession(cwd, extraRoots) {
|
|
594
705
|
const conn = this.conn;
|
|
595
706
|
if (conn === undefined)
|
|
596
707
|
throw new AcpError('agent_unreachable', 'acp: connection is not open');
|
|
@@ -609,6 +720,8 @@ export class AgentBridge {
|
|
|
609
720
|
turnSeq: 0,
|
|
610
721
|
closed: false,
|
|
611
722
|
released: false,
|
|
723
|
+
lastAgentActivityAt: Date.now(),
|
|
724
|
+
activeToolCalls: new Set(),
|
|
612
725
|
transcript: new Transcript(this.transcriptMax),
|
|
613
726
|
...(result.modes !== undefined && result.modes !== null
|
|
614
727
|
? {
|
|
@@ -743,27 +856,38 @@ export class AgentBridge {
|
|
|
743
856
|
return;
|
|
744
857
|
const turnGen = session.turnSeq;
|
|
745
858
|
const timer = setTimeout(() => {
|
|
746
|
-
|
|
747
|
-
return;
|
|
748
|
-
// Nothing is in flight anymore (the turn settled on its own): nothing to
|
|
749
|
-
// finalize. A still-pending settle gate counts as in flight even though
|
|
750
|
-
// the stream is gone — that gate is what the next prompt waits on.
|
|
751
|
-
if (!session.running && session.settled === undefined)
|
|
752
|
-
return;
|
|
753
|
-
session.running = false;
|
|
754
|
-
const settle = session.settledResolve;
|
|
755
|
-
session.settled = undefined;
|
|
756
|
-
session.settledResolve = undefined;
|
|
757
|
-
settle?.();
|
|
758
|
-
this.hub.broadcast({
|
|
759
|
-
type: 'turn/end',
|
|
760
|
-
sessionId: session.sessionId,
|
|
761
|
-
turnId: '',
|
|
762
|
-
error: `agent "${this.def.id}" did not respond to the cancel request within ${String(Math.round(this.cancelFallbackMs / 1000))}s; the turn was force-finalized`,
|
|
763
|
-
});
|
|
859
|
+
this.finalizeTurn(session, turnGen, `agent "${this.def.id}" did not respond to the cancel request within ${String(Math.round(this.cancelFallbackMs / 1000))}s; the turn was force-finalized`);
|
|
764
860
|
}, this.cancelFallbackMs);
|
|
765
861
|
timer.unref?.();
|
|
766
862
|
}
|
|
863
|
+
/**
|
|
864
|
+
* Force the local end of one turn whose agent never acknowledged it: clear
|
|
865
|
+
* `running`, open the settle gate, and broadcast the turn end with `turnId:
|
|
866
|
+
* ''` (there is no agent response to attribute it to). Shared by the cancel
|
|
867
|
+
* fallback and the stall watchdog.
|
|
868
|
+
*
|
|
869
|
+
* Generation-guarded: a turn that already ended — or that a later turn
|
|
870
|
+
* superseded — is not this call's to finalize. Returns whether it acted; a
|
|
871
|
+
* `false` means the caller was late and must leave the session alone.
|
|
872
|
+
*/
|
|
873
|
+
finalizeTurn(session, turnGen, message) {
|
|
874
|
+
if (session.closed || session.turnSeq !== turnGen)
|
|
875
|
+
return false;
|
|
876
|
+
// Nothing is in flight anymore (the turn settled on its own): nothing to
|
|
877
|
+
// finalize. A still-pending settle gate counts as in flight even though
|
|
878
|
+
// the stream is gone — that gate is what the next prompt waits on.
|
|
879
|
+
if (!session.running && session.settled === undefined)
|
|
880
|
+
return false;
|
|
881
|
+
session.running = false;
|
|
882
|
+
this.openSettleGate(session);
|
|
883
|
+
this.hub.broadcast({
|
|
884
|
+
type: 'turn/end',
|
|
885
|
+
sessionId: session.sessionId,
|
|
886
|
+
turnId: '',
|
|
887
|
+
error: message,
|
|
888
|
+
});
|
|
889
|
+
return true;
|
|
890
|
+
}
|
|
767
891
|
/**
|
|
768
892
|
* Bring a released session back on a fresh agent process via
|
|
769
893
|
* `session/load` ("send to recover"). Concurrent callers share one inflight
|
|
@@ -812,6 +936,13 @@ export class AgentBridge {
|
|
|
812
936
|
session.running = false;
|
|
813
937
|
session.closed = false;
|
|
814
938
|
session.released = false;
|
|
939
|
+
// A resume is a fresh agent-side state: nothing from the dead process's
|
|
940
|
+
// turn survives it, including the settle gate and the stall markers.
|
|
941
|
+
session.orphanPromptTurnSeq = undefined;
|
|
942
|
+
session.reclaimedTurnSeq = undefined;
|
|
943
|
+
session.activeToolCalls.clear();
|
|
944
|
+
session.lastAgentActivityAt = Date.now();
|
|
945
|
+
this.openSettleGate(session);
|
|
815
946
|
// Route replay updates into the fresh transcript while the load request
|
|
816
947
|
// is in flight, and let the browser drop its blocks/watermark first.
|
|
817
948
|
this.acpIndex.set(session.acpSessionId, session);
|
|
@@ -848,22 +979,33 @@ export class AgentBridge {
|
|
|
848
979
|
this.sessions.delete(session.sessionId);
|
|
849
980
|
session.released = true;
|
|
850
981
|
this.released.set(session.sessionId, session);
|
|
851
|
-
this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason: 'resume failed' });
|
|
982
|
+
this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason: 'resume failed', cause: 'resume-failed' });
|
|
852
983
|
throw error;
|
|
853
984
|
}
|
|
854
985
|
}
|
|
855
986
|
/** Release all live sessions (process died or went idle): keep them resumable, settle permissions, drop the connection. */
|
|
856
|
-
releaseAll(reason) {
|
|
987
|
+
releaseAll(reason, cause) {
|
|
857
988
|
this.clearIdleTimer();
|
|
858
989
|
for (const session of [...this.sessions.values()]) {
|
|
990
|
+
// A session already being closed is `closeSession`'s to delete. Marking
|
|
991
|
+
// it released would park it as resumable, and `doResumeSession` flips
|
|
992
|
+
// `closed` back to false — an idle release resurrecting a closed session.
|
|
993
|
+
if (session.closed)
|
|
994
|
+
continue;
|
|
859
995
|
if (session.running) {
|
|
860
996
|
session.running = false;
|
|
861
997
|
this.hub.broadcast({ type: 'turn/end', sessionId: session.sessionId, turnId: '', error: reason });
|
|
862
998
|
}
|
|
999
|
+
// The process that owed the turn is gone: leaving its settle gate closed
|
|
1000
|
+
// would make the next prompt wait for an answer that can no longer come.
|
|
1001
|
+
this.openSettleGate(session);
|
|
1002
|
+
// No call from the dead process can still be running: drop the tool set
|
|
1003
|
+
// so a resumed turn starts on the plain window.
|
|
1004
|
+
session.activeToolCalls.clear();
|
|
863
1005
|
session.released = true;
|
|
864
1006
|
this.settlePermissions(session);
|
|
865
1007
|
this.released.set(session.sessionId, session);
|
|
866
|
-
this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason });
|
|
1008
|
+
this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason, cause });
|
|
867
1009
|
}
|
|
868
1010
|
this.sessions.clear();
|
|
869
1011
|
this.acpIndex.clear();
|
|
@@ -896,14 +1038,18 @@ export class AgentBridge {
|
|
|
896
1038
|
return this.resuming.get(sessionId);
|
|
897
1039
|
}
|
|
898
1040
|
/** Run one host→agent command as work, so an idle teardown cannot cut it. */
|
|
899
|
-
async runCommand(request) {
|
|
900
|
-
this.pendingCommands
|
|
1041
|
+
async runCommand(session, request) {
|
|
1042
|
+
this.pendingCommands.set(session.sessionId, (this.pendingCommands.get(session.sessionId) ?? 0) + 1);
|
|
901
1043
|
this.touch();
|
|
902
1044
|
try {
|
|
903
1045
|
return await request();
|
|
904
1046
|
}
|
|
905
1047
|
finally {
|
|
906
|
-
this.pendingCommands
|
|
1048
|
+
const remaining = (this.pendingCommands.get(session.sessionId) ?? 1) - 1;
|
|
1049
|
+
if (remaining > 0)
|
|
1050
|
+
this.pendingCommands.set(session.sessionId, remaining);
|
|
1051
|
+
else
|
|
1052
|
+
this.pendingCommands.delete(session.sessionId);
|
|
907
1053
|
this.touch();
|
|
908
1054
|
}
|
|
909
1055
|
}
|
|
@@ -911,7 +1057,7 @@ export class AgentBridge {
|
|
|
911
1057
|
const conn = this.conn;
|
|
912
1058
|
if (conn === undefined)
|
|
913
1059
|
throw new AcpError('agent_unreachable', 'acp: connection is not open');
|
|
914
|
-
await this.runCommand(() => conn.agent.request(methods.agent.session.setMode, { sessionId: session.acpSessionId, modeId }));
|
|
1060
|
+
await this.runCommand(session, () => conn.agent.request(methods.agent.session.setMode, { sessionId: session.acpSessionId, modeId }));
|
|
915
1061
|
// Well-behaved agents push `current_mode_update`, but the spec does not
|
|
916
1062
|
// require it — apply locally so the browser selector never goes stale.
|
|
917
1063
|
if (session.modes !== undefined)
|
|
@@ -927,7 +1073,7 @@ export class AgentBridge {
|
|
|
927
1073
|
const params = typeof value === 'boolean'
|
|
928
1074
|
? { sessionId: session.acpSessionId, configId, value, type: 'boolean' }
|
|
929
1075
|
: { sessionId: session.acpSessionId, configId, value };
|
|
930
|
-
const result = await this.runCommand(() => conn.agent.request(methods.agent.session.setConfigOption, params));
|
|
1076
|
+
const result = await this.runCommand(session, () => conn.agent.request(methods.agent.session.setConfigOption, params));
|
|
931
1077
|
// The response carries the full authoritative option set; some agents
|
|
932
1078
|
// (pi-acp observed) never re-notify after a client-driven set. It feeds the
|
|
933
1079
|
// SESSION snapshot first — `currentValue` in a per-session answer describes
|
|
@@ -960,8 +1106,25 @@ export class AgentBridge {
|
|
|
960
1106
|
this.settlePermissions(session);
|
|
961
1107
|
const conn = this.conn;
|
|
962
1108
|
if (conn !== undefined && this.state === 'ready') {
|
|
963
|
-
// `session/close` is an optional capability; the local teardown stands
|
|
964
|
-
|
|
1109
|
+
// `session/close` is an optional capability; the local teardown stands
|
|
1110
|
+
// regardless. Bound the wait: a wedged agent that ignores the request
|
|
1111
|
+
// must not hang the caller (the stall reclaim stops through here), so
|
|
1112
|
+
// the request is raced against a timeout and left behind if it loses.
|
|
1113
|
+
const close = conn.agent.request(methods.agent.session.close, { sessionId: session.acpSessionId }).catch(() => { });
|
|
1114
|
+
let timer;
|
|
1115
|
+
try {
|
|
1116
|
+
await Promise.race([
|
|
1117
|
+
close,
|
|
1118
|
+
new Promise(resolve => {
|
|
1119
|
+
timer = setTimeout(resolve, SESSION_CLOSE_TIMEOUT_MS);
|
|
1120
|
+
timer.unref?.();
|
|
1121
|
+
}),
|
|
1122
|
+
]);
|
|
1123
|
+
}
|
|
1124
|
+
finally {
|
|
1125
|
+
if (timer !== undefined)
|
|
1126
|
+
clearTimeout(timer);
|
|
1127
|
+
}
|
|
965
1128
|
}
|
|
966
1129
|
this.sessions.delete(session.sessionId);
|
|
967
1130
|
this.acpIndex.delete(session.acpSessionId);
|
|
@@ -988,6 +1151,35 @@ export class AgentBridge {
|
|
|
988
1151
|
this.initResult = undefined;
|
|
989
1152
|
this.publishStatus();
|
|
990
1153
|
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Drop every live session record and kill the child WITHOUT asking the agent
|
|
1156
|
+
* to close them. `stop` sends `session/close` per session, and a wedged agent
|
|
1157
|
+
* that ignores it would hang the caller (the stall reclaim) — the very agent
|
|
1158
|
+
* this teardown exists to get rid of. The local teardown is what frees the
|
|
1159
|
+
* sessions, so it runs unconditionally and immediately.
|
|
1160
|
+
*
|
|
1161
|
+
* Deliberately does NOT clear `released`: those records are constraint 10's
|
|
1162
|
+
* — a released session survives a process death and is resumed by its next
|
|
1163
|
+
* message. This is a stop, not a discard.
|
|
1164
|
+
*/
|
|
1165
|
+
forceReset(reason) {
|
|
1166
|
+
this.clearIdleTimer();
|
|
1167
|
+
for (const session of [...this.sessions.values()]) {
|
|
1168
|
+
session.closed = true;
|
|
1169
|
+
this.settlePermissions(session);
|
|
1170
|
+
this.openSettleGate(session);
|
|
1171
|
+
session.activeToolCalls.clear();
|
|
1172
|
+
this.sessions.delete(session.sessionId);
|
|
1173
|
+
this.acpIndex.delete(session.acpSessionId);
|
|
1174
|
+
this.hub.broadcast({ type: 'session/closed', sessionId: session.sessionId, reason });
|
|
1175
|
+
}
|
|
1176
|
+
this.conn?.close();
|
|
1177
|
+
this.conn = undefined;
|
|
1178
|
+
this.killChild();
|
|
1179
|
+
this.state = 'stopped';
|
|
1180
|
+
this.initResult = undefined;
|
|
1181
|
+
this.publishStatus();
|
|
1182
|
+
}
|
|
991
1183
|
killChild() {
|
|
992
1184
|
const child = this.child;
|
|
993
1185
|
this.child = undefined;
|
|
@@ -1046,6 +1238,9 @@ export class AgentBridge {
|
|
|
1046
1238
|
const session = this.acpIndex.get(acpSessionId);
|
|
1047
1239
|
if (session === undefined || session.closed)
|
|
1048
1240
|
return { outcome: { outcome: 'cancelled' } };
|
|
1241
|
+
// An ask is the agent speaking, not silence: the stall watchdog measures
|
|
1242
|
+
// from here, and the pending ask exempts the session anyway.
|
|
1243
|
+
session.lastAgentActivityAt = Date.now();
|
|
1049
1244
|
// The deadline and the mode are read ONCE, here: an ask that started under
|
|
1050
1245
|
// one pair keeps it, even if the settings page rewrites them mid-flight.
|
|
1051
1246
|
const mode = this.permissionTimeoutMode;
|
|
@@ -1079,6 +1274,9 @@ export class AgentBridge {
|
|
|
1079
1274
|
clearTimeout(timer);
|
|
1080
1275
|
detachSignal?.();
|
|
1081
1276
|
this.touch();
|
|
1277
|
+
// The answer releases the agent to continue; the turn's clock restarts
|
|
1278
|
+
// here, not at the ask that may have waited on a human for minutes.
|
|
1279
|
+
session.lastAgentActivityAt = Date.now();
|
|
1082
1280
|
this.hub.broadcast({
|
|
1083
1281
|
type: 'permission/resolved',
|
|
1084
1282
|
requestId,
|
|
@@ -1169,6 +1367,7 @@ export class AgentBridge {
|
|
|
1169
1367
|
* hanging is worse than either.
|
|
1170
1368
|
*/
|
|
1171
1369
|
settleExpiredAsk(session, mode, toolCall, options, timeoutMs) {
|
|
1370
|
+
session.lastAgentActivityAt = Date.now();
|
|
1172
1371
|
const outcome = this.announceExpiredAsk(session, mode, toolCall, options, timeoutMs);
|
|
1173
1372
|
return outcome.outcome === 'selected'
|
|
1174
1373
|
? { outcome: { outcome: 'selected', optionId: outcome.optionId } }
|
|
@@ -1236,6 +1435,23 @@ export class AgentBridge {
|
|
|
1236
1435
|
const session = this.acpIndex.get(params.sessionId);
|
|
1237
1436
|
if (session === undefined)
|
|
1238
1437
|
return; // update for a session already closed locally
|
|
1438
|
+
// Any update is the agent speaking: restart the stall clock here. A
|
|
1439
|
+
// tool-call status also says whether the agent is inside a long-running
|
|
1440
|
+
// tool, which earns the wider watchdog window.
|
|
1441
|
+
session.lastAgentActivityAt = Date.now();
|
|
1442
|
+
const kind = params.update.sessionUpdate;
|
|
1443
|
+
if (kind === 'tool_call' || kind === 'tool_call_update') {
|
|
1444
|
+
const { toolCallId, status } = params.update;
|
|
1445
|
+
if (status === 'completed' || status === 'failed')
|
|
1446
|
+
session.activeToolCalls.delete(toolCallId);
|
|
1447
|
+
else if (status === 'pending' || status === 'in_progress')
|
|
1448
|
+
session.activeToolCalls.add(toolCallId);
|
|
1449
|
+
// A `tool_call` without a status is a call the agent just announced and
|
|
1450
|
+
// has not finished: count it as in flight. On an UPDATE the status is
|
|
1451
|
+
// optional, and an absent one carries no news either way.
|
|
1452
|
+
else if (kind === 'tool_call' && (status === undefined || status === null))
|
|
1453
|
+
session.activeToolCalls.add(toolCallId);
|
|
1454
|
+
}
|
|
1239
1455
|
this.applyDerived(session, params.update);
|
|
1240
1456
|
const seq = session.transcript.append(params.update);
|
|
1241
1457
|
this.hub.broadcast({ type: 'session/update', sessionId: session.sessionId, seq, update: params.update });
|
|
@@ -1653,6 +1869,14 @@ export const AGENT_PRESETS = [
|
|
|
1653
1869
|
defaultCommand: 'opencode',
|
|
1654
1870
|
defaultArgs: ['acp'],
|
|
1655
1871
|
},
|
|
1872
|
+
{
|
|
1873
|
+
id: 'cursor',
|
|
1874
|
+
name: 'Cursor CLI',
|
|
1875
|
+
description: 'Cursor CLI — run as `agent acp` over stdio (legacy alias `cursor-agent acp`)',
|
|
1876
|
+
executables: ['agent', 'cursor-agent'],
|
|
1877
|
+
defaultCommand: 'agent',
|
|
1878
|
+
defaultArgs: ['acp'],
|
|
1879
|
+
},
|
|
1656
1880
|
{
|
|
1657
1881
|
id: 'qwen',
|
|
1658
1882
|
name: 'Qwen Code',
|
|
@@ -2102,6 +2326,10 @@ class TurnStreamController {
|
|
|
2102
2326
|
get hasSettled() {
|
|
2103
2327
|
return this.settled;
|
|
2104
2328
|
}
|
|
2329
|
+
/** Whether a segment is on air — the agent is expected to speak into a live driver step. */
|
|
2330
|
+
get hasOpenSegment() {
|
|
2331
|
+
return this.segment !== undefined && !this.segment.finished;
|
|
2332
|
+
}
|
|
2105
2333
|
/** Generation guard: a controller whose turn has been superseded must not touch the session. */
|
|
2106
2334
|
get stale() {
|
|
2107
2335
|
return this.disposed || this.session.turnSeq !== this.turnSeqAtStart;
|
|
@@ -2115,6 +2343,13 @@ class TurnStreamController {
|
|
|
2115
2343
|
* consuming it, so the next segment replays it again.
|
|
2116
2344
|
*/
|
|
2117
2345
|
startSegment() {
|
|
2346
|
+
// A step opening is the HOST handing the turn back to the agent — this
|
|
2347
|
+
// runs for the first step and for each continuation after the host
|
|
2348
|
+
// executed a card. The watchdog measures the stretch the AGENT holds the
|
|
2349
|
+
// floor, so the bounded gap a host step owns must not count as agent
|
|
2350
|
+
// silence; resetting here cannot mask a later agent silence because the
|
|
2351
|
+
// reset itself is bounded by one driver step.
|
|
2352
|
+
this.session.lastAgentActivityAt = Date.now();
|
|
2118
2353
|
const seg = {
|
|
2119
2354
|
queue: new AsyncQueue(),
|
|
2120
2355
|
index: -1,
|
|
@@ -2154,53 +2389,61 @@ class TurnStreamController {
|
|
|
2154
2389
|
if (this.settled)
|
|
2155
2390
|
return;
|
|
2156
2391
|
this.settled = true;
|
|
2392
|
+
// A late answer to a SUPERSEDED turn still has to close this controller's
|
|
2393
|
+
// queue (a continuation step may be parked on it) and its settle gate —
|
|
2394
|
+
// but it must not touch the session the NEWER turn now owns, nor release
|
|
2395
|
+
// process-wide waiters that belong to that turn.
|
|
2396
|
+
const current = this.session.turnSeq === this.turnSeqAtStart;
|
|
2157
2397
|
const reason = outcome.kind === 'cancelled'
|
|
2158
2398
|
? { kind: 'aborted', failure: { message: 'Turn was cancelled', code: 'CANCELLED' } }
|
|
2159
2399
|
: outcome.kind === 'error'
|
|
2160
|
-
? { kind: 'error', failure: { message: outcome.message, code: 'INTERNAL' } }
|
|
2400
|
+
? { kind: 'error', failure: { message: outcome.message, code: outcome.code ?? 'INTERNAL' } }
|
|
2161
2401
|
: { kind: 'stop' };
|
|
2162
|
-
if (
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
this.session.provisionalLock
|
|
2175
|
-
|
|
2176
|
-
|
|
2402
|
+
if (current) {
|
|
2403
|
+
if (outcome.kind === 'cancelled') {
|
|
2404
|
+
this.hub.broadcast({ type: 'turn/end', sessionId: this.session.sessionId, turnId: this.turnId, stopReason: 'cancelled' });
|
|
2405
|
+
}
|
|
2406
|
+
else if (outcome.kind === 'error') {
|
|
2407
|
+
this.hub.broadcast({ type: 'turn/end', sessionId: this.session.sessionId, turnId: this.turnId, error: outcome.message });
|
|
2408
|
+
}
|
|
2409
|
+
else {
|
|
2410
|
+
this.hub.broadcast({ type: 'turn/end', sessionId: this.session.sessionId, turnId: this.turnId, stopReason: outcome.stopReason });
|
|
2411
|
+
}
|
|
2412
|
+
// The provisional lock settles BEFORE any synthesized warning below: the
|
|
2413
|
+
// warning is our text, not evidence that the first turn delivered anything.
|
|
2414
|
+
if (this.session.provisionalLock === true) {
|
|
2415
|
+
this.session.provisionalLock = false;
|
|
2416
|
+
if (!this.sawVisibleBlock)
|
|
2417
|
+
this.rollbackFirstTurn(this.session);
|
|
2418
|
+
}
|
|
2177
2419
|
}
|
|
2178
2420
|
const seg = this.segment;
|
|
2179
2421
|
if (seg !== undefined) {
|
|
2180
|
-
if (outcome.kind
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
// plane renders. Aborted or failing turns need no extra hint; this
|
|
2187
|
-
// is specifically "protocol says success, the bubble is empty".
|
|
2188
|
-
const line = `\n[dsh-ntr] agent "${this.session.agentId}" ended the turn with no visible output (stop reason: ${outcome.stopReason}). Some agents fold upstream failures — auth, billing, rate limits — into a normal stop; try resending, and check the agent process logs if it repeats.\n`;
|
|
2189
|
-
this.emitProse(seg, 'text', line);
|
|
2190
|
-
}
|
|
2191
|
-
this.finishSegment(seg, reason);
|
|
2422
|
+
if (current && outcome.kind === 'stop' && !this.sawVisibleBlock) {
|
|
2423
|
+
// The silent-failure report: a normal stop with nothing the chat
|
|
2424
|
+
// plane renders. Aborted or failing turns need no extra hint; this
|
|
2425
|
+
// is specifically "protocol says success, the bubble is empty".
|
|
2426
|
+
const line = `\n[dsh-ntr] agent "${this.session.agentId}" ended the turn with no visible output (stop reason: ${outcome.stopReason}). Some agents fold upstream failures — auth, billing, rate limits — into a normal stop; try resending, and check the agent process logs if it repeats.\n`;
|
|
2427
|
+
this.emitProse(seg, 'text', line);
|
|
2192
2428
|
}
|
|
2429
|
+
this.finishSegment(seg, reason);
|
|
2193
2430
|
}
|
|
2194
2431
|
// When no segment is on air (the turn ended on a card) the FINAL
|
|
2195
2432
|
// continuation step collects the tail: `startSegment` sees `settled` and
|
|
2196
2433
|
// finishes it with the appropriate closing line.
|
|
2197
|
-
|
|
2434
|
+
if (current) {
|
|
2435
|
+
this.session.running = false;
|
|
2436
|
+
// The turn is over: no call from it can still earn the long tool window.
|
|
2437
|
+
this.session.activeToolCalls.clear();
|
|
2438
|
+
releaseAllToolOutputWaiters();
|
|
2439
|
+
}
|
|
2198
2440
|
this.endTurn();
|
|
2199
|
-
releaseAllToolOutputWaiters();
|
|
2200
2441
|
// The controller stays indexed only while a continuation step can still
|
|
2201
2442
|
// come for its cards. A cancelled, failed, or card-less turn has none:
|
|
2202
2443
|
// retire it now; the next turn's `rememberTurnStream` would anyway.
|
|
2203
|
-
if (
|
|
2444
|
+
if (!current)
|
|
2445
|
+
this.retire();
|
|
2446
|
+
else if (outcome.kind !== 'stop' || this.seenCalls.size === 0)
|
|
2204
2447
|
this.dispose();
|
|
2205
2448
|
}
|
|
2206
2449
|
/** Cancelled by the user: finish the on-air segment and release the turn's slots. */
|
|
@@ -2224,12 +2467,28 @@ class TurnStreamController {
|
|
|
2224
2467
|
}
|
|
2225
2468
|
/** Stop observing and release the manager's index. Idempotent. */
|
|
2226
2469
|
dispose() {
|
|
2470
|
+
if (this.disposed)
|
|
2471
|
+
return;
|
|
2472
|
+
this.retire();
|
|
2473
|
+
releaseAllToolOutputWaiters();
|
|
2474
|
+
}
|
|
2475
|
+
/**
|
|
2476
|
+
* Stop observing and release the manager's index WITHOUT touching the
|
|
2477
|
+
* process-wide tool-output waiters. A late settle of a superseded turn must
|
|
2478
|
+
* use this: those waiters now belong to the turn on air, and waking them
|
|
2479
|
+
* early would hand the guard a "no output" answer for a tool still running.
|
|
2480
|
+
*/
|
|
2481
|
+
retire() {
|
|
2227
2482
|
if (this.disposed)
|
|
2228
2483
|
return;
|
|
2229
2484
|
this.disposed = true;
|
|
2230
2485
|
this.unsubscribe();
|
|
2486
|
+
// The session must not keep pointing at a retired turn: the stall watchdog
|
|
2487
|
+
// reads this slot as "what is streaming now". A newer controller may have
|
|
2488
|
+
// replaced it since, and that one is not ours to clear.
|
|
2489
|
+
if (this.session.turnController === this)
|
|
2490
|
+
this.session.turnController = undefined;
|
|
2231
2491
|
this.manager.forgetTurnStream(this);
|
|
2232
|
-
releaseAllToolOutputWaiters();
|
|
2233
2492
|
}
|
|
2234
2493
|
// ---- internals ----
|
|
2235
2494
|
finishSegment(seg, reason) {
|
|
@@ -2627,6 +2886,105 @@ export class BridgeManager {
|
|
|
2627
2886
|
}
|
|
2628
2887
|
}
|
|
2629
2888
|
}
|
|
2889
|
+
/**
|
|
2890
|
+
* The stall watchdog's scan loop. Armed lazily at the first turn start and
|
|
2891
|
+
* DROPPED once nothing anywhere is running: a manager that never runs a turn
|
|
2892
|
+
* never holds a timer, and one that goes quiet does not keep the host awake.
|
|
2893
|
+
*/
|
|
2894
|
+
stallTimer;
|
|
2895
|
+
armStallTimer() {
|
|
2896
|
+
// `turnStallMs === 0` disables the WHOLE watchdog — the base window is the
|
|
2897
|
+
// switch. `turnStallToolMs` cannot re-enable it: that number only widens
|
|
2898
|
+
// the window for a turn that already has a tool in flight.
|
|
2899
|
+
if (this.tuning.turnStallMs <= 0 || this.stallTimer !== undefined)
|
|
2900
|
+
return;
|
|
2901
|
+
const timer = setInterval(() => { this.reapStalledTurns(); }, Math.max(1, this.tuning.turnStallTickMs));
|
|
2902
|
+
timer.unref?.();
|
|
2903
|
+
this.stallTimer = timer;
|
|
2904
|
+
}
|
|
2905
|
+
/**
|
|
2906
|
+
* Force-finalize turns the agent has gone silent in.
|
|
2907
|
+
*
|
|
2908
|
+
* "Silent" is scoped to the stretch where the AGENT owes the turn a word: a
|
|
2909
|
+
* running session with a segment on air and no pending ask. A turn waiting on
|
|
2910
|
+
* the host (tool cards executing between segments) or on a human (an
|
|
2911
|
+
* unanswered permission) is not stuck and is skipped.
|
|
2912
|
+
*
|
|
2913
|
+
* Firing does NOT kill the process (see `LiveSession.orphanPromptTurnSeq`):
|
|
2914
|
+
* the session, its transcript and its binding survive, and the next message
|
|
2915
|
+
* reclaims the process. The turn's streaming face ends through its own
|
|
2916
|
+
* controller when there is one, so the driver step on air closes with an
|
|
2917
|
+
* error instead of being torn down mid-stream.
|
|
2918
|
+
*/
|
|
2919
|
+
reapStalledTurns() {
|
|
2920
|
+
const now = Date.now();
|
|
2921
|
+
let anyRunning = false;
|
|
2922
|
+
for (const bridge of this.bridges.values()) {
|
|
2923
|
+
for (const session of bridge.sessionList) {
|
|
2924
|
+
if (session.running)
|
|
2925
|
+
anyRunning = true;
|
|
2926
|
+
if (!session.running || session.closed || session.released)
|
|
2927
|
+
continue;
|
|
2928
|
+
// A question is on the table: the agent is waiting on a human, not
|
|
2929
|
+
// stuck, and that wait is the permission deadline's business.
|
|
2930
|
+
if (bridge.permissions.hasPendingFor(session.sessionId))
|
|
2931
|
+
continue;
|
|
2932
|
+
// The controller this turn is streaming through. A chat turn is indexed
|
|
2933
|
+
// by chat id (its continuation steps look it up that way) AND attached
|
|
2934
|
+
// to the session; an EPHEMERAL turn has no chat id and therefore only
|
|
2935
|
+
// the attachment — treating it as an `/acp` turn would finalize the
|
|
2936
|
+
// session state without closing the driver's segment queue.
|
|
2937
|
+
const indexed = session.dshSessionId === undefined ? undefined : this.turnStreams.get(session.dshSessionId);
|
|
2938
|
+
// An index hit only speaks for THIS session: a concurrent first turn
|
|
2939
|
+
// can have mapped another LiveSession under the same chat id.
|
|
2940
|
+
const attached = session.turnController
|
|
2941
|
+
?? (indexed !== undefined && indexed.session === session ? indexed : undefined);
|
|
2942
|
+
// A controller from an EARLIER turn is stale the moment any newer turn
|
|
2943
|
+
// (e.g. an `/acp` prompt on the same chat) advances `turnSeq`: its
|
|
2944
|
+
// segment is already closed, `onAir` would read false, and the
|
|
2945
|
+
// watchdog would skip this turn forever. Treat it as absent.
|
|
2946
|
+
const live = attached !== undefined && !attached.stale ? attached : undefined;
|
|
2947
|
+
// An `/acp`-RPC turn has no driver stream, so "on air" reads as true.
|
|
2948
|
+
const onAir = live === undefined ? true : live.hasOpenSegment;
|
|
2949
|
+
if (!onAir)
|
|
2950
|
+
continue;
|
|
2951
|
+
const toolWindow = this.tuning.turnStallToolMs > 0 ? this.tuning.turnStallToolMs : this.tuning.turnStallMs;
|
|
2952
|
+
const threshold = session.activeToolCalls.size > 0 ? toolWindow : this.tuning.turnStallMs;
|
|
2953
|
+
if (now - session.lastAgentActivityAt < threshold)
|
|
2954
|
+
continue;
|
|
2955
|
+
void bridge.cancel(session);
|
|
2956
|
+
session.orphanPromptTurnSeq = session.turnSeq;
|
|
2957
|
+
const turnGen = session.turnSeq;
|
|
2958
|
+
const message = `acp: agent "${session.agentId}" sent no activity for ${String(Math.round(threshold / 1000))}s; the stalled turn was force-finalized`;
|
|
2959
|
+
// A provisional first turn that showed nothing is about to be rolled
|
|
2960
|
+
// back by `settle` (lock released, session closed), so the notice must
|
|
2961
|
+
// not promise a resume that will not happen.
|
|
2962
|
+
const willRollback = live !== undefined && live.session.provisionalLock === true && !live.sawVisibleBlock;
|
|
2963
|
+
if (live !== undefined)
|
|
2964
|
+
live.settle({ kind: 'error', message, code: 'INACTIVITY_TIMEOUT' });
|
|
2965
|
+
else
|
|
2966
|
+
bridge.finalizeTurn(session, turnGen, message);
|
|
2967
|
+
// What the next message can do depends on the capability the agent
|
|
2968
|
+
// advertised at connect time, and on whether anyone else is using it.
|
|
2969
|
+
const capabilities = bridge.view().agentCapabilities;
|
|
2970
|
+
const next = willRollback
|
|
2971
|
+
? 'This was the conversation\'s first turn and it delivered nothing, so it was discarded; the next message starts a new ACP session.'
|
|
2972
|
+
: capabilities?.loadSession === true
|
|
2973
|
+
? 'The next message will restart the agent and resume the session.'
|
|
2974
|
+
: 'The next message will reset the ACP session; this agent does not support session resume.';
|
|
2975
|
+
this.hub.broadcast({
|
|
2976
|
+
type: 'agent/notice',
|
|
2977
|
+
agentId: session.agentId,
|
|
2978
|
+
level: 'error',
|
|
2979
|
+
text: `${message} ${next} If another conversation is still using the agent, the next send may be refused until that turn finishes.`,
|
|
2980
|
+
});
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
if (!anyRunning && this.stallTimer !== undefined) {
|
|
2984
|
+
clearInterval(this.stallTimer);
|
|
2985
|
+
this.stallTimer = undefined;
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2630
2988
|
constructor(defs, hub, transcriptMax, configStoragePath, catalogStoragePath,
|
|
2631
2989
|
/** Auto-probe agents for model catalogs (boot + lazy); manual probes always run. */
|
|
2632
2990
|
probeModelsOnBoot = true, tuning) {
|
|
@@ -3374,6 +3732,39 @@ export class BridgeManager {
|
|
|
3374
3732
|
}
|
|
3375
3733
|
return acpPresetStatusFromRoster(rows, ACP_PRESET_ID);
|
|
3376
3734
|
}
|
|
3735
|
+
/** The installed package's version (the settings page's badge). */
|
|
3736
|
+
pluginVersion() {
|
|
3737
|
+
return PLUGIN_VERSION;
|
|
3738
|
+
}
|
|
3739
|
+
/** Wire the built-in updater (host entry only; absent in pure tests). */
|
|
3740
|
+
setUpdateService(service) {
|
|
3741
|
+
this.updateService = service;
|
|
3742
|
+
}
|
|
3743
|
+
/** Registry check for the settings card; `unsupported` when no updater is wired. */
|
|
3744
|
+
async pluginUpdateCheck() {
|
|
3745
|
+
return this.updateService?.check() ?? { status: 'unsupported', current: PLUGIN_VERSION };
|
|
3746
|
+
}
|
|
3747
|
+
/** Delegate one install to the host plugin manager; never touches files itself. */
|
|
3748
|
+
async pluginUpdateApply(version) {
|
|
3749
|
+
return this.updateService?.apply(version) ?? { status: 'unsupported', current: PLUGIN_VERSION };
|
|
3750
|
+
}
|
|
3751
|
+
/**
|
|
3752
|
+
* Whether any ACP work is in flight. An update install may restart the host,
|
|
3753
|
+
* so check/apply describe the host as busy while a turn is running.
|
|
3754
|
+
*
|
|
3755
|
+
* Segmented turn streams count even between steps (the host is still
|
|
3756
|
+
* continuing that turn); `running` / `settled` on a session covers `/acp`
|
|
3757
|
+
* turns and the window between a prompt and the agent's acknowledgement.
|
|
3758
|
+
*/
|
|
3759
|
+
hasActiveTurns() {
|
|
3760
|
+
if (this.turnStreams.size > 0)
|
|
3761
|
+
return true;
|
|
3762
|
+
for (const bridge of this.bridges.values()) {
|
|
3763
|
+
if (bridge.hasActiveTurn())
|
|
3764
|
+
return true;
|
|
3765
|
+
}
|
|
3766
|
+
return false;
|
|
3767
|
+
}
|
|
3377
3768
|
getStoragePath() {
|
|
3378
3769
|
if (this.configStoragePath !== undefined)
|
|
3379
3770
|
return this.configStoragePath;
|
|
@@ -4263,7 +4654,7 @@ export class BridgeManager {
|
|
|
4263
4654
|
// here (their `runPrompt` promise also rejects into settle, but this also
|
|
4264
4655
|
// covers a promise the connection teardown leaves pending).
|
|
4265
4656
|
this.disposeTurnStreamsOf(controller => controller.session.agentId === bridge.def.id);
|
|
4266
|
-
bridge.releaseAll(reason);
|
|
4657
|
+
bridge.releaseAll(reason, 'died');
|
|
4267
4658
|
}
|
|
4268
4659
|
agentViews() {
|
|
4269
4660
|
return [...this.defsMap.values()].map(def => (this.bridges.get(def.id) ?? this.makeBridge(def, () => { })).view());
|
|
@@ -4421,19 +4812,68 @@ export class BridgeManager {
|
|
|
4421
4812
|
return { sessionId: session.sessionId };
|
|
4422
4813
|
}
|
|
4423
4814
|
async prompt(sessionId, text) {
|
|
4424
|
-
|
|
4815
|
+
let located = await this.locateOrResume(sessionId);
|
|
4816
|
+
// The watchdog abandoned a previous turn here and the agent may still be
|
|
4817
|
+
// holding its prompt slot on the old process: reclaim BEFORE sending, or
|
|
4818
|
+
// this message queues behind a turn that will never answer.
|
|
4819
|
+
if (located.session.orphanPromptTurnSeq !== undefined) {
|
|
4820
|
+
const prepared = await this.prepareStalledSession(located.session);
|
|
4821
|
+
if (!prepared.ok)
|
|
4822
|
+
throw new AcpError('agent_unresponsive', prepared.message);
|
|
4823
|
+
// Without `session/load` there is nothing to re-locate: the record was
|
|
4824
|
+
// dropped on purpose, and "/acp" has no conversation to recreate it from.
|
|
4825
|
+
// Re-locating anyway would surface a misleading `unknown_session`.
|
|
4826
|
+
if (prepared.reset === true) {
|
|
4827
|
+
throw new AcpError('session_reset', `acp: agent "${located.session.agentId}" does not support session resume; start a new session`);
|
|
4828
|
+
}
|
|
4829
|
+
// A resumable reclaim released the record: re-locate what is live now.
|
|
4830
|
+
located = await this.locateOrResume(sessionId);
|
|
4831
|
+
}
|
|
4832
|
+
const { bridge, session } = located;
|
|
4425
4833
|
if (session.running)
|
|
4426
4834
|
throw new AcpError('turn_in_flight', 'acp: this session already has a prompt in flight');
|
|
4427
4835
|
session.running = true;
|
|
4428
4836
|
session.turnSeq += 1;
|
|
4837
|
+
const turnGen = session.turnSeq;
|
|
4838
|
+
session.lastAgentActivityAt = Date.now();
|
|
4839
|
+
session.activeToolCalls.clear();
|
|
4840
|
+
// A new turn owns the session from here: any reclaim marker named an
|
|
4841
|
+
// earlier generation and must not suppress THIS turn's settle.
|
|
4842
|
+
session.reclaimedTurnSeq = undefined;
|
|
4843
|
+
this.armStallTimer();
|
|
4429
4844
|
const turnId = randomUUID();
|
|
4430
4845
|
this.hub.broadcast({ type: 'turn/start', sessionId, turnId, text });
|
|
4431
4846
|
void bridge.runPrompt(session, text, turnId)
|
|
4432
4847
|
.then(stopReason => {
|
|
4848
|
+
// The reclaim already ended this turn locally; the answer it caused is
|
|
4849
|
+
// that same turn reporting back, not a second turn end for the caller.
|
|
4850
|
+
if (session.reclaimedTurnSeq === turnGen) {
|
|
4851
|
+
session.reclaimedTurnSeq = undefined;
|
|
4852
|
+
return;
|
|
4853
|
+
}
|
|
4854
|
+
// A superseded turn's late answer must not end the turn now on air.
|
|
4855
|
+
if (session.turnSeq !== turnGen)
|
|
4856
|
+
return;
|
|
4857
|
+
this.clearOrphanPrompt(session, turnGen);
|
|
4433
4858
|
session.running = false;
|
|
4434
4859
|
this.hub.broadcast({ type: 'turn/end', sessionId, turnId, stopReason });
|
|
4435
4860
|
})
|
|
4436
4861
|
.catch((error) => {
|
|
4862
|
+
if (session.reclaimedTurnSeq === turnGen) {
|
|
4863
|
+
session.reclaimedTurnSeq = undefined;
|
|
4864
|
+
return;
|
|
4865
|
+
}
|
|
4866
|
+
if (session.turnSeq !== turnGen)
|
|
4867
|
+
return;
|
|
4868
|
+
// The turn was ended by an EXTERNAL close (idle release, agent stop):
|
|
4869
|
+
// that teardown already broadcast the turn's end, and the session is
|
|
4870
|
+
// parked or closed. Reporting the close's rejection again would put a
|
|
4871
|
+
// second terminal frame on a turn the browser already saw finish.
|
|
4872
|
+
if (session.released || session.closed) {
|
|
4873
|
+
this.clearOrphanPrompt(session, turnGen);
|
|
4874
|
+
return;
|
|
4875
|
+
}
|
|
4876
|
+
this.clearOrphanPrompt(session, turnGen);
|
|
4437
4877
|
session.running = false;
|
|
4438
4878
|
this.hub.broadcast({
|
|
4439
4879
|
type: 'turn/end',
|
|
@@ -4444,6 +4884,11 @@ export class BridgeManager {
|
|
|
4444
4884
|
});
|
|
4445
4885
|
return { turnId };
|
|
4446
4886
|
}
|
|
4887
|
+
/** The late answer landed (or failed): the turn is no longer an orphan. */
|
|
4888
|
+
clearOrphanPrompt(session, turnGen) {
|
|
4889
|
+
if (session.orphanPromptTurnSeq === turnGen)
|
|
4890
|
+
session.orphanPromptTurnSeq = undefined;
|
|
4891
|
+
}
|
|
4447
4892
|
async cancel(sessionId) {
|
|
4448
4893
|
const live = this.tryLocate(sessionId);
|
|
4449
4894
|
if (live === undefined)
|
|
@@ -4513,12 +4958,21 @@ export class BridgeManager {
|
|
|
4513
4958
|
await this.bridges.get(agentId)?.stop('agent-stopped');
|
|
4514
4959
|
}
|
|
4515
4960
|
async shutdownAll() {
|
|
4961
|
+
if (this.stallTimer !== undefined) {
|
|
4962
|
+
clearInterval(this.stallTimer);
|
|
4963
|
+
this.stallTimer = undefined;
|
|
4964
|
+
}
|
|
4516
4965
|
this.dshSessionMap.clear();
|
|
4517
4966
|
this.disposeTurnStreamsOf(() => true);
|
|
4518
4967
|
await Promise.all([...this.bridges.values()].map(b => b.stop('agent-stopped')));
|
|
4519
4968
|
}
|
|
4520
4969
|
/** Host-side reader of the harness preset roster (`ctx.agentPresets`), for `presetStatus`. */
|
|
4521
4970
|
presetRosterReader;
|
|
4971
|
+
/**
|
|
4972
|
+
* The built-in updater, wired by the host entry. Absent in pure bridge tests —
|
|
4973
|
+
* the update RPCs then answer `unsupported` instead of inventing a source.
|
|
4974
|
+
*/
|
|
4975
|
+
updateService;
|
|
4522
4976
|
/**
|
|
4523
4977
|
* The plugin's own default ACP agent (the panel's ★ gesture): which agent an
|
|
4524
4978
|
* ACP-mode conversation starts on. Host-side state in the agent registry
|
|
@@ -4862,6 +5316,83 @@ export class BridgeManager {
|
|
|
4862
5316
|
}
|
|
4863
5317
|
return session;
|
|
4864
5318
|
}
|
|
5319
|
+
/**
|
|
5320
|
+
* Take the agent process back from a turn the stall watchdog abandoned.
|
|
5321
|
+
*
|
|
5322
|
+
* The wedged agent still holds its `session/prompt`, so the next message
|
|
5323
|
+
* cannot simply be sent: it would queue behind a turn that may never answer.
|
|
5324
|
+
* The process is restarted instead — unless another conversation on the same
|
|
5325
|
+
* connection is doing real work, because killing the child destroys THEIR
|
|
5326
|
+
* context too, and "retry after they finish" is the honest answer.
|
|
5327
|
+
*
|
|
5328
|
+
* A restart that can `session/load` costs the conversation nothing: the
|
|
5329
|
+
* session is parked `released` and the normal acquisition path resumes it, so
|
|
5330
|
+
* chat keeps its lock / `applyChatChoice` semantics and `/acp` keeps
|
|
5331
|
+
* `locateOrResume`. An agent without resume support is reset instead, and the
|
|
5332
|
+
* user is told the context is gone.
|
|
5333
|
+
*/
|
|
5334
|
+
async prepareStalledSession(session) {
|
|
5335
|
+
// A session whose `session/close` is still in flight must not be touched:
|
|
5336
|
+
// releasing it would park it as resumable and `doResumeSession` flips
|
|
5337
|
+
// `closed` back to false, resurrecting a closed session. Its owner deletes
|
|
5338
|
+
// the record; the caller's own acquisition creates a new session (chat) or
|
|
5339
|
+
// rejects `session_closed` (`/acp`).
|
|
5340
|
+
if (session.closed)
|
|
5341
|
+
return { ok: true };
|
|
5342
|
+
if (session.released)
|
|
5343
|
+
return { ok: true }; // the acquisition resumes it
|
|
5344
|
+
const bridge = this.bridge(session.agentId);
|
|
5345
|
+
const blocked = `acp: agent "${session.agentId}" is unresponsive, but other conversations are still using it; retry after they finish`;
|
|
5346
|
+
if (bridge.hasOtherActiveWork(session))
|
|
5347
|
+
return { ok: false, message: blocked };
|
|
5348
|
+
// A sibling conversation the agent already settled is not "work" by
|
|
5349
|
+
// `hasOtherActiveWork` — but its HOST may still owe the turn a
|
|
5350
|
+
// continuation step (a card it is executing right now), and restarting the
|
|
5351
|
+
// process would park and resume that live turn. Its indexed controller is
|
|
5352
|
+
// the evidence. Scoped to THIS agent: the restart kills this agent's child
|
|
5353
|
+
// only, so another agent's pending step is not this bridge's business.
|
|
5354
|
+
// A closed or released session has no host behind it (a leftover
|
|
5355
|
+
// controller), so it does not pin the child either.
|
|
5356
|
+
for (const controller of this.turnStreams.values()) {
|
|
5357
|
+
if (controller.session === session)
|
|
5358
|
+
continue;
|
|
5359
|
+
if (controller.session.agentId !== session.agentId)
|
|
5360
|
+
continue;
|
|
5361
|
+
if (controller.session.closed || controller.session.released)
|
|
5362
|
+
continue;
|
|
5363
|
+
if (!controller.stale)
|
|
5364
|
+
return { ok: false, message: blocked };
|
|
5365
|
+
}
|
|
5366
|
+
const capabilities = bridge.view().agentCapabilities;
|
|
5367
|
+
if (capabilities?.loadSession === true) {
|
|
5368
|
+
// Whatever teardown runs, the agent's in-flight `session/prompt` is about
|
|
5369
|
+
// to be rejected by the closing connection. Mark the generation so that
|
|
5370
|
+
// rejection does not broadcast a second `turn/end` for a turn already
|
|
5371
|
+
// finalized by the watchdog (`LiveSession.reclaimedTurnSeq`).
|
|
5372
|
+
session.reclaimedTurnSeq = session.turnSeq;
|
|
5373
|
+
bridge.releaseAll('agent did not acknowledge the stalled turn', 'stalled');
|
|
5374
|
+
session.orphanPromptTurnSeq = undefined;
|
|
5375
|
+
return { ok: true };
|
|
5376
|
+
}
|
|
5377
|
+
// No resume: the restart IS a reset, so only this session's context is at
|
|
5378
|
+
// stake — and any other live session pins the process even while idle.
|
|
5379
|
+
if (bridge.sessionList.some(other => other !== session))
|
|
5380
|
+
return { ok: false, message: blocked };
|
|
5381
|
+
const agentId = session.agentId;
|
|
5382
|
+
// `stop` would ask a wedged agent to close its sessions and wait for an
|
|
5383
|
+
// answer that never comes; `forceReset` is the local half only.
|
|
5384
|
+
session.reclaimedTurnSeq = session.turnSeq;
|
|
5385
|
+
bridge.forceReset('agent-stopped');
|
|
5386
|
+
this.forgetDshSession(session.sessionId);
|
|
5387
|
+
session.orphanPromptTurnSeq = undefined;
|
|
5388
|
+
this.hub.broadcast({
|
|
5389
|
+
type: 'agent/notice',
|
|
5390
|
+
agentId,
|
|
5391
|
+
level: 'info',
|
|
5392
|
+
text: `acp: agent "${agentId}" does not support session resume; the ACP session was reset (context could not be restored)`,
|
|
5393
|
+
});
|
|
5394
|
+
return { ok: true, reset: true };
|
|
5395
|
+
}
|
|
4865
5396
|
async *streamPrompt(options) {
|
|
4866
5397
|
// The plugin's own conversation↔agent mapping decides who serves the turn
|
|
4867
5398
|
// (see `bindChatAgent`): the driver may arrive here with the previous
|
|
@@ -4972,6 +5503,20 @@ export class BridgeManager {
|
|
|
4972
5503
|
return;
|
|
4973
5504
|
}
|
|
4974
5505
|
let session;
|
|
5506
|
+
// Same key `getOrCreateDshSession` looks up: a session the watchdog left
|
|
5507
|
+
// orphaned must be reclaimed BEFORE the acquisition, so this message cannot
|
|
5508
|
+
// queue behind a prompt the agent will never answer.
|
|
5509
|
+
const stalled = this.dshSessionMap.get(`${options.sessionId ?? 'ephemeral'}:${agentId}`);
|
|
5510
|
+
if (stalled !== undefined && stalled.orphanPromptTurnSeq !== undefined) {
|
|
5511
|
+
const prepared = await this.prepareStalledSession(stalled);
|
|
5512
|
+
if (!prepared.ok) {
|
|
5513
|
+
yield {
|
|
5514
|
+
type: 'finish',
|
|
5515
|
+
reason: { kind: 'error', failure: { message: prepared.message, code: 'agent_unresponsive' } },
|
|
5516
|
+
};
|
|
5517
|
+
return;
|
|
5518
|
+
}
|
|
5519
|
+
}
|
|
4975
5520
|
try {
|
|
4976
5521
|
session = await this.getOrCreateDshSession(options.sessionId, agentId);
|
|
4977
5522
|
}
|
|
@@ -5103,6 +5648,13 @@ export class BridgeManager {
|
|
|
5103
5648
|
}
|
|
5104
5649
|
session.running = true;
|
|
5105
5650
|
session.turnSeq += 1;
|
|
5651
|
+
const turnGen = session.turnSeq;
|
|
5652
|
+
session.lastAgentActivityAt = Date.now();
|
|
5653
|
+
session.activeToolCalls.clear();
|
|
5654
|
+
// A new turn owns the session from here: any reclaim marker named an
|
|
5655
|
+
// earlier generation and must not suppress THIS turn's settle.
|
|
5656
|
+
session.reclaimedTurnSeq = undefined;
|
|
5657
|
+
this.armStallTimer();
|
|
5106
5658
|
const turnId = randomUUID();
|
|
5107
5659
|
this.hub.broadcast({ type: 'turn/start', sessionId: session.sessionId, turnId, text: promptText });
|
|
5108
5660
|
// The segmented turn: ONE controller for the whole agent-side prompt, one
|
|
@@ -5114,6 +5666,14 @@ export class BridgeManager {
|
|
|
5114
5666
|
const controller = new TurnStreamController(this, this.hub, session, turnId, endTurn, victim => this.rollbackFirstTurn(victim));
|
|
5115
5667
|
if (session.dshSessionId !== undefined)
|
|
5116
5668
|
this.rememberTurnStream(session.dshSessionId, controller);
|
|
5669
|
+
// The session itself carries the controller too: an EPHEMERAL turn
|
|
5670
|
+
// (`options.sessionId === undefined`) has no chat id to index under, and
|
|
5671
|
+
// the stall watchdog needs SOME route to the controller to close the
|
|
5672
|
+
// driver's segment queue. For chat turns this also disambiguates a chat id
|
|
5673
|
+
// two LiveSessions briefly share (a concurrent first turn). Assigned AFTER
|
|
5674
|
+
// `rememberTurnStream` so the controller it just retired cannot clear this
|
|
5675
|
+
// attachment.
|
|
5676
|
+
session.turnController = controller;
|
|
5117
5677
|
const segment = controller.startSegment();
|
|
5118
5678
|
const onAbort = () => {
|
|
5119
5679
|
if (controller.stale)
|
|
@@ -5133,12 +5693,30 @@ export class BridgeManager {
|
|
|
5133
5693
|
// or a continuation one.
|
|
5134
5694
|
bridge.runPrompt(session, promptText, turnId)
|
|
5135
5695
|
.then(stopReason => {
|
|
5696
|
+
// The reclaim already ended this turn locally, and the close it
|
|
5697
|
+
// performed rejected this very request: the late answer is that same
|
|
5698
|
+
// turn, not a second one to broadcast.
|
|
5699
|
+
if (session.reclaimedTurnSeq === turnGen) {
|
|
5700
|
+
session.reclaimedTurnSeq = undefined;
|
|
5701
|
+
return;
|
|
5702
|
+
}
|
|
5136
5703
|
controller.settle(stopReason === 'cancelled'
|
|
5137
5704
|
? { kind: 'cancelled' }
|
|
5138
5705
|
: { kind: 'stop', stopReason });
|
|
5139
5706
|
})
|
|
5140
5707
|
.catch((err) => {
|
|
5708
|
+
if (session.reclaimedTurnSeq === turnGen) {
|
|
5709
|
+
session.reclaimedTurnSeq = undefined;
|
|
5710
|
+
return;
|
|
5711
|
+
}
|
|
5141
5712
|
controller.settle({ kind: 'error', message: describeError(err) });
|
|
5713
|
+
})
|
|
5714
|
+
// The agent's own answer closed the turn: whatever the watchdog marked
|
|
5715
|
+
// orphaned has just reported back and no longer needs a reclaim.
|
|
5716
|
+
.finally(() => {
|
|
5717
|
+
if (session.reclaimedTurnSeq === turnGen)
|
|
5718
|
+
session.reclaimedTurnSeq = undefined;
|
|
5719
|
+
this.clearOrphanPrompt(session, turnGen);
|
|
5142
5720
|
});
|
|
5143
5721
|
}
|
|
5144
5722
|
try {
|