dsh-ntr 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
- /** Host→agent session commands in flight (mode / config); not turns. */
132
- pendingCommands = 0;
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
@@ -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.pendingCommands > 0
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
@@ -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: '0.1.0' },
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
- if (session.closed || session.turnSeq !== turnGen)
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);
@@ -856,10 +987,21 @@ export class AgentBridge {
856
987
  releaseAll(reason) {
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);
@@ -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 += 1;
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 -= 1;
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 regardless.
964
- await conn.agent.request(methods.agent.session.close, { sessionId: session.acpSessionId }).catch(() => { });
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',
@@ -2051,6 +2275,8 @@ export function releaseAllToolOutputWaiters() {
2051
2275
  for (const waiter of [...set])
2052
2276
  waiter(undefined);
2053
2277
  }
2278
+ /** ACP `usage_update` is context occupancy, not request tokens; host usage is a 4-chars/token OUTPUT estimate (tool args excluded). */
2279
+ const OUTPUT_CHARS_PER_TOKEN = 4;
2054
2280
  /**
2055
2281
  * The state of ONE ACP prompt, shared across every driver step of the turn.
2056
2282
  *
@@ -2059,7 +2285,7 @@ export function releaseAllToolOutputWaiters() {
2059
2285
  * `transcript.since(cursor)` — events that arrive while no segment is open are
2060
2286
  * recorded, never lost), the seen-call registry (a call id gets exactly one
2061
2287
  * card per turn, wherever its restatements land), and the turn-level facts
2062
- * (`sawVisibleBlock`, usage) that outlive any single segment.
2288
+ * (`sawVisibleBlock`) that outlive any single segment.
2063
2289
  */
2064
2290
  class TurnStreamController {
2065
2291
  manager;
@@ -2073,7 +2299,6 @@ class TurnStreamController {
2073
2299
  disposed = false;
2074
2300
  seenCalls = new Set();
2075
2301
  announcedKinds = new Map();
2076
- latestUsage = null;
2077
2302
  /** Narrative lines (permission asks) that arrived while no segment was open. */
2078
2303
  pendingProse = [];
2079
2304
  sawVisibleBlock = false;
@@ -2101,6 +2326,10 @@ class TurnStreamController {
2101
2326
  get hasSettled() {
2102
2327
  return this.settled;
2103
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
+ }
2104
2333
  /** Generation guard: a controller whose turn has been superseded must not touch the session. */
2105
2334
  get stale() {
2106
2335
  return this.disposed || this.session.turnSeq !== this.turnSeqAtStart;
@@ -2114,11 +2343,19 @@ class TurnStreamController {
2114
2343
  * consuming it, so the next segment replays it again.
2115
2344
  */
2116
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();
2117
2353
  const seg = {
2118
2354
  queue: new AsyncQueue(),
2119
2355
  index: -1,
2120
2356
  activeType: null,
2121
2357
  activeText: '',
2358
+ outputChars: 0,
2122
2359
  activeToolCall: undefined,
2123
2360
  hasCard: false,
2124
2361
  finished: false,
@@ -2152,53 +2389,61 @@ class TurnStreamController {
2152
2389
  if (this.settled)
2153
2390
  return;
2154
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;
2155
2397
  const reason = outcome.kind === 'cancelled'
2156
2398
  ? { kind: 'aborted', failure: { message: 'Turn was cancelled', code: 'CANCELLED' } }
2157
2399
  : outcome.kind === 'error'
2158
- ? { kind: 'error', failure: { message: outcome.message, code: 'INTERNAL' } }
2400
+ ? { kind: 'error', failure: { message: outcome.message, code: outcome.code ?? 'INTERNAL' } }
2159
2401
  : { kind: 'stop' };
2160
- if (outcome.kind === 'cancelled') {
2161
- this.hub.broadcast({ type: 'turn/end', sessionId: this.session.sessionId, turnId: this.turnId, stopReason: 'cancelled' });
2162
- }
2163
- else if (outcome.kind === 'error') {
2164
- this.hub.broadcast({ type: 'turn/end', sessionId: this.session.sessionId, turnId: this.turnId, error: outcome.message });
2165
- }
2166
- else {
2167
- this.hub.broadcast({ type: 'turn/end', sessionId: this.session.sessionId, turnId: this.turnId, stopReason: outcome.stopReason });
2168
- }
2169
- // The provisional lock settles BEFORE any synthesized warning below: the
2170
- // warning is our text, not evidence that the first turn delivered anything.
2171
- if (this.session.provisionalLock === true) {
2172
- this.session.provisionalLock = false;
2173
- if (!this.sawVisibleBlock)
2174
- this.rollbackFirstTurn(this.session);
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
+ }
2175
2419
  }
2176
2420
  const seg = this.segment;
2177
2421
  if (seg !== undefined) {
2178
- if (outcome.kind !== 'stop') {
2179
- this.finishSegment(seg, reason);
2180
- }
2181
- else {
2182
- if (!this.sawVisibleBlock) {
2183
- // The silent-failure report: a normal stop with nothing the chat
2184
- // plane renders. Aborted or failing turns need no extra hint; this
2185
- // is specifically "protocol says success, the bubble is empty".
2186
- 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`;
2187
- this.emitProse(seg, 'text', line);
2188
- }
2189
- 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);
2190
2428
  }
2429
+ this.finishSegment(seg, reason);
2191
2430
  }
2192
2431
  // When no segment is on air (the turn ended on a card) the FINAL
2193
2432
  // continuation step collects the tail: `startSegment` sees `settled` and
2194
2433
  // finishes it with the appropriate closing line.
2195
- this.session.running = false;
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
+ }
2196
2440
  this.endTurn();
2197
- releaseAllToolOutputWaiters();
2198
2441
  // The controller stays indexed only while a continuation step can still
2199
2442
  // come for its cards. A cancelled, failed, or card-less turn has none:
2200
2443
  // retire it now; the next turn's `rememberTurnStream` would anyway.
2201
- if (outcome.kind !== 'stop' || this.seenCalls.size === 0)
2444
+ if (!current)
2445
+ this.retire();
2446
+ else if (outcome.kind !== 'stop' || this.seenCalls.size === 0)
2202
2447
  this.dispose();
2203
2448
  }
2204
2449
  /** Cancelled by the user: finish the on-air segment and release the turn's slots. */
@@ -2222,12 +2467,28 @@ class TurnStreamController {
2222
2467
  }
2223
2468
  /** Stop observing and release the manager's index. Idempotent. */
2224
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() {
2225
2482
  if (this.disposed)
2226
2483
  return;
2227
2484
  this.disposed = true;
2228
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;
2229
2491
  this.manager.forgetTurnStream(this);
2230
- releaseAllToolOutputWaiters();
2231
2492
  }
2232
2493
  // ---- internals ----
2233
2494
  finishSegment(seg, reason) {
@@ -2245,8 +2506,10 @@ class TurnStreamController {
2245
2506
  seg.activeType = null;
2246
2507
  seg.activeToolCall = undefined;
2247
2508
  }
2248
- if (this.latestUsage !== null)
2249
- seg.queue.push({ type: 'usage', usage: this.latestUsage });
2509
+ const outputTokens = Math.round(seg.outputChars / OUTPUT_CHARS_PER_TOKEN);
2510
+ // Zero input keeps the host's cache-hit pill hidden instead of faking 0%.
2511
+ if (outputTokens > 0)
2512
+ seg.queue.push({ type: 'usage', usage: { inputTokens: 0, outputTokens } });
2250
2513
  seg.queue.push({ type: 'finish', reason });
2251
2514
  seg.queue.close();
2252
2515
  if (this.segment === seg)
@@ -2289,11 +2552,10 @@ class TurnStreamController {
2289
2552
  const seg = this.segment;
2290
2553
  if (seg === undefined || seg.finished) {
2291
2554
  // Between segments: tool outputs still reach the shared registry (the
2292
- // guard's virtual tool may be parked on this very record) and usage
2293
- // keeps the turn's tally fresh, but the cursor does NOT advance — the
2294
- // next segment replays everything since it, which re-records those
2295
- // idempotently and folds the prose the agent streamed while its cards
2296
- // executed on the host.
2555
+ // guard's virtual tool may be parked on this very record), but the
2556
+ // cursor does NOT advance — the next segment replays everything since
2557
+ // it, which re-records those idempotently and folds the prose the agent
2558
+ // streamed while its cards executed on the host.
2297
2559
  this.recordWithoutSegment(update);
2298
2560
  return;
2299
2561
  }
@@ -2337,17 +2599,8 @@ class TurnStreamController {
2337
2599
  /** Events that arrive between segments: recorded, replayed into the next one. */
2338
2600
  recordWithoutSegment(update) {
2339
2601
  const kind = String(update.sessionUpdate ?? '');
2340
- if (kind === 'usage_update') {
2341
- this.noteUsage(update);
2342
- }
2343
- else if (kind === 'tool_call' || kind === 'tool_call_update') {
2602
+ if (kind === 'tool_call' || kind === 'tool_call_update')
2344
2603
  this.noteToolRecord(update);
2345
- }
2346
- }
2347
- noteUsage(update) {
2348
- const used = typeof update.used === 'number' ? update.used : 0;
2349
- const size = typeof update.size === 'number' ? update.size : 0;
2350
- this.latestUsage = { inputTokens: size, outputTokens: used };
2351
2604
  }
2352
2605
  /** Record a tool call's output (idempotent) and wake any waiter. */
2353
2606
  noteToolRecord(update) {
@@ -2373,6 +2626,7 @@ class TurnStreamController {
2373
2626
  this.finishSegment(seg, { kind: 'stop' });
2374
2627
  return false;
2375
2628
  }
2629
+ seg.outputChars += text.length;
2376
2630
  this.emitProse(seg, kind === 'agent_thought_chunk' ? 'reasoning' : 'text', text);
2377
2631
  return true;
2378
2632
  }
@@ -2395,10 +2649,6 @@ class TurnStreamController {
2395
2649
  this.openToolCallBlock(seg, snapshot);
2396
2650
  return true;
2397
2651
  }
2398
- else if (kind === 'usage_update') {
2399
- this.noteUsage(update);
2400
- return true;
2401
- }
2402
2652
  else if (!BOOKKEEPING_UPDATES.has(kind)) {
2403
2653
  const text = describeUnhandledUpdate(kind, update);
2404
2654
  if (this.announcedKinds.get(kind) === text)
@@ -2636,6 +2886,105 @@ export class BridgeManager {
2636
2886
  }
2637
2887
  }
2638
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
+ }
2639
2988
  constructor(defs, hub, transcriptMax, configStoragePath, catalogStoragePath,
2640
2989
  /** Auto-probe agents for model catalogs (boot + lazy); manual probes always run. */
2641
2990
  probeModelsOnBoot = true, tuning) {
@@ -3383,6 +3732,39 @@ export class BridgeManager {
3383
3732
  }
3384
3733
  return acpPresetStatusFromRoster(rows, ACP_PRESET_ID);
3385
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
+ }
3386
3768
  getStoragePath() {
3387
3769
  if (this.configStoragePath !== undefined)
3388
3770
  return this.configStoragePath;
@@ -4430,19 +4812,68 @@ export class BridgeManager {
4430
4812
  return { sessionId: session.sessionId };
4431
4813
  }
4432
4814
  async prompt(sessionId, text) {
4433
- const { bridge, session } = await this.locateOrResume(sessionId);
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;
4434
4833
  if (session.running)
4435
4834
  throw new AcpError('turn_in_flight', 'acp: this session already has a prompt in flight');
4436
4835
  session.running = true;
4437
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();
4438
4844
  const turnId = randomUUID();
4439
4845
  this.hub.broadcast({ type: 'turn/start', sessionId, turnId, text });
4440
4846
  void bridge.runPrompt(session, text, turnId)
4441
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);
4442
4858
  session.running = false;
4443
4859
  this.hub.broadcast({ type: 'turn/end', sessionId, turnId, stopReason });
4444
4860
  })
4445
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);
4446
4877
  session.running = false;
4447
4878
  this.hub.broadcast({
4448
4879
  type: 'turn/end',
@@ -4453,6 +4884,11 @@ export class BridgeManager {
4453
4884
  });
4454
4885
  return { turnId };
4455
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
+ }
4456
4892
  async cancel(sessionId) {
4457
4893
  const live = this.tryLocate(sessionId);
4458
4894
  if (live === undefined)
@@ -4522,12 +4958,21 @@ export class BridgeManager {
4522
4958
  await this.bridges.get(agentId)?.stop('agent-stopped');
4523
4959
  }
4524
4960
  async shutdownAll() {
4961
+ if (this.stallTimer !== undefined) {
4962
+ clearInterval(this.stallTimer);
4963
+ this.stallTimer = undefined;
4964
+ }
4525
4965
  this.dshSessionMap.clear();
4526
4966
  this.disposeTurnStreamsOf(() => true);
4527
4967
  await Promise.all([...this.bridges.values()].map(b => b.stop('agent-stopped')));
4528
4968
  }
4529
4969
  /** Host-side reader of the harness preset roster (`ctx.agentPresets`), for `presetStatus`. */
4530
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;
4531
4976
  /**
4532
4977
  * The plugin's own default ACP agent (the panel's ★ gesture): which agent an
4533
4978
  * ACP-mode conversation starts on. Host-side state in the agent registry
@@ -4871,6 +5316,83 @@ export class BridgeManager {
4871
5316
  }
4872
5317
  return session;
4873
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');
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
+ }
4874
5396
  async *streamPrompt(options) {
4875
5397
  // The plugin's own conversation↔agent mapping decides who serves the turn
4876
5398
  // (see `bindChatAgent`): the driver may arrive here with the previous
@@ -4981,6 +5503,20 @@ export class BridgeManager {
4981
5503
  return;
4982
5504
  }
4983
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
+ }
4984
5520
  try {
4985
5521
  session = await this.getOrCreateDshSession(options.sessionId, agentId);
4986
5522
  }
@@ -5112,6 +5648,13 @@ export class BridgeManager {
5112
5648
  }
5113
5649
  session.running = true;
5114
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();
5115
5658
  const turnId = randomUUID();
5116
5659
  this.hub.broadcast({ type: 'turn/start', sessionId: session.sessionId, turnId, text: promptText });
5117
5660
  // The segmented turn: ONE controller for the whole agent-side prompt, one
@@ -5123,6 +5666,14 @@ export class BridgeManager {
5123
5666
  const controller = new TurnStreamController(this, this.hub, session, turnId, endTurn, victim => this.rollbackFirstTurn(victim));
5124
5667
  if (session.dshSessionId !== undefined)
5125
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;
5126
5677
  const segment = controller.startSegment();
5127
5678
  const onAbort = () => {
5128
5679
  if (controller.stale)
@@ -5142,12 +5693,30 @@ export class BridgeManager {
5142
5693
  // or a continuation one.
5143
5694
  bridge.runPrompt(session, promptText, turnId)
5144
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
+ }
5145
5703
  controller.settle(stopReason === 'cancelled'
5146
5704
  ? { kind: 'cancelled' }
5147
5705
  : { kind: 'stop', stopReason });
5148
5706
  })
5149
5707
  .catch((err) => {
5708
+ if (session.reclaimedTurnSeq === turnGen) {
5709
+ session.reclaimedTurnSeq = undefined;
5710
+ return;
5711
+ }
5150
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);
5151
5720
  });
5152
5721
  }
5153
5722
  try {