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/src/bridge.ts CHANGED
@@ -69,17 +69,40 @@ import type {
69
69
  RosterPresetRow,
70
70
  } from './impl.ts'
71
71
  import type {
72
- AcpAgentCatalog, AcpAgentDef, AcpApprovalDeadlines, AcpConversationChoice, AcpEvent, AcpMcpServerDef, AcpPresetStatus, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult, AgentView, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, GenerateOptions, LlmAdapterLike,
72
+ AcpAgentCatalog, AcpAgentDef, AcpApprovalDeadlines, AcpConversationChoice, AcpEvent, AcpMcpServerDef, AcpPresetStatus, AcpReleaseCause, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult, AgentView, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, GenerateOptions, LlmAdapterLike,
73
73
  LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo, LlmReasoningEffortInfo, LlmResolvedModelInfo,
74
- ApprovalDeadlineKey, ApprovalDeadlinePatch, PermissionOutcome, PermissionOptionView, PermissionTimeoutMode, PreparedAdapterCall, SessionView, StreamChunk,
74
+ ApprovalDeadlineKey, ApprovalDeadlinePatch, PermissionOutcome, PermissionOptionView, PermissionTimeoutMode, PluginUpdateState, PreparedAdapterCall, SessionView, StreamChunk,
75
75
  } from './protocol.ts'
76
76
  import { DEFAULT_TAG_HALO } from './protocol.ts'
77
+ import { UpdateService } from './update.ts'
78
+
79
+ /**
80
+ * Version of this installed package, read from the shipped `package.json` —
81
+ * one level above both `src/` at test time and `lib/` at runtime. The settings
82
+ * badge and the ACP handshake's `clientInfo` both report it; a copied literal
83
+ * would go stale on every release.
84
+ */
85
+ export const PLUGIN_VERSION: string = (() => {
86
+ try {
87
+ const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')) as { version?: unknown }
88
+ return typeof manifest.version === 'string' ? manifest.version : ''
89
+ } catch {
90
+ return ''
91
+ }
92
+ })()
77
93
 
78
94
  const STDERR_TAIL_LINES = 40
79
95
  /** Single fs/read_text_file answer cap — agents reading huge files must page via `line`/`limit`. */
80
96
  const FS_MAX_READ_BYTES = 2 * 1024 * 1024
81
97
  /** Single fs/write_text_file payload cap. */
82
98
  const FS_MAX_WRITE_BYTES = 2 * 1024 * 1024
99
+ /**
100
+ * How long a stop waits for the agent to answer `session/close` before killing
101
+ * the child anyway. The capability is optional and the LOCAL teardown is what
102
+ * actually frees the session, so a wedged agent must not be able to hang a
103
+ * stop — and with it the stall reclaim that calls it — forever.
104
+ */
105
+ const SESSION_CLOSE_TIMEOUT_MS = 5_000
83
106
 
84
107
  /** What the composer showed for an agent before a conversation picked it. */
85
108
  interface DisplayedChatDefaults {
@@ -115,6 +138,20 @@ interface LiveSession {
115
138
  closed: boolean
116
139
  /** The agent process died; the session awaits a `session/load` resume. */
117
140
  released: boolean
141
+ /**
142
+ * When the agent last showed ANY sign of life on this session: an update, a
143
+ * permission ask (arrival or answer), or a turn start. The stall watchdog
144
+ * measures silence from here rather than from the prompt, which may
145
+ * legitimately be followed by a long tool run.
146
+ */
147
+ lastAgentActivityAt: number
148
+ /**
149
+ * Tool calls the agent announced and has not finished, by ACP `toolCallId`.
150
+ * A SET, not a flag: parallel calls finish independently, and one sibling's
151
+ * `completed` must not clear the long tool window while another is still
152
+ * running. Non-empty = the agent is working on its own clock.
153
+ */
154
+ activeToolCalls: Set<string>
118
155
  /**
119
156
  * Owning DSH chat session id, when the session was opened through the LLM
120
157
  * adapter (`AcpLlmAdapter`). It is what the conversation's permission card
@@ -168,6 +205,36 @@ interface LiveSession {
168
205
  * that answer means the message was silently dropped.
169
206
  */
170
207
  cancelRequestedAtTurn?: number
208
+ /**
209
+ * The {@link turnSeq} of a turn the stall watchdog force-finalized while the
210
+ * agent still held its `session/prompt`. The agent is presumed wedged, so the
211
+ * NEXT prompt reclaims the process (`BridgeManager.prepareStalledSession`)
212
+ * instead of queueing behind a turn that may never answer. Cleared when the
213
+ * late response lands or when the reclaim replaces the process.
214
+ */
215
+ orphanPromptTurnSeq?: number
216
+ /**
217
+ * The {@link turnSeq} of a turn a reclaim (`prepareStalledSession`) ended by
218
+ * closing the connection while the agent still held its `session/prompt`.
219
+ * The close rejects that request, and the caller's catch would broadcast a
220
+ * second `turn/end` for a turn already finalized: this marker tells the
221
+ * late `then`/`catch` to drop the answer instead. Set by the reclaim, cleared
222
+ * when that answer lands, at every new turn start, and by a resume.
223
+ */
224
+ reclaimedTurnSeq?: number
225
+ /**
226
+ * The controller of the turn currently streaming through this session.
227
+ *
228
+ * Chat turns are ALSO indexed by chat id ({@link BridgeManager}'s
229
+ * `turnStreams`) because their continuation steps arrive as fresh
230
+ * `streamPrompt` calls that look the turn up by chat id. An EPHEMERAL turn
231
+ * (`options.sessionId === undefined`) has no chat id to index under, so this
232
+ * attachment is the only way the stall watchdog can find its controller —
233
+ * without it a silent ephemeral turn falls into the `/acp` branch, which
234
+ * leaves the driver's segment queue open instead of finishing it. Cleared by
235
+ * the controller's own `retire` when it still points at it.
236
+ */
237
+ turnController?: TurnStreamController
171
238
  transcript: Transcript
172
239
  }
173
240
 
@@ -234,6 +301,23 @@ export interface BridgeTuning {
234
301
  * racing a concurrent `session/prompt` into the agent.
235
302
  */
236
303
  turnSettleMs?: number
304
+ /**
305
+ * How long a turn may stay SILENT — no update, no ask, no turn start — before
306
+ * the watchdog force-finalizes it as stalled. The turn is only watched while
307
+ * the agent is the one holding the floor (a segment on air, no pending ask),
308
+ * and the process survives: the next message reclaims it.
309
+ *
310
+ * `0` disables the watchdog.
311
+ */
312
+ turnStallMs?: number
313
+ /**
314
+ * The longer silence window used while an ACP tool call is in progress: the
315
+ * agent runs the tool on its own clock, so the plain turn window would call
316
+ * a healthy long-running tool stalled. `0` falls back to `turnStallMs`.
317
+ */
318
+ turnStallToolMs?: number
319
+ /** How often the watchdog scans for silent turns. */
320
+ turnStallTickMs?: number
237
321
  /**
238
322
  * Default idle-release window for agents without their own
239
323
  * `AcpAgentDef.idleTimeoutMs`: a READY process that holds no live session
@@ -328,6 +412,9 @@ const DEFAULT_TUNING: Required<BridgeTuning> = {
328
412
  staleCancelRetryDelayMs: 600,
329
413
  staleCancelMaxRetries: 2,
330
414
  turnSettleMs: 5_000,
415
+ turnStallMs: 600_000,
416
+ turnStallToolMs: 1_800_000,
417
+ turnStallTickMs: 30_000,
331
418
  idleTimeoutMs: 1_800_000,
332
419
  probeIdleTimeoutMs: 300_000,
333
420
  }
@@ -374,8 +461,19 @@ export class AgentBridge {
374
461
  private lastActivityAt = 0
375
462
  /** Throwaway catalog probes in flight — they hold no live session of their own. */
376
463
  private pendingProbes = 0
377
- /** Host→agent session commands in flight (mode / config); not turns. */
378
- private pendingCommands = 0
464
+ /**
465
+ * Host→agent session commands in flight (mode / config); not turns. Counted
466
+ * PER SESSION: a command the wedged agent never answers must not pin the
467
+ * bridge against reclaiming THAT session (it is exactly the state the
468
+ * watchdog is recovering from), while another session's command still does.
469
+ */
470
+ private readonly pendingCommands = new Map<string, number>()
471
+ /**
472
+ * `session/new` requests in flight. The session does not exist in `sessions`
473
+ * yet, so nothing else would tell a teardown (`releaseAll` / idle release)
474
+ * that a caller is waiting on the child it is about to kill.
475
+ */
476
+ private pendingCreates = 0
379
477
  /**
380
478
  * Whether a REAL session has ever been registered on this bridge. Probes
381
479
  * bypass session bookkeeping, so a bridge that has never hosted anything was
@@ -539,7 +637,7 @@ export class AgentBridge {
539
637
  this.touch()
540
638
  return
541
639
  }
542
- this.releaseAll(`agent "${this.def.id}" was released after ${String(Math.round(this.currentIdleTimeoutMs / 1000))}s idle`)
640
+ this.releaseAll(`agent "${this.def.id}" was released after ${String(Math.round(this.currentIdleTimeoutMs / 1000))}s idle`, 'idle')
543
641
  }
544
642
 
545
643
  /**
@@ -603,6 +701,18 @@ export class AgentBridge {
603
701
  }
604
702
  }
605
703
 
704
+ /**
705
+ * Open a session's settle gate without owning its turn stream: the turn's
706
+ * stream is gone (a resume, a release) but the next prompt still waits on
707
+ * this promise, and nothing is left that could ever resolve it.
708
+ */
709
+ private openSettleGate(session: LiveSession): void {
710
+ const settle = session.settledResolve
711
+ session.settled = undefined
712
+ session.settledResolve = undefined
713
+ settle?.()
714
+ }
715
+
606
716
  view(): AgentView {
607
717
  return {
608
718
  id: this.def.id,
@@ -633,7 +743,8 @@ export class AgentBridge {
633
743
  */
634
744
  private hasActiveWork(): boolean {
635
745
  if (this.pendingProbes > 0
636
- || this.pendingCommands > 0
746
+ || this.pendingCreates > 0
747
+ || this.pendingCommands.size > 0
637
748
  || this.resuming.size > 0
638
749
  || this.permissions.sessionIds().length > 0
639
750
  || this.starting !== undefined) return true
@@ -643,6 +754,33 @@ export class AgentBridge {
643
754
  return false
644
755
  }
645
756
 
757
+ /**
758
+ * Active work on this bridge EXCEPT `session`'s own — what a restart-carrying
759
+ * reclaim (the stall watchdog's follow-up) would destroy. Killing this child
760
+ * restarts every ACP session on the connection, so a live conversation that
761
+ * is mid-turn, mid-resume, or waiting on a human pins the process: its
762
+ * context is the one thing a restart cannot give back.
763
+ *
764
+ * `session`'s OWN mode/config command is deliberately not counted: an agent
765
+ * that ignores `session/set_mode` would otherwise make its own stall
766
+ * unrecoverable — the command's counter is exactly the evidence of the wedge.
767
+ */
768
+ hasOtherActiveWork(session: LiveSession): boolean {
769
+ if (this.pendingProbes > 0
770
+ || this.pendingCreates > 0
771
+ || this.resuming.size > 0
772
+ || this.starting !== undefined) return true
773
+ for (const sessionId of this.pendingCommands.keys()) {
774
+ if (sessionId !== session.sessionId) return true
775
+ }
776
+ if (this.permissions.sessionIds().some(entry => entry.sessionId !== session.sessionId)) return true
777
+ for (const other of this.sessions.values()) {
778
+ if (other === session) continue
779
+ if (other.running || other.settled !== undefined) return true
780
+ }
781
+ return false
782
+ }
783
+
646
784
  /**
647
785
  * Nothing depends on this child's identity right now: no live session and no
648
786
  * work. This is the WORKSPACE MOVE's predicate. It stays stricter than the
@@ -654,6 +792,19 @@ export class AgentBridge {
654
792
  return this.sessions.size === 0 && !this.hasActiveWork()
655
793
  }
656
794
 
795
+ /**
796
+ * A turn is in flight (or the agent has not settled its response yet) on any
797
+ * of this bridge's sessions. `settled` only stays non-undefined while the
798
+ * agent's answer is outstanding — the settler clears it on completion — so
799
+ * this is real work, not an idle session.
800
+ */
801
+ hasActiveTurn(): boolean {
802
+ for (const session of this.sessions.values()) {
803
+ if (session.running || session.settled !== undefined) return true
804
+ }
805
+ return false
806
+ }
807
+
657
808
  /**
658
809
  * An idle teardown is safe when nothing is WORKING. Idle live sessions do not
659
810
  * block it: `releaseAll` parks them as `released` (never closed), and the next
@@ -684,7 +835,7 @@ export class AgentBridge {
684
835
  if (this.state === 'ready' && this.conn !== undefined && this.spawnCwd !== spawnCwd && this.isQuiet()) {
685
836
  const reason = `agent "${this.def.id}" restarts in ${spawnCwd} (the workspace of the session using it)`
686
837
  this.hub.broadcast({ type: 'agent/notice', agentId: this.def.id, level: 'info', text: `acp: ${reason}` })
687
- this.releaseAll(reason)
838
+ this.releaseAll(reason, 'workspace')
688
839
  }
689
840
  }
690
841
  if (this.state === 'ready' && this.conn !== undefined) return Promise.resolve()
@@ -788,7 +939,7 @@ export class AgentBridge {
788
939
  terminal: false,
789
940
  session: { configOptions: { boolean: {} } },
790
941
  },
791
- clientInfo: { name: 'dsh-ntr', title: 'DSH NTR', version: '0.1.0' },
942
+ clientInfo: { name: 'dsh-ntr', title: 'DSH NTR', version: PLUGIN_VERSION },
792
943
  }),
793
944
  'initialize',
794
945
  )
@@ -837,7 +988,26 @@ export class AgentBridge {
837
988
  * @param spawnCwd - where the agent PROCESS should run; see `ensureReady`.
838
989
  */
839
990
  async createSession(cwd: string | undefined, extraRoots: readonly string[] = [], spawnCwd?: string): Promise<LiveSession> {
991
+ // Readiness (and its possible restart into `spawnCwd`, see `ensureReady`)
992
+ // comes FIRST: `isQuiet` must not count THIS call's in-flight `session/new`
993
+ // against that move, or a fresh conversation could never bring a quiet
994
+ // process into its workspace. The counter is bumped in the same microtask
995
+ // that resumes here, so no idle-release timer can slip into the gap.
840
996
  await this.ensureReady(spawnCwd)
997
+ // Counted while the request is in flight: until `sessions.set` runs, the
998
+ // session is invisible to every teardown predicate, and a release that
999
+ // killed the child here would strand this promise on a dead connection.
1000
+ this.pendingCreates += 1
1001
+ try {
1002
+ return await this.openSession(cwd, extraRoots)
1003
+ } finally {
1004
+ this.pendingCreates -= 1
1005
+ this.touch()
1006
+ }
1007
+ }
1008
+
1009
+ /** The body of {@link createSession}; the caller keeps `pendingCreates` for it. */
1010
+ private async openSession(cwd: string | undefined, extraRoots: readonly string[]): Promise<LiveSession> {
841
1011
  const conn = this.conn
842
1012
  if (conn === undefined) throw new AcpError('agent_unreachable', 'acp: connection is not open')
843
1013
  const resolved = resolveSessionCwd(this.def, cwd, p => realpathSync(p), extraRoots)
@@ -858,6 +1028,8 @@ export class AgentBridge {
858
1028
  turnSeq: 0,
859
1029
  closed: false,
860
1030
  released: false,
1031
+ lastAgentActivityAt: Date.now(),
1032
+ activeToolCalls: new Set(),
861
1033
  transcript: new Transcript(this.transcriptMax),
862
1034
  ...(result.modes !== undefined && result.modes !== null
863
1035
  ? {
@@ -992,26 +1164,42 @@ export class AgentBridge {
992
1164
  if (this.cancelFallbackMs <= 0) return
993
1165
  const turnGen = session.turnSeq
994
1166
  const timer = setTimeout(() => {
995
- if (session.closed || session.turnSeq !== turnGen) return
996
- // Nothing is in flight anymore (the turn settled on its own): nothing to
997
- // finalize. A still-pending settle gate counts as in flight even though
998
- // the stream is gone — that gate is what the next prompt waits on.
999
- if (!session.running && session.settled === undefined) return
1000
- session.running = false
1001
- const settle = session.settledResolve
1002
- session.settled = undefined
1003
- session.settledResolve = undefined
1004
- settle?.()
1005
- this.hub.broadcast({
1006
- type: 'turn/end',
1007
- sessionId: session.sessionId,
1008
- turnId: '',
1009
- 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`,
1010
- })
1167
+ this.finalizeTurn(
1168
+ session,
1169
+ turnGen,
1170
+ `agent "${this.def.id}" did not respond to the cancel request within ${String(Math.round(this.cancelFallbackMs / 1000))}s; the turn was force-finalized`,
1171
+ )
1011
1172
  }, this.cancelFallbackMs)
1012
1173
  timer.unref?.()
1013
1174
  }
1014
1175
 
1176
+ /**
1177
+ * Force the local end of one turn whose agent never acknowledged it: clear
1178
+ * `running`, open the settle gate, and broadcast the turn end with `turnId:
1179
+ * ''` (there is no agent response to attribute it to). Shared by the cancel
1180
+ * fallback and the stall watchdog.
1181
+ *
1182
+ * Generation-guarded: a turn that already ended — or that a later turn
1183
+ * superseded — is not this call's to finalize. Returns whether it acted; a
1184
+ * `false` means the caller was late and must leave the session alone.
1185
+ */
1186
+ finalizeTurn(session: LiveSession, turnGen: number, message: string): boolean {
1187
+ if (session.closed || session.turnSeq !== turnGen) return false
1188
+ // Nothing is in flight anymore (the turn settled on its own): nothing to
1189
+ // finalize. A still-pending settle gate counts as in flight even though
1190
+ // the stream is gone — that gate is what the next prompt waits on.
1191
+ if (!session.running && session.settled === undefined) return false
1192
+ session.running = false
1193
+ this.openSettleGate(session)
1194
+ this.hub.broadcast({
1195
+ type: 'turn/end',
1196
+ sessionId: session.sessionId,
1197
+ turnId: '',
1198
+ error: message,
1199
+ })
1200
+ return true
1201
+ }
1202
+
1015
1203
  /**
1016
1204
  * Bring a released session back on a fresh agent process via
1017
1205
  * `session/load` ("send to recover"). Concurrent callers share one inflight
@@ -1059,6 +1247,13 @@ export class AgentBridge {
1059
1247
  session.running = false
1060
1248
  session.closed = false
1061
1249
  session.released = false
1250
+ // A resume is a fresh agent-side state: nothing from the dead process's
1251
+ // turn survives it, including the settle gate and the stall markers.
1252
+ session.orphanPromptTurnSeq = undefined
1253
+ session.reclaimedTurnSeq = undefined
1254
+ session.activeToolCalls.clear()
1255
+ session.lastAgentActivityAt = Date.now()
1256
+ this.openSettleGate(session)
1062
1257
  // Route replay updates into the fresh transcript while the load request
1063
1258
  // is in flight, and let the browser drop its blocks/watermark first.
1064
1259
  this.acpIndex.set(session.acpSessionId, session)
@@ -1096,23 +1291,33 @@ export class AgentBridge {
1096
1291
  this.sessions.delete(session.sessionId)
1097
1292
  session.released = true
1098
1293
  this.released.set(session.sessionId, session)
1099
- this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason: 'resume failed' })
1294
+ this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason: 'resume failed', cause: 'resume-failed' })
1100
1295
  throw error
1101
1296
  }
1102
1297
  }
1103
1298
 
1104
1299
  /** Release all live sessions (process died or went idle): keep them resumable, settle permissions, drop the connection. */
1105
- releaseAll(reason: string): void {
1300
+ releaseAll(reason: string, cause: AcpReleaseCause): void {
1106
1301
  this.clearIdleTimer()
1107
1302
  for (const session of [...this.sessions.values()]) {
1303
+ // A session already being closed is `closeSession`'s to delete. Marking
1304
+ // it released would park it as resumable, and `doResumeSession` flips
1305
+ // `closed` back to false — an idle release resurrecting a closed session.
1306
+ if (session.closed) continue
1108
1307
  if (session.running) {
1109
1308
  session.running = false
1110
1309
  this.hub.broadcast({ type: 'turn/end', sessionId: session.sessionId, turnId: '', error: reason })
1111
1310
  }
1311
+ // The process that owed the turn is gone: leaving its settle gate closed
1312
+ // would make the next prompt wait for an answer that can no longer come.
1313
+ this.openSettleGate(session)
1314
+ // No call from the dead process can still be running: drop the tool set
1315
+ // so a resumed turn starts on the plain window.
1316
+ session.activeToolCalls.clear()
1112
1317
  session.released = true
1113
1318
  this.settlePermissions(session)
1114
1319
  this.released.set(session.sessionId, session)
1115
- this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason })
1320
+ this.hub.broadcast({ type: 'session/released', sessionId: session.sessionId, session: snapshot(session), reason, cause })
1116
1321
  }
1117
1322
  this.sessions.clear()
1118
1323
  this.acpIndex.clear()
@@ -1150,13 +1355,15 @@ export class AgentBridge {
1150
1355
  }
1151
1356
 
1152
1357
  /** Run one host→agent command as work, so an idle teardown cannot cut it. */
1153
- private async runCommand<T>(request: () => Promise<T>): Promise<T> {
1154
- this.pendingCommands += 1
1358
+ private async runCommand<T>(session: LiveSession, request: () => Promise<T>): Promise<T> {
1359
+ this.pendingCommands.set(session.sessionId, (this.pendingCommands.get(session.sessionId) ?? 0) + 1)
1155
1360
  this.touch()
1156
1361
  try {
1157
1362
  return await request()
1158
1363
  } finally {
1159
- this.pendingCommands -= 1
1364
+ const remaining = (this.pendingCommands.get(session.sessionId) ?? 1) - 1
1365
+ if (remaining > 0) this.pendingCommands.set(session.sessionId, remaining)
1366
+ else this.pendingCommands.delete(session.sessionId)
1160
1367
  this.touch()
1161
1368
  }
1162
1369
  }
@@ -1164,7 +1371,7 @@ export class AgentBridge {
1164
1371
  async setMode(session: LiveSession, modeId: string): Promise<void> {
1165
1372
  const conn = this.conn
1166
1373
  if (conn === undefined) throw new AcpError('agent_unreachable', 'acp: connection is not open')
1167
- await this.runCommand(() => conn.agent.request(methods.agent.session.setMode, { sessionId: session.acpSessionId, modeId }))
1374
+ await this.runCommand(session, () => conn.agent.request(methods.agent.session.setMode, { sessionId: session.acpSessionId, modeId }))
1168
1375
  // Well-behaved agents push `current_mode_update`, but the spec does not
1169
1376
  // require it — apply locally so the browser selector never goes stale.
1170
1377
  if (session.modes !== undefined) session.modes = { ...session.modes, currentModeId: modeId }
@@ -1179,7 +1386,7 @@ export class AgentBridge {
1179
1386
  const params = typeof value === 'boolean'
1180
1387
  ? { sessionId: session.acpSessionId, configId, value, type: 'boolean' as const }
1181
1388
  : { sessionId: session.acpSessionId, configId, value }
1182
- const result = await this.runCommand(() => conn.agent.request(methods.agent.session.setConfigOption, params)) as { configOptions?: unknown }
1389
+ const result = await this.runCommand(session, () => conn.agent.request(methods.agent.session.setConfigOption, params)) as { configOptions?: unknown }
1183
1390
  // The response carries the full authoritative option set; some agents
1184
1391
  // (pi-acp observed) never re-notify after a client-driven set. It feeds the
1185
1392
  // SESSION snapshot first — `currentValue` in a per-session answer describes
@@ -1213,8 +1420,23 @@ export class AgentBridge {
1213
1420
  this.settlePermissions(session)
1214
1421
  const conn = this.conn
1215
1422
  if (conn !== undefined && this.state === 'ready') {
1216
- // `session/close` is an optional capability; the local teardown stands regardless.
1217
- await conn.agent.request(methods.agent.session.close, { sessionId: session.acpSessionId }).catch(() => { /* unsupported */ })
1423
+ // `session/close` is an optional capability; the local teardown stands
1424
+ // regardless. Bound the wait: a wedged agent that ignores the request
1425
+ // must not hang the caller (the stall reclaim stops through here), so
1426
+ // the request is raced against a timeout and left behind if it loses.
1427
+ const close = conn.agent.request(methods.agent.session.close, { sessionId: session.acpSessionId }).catch(() => { /* unsupported */ })
1428
+ let timer: ReturnType<typeof setTimeout> | undefined
1429
+ try {
1430
+ await Promise.race([
1431
+ close,
1432
+ new Promise<void>(resolve => {
1433
+ timer = setTimeout(resolve, SESSION_CLOSE_TIMEOUT_MS)
1434
+ timer.unref?.()
1435
+ }),
1436
+ ])
1437
+ } finally {
1438
+ if (timer !== undefined) clearTimeout(timer)
1439
+ }
1218
1440
  }
1219
1441
  this.sessions.delete(session.sessionId)
1220
1442
  this.acpIndex.delete(session.acpSessionId)
@@ -1245,6 +1467,36 @@ export class AgentBridge {
1245
1467
  this.publishStatus()
1246
1468
  }
1247
1469
 
1470
+ /**
1471
+ * Drop every live session record and kill the child WITHOUT asking the agent
1472
+ * to close them. `stop` sends `session/close` per session, and a wedged agent
1473
+ * that ignores it would hang the caller (the stall reclaim) — the very agent
1474
+ * this teardown exists to get rid of. The local teardown is what frees the
1475
+ * sessions, so it runs unconditionally and immediately.
1476
+ *
1477
+ * Deliberately does NOT clear `released`: those records are constraint 10's
1478
+ * — a released session survives a process death and is resumed by its next
1479
+ * message. This is a stop, not a discard.
1480
+ */
1481
+ forceReset(reason: 'agent-stopped'): void {
1482
+ this.clearIdleTimer()
1483
+ for (const session of [...this.sessions.values()]) {
1484
+ session.closed = true
1485
+ this.settlePermissions(session)
1486
+ this.openSettleGate(session)
1487
+ session.activeToolCalls.clear()
1488
+ this.sessions.delete(session.sessionId)
1489
+ this.acpIndex.delete(session.acpSessionId)
1490
+ this.hub.broadcast({ type: 'session/closed', sessionId: session.sessionId, reason })
1491
+ }
1492
+ this.conn?.close()
1493
+ this.conn = undefined
1494
+ this.killChild()
1495
+ this.state = 'stopped'
1496
+ this.initResult = undefined
1497
+ this.publishStatus()
1498
+ }
1499
+
1248
1500
  private killChild(): void {
1249
1501
  const child = this.child
1250
1502
  this.child = undefined
@@ -1298,6 +1550,9 @@ export class AgentBridge {
1298
1550
  private async handlePermissionRequest(acpSessionId: string, toolCall: unknown, options: PermissionOption[], signal?: AbortSignal): Promise<RequestPermissionResponse> {
1299
1551
  const session = this.acpIndex.get(acpSessionId)
1300
1552
  if (session === undefined || session.closed) return { outcome: { outcome: 'cancelled' } }
1553
+ // An ask is the agent speaking, not silence: the stall watchdog measures
1554
+ // from here, and the pending ask exempts the session anyway.
1555
+ session.lastAgentActivityAt = Date.now()
1301
1556
  // The deadline and the mode are read ONCE, here: an ask that started under
1302
1557
  // one pair keeps it, even if the settings page rewrites them mid-flight.
1303
1558
  const mode = this.permissionTimeoutMode
@@ -1331,6 +1586,9 @@ export class AgentBridge {
1331
1586
  clearTimeout(timer)
1332
1587
  detachSignal?.()
1333
1588
  this.touch()
1589
+ // The answer releases the agent to continue; the turn's clock restarts
1590
+ // here, not at the ask that may have waited on a human for minutes.
1591
+ session.lastAgentActivityAt = Date.now()
1334
1592
  this.hub.broadcast({
1335
1593
  type: 'permission/resolved',
1336
1594
  requestId,
@@ -1433,6 +1691,7 @@ export class AgentBridge {
1433
1691
  options: PermissionOption[],
1434
1692
  timeoutMs: number,
1435
1693
  ): RequestPermissionResponse {
1694
+ session.lastAgentActivityAt = Date.now()
1436
1695
  const outcome = this.announceExpiredAsk(session, mode, toolCall, options, timeoutMs)
1437
1696
  return outcome.outcome === 'selected'
1438
1697
  ? { outcome: { outcome: 'selected', optionId: outcome.optionId } }
@@ -1503,6 +1762,20 @@ export class AgentBridge {
1503
1762
  private handleSessionUpdate(params: SessionNotification): void {
1504
1763
  const session = this.acpIndex.get(params.sessionId)
1505
1764
  if (session === undefined) return // update for a session already closed locally
1765
+ // Any update is the agent speaking: restart the stall clock here. A
1766
+ // tool-call status also says whether the agent is inside a long-running
1767
+ // tool, which earns the wider watchdog window.
1768
+ session.lastAgentActivityAt = Date.now()
1769
+ const kind = params.update.sessionUpdate
1770
+ if (kind === 'tool_call' || kind === 'tool_call_update') {
1771
+ const { toolCallId, status } = params.update as { toolCallId: string; status?: string | null }
1772
+ if (status === 'completed' || status === 'failed') session.activeToolCalls.delete(toolCallId)
1773
+ else if (status === 'pending' || status === 'in_progress') session.activeToolCalls.add(toolCallId)
1774
+ // A `tool_call` without a status is a call the agent just announced and
1775
+ // has not finished: count it as in flight. On an UPDATE the status is
1776
+ // optional, and an absent one carries no news either way.
1777
+ else if (kind === 'tool_call' && (status === undefined || status === null)) session.activeToolCalls.add(toolCallId)
1778
+ }
1506
1779
  this.applyDerived(session, params.update)
1507
1780
  const seq = session.transcript.append(params.update)
1508
1781
  this.hub.broadcast({ type: 'session/update', sessionId: session.sessionId, seq, update: params.update })
@@ -1937,6 +2210,14 @@ export const AGENT_PRESETS: AgentPreset[] = [
1937
2210
  defaultCommand: 'opencode',
1938
2211
  defaultArgs: ['acp'],
1939
2212
  },
2213
+ {
2214
+ id: 'cursor',
2215
+ name: 'Cursor CLI',
2216
+ description: 'Cursor CLI — run as `agent acp` over stdio (legacy alias `cursor-agent acp`)',
2217
+ executables: ['agent', 'cursor-agent'],
2218
+ defaultCommand: 'agent',
2219
+ defaultArgs: ['acp'],
2220
+ },
1940
2221
  {
1941
2222
  id: 'qwen',
1942
2223
  name: 'Qwen Code',
@@ -2422,7 +2703,7 @@ type SegmentFinishReason =
2422
2703
  type TurnSettle =
2423
2704
  | { kind: 'stop'; stopReason: string }
2424
2705
  | { kind: 'cancelled' }
2425
- | { kind: 'error'; message: string }
2706
+ | { kind: 'error'; message: string; code?: string }
2426
2707
 
2427
2708
  /**
2428
2709
  * The state of ONE ACP prompt, shared across every driver step of the turn.
@@ -2472,6 +2753,11 @@ class TurnStreamController {
2472
2753
  return this.settled
2473
2754
  }
2474
2755
 
2756
+ /** Whether a segment is on air — the agent is expected to speak into a live driver step. */
2757
+ get hasOpenSegment(): boolean {
2758
+ return this.segment !== undefined && !this.segment.finished
2759
+ }
2760
+
2475
2761
  /** Generation guard: a controller whose turn has been superseded must not touch the session. */
2476
2762
  get stale(): boolean {
2477
2763
  return this.disposed || this.session.turnSeq !== this.turnSeqAtStart
@@ -2486,6 +2772,13 @@ class TurnStreamController {
2486
2772
  * consuming it, so the next segment replays it again.
2487
2773
  */
2488
2774
  startSegment(): SegmentState {
2775
+ // A step opening is the HOST handing the turn back to the agent — this
2776
+ // runs for the first step and for each continuation after the host
2777
+ // executed a card. The watchdog measures the stretch the AGENT holds the
2778
+ // floor, so the bounded gap a host step owns must not count as agent
2779
+ // silence; resetting here cannot mask a later agent silence because the
2780
+ // reset itself is bounded by one driver step.
2781
+ this.session.lastAgentActivityAt = Date.now()
2489
2782
  const seg: SegmentState = {
2490
2783
  queue: new AsyncQueue<StreamChunk>(),
2491
2784
  index: -1,
@@ -2522,49 +2815,57 @@ class TurnStreamController {
2522
2815
  settle(outcome: TurnSettle): void {
2523
2816
  if (this.settled) return
2524
2817
  this.settled = true
2525
- const reason = outcome.kind === 'cancelled'
2526
- ? { kind: 'aborted' as const, failure: { message: 'Turn was cancelled', code: 'CANCELLED' } }
2818
+ // A late answer to a SUPERSEDED turn still has to close this controller's
2819
+ // queue (a continuation step may be parked on it) and its settle gate —
2820
+ // but it must not touch the session the NEWER turn now owns, nor release
2821
+ // process-wide waiters that belong to that turn.
2822
+ const current = this.session.turnSeq === this.turnSeqAtStart
2823
+ const reason: SegmentFinishReason = outcome.kind === 'cancelled'
2824
+ ? { kind: 'aborted', failure: { message: 'Turn was cancelled', code: 'CANCELLED' } }
2527
2825
  : outcome.kind === 'error'
2528
- ? { kind: 'error' as const, failure: { message: outcome.message, code: 'INTERNAL' } }
2529
- : { kind: 'stop' as const }
2530
- if (outcome.kind === 'cancelled') {
2531
- this.hub.broadcast({ type: 'turn/end', sessionId: this.session.sessionId, turnId: this.turnId, stopReason: 'cancelled' })
2532
- } else if (outcome.kind === 'error') {
2533
- this.hub.broadcast({ type: 'turn/end', sessionId: this.session.sessionId, turnId: this.turnId, error: outcome.message })
2534
- } else {
2535
- this.hub.broadcast({ type: 'turn/end', sessionId: this.session.sessionId, turnId: this.turnId, stopReason: outcome.stopReason })
2536
- }
2537
- // The provisional lock settles BEFORE any synthesized warning below: the
2538
- // warning is our text, not evidence that the first turn delivered anything.
2539
- if (this.session.provisionalLock === true) {
2540
- this.session.provisionalLock = false
2541
- if (!this.sawVisibleBlock) this.rollbackFirstTurn(this.session)
2826
+ ? { kind: 'error', failure: { message: outcome.message, code: outcome.code ?? 'INTERNAL' } }
2827
+ : { kind: 'stop' }
2828
+ if (current) {
2829
+ if (outcome.kind === 'cancelled') {
2830
+ this.hub.broadcast({ type: 'turn/end', sessionId: this.session.sessionId, turnId: this.turnId, stopReason: 'cancelled' })
2831
+ } else if (outcome.kind === 'error') {
2832
+ this.hub.broadcast({ type: 'turn/end', sessionId: this.session.sessionId, turnId: this.turnId, error: outcome.message })
2833
+ } else {
2834
+ this.hub.broadcast({ type: 'turn/end', sessionId: this.session.sessionId, turnId: this.turnId, stopReason: outcome.stopReason })
2835
+ }
2836
+ // The provisional lock settles BEFORE any synthesized warning below: the
2837
+ // warning is our text, not evidence that the first turn delivered anything.
2838
+ if (this.session.provisionalLock === true) {
2839
+ this.session.provisionalLock = false
2840
+ if (!this.sawVisibleBlock) this.rollbackFirstTurn(this.session)
2841
+ }
2542
2842
  }
2543
2843
  const seg = this.segment
2544
2844
  if (seg !== undefined) {
2545
- if (outcome.kind !== 'stop') {
2546
- this.finishSegment(seg, reason)
2547
- } else {
2548
- if (!this.sawVisibleBlock) {
2549
- // The silent-failure report: a normal stop with nothing the chat
2550
- // plane renders. Aborted or failing turns need no extra hint; this
2551
- // is specifically "protocol says success, the bubble is empty".
2552
- 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`
2553
- this.emitProse(seg, 'text', line)
2554
- }
2555
- this.finishSegment(seg, reason)
2845
+ if (current && outcome.kind === 'stop' && !this.sawVisibleBlock) {
2846
+ // The silent-failure report: a normal stop with nothing the chat
2847
+ // plane renders. Aborted or failing turns need no extra hint; this
2848
+ // is specifically "protocol says success, the bubble is empty".
2849
+ 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`
2850
+ this.emitProse(seg, 'text', line)
2556
2851
  }
2852
+ this.finishSegment(seg, reason)
2557
2853
  }
2558
2854
  // When no segment is on air (the turn ended on a card) the FINAL
2559
2855
  // continuation step collects the tail: `startSegment` sees `settled` and
2560
2856
  // finishes it with the appropriate closing line.
2561
- this.session.running = false
2857
+ if (current) {
2858
+ this.session.running = false
2859
+ // The turn is over: no call from it can still earn the long tool window.
2860
+ this.session.activeToolCalls.clear()
2861
+ releaseAllToolOutputWaiters()
2862
+ }
2562
2863
  this.endTurn()
2563
- releaseAllToolOutputWaiters()
2564
2864
  // The controller stays indexed only while a continuation step can still
2565
2865
  // come for its cards. A cancelled, failed, or card-less turn has none:
2566
2866
  // retire it now; the next turn's `rememberTurnStream` would anyway.
2567
- if (outcome.kind !== 'stop' || this.seenCalls.size === 0) this.dispose()
2867
+ if (!current) this.retire()
2868
+ else if (outcome.kind !== 'stop' || this.seenCalls.size === 0) this.dispose()
2568
2869
  }
2569
2870
 
2570
2871
  /** Cancelled by the user: finish the on-air segment and release the turn's slots. */
@@ -2587,11 +2888,26 @@ class TurnStreamController {
2587
2888
 
2588
2889
  /** Stop observing and release the manager's index. Idempotent. */
2589
2890
  dispose(): void {
2891
+ if (this.disposed) return
2892
+ this.retire()
2893
+ releaseAllToolOutputWaiters()
2894
+ }
2895
+
2896
+ /**
2897
+ * Stop observing and release the manager's index WITHOUT touching the
2898
+ * process-wide tool-output waiters. A late settle of a superseded turn must
2899
+ * use this: those waiters now belong to the turn on air, and waking them
2900
+ * early would hand the guard a "no output" answer for a tool still running.
2901
+ */
2902
+ retire(): void {
2590
2903
  if (this.disposed) return
2591
2904
  this.disposed = true
2592
2905
  this.unsubscribe()
2906
+ // The session must not keep pointing at a retired turn: the stall watchdog
2907
+ // reads this slot as "what is streaming now". A newer controller may have
2908
+ // replaced it since, and that one is not ours to clear.
2909
+ if (this.session.turnController === this) this.session.turnController = undefined
2593
2910
  this.manager.forgetTurnStream(this)
2594
- releaseAllToolOutputWaiters()
2595
2911
  }
2596
2912
 
2597
2913
  // ---- internals ----
@@ -2972,6 +3288,100 @@ export class BridgeManager {
2972
3288
  }
2973
3289
  }
2974
3290
 
3291
+ /**
3292
+ * The stall watchdog's scan loop. Armed lazily at the first turn start and
3293
+ * DROPPED once nothing anywhere is running: a manager that never runs a turn
3294
+ * never holds a timer, and one that goes quiet does not keep the host awake.
3295
+ */
3296
+ private stallTimer?: ReturnType<typeof setInterval>
3297
+
3298
+ private armStallTimer(): void {
3299
+ // `turnStallMs === 0` disables the WHOLE watchdog — the base window is the
3300
+ // switch. `turnStallToolMs` cannot re-enable it: that number only widens
3301
+ // the window for a turn that already has a tool in flight.
3302
+ if (this.tuning.turnStallMs <= 0 || this.stallTimer !== undefined) return
3303
+ const timer = setInterval(() => { this.reapStalledTurns() }, Math.max(1, this.tuning.turnStallTickMs))
3304
+ timer.unref?.()
3305
+ this.stallTimer = timer
3306
+ }
3307
+
3308
+ /**
3309
+ * Force-finalize turns the agent has gone silent in.
3310
+ *
3311
+ * "Silent" is scoped to the stretch where the AGENT owes the turn a word: a
3312
+ * running session with a segment on air and no pending ask. A turn waiting on
3313
+ * the host (tool cards executing between segments) or on a human (an
3314
+ * unanswered permission) is not stuck and is skipped.
3315
+ *
3316
+ * Firing does NOT kill the process (see `LiveSession.orphanPromptTurnSeq`):
3317
+ * the session, its transcript and its binding survive, and the next message
3318
+ * reclaims the process. The turn's streaming face ends through its own
3319
+ * controller when there is one, so the driver step on air closes with an
3320
+ * error instead of being torn down mid-stream.
3321
+ */
3322
+ private reapStalledTurns(): void {
3323
+ const now = Date.now()
3324
+ let anyRunning = false
3325
+ for (const bridge of this.bridges.values()) {
3326
+ for (const session of bridge.sessionList) {
3327
+ if (session.running) anyRunning = true
3328
+ if (!session.running || session.closed || session.released) continue
3329
+ // A question is on the table: the agent is waiting on a human, not
3330
+ // stuck, and that wait is the permission deadline's business.
3331
+ if (bridge.permissions.hasPendingFor(session.sessionId)) continue
3332
+ // The controller this turn is streaming through. A chat turn is indexed
3333
+ // by chat id (its continuation steps look it up that way) AND attached
3334
+ // to the session; an EPHEMERAL turn has no chat id and therefore only
3335
+ // the attachment — treating it as an `/acp` turn would finalize the
3336
+ // session state without closing the driver's segment queue.
3337
+ const indexed = session.dshSessionId === undefined ? undefined : this.turnStreams.get(session.dshSessionId)
3338
+ // An index hit only speaks for THIS session: a concurrent first turn
3339
+ // can have mapped another LiveSession under the same chat id.
3340
+ const attached = session.turnController
3341
+ ?? (indexed !== undefined && indexed.session === session ? indexed : undefined)
3342
+ // A controller from an EARLIER turn is stale the moment any newer turn
3343
+ // (e.g. an `/acp` prompt on the same chat) advances `turnSeq`: its
3344
+ // segment is already closed, `onAir` would read false, and the
3345
+ // watchdog would skip this turn forever. Treat it as absent.
3346
+ const live = attached !== undefined && !attached.stale ? attached : undefined
3347
+ // An `/acp`-RPC turn has no driver stream, so "on air" reads as true.
3348
+ const onAir = live === undefined ? true : live.hasOpenSegment
3349
+ if (!onAir) continue
3350
+ const toolWindow = this.tuning.turnStallToolMs > 0 ? this.tuning.turnStallToolMs : this.tuning.turnStallMs
3351
+ const threshold = session.activeToolCalls.size > 0 ? toolWindow : this.tuning.turnStallMs
3352
+ if (now - session.lastAgentActivityAt < threshold) continue
3353
+ void bridge.cancel(session)
3354
+ session.orphanPromptTurnSeq = session.turnSeq
3355
+ const turnGen = session.turnSeq
3356
+ const message = `acp: agent "${session.agentId}" sent no activity for ${String(Math.round(threshold / 1000))}s; the stalled turn was force-finalized`
3357
+ // A provisional first turn that showed nothing is about to be rolled
3358
+ // back by `settle` (lock released, session closed), so the notice must
3359
+ // not promise a resume that will not happen.
3360
+ const willRollback = live !== undefined && live.session.provisionalLock === true && !live.sawVisibleBlock
3361
+ if (live !== undefined) live.settle({ kind: 'error', message, code: 'INACTIVITY_TIMEOUT' })
3362
+ else bridge.finalizeTurn(session, turnGen, message)
3363
+ // What the next message can do depends on the capability the agent
3364
+ // advertised at connect time, and on whether anyone else is using it.
3365
+ const capabilities = bridge.view().agentCapabilities as { loadSession?: boolean } | undefined
3366
+ const next = willRollback
3367
+ ? 'This was the conversation\'s first turn and it delivered nothing, so it was discarded; the next message starts a new ACP session.'
3368
+ : capabilities?.loadSession === true
3369
+ ? 'The next message will restart the agent and resume the session.'
3370
+ : 'The next message will reset the ACP session; this agent does not support session resume.'
3371
+ this.hub.broadcast({
3372
+ type: 'agent/notice',
3373
+ agentId: session.agentId,
3374
+ level: 'error',
3375
+ text: `${message} ${next} If another conversation is still using the agent, the next send may be refused until that turn finishes.`,
3376
+ })
3377
+ }
3378
+ }
3379
+ if (!anyRunning && this.stallTimer !== undefined) {
3380
+ clearInterval(this.stallTimer)
3381
+ this.stallTimer = undefined
3382
+ }
3383
+ }
3384
+
2975
3385
  constructor(
2976
3386
  defs: readonly AcpAgentDef[],
2977
3387
  readonly hub: EventHub,
@@ -3716,6 +4126,42 @@ export class BridgeManager {
3716
4126
  return acpPresetStatusFromRoster(rows, ACP_PRESET_ID)
3717
4127
  }
3718
4128
 
4129
+ /** The installed package's version (the settings page's badge). */
4130
+ pluginVersion(): string {
4131
+ return PLUGIN_VERSION
4132
+ }
4133
+
4134
+ /** Wire the built-in updater (host entry only; absent in pure tests). */
4135
+ setUpdateService(service?: UpdateService): void {
4136
+ this.updateService = service
4137
+ }
4138
+
4139
+ /** Registry check for the settings card; `unsupported` when no updater is wired. */
4140
+ async pluginUpdateCheck(): Promise<PluginUpdateState> {
4141
+ return this.updateService?.check() ?? { status: 'unsupported', current: PLUGIN_VERSION }
4142
+ }
4143
+
4144
+ /** Delegate one install to the host plugin manager; never touches files itself. */
4145
+ async pluginUpdateApply(version: string): Promise<PluginUpdateState> {
4146
+ return this.updateService?.apply(version) ?? { status: 'unsupported', current: PLUGIN_VERSION }
4147
+ }
4148
+
4149
+ /**
4150
+ * Whether any ACP work is in flight. An update install may restart the host,
4151
+ * so check/apply describe the host as busy while a turn is running.
4152
+ *
4153
+ * Segmented turn streams count even between steps (the host is still
4154
+ * continuing that turn); `running` / `settled` on a session covers `/acp`
4155
+ * turns and the window between a prompt and the agent's acknowledgement.
4156
+ */
4157
+ hasActiveTurns(): boolean {
4158
+ if (this.turnStreams.size > 0) return true
4159
+ for (const bridge of this.bridges.values()) {
4160
+ if (bridge.hasActiveTurn()) return true
4161
+ }
4162
+ return false
4163
+ }
4164
+
3719
4165
  private getStoragePath(): string | undefined {
3720
4166
  if (this.configStoragePath !== undefined) return this.configStoragePath
3721
4167
  if (process.env.DSH_ACP_CONFIG_FILE) return process.env.DSH_ACP_CONFIG_FILE
@@ -4573,7 +5019,7 @@ export class BridgeManager {
4573
5019
  // here (their `runPrompt` promise also rejects into settle, but this also
4574
5020
  // covers a promise the connection teardown leaves pending).
4575
5021
  this.disposeTurnStreamsOf(controller => controller.session.agentId === bridge.def.id)
4576
- bridge.releaseAll(reason)
5022
+ bridge.releaseAll(reason, 'died')
4577
5023
  }
4578
5024
 
4579
5025
  agentViews(): AgentView[] {
@@ -4731,18 +5177,64 @@ export class BridgeManager {
4731
5177
  }
4732
5178
 
4733
5179
  async prompt(sessionId: string, text: string): Promise<{ turnId: string }> {
4734
- const { bridge, session } = await this.locateOrResume(sessionId)
5180
+ let located = await this.locateOrResume(sessionId)
5181
+ // The watchdog abandoned a previous turn here and the agent may still be
5182
+ // holding its prompt slot on the old process: reclaim BEFORE sending, or
5183
+ // this message queues behind a turn that will never answer.
5184
+ if (located.session.orphanPromptTurnSeq !== undefined) {
5185
+ const prepared = await this.prepareStalledSession(located.session)
5186
+ if (!prepared.ok) throw new AcpError('agent_unresponsive', prepared.message)
5187
+ // Without `session/load` there is nothing to re-locate: the record was
5188
+ // dropped on purpose, and "/acp" has no conversation to recreate it from.
5189
+ // Re-locating anyway would surface a misleading `unknown_session`.
5190
+ if (prepared.reset === true) {
5191
+ throw new AcpError('session_reset', `acp: agent "${located.session.agentId}" does not support session resume; start a new session`)
5192
+ }
5193
+ // A resumable reclaim released the record: re-locate what is live now.
5194
+ located = await this.locateOrResume(sessionId)
5195
+ }
5196
+ const { bridge, session } = located
4735
5197
  if (session.running) throw new AcpError('turn_in_flight', 'acp: this session already has a prompt in flight')
4736
5198
  session.running = true
4737
5199
  session.turnSeq += 1
5200
+ const turnGen = session.turnSeq
5201
+ session.lastAgentActivityAt = Date.now()
5202
+ session.activeToolCalls.clear()
5203
+ // A new turn owns the session from here: any reclaim marker named an
5204
+ // earlier generation and must not suppress THIS turn's settle.
5205
+ session.reclaimedTurnSeq = undefined
5206
+ this.armStallTimer()
4738
5207
  const turnId = randomUUID()
4739
5208
  this.hub.broadcast({ type: 'turn/start', sessionId, turnId, text })
4740
5209
  void bridge.runPrompt(session, text, turnId)
4741
5210
  .then(stopReason => {
5211
+ // The reclaim already ended this turn locally; the answer it caused is
5212
+ // that same turn reporting back, not a second turn end for the caller.
5213
+ if (session.reclaimedTurnSeq === turnGen) {
5214
+ session.reclaimedTurnSeq = undefined
5215
+ return
5216
+ }
5217
+ // A superseded turn's late answer must not end the turn now on air.
5218
+ if (session.turnSeq !== turnGen) return
5219
+ this.clearOrphanPrompt(session, turnGen)
4742
5220
  session.running = false
4743
5221
  this.hub.broadcast({ type: 'turn/end', sessionId, turnId, stopReason })
4744
5222
  })
4745
5223
  .catch((error: unknown) => {
5224
+ if (session.reclaimedTurnSeq === turnGen) {
5225
+ session.reclaimedTurnSeq = undefined
5226
+ return
5227
+ }
5228
+ if (session.turnSeq !== turnGen) return
5229
+ // The turn was ended by an EXTERNAL close (idle release, agent stop):
5230
+ // that teardown already broadcast the turn's end, and the session is
5231
+ // parked or closed. Reporting the close's rejection again would put a
5232
+ // second terminal frame on a turn the browser already saw finish.
5233
+ if (session.released || session.closed) {
5234
+ this.clearOrphanPrompt(session, turnGen)
5235
+ return
5236
+ }
5237
+ this.clearOrphanPrompt(session, turnGen)
4746
5238
  session.running = false
4747
5239
  this.hub.broadcast({
4748
5240
  type: 'turn/end',
@@ -4754,6 +5246,11 @@ export class BridgeManager {
4754
5246
  return { turnId }
4755
5247
  }
4756
5248
 
5249
+ /** The late answer landed (or failed): the turn is no longer an orphan. */
5250
+ private clearOrphanPrompt(session: LiveSession, turnGen: number): void {
5251
+ if (session.orphanPromptTurnSeq === turnGen) session.orphanPromptTurnSeq = undefined
5252
+ }
5253
+
4757
5254
  async cancel(sessionId: string): Promise<void> {
4758
5255
  const live = this.tryLocate(sessionId)
4759
5256
  if (live === undefined) return // released/stale session: nothing to cancel
@@ -4828,6 +5325,10 @@ export class BridgeManager {
4828
5325
  }
4829
5326
 
4830
5327
  async shutdownAll(): Promise<void> {
5328
+ if (this.stallTimer !== undefined) {
5329
+ clearInterval(this.stallTimer)
5330
+ this.stallTimer = undefined
5331
+ }
4831
5332
  this.dshSessionMap.clear()
4832
5333
  this.disposeTurnStreamsOf(() => true)
4833
5334
  await Promise.all([...this.bridges.values()].map(b => b.stop('agent-stopped')))
@@ -4836,6 +5337,12 @@ export class BridgeManager {
4836
5337
  /** Host-side reader of the harness preset roster (`ctx.agentPresets`), for `presetStatus`. */
4837
5338
  private presetRosterReader?: () => Promise<readonly RosterPresetRow[] | undefined>
4838
5339
 
5340
+ /**
5341
+ * The built-in updater, wired by the host entry. Absent in pure bridge tests —
5342
+ * the update RPCs then answer `unsupported` instead of inventing a source.
5343
+ */
5344
+ private updateService?: UpdateService
5345
+
4839
5346
  /**
4840
5347
  * The plugin's own default ACP agent (the panel's ★ gesture): which agent an
4841
5348
  * ACP-mode conversation starts on. Host-side state in the agent registry
@@ -5183,6 +5690,76 @@ export class BridgeManager {
5183
5690
  return session
5184
5691
  }
5185
5692
 
5693
+ /**
5694
+ * Take the agent process back from a turn the stall watchdog abandoned.
5695
+ *
5696
+ * The wedged agent still holds its `session/prompt`, so the next message
5697
+ * cannot simply be sent: it would queue behind a turn that may never answer.
5698
+ * The process is restarted instead — unless another conversation on the same
5699
+ * connection is doing real work, because killing the child destroys THEIR
5700
+ * context too, and "retry after they finish" is the honest answer.
5701
+ *
5702
+ * A restart that can `session/load` costs the conversation nothing: the
5703
+ * session is parked `released` and the normal acquisition path resumes it, so
5704
+ * chat keeps its lock / `applyChatChoice` semantics and `/acp` keeps
5705
+ * `locateOrResume`. An agent without resume support is reset instead, and the
5706
+ * user is told the context is gone.
5707
+ */
5708
+ private async prepareStalledSession(session: LiveSession): Promise<{ ok: true; reset?: boolean } | { ok: false; message: string }> {
5709
+ // A session whose `session/close` is still in flight must not be touched:
5710
+ // releasing it would park it as resumable and `doResumeSession` flips
5711
+ // `closed` back to false, resurrecting a closed session. Its owner deletes
5712
+ // the record; the caller's own acquisition creates a new session (chat) or
5713
+ // rejects `session_closed` (`/acp`).
5714
+ if (session.closed) return { ok: true }
5715
+ if (session.released) return { ok: true } // the acquisition resumes it
5716
+ const bridge = this.bridge(session.agentId)
5717
+ const blocked = `acp: agent "${session.agentId}" is unresponsive, but other conversations are still using it; retry after they finish`
5718
+ if (bridge.hasOtherActiveWork(session)) return { ok: false, message: blocked }
5719
+ // A sibling conversation the agent already settled is not "work" by
5720
+ // `hasOtherActiveWork` — but its HOST may still owe the turn a
5721
+ // continuation step (a card it is executing right now), and restarting the
5722
+ // process would park and resume that live turn. Its indexed controller is
5723
+ // the evidence. Scoped to THIS agent: the restart kills this agent's child
5724
+ // only, so another agent's pending step is not this bridge's business.
5725
+ // A closed or released session has no host behind it (a leftover
5726
+ // controller), so it does not pin the child either.
5727
+ for (const controller of this.turnStreams.values()) {
5728
+ if (controller.session === session) continue
5729
+ if (controller.session.agentId !== session.agentId) continue
5730
+ if (controller.session.closed || controller.session.released) continue
5731
+ if (!controller.stale) return { ok: false, message: blocked }
5732
+ }
5733
+ const capabilities = bridge.view().agentCapabilities as { loadSession?: boolean } | undefined
5734
+ if (capabilities?.loadSession === true) {
5735
+ // Whatever teardown runs, the agent's in-flight `session/prompt` is about
5736
+ // to be rejected by the closing connection. Mark the generation so that
5737
+ // rejection does not broadcast a second `turn/end` for a turn already
5738
+ // finalized by the watchdog (`LiveSession.reclaimedTurnSeq`).
5739
+ session.reclaimedTurnSeq = session.turnSeq
5740
+ bridge.releaseAll('agent did not acknowledge the stalled turn', 'stalled')
5741
+ session.orphanPromptTurnSeq = undefined
5742
+ return { ok: true }
5743
+ }
5744
+ // No resume: the restart IS a reset, so only this session's context is at
5745
+ // stake — and any other live session pins the process even while idle.
5746
+ if (bridge.sessionList.some(other => other !== session)) return { ok: false, message: blocked }
5747
+ const agentId = session.agentId
5748
+ // `stop` would ask a wedged agent to close its sessions and wait for an
5749
+ // answer that never comes; `forceReset` is the local half only.
5750
+ session.reclaimedTurnSeq = session.turnSeq
5751
+ bridge.forceReset('agent-stopped')
5752
+ this.forgetDshSession(session.sessionId)
5753
+ session.orphanPromptTurnSeq = undefined
5754
+ this.hub.broadcast({
5755
+ type: 'agent/notice',
5756
+ agentId,
5757
+ level: 'info',
5758
+ text: `acp: agent "${agentId}" does not support session resume; the ACP session was reset (context could not be restored)`,
5759
+ })
5760
+ return { ok: true, reset: true }
5761
+ }
5762
+
5186
5763
  async *streamPrompt(options: GenerateOptions): AsyncIterable<StreamChunk> {
5187
5764
  // The plugin's own conversation↔agent mapping decides who serves the turn
5188
5765
  // (see `bindChatAgent`): the driver may arrive here with the previous
@@ -5293,6 +5870,20 @@ export class BridgeManager {
5293
5870
  }
5294
5871
 
5295
5872
  let session: LiveSession
5873
+ // Same key `getOrCreateDshSession` looks up: a session the watchdog left
5874
+ // orphaned must be reclaimed BEFORE the acquisition, so this message cannot
5875
+ // queue behind a prompt the agent will never answer.
5876
+ const stalled = this.dshSessionMap.get(`${options.sessionId ?? 'ephemeral'}:${agentId}`)
5877
+ if (stalled !== undefined && stalled.orphanPromptTurnSeq !== undefined) {
5878
+ const prepared = await this.prepareStalledSession(stalled)
5879
+ if (!prepared.ok) {
5880
+ yield {
5881
+ type: 'finish',
5882
+ reason: { kind: 'error', failure: { message: prepared.message, code: 'agent_unresponsive' } },
5883
+ }
5884
+ return
5885
+ }
5886
+ }
5296
5887
  try {
5297
5888
  session = await this.getOrCreateDshSession(options.sessionId, agentId)
5298
5889
  } catch (err) {
@@ -5424,6 +6015,13 @@ export class BridgeManager {
5424
6015
 
5425
6016
  session.running = true
5426
6017
  session.turnSeq += 1
6018
+ const turnGen = session.turnSeq
6019
+ session.lastAgentActivityAt = Date.now()
6020
+ session.activeToolCalls.clear()
6021
+ // A new turn owns the session from here: any reclaim marker named an
6022
+ // earlier generation and must not suppress THIS turn's settle.
6023
+ session.reclaimedTurnSeq = undefined
6024
+ this.armStallTimer()
5427
6025
  const turnId = randomUUID()
5428
6026
  this.hub.broadcast({ type: 'turn/start', sessionId: session.sessionId, turnId, text: promptText })
5429
6027
 
@@ -5442,6 +6040,14 @@ export class BridgeManager {
5442
6040
  victim => this.rollbackFirstTurn(victim),
5443
6041
  )
5444
6042
  if (session.dshSessionId !== undefined) this.rememberTurnStream(session.dshSessionId, controller)
6043
+ // The session itself carries the controller too: an EPHEMERAL turn
6044
+ // (`options.sessionId === undefined`) has no chat id to index under, and
6045
+ // the stall watchdog needs SOME route to the controller to close the
6046
+ // driver's segment queue. For chat turns this also disambiguates a chat id
6047
+ // two LiveSessions briefly share (a concurrent first turn). Assigned AFTER
6048
+ // `rememberTurnStream` so the controller it just retired cannot clear this
6049
+ // attachment.
6050
+ session.turnController = controller
5445
6051
  const segment = controller.startSegment()
5446
6052
  const onAbort = (): void => {
5447
6053
  if (controller.stale) return
@@ -5460,13 +6066,30 @@ export class BridgeManager {
5460
6066
  // or a continuation one.
5461
6067
  bridge.runPrompt(session, promptText, turnId)
5462
6068
  .then(stopReason => {
6069
+ // The reclaim already ended this turn locally, and the close it
6070
+ // performed rejected this very request: the late answer is that same
6071
+ // turn, not a second one to broadcast.
6072
+ if (session.reclaimedTurnSeq === turnGen) {
6073
+ session.reclaimedTurnSeq = undefined
6074
+ return
6075
+ }
5463
6076
  controller.settle(stopReason === 'cancelled'
5464
6077
  ? { kind: 'cancelled' }
5465
6078
  : { kind: 'stop', stopReason })
5466
6079
  })
5467
6080
  .catch((err: unknown) => {
6081
+ if (session.reclaimedTurnSeq === turnGen) {
6082
+ session.reclaimedTurnSeq = undefined
6083
+ return
6084
+ }
5468
6085
  controller.settle({ kind: 'error', message: describeError(err) })
5469
6086
  })
6087
+ // The agent's own answer closed the turn: whatever the watchdog marked
6088
+ // orphaned has just reported back and no longer needs a reclaim.
6089
+ .finally(() => {
6090
+ if (session.reclaimedTurnSeq === turnGen) session.reclaimedTurnSeq = undefined
6091
+ this.clearOrphanPrompt(session, turnGen)
6092
+ })
5470
6093
  }
5471
6094
 
5472
6095
  try {