dsh-ntr 0.2.1 → 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.
@@ -838,6 +968,7 @@ type TurnSettle = {
838
968
  } | {
839
969
  kind: 'error';
840
970
  message: string;
971
+ code?: string;
841
972
  };
842
973
  /**
843
974
  * The state of ONE ACP prompt, shared across every driver step of the turn.
@@ -875,6 +1006,8 @@ declare class TurnStreamController {
875
1006
  get replayCursor(): number;
876
1007
  /** Whether the agent already settled its `session/prompt` for this turn. */
877
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;
878
1011
  /** Generation guard: a controller whose turn has been superseded must not touch the session. */
879
1012
  get stale(): boolean;
880
1013
  /**
@@ -894,6 +1027,13 @@ declare class TurnStreamController {
894
1027
  abortActiveSegment(): void;
895
1028
  /** Stop observing and release the manager's index. Idempotent. */
896
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;
897
1037
  private finishSegment;
898
1038
  /** A segment opened on an already-settled turn: replay, then close it out. */
899
1039
  private finishAfterSettle;
@@ -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 {
@@ -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>>;
@@ -229,6 +229,50 @@ export interface AcpPluginConfig {
229
229
  * up and reports the `cancelled` stop as-is. Default 2.
230
230
  */
231
231
  staleCancelMaxRetries?: number;
232
+ /**
233
+ * Inactivity watchdog for a turn the agent owes an answer: how long the turn
234
+ * may stay SILENT before the host finalizes it as stalled.
235
+ *
236
+ * The clock is `LiveSession.lastAgentActivityAt`, refreshed by ANY
237
+ * `session/update`, at every turn start, at each segment start (the host
238
+ * handing the turn back after a card), and when a permission ask arrives or
239
+ * is resolved. The watchdog only READS it while the turn is watched: an OPEN
240
+ * agent segment (always true on the `/acp`-RPC path, which has no driver
241
+ * stream) and no pending approval. The host-card execution phase and an
242
+ * unanswered ask are exempt by that gate, not because the clock is frozen —
243
+ * they legitimately move it too.
244
+ *
245
+ * Firing finalizes the TURN only (an `agent/notice` plus the usual turn
246
+ * end); it does NOT kill the agent process. The wedged `session/prompt` is
247
+ * abandoned, and the next message reclaims the session: parked with
248
+ * `releaseAll` and resumed with `session/load`, or reset outright when the
249
+ * agent declares no `loadSession` (the context is gone and the user is
250
+ * told).
251
+ *
252
+ * Default 600000 ms (10 minutes); `0` disables the WHOLE watchdog, the tool
253
+ * window included.
254
+ */
255
+ turnStallMs?: number;
256
+ /**
257
+ * The longer silence window applied while ANY ACP tool call is still active
258
+ * by `toolCallId` — parallel calls are tracked as a set, so the window lasts
259
+ * until the LAST of them finishes. The agent is working on its own clock in
260
+ * there, so the plain turn window would call a healthy long-running tool
261
+ * stalled.
262
+ *
263
+ * Default 1800000 ms (30 minutes); `0` falls back to
264
+ * {@link AcpPluginConfig.turnStallMs}. It matches
265
+ * `TOOL_OUTPUT_WAIT_MS = 30 * 60 * 1000` in `presets/acp/acp-guard.mjs`, so
266
+ * the two halves of one turn agree on how long a tool may take.
267
+ */
268
+ turnStallToolMs?: number;
269
+ /**
270
+ * How often the watchdog scans for silent turns, in milliseconds. A tuning /
271
+ * test knob only: NO settings-page control exists for it — and none for the
272
+ * two windows above either; the whole watchdog is configured in the patch
273
+ * file. Default 30000 ms.
274
+ */
275
+ turnStallTickMs?: number;
232
276
  }
233
277
  export type AgentProcessState = 'stopped' | 'starting' | 'ready' | 'error';
234
278
  /** Agent facts the browser may render (never includes env/argv secrets beyond argv itself, which is config-visible). */
@@ -669,6 +713,35 @@ export interface DefaultAgentSetPayload {
669
713
  export type DefaultAgentResult = {
670
714
  agentId?: string;
671
715
  };
716
+ /** `plugin/version`: the installed host package's version, for the settings badge. */
717
+ export type PluginVersionResult = {
718
+ version?: string;
719
+ };
720
+ /**
721
+ * Where the plugin's own update check stands. The host half only ever reports
722
+ * facts it can read from the harness plugin manager and the npm registry — it
723
+ * never downloads, swaps files, or restarts itself.
724
+ */
725
+ export type PluginUpdateStatus = 'unsupported' | 'not-installed' | 'source-install' | 'managed' | 'current' | 'available' | 'applying' | 'busy' | 'applied' | 'restart-required' | 'failed';
726
+ /**
727
+ * One update-check answer, shared by `plugin/update-check` and
728
+ * `plugin/update-apply`.
729
+ */
730
+ export interface PluginUpdateState {
731
+ status: PluginUpdateStatus;
732
+ /** 运行中(内存里)的插件版本 */
733
+ current: string;
734
+ /** 磁盘上装着的版本(listBundles().version),可读时才有 */
735
+ installed?: string;
736
+ /** profile registry 链上最新的稳定版 */
737
+ latest?: string;
738
+ /** latest 的 npm 发布时间 ISO */
739
+ publishedAt?: string;
740
+ /** 实际按顺序问过的 registry */
741
+ registries?: string[];
742
+ /** 失败/额外说明(英文技术信息,UI 自己负责本地化) */
743
+ message?: string;
744
+ }
672
745
  /**
673
746
  * How the composer-edge agent tag shows liveness (the blue halo). `breath`
674
747
  * pulses the halo at the speed below; `steady` shows a constant glow.