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/impl.js CHANGED
@@ -514,6 +514,22 @@ export function validateConfig(raw) {
514
514
  if (typeof staleCancelMaxRetries !== 'number' || !Number.isInteger(staleCancelMaxRetries) || staleCancelMaxRetries < 0 || staleCancelMaxRetries > 5) {
515
515
  throw new AcpError('bad_config', 'acp: staleCancelMaxRetries must be an integer in [0, 5]');
516
516
  }
517
+ // Stall-recovery windows (inactivity watchdog). Two lengths on purpose: a
518
+ // silent turn with the floor is a stuck agent, but a turn waiting on a TOOL
519
+ // call is a working agent, and the tool half may take as long as the guard's
520
+ // own tool-output wait. `0` means "off" / "fall back", never "expire now".
521
+ const turnStallMs = value.turnStallMs ?? 600_000;
522
+ if (typeof turnStallMs !== 'number' || !Number.isInteger(turnStallMs) || turnStallMs < 0 || turnStallMs > 86_400_000) {
523
+ throw new AcpError('bad_config', 'acp: turnStallMs must be an integer in [0, 86400000] (0 = disable the watchdog)');
524
+ }
525
+ const turnStallToolMs = value.turnStallToolMs ?? 1_800_000;
526
+ if (typeof turnStallToolMs !== 'number' || !Number.isInteger(turnStallToolMs) || turnStallToolMs < 0 || turnStallToolMs > 86_400_000) {
527
+ throw new AcpError('bad_config', 'acp: turnStallToolMs must be an integer in [0, 86400000] (0 = fall back to turnStallMs)');
528
+ }
529
+ const turnStallTickMs = value.turnStallTickMs ?? 30_000;
530
+ if (typeof turnStallTickMs !== 'number' || !Number.isInteger(turnStallTickMs) || turnStallTickMs < 1 || turnStallTickMs > 60_000) {
531
+ throw new AcpError('bad_config', 'acp: turnStallTickMs must be an integer in [1, 60000]');
532
+ }
517
533
  return {
518
534
  agents,
519
535
  transcriptMaxEntries: max,
@@ -524,6 +540,9 @@ export function validateConfig(raw) {
524
540
  cancelFallbackMs,
525
541
  staleCancelRetryDelayMs,
526
542
  staleCancelMaxRetries,
543
+ turnStallMs,
544
+ turnStallToolMs,
545
+ turnStallTickMs,
527
546
  };
528
547
  }
529
548
  // ---- Workspace guard ----
@@ -1499,6 +1518,19 @@ export class PermissionLedger {
1499
1518
  sessionIds() {
1500
1519
  return [...this.pending.values()].map(e => ({ requestId: e.requestId, sessionId: e.sessionId }));
1501
1520
  }
1521
+ /**
1522
+ * Whether ANY outstanding ask belongs to `sessionId`, a HOST session id
1523
+ * (`LiveSession.sessionId`) — NOT the ACP protocol session id. Used by the
1524
+ * stall watchdog: a turn blocked on a human is not a silent turn, so the
1525
+ * session with a pending permission must not be finalized as stalled.
1526
+ */
1527
+ hasPendingFor(sessionId) {
1528
+ for (const entry of this.pending.values()) {
1529
+ if (entry.sessionId === sessionId)
1530
+ return true;
1531
+ }
1532
+ return false;
1533
+ }
1502
1534
  /**
1503
1535
  * Outstanding asks WITH the view the browser was shown, for the reconnect
1504
1536
  * replay. Entries created without a view are skipped: replaying a frame the
@@ -1734,6 +1766,21 @@ export async function dispatchRpc(manager, endpoint, payload) {
1734
1766
  }
1735
1767
  case 'preset/status':
1736
1768
  return ok({ preset: await manager.presetStatus?.() ?? null });
1769
+ case 'plugin/version':
1770
+ // Absent on a host half from before the badge existed: the browser
1771
+ // hides it instead of naming a version it cannot know.
1772
+ return ok({ version: manager.pluginVersion?.() });
1773
+ case 'plugin/update-check':
1774
+ // An older host half answers `unsupported` rather than failing: the
1775
+ // settings card then hides the update section.
1776
+ return ok(await (manager.pluginUpdateCheck?.() ?? { status: 'unsupported', current: '' }));
1777
+ case 'plugin/update-apply': {
1778
+ const version = str(body, 'version', 64);
1779
+ if (!/^\d+\.\d+\.\d+$/.test(version)) {
1780
+ throw new AcpError('bad_payload', 'acp: version must be a stable semver (x.y.z)');
1781
+ }
1782
+ return ok(await (manager.pluginUpdateApply?.(version) ?? { status: 'unsupported', current: '' }));
1783
+ }
1737
1784
  case 'agent/stop':
1738
1785
  await manager.stopAgent(str(body, 'agentId', 64));
1739
1786
  return ok({ done: true });
package/lib/index.js CHANGED
@@ -4,8 +4,9 @@
4
4
  * them down with the plugin fiber. Named exports only (a default export would
5
5
  * hide the loader metadata — see harness postmortem 0001).
6
6
  */
7
- import { ACP_PRESET_ID, AcpLlmAdapter, BridgeManager } from "./bridge.js";
7
+ import { ACP_PRESET_ID, AcpLlmAdapter, BridgeManager, PLUGIN_VERSION } from "./bridge.js";
8
8
  import { EventHub, dispatchRpc, permissionReplayFrames, sessionAgentPreset, sessionAgentPresetFromLog, validateConfig } from "./impl.js";
9
+ import { UpdateService } from "./update.js";
9
10
  export const name = 'dsh-ntr';
10
11
  // Deliberately minimal: every other host service is probed structurally
11
12
  // (`ctx.get` + a late `ctx.inject`) so a deployment without one still loads
@@ -28,8 +29,21 @@ export function apply(ctx, config) {
28
29
  cancelFallbackMs: resolved.cancelFallbackMs,
29
30
  staleCancelRetryDelayMs: resolved.staleCancelRetryDelayMs,
30
31
  staleCancelMaxRetries: resolved.staleCancelMaxRetries,
32
+ turnStallMs: resolved.turnStallMs,
33
+ turnStallToolMs: resolved.turnStallToolMs,
34
+ turnStallTickMs: resolved.turnStallTickMs,
31
35
  });
32
36
  const adapter = new AcpLlmAdapter(manager);
37
+ // Built-in update: the plugin never downloads or swaps files itself. The
38
+ // harness plugin manager owns the install; this only relays its facts.
39
+ // `resolveManager` re-probes on every call so a late-loading service is
40
+ // picked up without an `inject` dependency (a host without it must still
41
+ // load this plugin, and then reports `unsupported`).
42
+ manager.setUpdateService(new UpdateService({
43
+ resolveManager: () => (typeof ctx.get === 'function' ? ctx.get('pluginManager') : undefined),
44
+ currentVersion: PLUGIN_VERSION,
45
+ isBusy: () => manager.hasActiveTurns(),
46
+ }));
33
47
  // The RAW patch-file deadlines, before validation filled the defaults in.
34
48
  // `validateConfig` returns a number for every knob, so the validated object
35
49
  // cannot tell "the deployment asked for 10 minutes" from "the deployment said
@@ -9,7 +9,15 @@
9
9
  import { type InitializeResponse } from '@agentclientprotocol/sdk';
10
10
  import { EventHub, PermissionLedger, Transcript } from './impl.ts';
11
11
  import type { AcpToolCallSnapshot, PendingPermissionView, RosterPresetRow } from './impl.ts';
12
- import type { AcpAgentCatalog, AcpAgentDef, AcpApprovalDeadlines, AcpConversationChoice, AcpPresetStatus, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult, AgentView, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, GenerateOptions, LlmAdapterLike, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, ApprovalDeadlineKey, ApprovalDeadlinePatch, PermissionTimeoutMode, PreparedAdapterCall, SessionView, StreamChunk } from './protocol.ts';
12
+ import type { AcpAgentCatalog, AcpAgentDef, AcpApprovalDeadlines, AcpConversationChoice, AcpPresetStatus, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult, AgentView, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, GenerateOptions, LlmAdapterLike, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, ApprovalDeadlineKey, ApprovalDeadlinePatch, PermissionTimeoutMode, PluginUpdateState, PreparedAdapterCall, SessionView, StreamChunk } from './protocol.ts';
13
+ import { UpdateService } from './update.ts';
14
+ /**
15
+ * Version of this installed package, read from the shipped `package.json` —
16
+ * one level above both `src/` at test time and `lib/` at runtime. The settings
17
+ * badge and the ACP handshake's `clientInfo` both report it; a copied literal
18
+ * would go stale on every release.
19
+ */
20
+ export declare const PLUGIN_VERSION: string;
13
21
  interface LiveSession {
14
22
  /** Host-minted id (stable across an agent restart; the ACP id is per-connection). */
15
23
  sessionId: string;
@@ -37,6 +45,20 @@ interface LiveSession {
37
45
  closed: boolean;
38
46
  /** The agent process died; the session awaits a `session/load` resume. */
39
47
  released: boolean;
48
+ /**
49
+ * When the agent last showed ANY sign of life on this session: an update, a
50
+ * permission ask (arrival or answer), or a turn start. The stall watchdog
51
+ * measures silence from here rather than from the prompt, which may
52
+ * legitimately be followed by a long tool run.
53
+ */
54
+ lastAgentActivityAt: number;
55
+ /**
56
+ * Tool calls the agent announced and has not finished, by ACP `toolCallId`.
57
+ * A SET, not a flag: parallel calls finish independently, and one sibling's
58
+ * `completed` must not clear the long tool window while another is still
59
+ * running. Non-empty = the agent is working on its own clock.
60
+ */
61
+ activeToolCalls: Set<string>;
40
62
  /**
41
63
  * Owning DSH chat session id, when the session was opened through the LLM
42
64
  * adapter (`AcpLlmAdapter`). It is what the conversation's permission card
@@ -90,6 +112,36 @@ interface LiveSession {
90
112
  * that answer means the message was silently dropped.
91
113
  */
92
114
  cancelRequestedAtTurn?: number;
115
+ /**
116
+ * The {@link turnSeq} of a turn the stall watchdog force-finalized while the
117
+ * agent still held its `session/prompt`. The agent is presumed wedged, so the
118
+ * NEXT prompt reclaims the process (`BridgeManager.prepareStalledSession`)
119
+ * instead of queueing behind a turn that may never answer. Cleared when the
120
+ * late response lands or when the reclaim replaces the process.
121
+ */
122
+ orphanPromptTurnSeq?: number;
123
+ /**
124
+ * The {@link turnSeq} of a turn a reclaim (`prepareStalledSession`) ended by
125
+ * closing the connection while the agent still held its `session/prompt`.
126
+ * The close rejects that request, and the caller's catch would broadcast a
127
+ * second `turn/end` for a turn already finalized: this marker tells the
128
+ * late `then`/`catch` to drop the answer instead. Set by the reclaim, cleared
129
+ * when that answer lands, at every new turn start, and by a resume.
130
+ */
131
+ reclaimedTurnSeq?: number;
132
+ /**
133
+ * The controller of the turn currently streaming through this session.
134
+ *
135
+ * Chat turns are ALSO indexed by chat id ({@link BridgeManager}'s
136
+ * `turnStreams`) because their continuation steps arrive as fresh
137
+ * `streamPrompt` calls that look the turn up by chat id. An EPHEMERAL turn
138
+ * (`options.sessionId === undefined`) has no chat id to index under, so this
139
+ * attachment is the only way the stall watchdog can find its controller —
140
+ * without it a silent ephemeral turn falls into the `/acp` branch, which
141
+ * leaves the driver's segment queue open instead of finishing it. Cleared by
142
+ * the controller's own `retire` when it still points at it.
143
+ */
144
+ turnController?: TurnStreamController;
93
145
  transcript: Transcript;
94
146
  }
95
147
  /** Reliability tuning, threaded from `AcpPluginConfig` through the manager. */
@@ -155,6 +207,23 @@ export interface BridgeTuning {
155
207
  * racing a concurrent `session/prompt` into the agent.
156
208
  */
157
209
  turnSettleMs?: number;
210
+ /**
211
+ * How long a turn may stay SILENT — no update, no ask, no turn start — before
212
+ * the watchdog force-finalizes it as stalled. The turn is only watched while
213
+ * the agent is the one holding the floor (a segment on air, no pending ask),
214
+ * and the process survives: the next message reclaims it.
215
+ *
216
+ * `0` disables the watchdog.
217
+ */
218
+ turnStallMs?: number;
219
+ /**
220
+ * The longer silence window used while an ACP tool call is in progress: the
221
+ * agent runs the tool on its own clock, so the plain turn window would call
222
+ * a healthy long-running tool stalled. `0` falls back to `turnStallMs`.
223
+ */
224
+ turnStallToolMs?: number;
225
+ /** How often the watchdog scans for silent turns. */
226
+ turnStallTickMs?: number;
158
227
  /**
159
228
  * Default idle-release window for agents without their own
160
229
  * `AcpAgentDef.idleTimeoutMs`: a READY process that holds no live session
@@ -248,8 +317,19 @@ export declare class AgentBridge {
248
317
  private lastActivityAt;
249
318
  /** Throwaway catalog probes in flight — they hold no live session of their own. */
250
319
  private pendingProbes;
251
- /** Host→agent session commands in flight (mode / config); not turns. */
252
- private pendingCommands;
320
+ /**
321
+ * Host→agent session commands in flight (mode / config); not turns. Counted
322
+ * PER SESSION: a command the wedged agent never answers must not pin the
323
+ * bridge against reclaiming THAT session (it is exactly the state the
324
+ * watchdog is recovering from), while another session's command still does.
325
+ */
326
+ private readonly pendingCommands;
327
+ /**
328
+ * `session/new` requests in flight. The session does not exist in `sessions`
329
+ * yet, so nothing else would tell a teardown (`releaseAll` / idle release)
330
+ * that a caller is waiting on the child it is about to kill.
331
+ */
332
+ private pendingCreates;
253
333
  /**
254
334
  * Whether a REAL session has ever been registered on this bridge. Probes
255
335
  * bypass session bookkeeping, so a bridge that has never hosted anything was
@@ -383,6 +463,12 @@ export declare class AgentBridge {
383
463
  * @returns the settler (idempotent; also used by connection loss).
384
464
  */
385
465
  beginTurn(session: LiveSession): () => void;
466
+ /**
467
+ * Open a session's settle gate without owning its turn stream: the turn's
468
+ * stream is gone (a resume, a release) but the next prompt still waits on
469
+ * this promise, and nothing is left that could ever resolve it.
470
+ */
471
+ private openSettleGate;
386
472
  view(): AgentView;
387
473
  /**
388
474
  * Work a teardown would interrupt: a turn in flight (or one the agent is
@@ -390,6 +476,18 @@ export declare class AgentBridge {
390
476
  * probe, a resume, or a start. A registered-but-idle session is NOT work.
391
477
  */
392
478
  private hasActiveWork;
479
+ /**
480
+ * Active work on this bridge EXCEPT `session`'s own — what a restart-carrying
481
+ * reclaim (the stall watchdog's follow-up) would destroy. Killing this child
482
+ * restarts every ACP session on the connection, so a live conversation that
483
+ * is mid-turn, mid-resume, or waiting on a human pins the process: its
484
+ * context is the one thing a restart cannot give back.
485
+ *
486
+ * `session`'s OWN mode/config command is deliberately not counted: an agent
487
+ * that ignores `session/set_mode` would otherwise make its own stall
488
+ * unrecoverable — the command's counter is exactly the evidence of the wedge.
489
+ */
490
+ hasOtherActiveWork(session: LiveSession): boolean;
393
491
  /**
394
492
  * Nothing depends on this child's identity right now: no live session and no
395
493
  * work. This is the WORKSPACE MOVE's predicate. It stays stricter than the
@@ -398,6 +496,13 @@ export declare class AgentBridge {
398
496
  * state for a restart on every cross-workspace turn.
399
497
  */
400
498
  private isQuiet;
499
+ /**
500
+ * A turn is in flight (or the agent has not settled its response yet) on any
501
+ * of this bridge's sessions. `settled` only stays non-undefined while the
502
+ * agent's answer is outstanding — the settler clears it on completion — so
503
+ * this is real work, not an idle session.
504
+ */
505
+ hasActiveTurn(): boolean;
401
506
  /**
402
507
  * An idle teardown is safe when nothing is WORKING. Idle live sessions do not
403
508
  * block it: `releaseAll` parks them as `released` (never closed), and the next
@@ -437,6 +542,8 @@ export declare class AgentBridge {
437
542
  * @param spawnCwd - where the agent PROCESS should run; see `ensureReady`.
438
543
  */
439
544
  createSession(cwd: string | undefined, extraRoots?: readonly string[], spawnCwd?: string): Promise<LiveSession>;
545
+ /** The body of {@link createSession}; the caller keeps `pendingCreates` for it. */
546
+ private openSession;
440
547
  /**
441
548
  * Spawn a throwaway ACP session purely to read its `configOptions` (the
442
549
  * model catalog probe). Deliberately bypasses the live-session bookkeeping
@@ -482,6 +589,17 @@ export declare class AgentBridge {
482
589
  * successor — so a moved-on {@link LiveSession.turnSeq} disarms it.
483
590
  */
484
591
  private armCancelFallback;
592
+ /**
593
+ * Force the local end of one turn whose agent never acknowledged it: clear
594
+ * `running`, open the settle gate, and broadcast the turn end with `turnId:
595
+ * ''` (there is no agent response to attribute it to). Shared by the cancel
596
+ * fallback and the stall watchdog.
597
+ *
598
+ * Generation-guarded: a turn that already ended — or that a later turn
599
+ * superseded — is not this call's to finalize. Returns whether it acted; a
600
+ * `false` means the caller was late and must leave the session alone.
601
+ */
602
+ finalizeTurn(session: LiveSession, turnGen: number, message: string): boolean;
485
603
  /**
486
604
  * Bring a released session back on a fresh agent process via
487
605
  * `session/load` ("send to recover"). Concurrent callers share one inflight
@@ -512,6 +630,18 @@ export declare class AgentBridge {
512
630
  hasSession(sessionId: string): LiveSession | undefined;
513
631
  /** Kill the child and drop every local session bound to this connection. */
514
632
  stop(reason?: 'closed' | 'agent-stopped' | 'agent-died'): Promise<void>;
633
+ /**
634
+ * Drop every live session record and kill the child WITHOUT asking the agent
635
+ * to close them. `stop` sends `session/close` per session, and a wedged agent
636
+ * that ignores it would hang the caller (the stall reclaim) — the very agent
637
+ * this teardown exists to get rid of. The local teardown is what frees the
638
+ * sessions, so it runs unconditionally and immediately.
639
+ *
640
+ * Deliberately does NOT clear `released`: those records are constraint 10's
641
+ * — a released session survives a process death and is resumed by its next
642
+ * message. This is a stop, not a discard.
643
+ */
644
+ forceReset(reason: 'agent-stopped'): void;
515
645
  private killChild;
516
646
  /**
517
647
  * Answer one ACP permission ask.
@@ -805,6 +935,8 @@ interface SegmentState {
805
935
  index: number;
806
936
  activeType: 'reasoning' | 'text' | 'tool-call' | null;
807
937
  activeText: string;
938
+ /** UTF-16 units of this segment's text and reasoning chunks (the usage estimate). */
939
+ outputChars: number;
808
940
  /** The open tool-call block's snapshot, while `activeType === 'tool-call'`. */
809
941
  activeToolCall: AcpToolCallSnapshot | undefined;
810
942
  /** Whether this segment opened at least one tool-call block. */
@@ -836,6 +968,7 @@ type TurnSettle = {
836
968
  } | {
837
969
  kind: 'error';
838
970
  message: string;
971
+ code?: string;
839
972
  };
840
973
  /**
841
974
  * The state of ONE ACP prompt, shared across every driver step of the turn.
@@ -845,7 +978,7 @@ type TurnSettle = {
845
978
  * `transcript.since(cursor)` — events that arrive while no segment is open are
846
979
  * recorded, never lost), the seen-call registry (a call id gets exactly one
847
980
  * card per turn, wherever its restatements land), and the turn-level facts
848
- * (`sawVisibleBlock`, usage) that outlive any single segment.
981
+ * (`sawVisibleBlock`) that outlive any single segment.
849
982
  */
850
983
  declare class TurnStreamController {
851
984
  private readonly manager;
@@ -860,7 +993,6 @@ declare class TurnStreamController {
860
993
  private disposed;
861
994
  private readonly seenCalls;
862
995
  private readonly announcedKinds;
863
- private latestUsage;
864
996
  /** Narrative lines (permission asks) that arrived while no segment was open. */
865
997
  private readonly pendingProse;
866
998
  sawVisibleBlock: boolean;
@@ -874,6 +1006,8 @@ declare class TurnStreamController {
874
1006
  get replayCursor(): number;
875
1007
  /** Whether the agent already settled its `session/prompt` for this turn. */
876
1008
  get hasSettled(): boolean;
1009
+ /** Whether a segment is on air — the agent is expected to speak into a live driver step. */
1010
+ get hasOpenSegment(): boolean;
877
1011
  /** Generation guard: a controller whose turn has been superseded must not touch the session. */
878
1012
  get stale(): boolean;
879
1013
  /**
@@ -893,6 +1027,13 @@ declare class TurnStreamController {
893
1027
  abortActiveSegment(): void;
894
1028
  /** Stop observing and release the manager's index. Idempotent. */
895
1029
  dispose(): void;
1030
+ /**
1031
+ * Stop observing and release the manager's index WITHOUT touching the
1032
+ * process-wide tool-output waiters. A late settle of a superseded turn must
1033
+ * use this: those waiters now belong to the turn on air, and waking them
1034
+ * early would hand the guard a "no output" answer for a tool still running.
1035
+ */
1036
+ retire(): void;
896
1037
  private finishSegment;
897
1038
  /** A segment opened on an already-settled turn: replay, then close it out. */
898
1039
  private finishAfterSettle;
@@ -908,7 +1049,6 @@ declare class TurnStreamController {
908
1049
  private pushProse;
909
1050
  /** Events that arrive between segments: recorded, replayed into the next one. */
910
1051
  private recordWithoutSegment;
911
- private noteUsage;
912
1052
  /** Record a tool call's output (idempotent) and wake any waiter. */
913
1053
  private noteToolRecord;
914
1054
  /**
@@ -1016,6 +1156,28 @@ export declare class BridgeManager {
1016
1156
  forgetTurnStream(controller: TurnStreamController): void;
1017
1157
  /** Tear down the turn streams of every session of one bridge (close/release/stop). */
1018
1158
  private disposeTurnStreamsOf;
1159
+ /**
1160
+ * The stall watchdog's scan loop. Armed lazily at the first turn start and
1161
+ * DROPPED once nothing anywhere is running: a manager that never runs a turn
1162
+ * never holds a timer, and one that goes quiet does not keep the host awake.
1163
+ */
1164
+ private stallTimer?;
1165
+ private armStallTimer;
1166
+ /**
1167
+ * Force-finalize turns the agent has gone silent in.
1168
+ *
1169
+ * "Silent" is scoped to the stretch where the AGENT owes the turn a word: a
1170
+ * running session with a segment on air and no pending ask. A turn waiting on
1171
+ * the host (tool cards executing between segments) or on a human (an
1172
+ * unanswered permission) is not stuck and is skipped.
1173
+ *
1174
+ * Firing does NOT kill the process (see `LiveSession.orphanPromptTurnSeq`):
1175
+ * the session, its transcript and its binding survive, and the next message
1176
+ * reclaims the process. The turn's streaming face ends through its own
1177
+ * controller when there is one, so the driver step on air closes with an
1178
+ * error instead of being torn down mid-stream.
1179
+ */
1180
+ private reapStalledTurns;
1019
1181
  constructor(defs: readonly AcpAgentDef[], hub: EventHub, transcriptMax: number, configStoragePath?: string | undefined, catalogStoragePath?: string | undefined,
1020
1182
  /** Auto-probe agents for model catalogs (boot + lazy); manual probes always run. */
1021
1183
  probeModelsOnBoot?: boolean, tuning?: BridgeTuning);
@@ -1252,6 +1414,23 @@ export declare class BridgeManager {
1252
1414
  * the block rather than claiming the preset is gone.
1253
1415
  */
1254
1416
  presetStatus(): Promise<AcpPresetStatus | undefined>;
1417
+ /** The installed package's version (the settings page's badge). */
1418
+ pluginVersion(): string;
1419
+ /** Wire the built-in updater (host entry only; absent in pure tests). */
1420
+ setUpdateService(service?: UpdateService): void;
1421
+ /** Registry check for the settings card; `unsupported` when no updater is wired. */
1422
+ pluginUpdateCheck(): Promise<PluginUpdateState>;
1423
+ /** Delegate one install to the host plugin manager; never touches files itself. */
1424
+ pluginUpdateApply(version: string): Promise<PluginUpdateState>;
1425
+ /**
1426
+ * Whether any ACP work is in flight. An update install may restart the host,
1427
+ * so check/apply describe the host as busy while a turn is running.
1428
+ *
1429
+ * Segmented turn streams count even between steps (the host is still
1430
+ * continuing that turn); `running` / `settled` on a session covers `/acp`
1431
+ * turns and the window between a prompt and the agent's acknowledgement.
1432
+ */
1433
+ hasActiveTurns(): boolean;
1255
1434
  private getStoragePath;
1256
1435
  /**
1257
1436
  * Read the agent registry file.
@@ -1522,6 +1701,8 @@ export declare class BridgeManager {
1522
1701
  prompt(sessionId: string, text: string): Promise<{
1523
1702
  turnId: string;
1524
1703
  }>;
1704
+ /** The late answer landed (or failed): the turn is no longer an orphan. */
1705
+ private clearOrphanPrompt;
1525
1706
  cancel(sessionId: string): Promise<void>;
1526
1707
  closeSession(sessionId: string): Promise<void>;
1527
1708
  setMode(sessionId: string, modeId: string): Promise<void>;
@@ -1539,6 +1720,11 @@ export declare class BridgeManager {
1539
1720
  shutdownAll(): Promise<void>;
1540
1721
  /** Host-side reader of the harness preset roster (`ctx.agentPresets`), for `presetStatus`. */
1541
1722
  private presetRosterReader?;
1723
+ /**
1724
+ * The built-in updater, wired by the host entry. Absent in pure bridge tests —
1725
+ * the update RPCs then answer `unsupported` instead of inventing a source.
1726
+ */
1727
+ private updateService?;
1542
1728
  /**
1543
1729
  * The plugin's own default ACP agent (the panel's ★ gesture): which agent an
1544
1730
  * ACP-mode conversation starts on. Host-side state in the agent registry
@@ -1633,6 +1819,22 @@ export declare class BridgeManager {
1633
1819
  */
1634
1820
  private seedChatChoiceFromCatalog;
1635
1821
  getOrCreateDshSession(dshSessionId: string | undefined, agentId: string, cwd?: string): Promise<LiveSession>;
1822
+ /**
1823
+ * Take the agent process back from a turn the stall watchdog abandoned.
1824
+ *
1825
+ * The wedged agent still holds its `session/prompt`, so the next message
1826
+ * cannot simply be sent: it would queue behind a turn that may never answer.
1827
+ * The process is restarted instead — unless another conversation on the same
1828
+ * connection is doing real work, because killing the child destroys THEIR
1829
+ * context too, and "retry after they finish" is the honest answer.
1830
+ *
1831
+ * A restart that can `session/load` costs the conversation nothing: the
1832
+ * session is parked `released` and the normal acquisition path resumes it, so
1833
+ * chat keeps its lock / `applyChatChoice` semantics and `/acp` keeps
1834
+ * `locateOrResume`. An agent without resume support is reset instead, and the
1835
+ * user is told the context is gone.
1836
+ */
1837
+ private prepareStalledSession;
1636
1838
  streamPrompt(options: GenerateOptions): AsyncIterable<StreamChunk>;
1637
1839
  }
1638
1840
  export declare class AcpLlmAdapter implements LlmAdapterLike {
@@ -86,7 +86,6 @@ export declare function approvalDeadlineFacts(deadlines: AcpApprovalDeadlines):
86
86
  /** A reset is only honest when something is actually being outranked. */
87
87
  overridden: boolean;
88
88
  };
89
- /** Settings configuration section for ACP agents. */
90
89
  export declare function AcpSettingsSection(props: WithStore): import("react").JSX.Element;
91
90
  /** Everything the connection form edits, in one object (kept in the row). */
92
91
  interface AgentForm {
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Deliberately framework-free: React binds through `useSyncExternalStore`.
7
7
  */
8
- import type { AcpApprovalDeadlines, AcpConversationChoice, AcpPresetStatus, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentSavePayload, AgentTestResult, AgentView, ChatSessionView, DetectedAgentInfo, PermissionTimeoutMode, SessionView } from '../protocol.ts';
8
+ import type { AcpApprovalDeadlines, AcpConversationChoice, AcpPresetStatus, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentSavePayload, AgentTestResult, AgentView, ChatSessionView, DetectedAgentInfo, PermissionTimeoutMode, PluginUpdateState, SessionView } from '../protocol.ts';
9
9
  /** One slash command the ACP agent declared, whole row (the menu draws name, description and `input.hint`). */
10
10
  export type AgentCommand = NonNullable<ChatSessionView['availableCommands']>[number];
11
11
  /** The unary RPC face (narrowed `ctx.connection.rpc.call`). */
@@ -304,6 +304,18 @@ export interface UiState {
304
304
  * (`preset/status` answering `null`), in which case the panel hides the block.
305
305
  */
306
306
  preset?: AcpPresetStatus;
307
+ /**
308
+ * The installed host package's version (`plugin/version`), rendered as the
309
+ * settings page badge. Absent on a host half without the endpoint, which
310
+ * hides the badge rather than naming a version this browser invented.
311
+ */
312
+ pluginVersion?: string;
313
+ /**
314
+ * The running version and its update status (`plugin/update-check`). Absent
315
+ * on a host half without the endpoint, which hides the update entry instead
316
+ * of offering an install this browser cannot perform.
317
+ */
318
+ pluginUpdate?: PluginUpdateState;
307
319
  }
308
320
  interface Listener {
309
321
  (): void;
@@ -375,6 +387,22 @@ export declare class AcpStore {
375
387
  private readonly cachedModelReads;
376
388
  /** Counts behind the last successful explicit re-detect, so a locale switch can re-render its summary. */
377
389
  private detectCounts;
390
+ /** When {@link UiState.pluginUpdate} was last read from the host (epoch ms). */
391
+ private pluginUpdateFetchedAt;
392
+ /**
393
+ * The shared in-flight `plugin/update-check`. The settings section mounts
394
+ * twice (both entries) and the update popover asks too; without this, two
395
+ * mounts in the same tick each fire their own registry read.
396
+ */
397
+ private pluginUpdateInflight?;
398
+ /** Re-entry guard for `plugin/update-apply` (a double click is one install). */
399
+ private pluginUpdateApplying;
400
+ /**
401
+ * Update read generation. An apply bumps it so a check that was already in
402
+ * flight (started before the install) cannot land afterwards and overwrite
403
+ * the install's verdict with the pre-install `available`.
404
+ */
405
+ private pluginUpdateGeneration;
378
406
  constructor(rpc: RpcCall, locale: (zh: string, en: string) => string);
379
407
  subscribe(listener: Listener): () => void;
380
408
  getSnapshot(): UiState;
@@ -398,6 +426,31 @@ export declare class AcpStore {
398
426
  private call;
399
427
  /** Snapshot refresh used on panel open and after a stream outage. */
400
428
  resync(): Promise<void>;
429
+ /**
430
+ * Read the plugin's update state (`plugin/update-check`). Throttled to ten
431
+ * minutes unless `force`: the settings panel asks on mount, and every
432
+ * remount must not hit the registry.
433
+ *
434
+ * An older host serves no such endpoint (`unknown endpoint`). That is a
435
+ * normal degradation, not an error worth a notice: the answer is `undefined`
436
+ * and the panel simply keeps its update entry hidden.
437
+ */
438
+ checkPluginUpdate(force?: boolean): Promise<PluginUpdateState | undefined>;
439
+ private readPluginUpdate;
440
+ /**
441
+ * Install one exact version (`plugin/update-apply`). The status flips to
442
+ * `applying` first so the badge answers the click; the host's answer then
443
+ * becomes the state.
444
+ *
445
+ * A failed call does NOT prove the install did not happen: on an HMR host
446
+ * the update uninstalls the plugin before reinstalling it, which can cut the
447
+ * response. Whatever the error, the disk may already carry the new version,
448
+ * so the state becomes `restart-required` (the one neutral verdict for "the
449
+ * running code is not the disk's") with `installed` dropped (unknown, not a
450
+ * fact to display) and the notice asks for a reload rather than restoring
451
+ * `available` and pretending nothing changed.
452
+ */
453
+ applyPluginUpdate(version: string): Promise<PluginUpdateState | undefined>;
401
454
  detectAgents(): Promise<void>;
402
455
  private detectSummaryOf;
403
456
  saveAgent(payload: AgentSavePayload): Promise<void>;
@@ -5,7 +5,7 @@
5
5
  * fakes.
6
6
  */
7
7
  import { randomUUID } from './ids.ts';
8
- import type { AcpAgentCatalog, AcpAgentDef, AcpApprovalDeadlines, AcpConversationChoice, AcpEvent, AcpMcpServerDef, AcpPluginConfig, AcpPresetStatus, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult, AgentView, ApprovalDeadlineKey, ApprovalDeadlinePatch, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, PermissionOptionView, PermissionOutcome, PermissionTimeoutMode, RpcResult } from './protocol.ts';
8
+ import type { AcpAgentCatalog, AcpAgentDef, AcpApprovalDeadlines, AcpConversationChoice, AcpEvent, AcpMcpServerDef, AcpPluginConfig, AcpPresetStatus, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult, AgentView, ApprovalDeadlineKey, ApprovalDeadlinePatch, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, PermissionOptionView, PermissionOutcome, PermissionTimeoutMode, PluginUpdateState, RpcResult } from './protocol.ts';
9
9
  export { randomUUID };
10
10
  /** Structured failure the RPC layer converts to `{ok:false, error}` and `apply()` surfaces at boot. */
11
11
  export declare class AcpError extends Error {
@@ -605,6 +605,13 @@ export declare class PermissionLedger {
605
605
  requestId: string;
606
606
  sessionId: string;
607
607
  }>;
608
+ /**
609
+ * Whether ANY outstanding ask belongs to `sessionId`, a HOST session id
610
+ * (`LiveSession.sessionId`) — NOT the ACP protocol session id. Used by the
611
+ * stall watchdog: a turn blocked on a human is not a silent turn, so the
612
+ * session with a pending permission must not be finalized as stalled.
613
+ */
614
+ hasPendingFor(sessionId: string): boolean;
608
615
  /**
609
616
  * Outstanding asks WITH the view the browser was shown, for the reconnect
610
617
  * replay. Entries created without a view are skipped: replaying a frame the
@@ -808,6 +815,24 @@ export interface AcpManager {
808
815
  * the registry service), which hides the panel block instead of guessing.
809
816
  */
810
817
  presetStatus?(): Promise<AcpPresetStatus | undefined>;
818
+ /**
819
+ * The installed host package's version, reported for the settings page's
820
+ * badge. `undefined` on a host half that predates the endpoint, which hides
821
+ * the badge rather than showing a number the browser invented.
822
+ */
823
+ pluginVersion?(): string | undefined;
824
+ /**
825
+ * Ask the host's plugin manager what is published for this package. Read-only
826
+ * toward the installation: never downloads, never swaps files, never restarts
827
+ * the host. Absent on a host half from before the updater existed.
828
+ */
829
+ pluginUpdateCheck?(): Promise<PluginUpdateState>;
830
+ /**
831
+ * Install one exact stable version THROUGH the host's plugin manager. The
832
+ * manager owns download/restart semantics; this plugin only relays. Absent on
833
+ * an older host half.
834
+ */
835
+ pluginUpdateApply?(version: string): Promise<PluginUpdateState>;
811
836
  }
812
837
  /** Dispatch one decoded `/acp` channel call. Never rejects; protocol errors come back as `ok:false`. */
813
838
  export declare function dispatchRpc(manager: AcpManager, endpoint: string, payload: unknown): Promise<RpcResult<unknown>>;