dsh-ntr 0.3.1 → 0.3.2

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.
Files changed (48) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +1 -1
  3. package/cordis.patch.yml +17 -1
  4. package/lib/bridge.js +607 -18
  5. package/lib/client/acp-ui.js +103 -3
  6. package/lib/client/acp-view.js +24 -4
  7. package/lib/client/branch-action.js +133 -0
  8. package/lib/client/composer-mode.js +48 -13
  9. package/lib/client/context-meter.js +86 -0
  10. package/lib/client/index.js +238 -15
  11. package/lib/client/panel.js +96 -29
  12. package/lib/client/released-banner.js +66 -0
  13. package/lib/client/session-controls.js +38 -13
  14. package/lib/client/slash-isolation.js +2 -2
  15. package/lib/client/store.js +348 -13
  16. package/lib/client.js +1138 -67
  17. package/lib/client.js.map +1 -1
  18. package/lib/impl.js +54 -1
  19. package/lib/index.js +7 -0
  20. package/lib/protocol.js +19 -0
  21. package/lib/types/bridge.d.ts +229 -6
  22. package/lib/types/client/acp-ui.d.ts +34 -2
  23. package/lib/types/client/acp-view.d.ts +13 -2
  24. package/lib/types/client/branch-action.d.ts +40 -0
  25. package/lib/types/client/composer-mode.d.ts +28 -0
  26. package/lib/types/client/context-meter.d.ts +51 -0
  27. package/lib/types/client/released-banner.d.ts +34 -0
  28. package/lib/types/client/slash-isolation.d.ts +1 -1
  29. package/lib/types/client/store.d.ts +98 -1
  30. package/lib/types/impl.d.ts +41 -1
  31. package/lib/types/protocol.d.ts +123 -0
  32. package/package.json +2 -1
  33. package/src/bridge.ts +638 -20
  34. package/src/client/acp-ui.tsx +123 -2
  35. package/src/client/acp-view.tsx +30 -5
  36. package/src/client/branch-action.tsx +160 -0
  37. package/src/client/composer-mode.tsx +59 -13
  38. package/src/client/context-meter.tsx +128 -0
  39. package/src/client/index.tsx +265 -14
  40. package/src/client/panel.tsx +157 -40
  41. package/src/client/primitives.d.ts +27 -0
  42. package/src/client/released-banner.tsx +97 -0
  43. package/src/client/session-controls.tsx +34 -11
  44. package/src/client/slash-isolation.ts +2 -1
  45. package/src/client/store.ts +375 -14
  46. package/src/impl.ts +84 -2
  47. package/src/index.ts +8 -0
  48. package/src/protocol.ts +121 -0
package/lib/impl.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * fakes.
6
6
  */
7
7
  import { randomUUID } from "./ids.js";
8
- import { DEFAULT_TAG_HALO } from "./protocol.js";
8
+ import { DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_TAG_HALO, IDLE_RELEASE_MS } from "./protocol.js";
9
9
  export { randomUUID };
10
10
  /** Structured failure the RPC layer converts to `{ok:false, error}` and `apply()` surfaces at boot. */
11
11
  export class AcpError extends Error {
@@ -158,6 +158,20 @@ export function validateApprovalDeadlines(value) {
158
158
  }
159
159
  return out;
160
160
  }
161
+ /**
162
+ * Validate the idle-release window the settings page writes.
163
+ *
164
+ * The window is expressed in MILLISECONDS on the shared ladder
165
+ * ({@link IDLE_RELEASE_MS}), so a value the slider can produce is always a
166
+ * value the host runs — the same one-ladder rule the approval deadline card
167
+ * follows.
168
+ */
169
+ export function validateIdleReleaseMs(raw, code) {
170
+ if (typeof raw !== 'number' || !Number.isInteger(raw) || !IDLE_RELEASE_MS.includes(raw)) {
171
+ throw new AcpError(code, `acp: idleTimeoutMs must be one of ${IDLE_RELEASE_MS.join(', ')}`);
172
+ }
173
+ return raw;
174
+ }
161
175
  /**
162
176
  * Validate the mode a write asks for. Three values, and the list is the whole
163
177
  * vocabulary: an unanswered ask is either answered for you, kept waiting, or
@@ -502,6 +516,14 @@ export function validateConfig(raw) {
502
516
  // `abort` a zero window would refuse every ask on arrival — the agent stops
503
517
  // at every gate — so it becomes the shortest real wait.
504
518
  const effectivePermissionTimeoutMs = normalizeApprovalWindow(permissionTimeoutMode, permissionTimeoutMs);
519
+ // The chat-process idle-release window (the settings page edits it).
520
+ // Same shape as the per-agent field: 0 = never release, otherwise an
521
+ // integer number of milliseconds.
522
+ const idleTimeoutMs = value.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
523
+ if (typeof idleTimeoutMs !== 'number' || !Number.isInteger(idleTimeoutMs)
524
+ || idleTimeoutMs > 86_400_000 || (idleTimeoutMs !== 0 && idleTimeoutMs < 1_000)) {
525
+ throw new AcpError('bad_config', 'acp: idleTimeoutMs must be 0 (never release) or an integer in [1000, 86400000]');
526
+ }
505
527
  const cancelFallbackMs = value.cancelFallbackMs ?? 15_000;
506
528
  if (typeof cancelFallbackMs !== 'number' || !Number.isInteger(cancelFallbackMs) || cancelFallbackMs < 0 || cancelFallbackMs > 600_000) {
507
529
  throw new AcpError('bad_config', 'acp: cancelFallbackMs must be an integer in [0, 600000] (0 = disable the fallback)');
@@ -543,6 +565,7 @@ export function validateConfig(raw) {
543
565
  turnStallMs,
544
566
  turnStallToolMs,
545
567
  turnStallTickMs,
568
+ idleTimeoutMs,
546
569
  };
547
570
  }
548
571
  // ---- Workspace guard ----
@@ -1733,6 +1756,26 @@ export async function dispatchRpc(manager, endpoint, payload) {
1733
1756
  await manager.setChatSessionMode?.(dshSessionId, modeId, optionalStr(body, 'agentId', 64));
1734
1757
  return ok({ done: true });
1735
1758
  }
1759
+ case 'session/chat-resume': {
1760
+ // The released-session banner's explicit resume: the recovery the next
1761
+ // prompt performs lazily, on the user's gesture instead. Addressed by
1762
+ // DSH chat session id, like the rest of the conversation surfaces.
1763
+ const dshSessionId = str(body, 'dshSessionId', 128);
1764
+ await manager.resumeChatSession?.(dshSessionId);
1765
+ return ok({ done: true });
1766
+ }
1767
+ case 'session/chat-fork-adopt': {
1768
+ // The second half of a branched conversation: bind the DSH session the
1769
+ // host's `sessions.fork` just created to the agent's own `session/fork`
1770
+ // of the parent's live ACP session. Both ids are validated before the
1771
+ // optional call — the same shape rule as `session/chat-list` above.
1772
+ // The adopted ids ride the answer (the protocol's ChatForkAdoptResult);
1773
+ // a manager without the method keeps the plain `done` shape.
1774
+ const dshSessionId = str(body, 'dshSessionId', 128);
1775
+ const parentDshSessionId = str(body, 'parentDshSessionId', 128);
1776
+ const adopted = await manager.adoptForkedSession?.(dshSessionId, parentDshSessionId);
1777
+ return ok(adopted ?? { done: true });
1778
+ }
1736
1779
  case 'session/chat-bind': {
1737
1780
  // Point one chat conversation at an ACP agent. The plugin's own
1738
1781
  // conversation↔agent mapping — NOT a DSH model route (see
@@ -1820,6 +1863,16 @@ export async function dispatchRpc(manager, endpoint, payload) {
1820
1863
  await manager.setApprovalDeadlines?.(patch, body.reset === true);
1821
1864
  return ok({ deadlines: manager.approvalDeadlines?.() });
1822
1865
  }
1866
+ case 'ui/idle-release':
1867
+ // Absent on a host half from before the tab existed: the browser then
1868
+ // renders nothing rather than a number it cannot trust.
1869
+ return ok({ idleRelease: manager.idleRelease?.() });
1870
+ case 'ui/idle-release-set': {
1871
+ const reset = body.reset === true;
1872
+ const idleTimeoutMs = reset ? undefined : validateIdleReleaseMs(body.idleTimeoutMs, 'bad_payload');
1873
+ await manager.setIdleRelease?.(idleTimeoutMs, reset);
1874
+ return ok({ idleRelease: manager.idleRelease?.() });
1875
+ }
1823
1876
  case 'agent/set-exposed-models': {
1824
1877
  const agentId = str(body, 'agentId', 64);
1825
1878
  const raw = body.exposedModels;
package/lib/index.js CHANGED
@@ -32,6 +32,7 @@ export function apply(ctx, config) {
32
32
  turnStallMs: resolved.turnStallMs,
33
33
  turnStallToolMs: resolved.turnStallToolMs,
34
34
  turnStallTickMs: resolved.turnStallTickMs,
35
+ idleTimeoutMs: resolved.idleTimeoutMs,
35
36
  });
36
37
  const adapter = new AcpLlmAdapter(manager);
37
38
  // Built-in update: the plugin never downloads or swaps files itself. The
@@ -66,6 +67,12 @@ export function apply(ctx, config) {
66
67
  ? rawConfig.permissionTimeoutMode
67
68
  : undefined;
68
69
  manager.setConfiguredApprovalDeadlines(configuredDeadlines, configuredMode);
70
+ // The idle-release baseline the settings page's pick outranks. Only the
71
+ // raw value counts: `validateConfig` fills a default in, so the
72
+ // validated number cannot tell "the file says 10 minutes" from
73
+ // "the file says nothing, so 30 minutes applies".
74
+ const configuredIdleTimeoutMs = typeof rawConfig.idleTimeoutMs === 'number' ? rawConfig.idleTimeoutMs : undefined;
75
+ manager.setConfiguredIdleRelease(configuredIdleTimeoutMs);
69
76
  // Boot hook kept only so this entry point's shape does not change:
70
77
  // `bootstrapCatalogs` is intentionally empty — boot never spawns an agent,
71
78
  // in any environment and for any `probeModelsOnBoot` value. Catalogs fill
package/lib/protocol.js CHANGED
@@ -17,3 +17,22 @@
17
17
  */
18
18
  /** Default when nothing was ever saved: the breathing halo at normal speed. */
19
19
  export const DEFAULT_TAG_HALO = { mode: 'breath', speed: 'normal' };
20
+ /**
21
+ * The idle-release windows the settings page offers, in MILLISECONDS.
22
+ *
23
+ * ONE ladder, shared by both halves: the browser snaps its slider to these
24
+ * values and the host accepts only these values, so a window the slider can
25
+ * produce is always a window the host runs. The first rung is 1 minute (an
26
+ * aggressive release), the rest are 5-minute steps up to an hour.
27
+ */
28
+ export const IDLE_RELEASE_MS = [
29
+ 60_000, 300_000, 600_000, 900_000, 1_200_000, 1_500_000, 1_800_000, 2_100_000, 2_400_000, 2_700_000, 3_000_000, 3_300_000, 3_600_000,
30
+ ];
31
+ /**
32
+ * The built-in idle-release window for chat processes: 30 minutes.
33
+ *
34
+ * Lives beside the ladder it is a rung of, so the two cannot drift: a
35
+ * deployment that names no window of its own runs this, and the settings
36
+ * page's reset returns to it.
37
+ */
38
+ export const DEFAULT_IDLE_TIMEOUT_MS = 1_800_000;
@@ -9,7 +9,7 @@
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, AcpReleaseCause, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult, AgentView, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, GenerateOptions, LlmAdapterLike, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, ApprovalDeadlineKey, ApprovalDeadlinePatch, PermissionTimeoutMode, PluginUpdateState, PreparedAdapterCall, SessionView, StreamChunk } from './protocol.ts';
12
+ import type { AcpAgentCatalog, AcpAgentDef, AcpApprovalDeadlines, AcpConversationChoice, AcpPresetStatus, AcpReleaseCause, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult, AgentView, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, GenerateOptions, LlmAdapterLike, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, AcpIdleRelease, ApprovalDeadlineKey, ApprovalDeadlinePatch, PermissionTimeoutMode, PluginUpdateState, PreparedAdapterCall, SessionView, StreamChunk } from './protocol.ts';
13
13
  import { UpdateService } from './update.ts';
14
14
  /**
15
15
  * Version of this installed package, read from the shipped `package.json` —
@@ -45,6 +45,15 @@ interface LiveSession {
45
45
  closed: boolean;
46
46
  /** The agent process died; the session awaits a `session/load` resume. */
47
47
  released: boolean;
48
+ /**
49
+ * The agent-side session has history while the LOCAL buffer is empty: this
50
+ * record was restored from disk after a host restart (the replay has not
51
+ * landed yet), or a resume cleared the buffer ahead of the replay. The ACP
52
+ * view then says history replays on restore instead of "nothing yet". Set
53
+ * by `doResumeSession` (captured before the reset) and the restore path; the
54
+ * serializer ORs it with the live buffer length.
55
+ */
56
+ hasHistory?: boolean;
48
57
  /**
49
58
  * When the agent last showed ANY sign of life on this session: an update, a
50
59
  * permission ask (arrival or answer), or a turn start. The stall watchdog
@@ -279,6 +288,20 @@ export declare class AgentBridge {
279
288
  * this bridge was built.
280
289
  */
281
290
  private readonly sessionWorkspace?;
291
+ /**
292
+ * Told whenever the SESSION RECORDS change shape (a release parks them, a
293
+ * resume revives them): the manager persists `chatSessions` through it, so
294
+ * a parked conversation stays resumable across a host restart (see
295
+ * `BridgeManager.loadPersistedChatSessions`).
296
+ */
297
+ private readonly onSessionsDirty?;
298
+ /**
299
+ * Told whenever a fresh `initialize` reports the agent's
300
+ * `sessionCapabilities.fork` bit — the LAST KNOWN fork capability the
301
+ * manager persists and serves to the browser (the bridge's own
302
+ * `initResult` dies with the process; the manager's copy survives it).
303
+ */
304
+ private readonly onForkCapability?;
282
305
  state: BridgeState;
283
306
  initResult?: InitializeResponse;
284
307
  lastError?: string;
@@ -309,7 +332,7 @@ export declare class AgentBridge {
309
332
  private readonly staleCancelRetryDelayMs;
310
333
  private readonly staleCancelMaxRetries;
311
334
  private readonly turnSettleMs;
312
- private readonly defaultIdleTimeoutMs;
335
+ private defaultIdleTimeoutMs;
313
336
  private readonly probeIdleTimeoutMs;
314
337
  /** Armed while READY; fires the idle-release check. */
315
338
  private idleTimer?;
@@ -330,6 +353,13 @@ export declare class AgentBridge {
330
353
  * that a caller is waiting on the child it is about to kill.
331
354
  */
332
355
  private pendingCreates;
356
+ /**
357
+ * `session/fork` requests in flight (the adopt path of a branched
358
+ * conversation). Same teardown hazard as {@link pendingCreates}: until the
359
+ * forked session is registered, nothing else would stop an idle release
360
+ * from killing the child out from under the caller.
361
+ */
362
+ private pendingForks;
333
363
  /**
334
364
  * Whether a REAL session has ever been registered on this bridge. Probes
335
365
  * bypass session bookkeeping, so a bridge that has never hosted anything was
@@ -366,7 +396,21 @@ export declare class AgentBridge {
366
396
  * Resolved per turn: the host entry may wire the workspace reader after
367
397
  * this bridge was built.
368
398
  */
369
- sessionWorkspace?: (() => SessionWorkspaceResolver | undefined) | undefined);
399
+ sessionWorkspace?: (() => SessionWorkspaceResolver | undefined) | undefined,
400
+ /**
401
+ * Told whenever the SESSION RECORDS change shape (a release parks them, a
402
+ * resume revives them): the manager persists `chatSessions` through it, so
403
+ * a parked conversation stays resumable across a host restart (see
404
+ * `BridgeManager.loadPersistedChatSessions`).
405
+ */
406
+ onSessionsDirty?: (() => void) | undefined,
407
+ /**
408
+ * Told whenever a fresh `initialize` reports the agent's
409
+ * `sessionCapabilities.fork` bit — the LAST KNOWN fork capability the
410
+ * manager persists and serves to the browser (the bridge's own
411
+ * `initResult` dies with the process; the manager's copy survives it).
412
+ */
413
+ onForkCapability?: ((agentId: string, supported: boolean) => void) | undefined);
370
414
  /**
371
415
  * Adopt a rewritten approval deadline or mode (the settings page's write).
372
416
  * Only the window and the mode live here; every other tuning value is
@@ -381,6 +425,18 @@ export declare class AgentBridge {
381
425
  permissionTimeoutMs?: number;
382
426
  mode?: PermissionTimeoutMode;
383
427
  }): void;
428
+ /**
429
+ * Adopt a rewritten host-default idle window (the settings page's write).
430
+ *
431
+ * Only the DEFAULT moves: an agent that names its own `idleTimeoutMs` keeps
432
+ * it (that promise is about the agent, not about the deployment), and a
433
+ * probe-only child keeps the short probe window.
434
+ *
435
+ * The timer is re-armed so the new window governs from NOW: an already
436
+ * armed timer still holds the old window, so a shortened window would not
437
+ * take effect until the old one elapsed.
438
+ */
439
+ applyIdleRelease(defaultIdleTimeoutMs: number): void;
384
440
  /**
385
441
  * The idle-release window this agent honors for its CHAT process: its own
386
442
  * field, else the host default. This is the number the panel prefills and
@@ -559,6 +615,25 @@ export declare class AgentBridge {
559
615
  * only shape that keeps every session of that process consistent.
560
616
  */
561
617
  probeConfigOptions(): Promise<unknown[]>;
618
+ /**
619
+ * Fork one live session through the agent's own `session/fork` (the
620
+ * UNSTABLE fourth session-creation point): the agent copies its internal
621
+ * state into a NEW ACP session that remembers the parent's context — the
622
+ * second half of a branched conversation, after the host's `sessions.fork`
623
+ * has already built the DSH session tree. The child is addressed by the
624
+ * CHILD DSH conversation id, so its first message reaches an agent that
625
+ * actually remembers the parent.
626
+ *
627
+ * The parent must be live on this connection — a released parent has no
628
+ * process to fork from (the browser blocks that click; this is the hard
629
+ * stop behind it). The ACP request carries the SAME `mcpServers` list as
630
+ * `session/new` / `session/load` (one list per process; an empty list would
631
+ * evict the live sessions' MCP scope) and is bounded by the startup timeout
632
+ * like every session-creation step. Counted in {@link pendingForks} while
633
+ * in flight so an idle release cannot kill the child out from under the
634
+ * caller.
635
+ */
636
+ forkSession(parent: LiveSession, dshSessionId: string): Promise<LiveSession>;
562
637
  /**
563
638
  * Fire one prompt turn. Returns after the agent settles the turn; the caller
564
639
  * (manager) keeps the UI informed through broadcast events, so the RPC that
@@ -617,6 +692,12 @@ export declare class AgentBridge {
617
692
  takeReleased(sessionId: string): LiveSession | undefined;
618
693
  /** Drop a released session record for good (user closed it or the agent was stopped). */
619
694
  dropReleased(sessionId: string): boolean;
695
+ /**
696
+ * Register a session record restored from disk as already parked: a host
697
+ * restart killed every process, so it comes back exactly as `releaseAll`
698
+ * would have left it — resumable via `session/load`, never live.
699
+ */
700
+ parkReleased(session: LiveSession): void;
620
701
  get releasedList(): LiveSession[];
621
702
  hasReleased(sessionId: string): LiveSession | undefined;
622
703
  inflightResume(sessionId: string): Promise<LiveSession> | undefined;
@@ -1131,6 +1212,29 @@ export declare class BridgeManager {
1131
1212
  * write would fight with no way to tell which one is live.
1132
1213
  */
1133
1214
  private deadlineOverride;
1215
+ /**
1216
+ * The idle-release window the settings page picked, in ms, persisted
1217
+ * with the agent registry. `undefined` means the deployment baseline runs.
1218
+ *
1219
+ * Merged OVER {@link baselineIdleTimeoutMs} exactly like
1220
+ * {@link deadlineOverride} over {@link baselineDeadline}: a UI pick is what
1221
+ * runs, and `cordis.patch.yml` becomes the value the reset button returns
1222
+ * to. Without this split the two sources would fight with no way to tell
1223
+ * which one is live.
1224
+ */
1225
+ private idleReleaseOverride?;
1226
+ /**
1227
+ * The window `cordis.patch.yml` names, when it names one. Kept apart
1228
+ * from the override for the same reason the deadline keeps its baseline
1229
+ * apart: the settings card has to be able to say "the file asks for
1230
+ * 10 minutes, the value set here wins".
1231
+ */
1232
+ private configuredIdleTimeoutMs?;
1233
+ /**
1234
+ * The idle-release window before any override — the tuning this manager was
1235
+ * constructed with (the deployment baseline, or the built-in 30 minutes).
1236
+ */
1237
+ private baselineIdleTimeoutMs;
1134
1238
  /**
1135
1239
  * Tuning in force, defaults applied. The approval deadline is rewritten
1136
1240
  * IN PLACE by {@link applyDeadlineTuning}, which is why this is a live object
@@ -1238,6 +1342,29 @@ export declare class BridgeManager {
1238
1342
  * keeps the two controls from reading as duplicates.
1239
1343
  */
1240
1344
  chatSessions(dshSessionId: string): ChatSessionView[];
1345
+ /**
1346
+ * Adopt the ACP side of a branched conversation: the browser has already
1347
+ * asked the HOST to fork the DSH session tree (`sessions.fork`), and this
1348
+ * runs the agent's own `session/fork` of the parent conversation's live ACP
1349
+ * session so the child actually remembers the parent context — then binds
1350
+ * the child to it (the fourth session-creation point, with every discipline
1351
+ * the other three carry).
1352
+ *
1353
+ * Failure is deliberately all-or-nothing: nothing is written for the child
1354
+ * until the agent has answered, so a failed adopt leaves the child an
1355
+ * ordinary copied-history conversation whose first message starts a fresh
1356
+ * `session/new` (today's behavior) — never a half-bound record.
1357
+ *
1358
+ * @param childDshSessionId - the DSH session the host fork created.
1359
+ * @param parentDshSessionId - the conversation being branched; its ACP
1360
+ * session must be LIVE on this connection (a released parent has no
1361
+ * process to fork from — the browser blocks the click, this is the hard
1362
+ * stop behind it).
1363
+ */
1364
+ adoptForkedSession(childDshSessionId: string, parentDshSessionId: string): Promise<{
1365
+ sessionId: string;
1366
+ acpSessionId: string;
1367
+ }>;
1241
1368
  /**
1242
1369
  * Bind one DSH chat session to an ACP agent.
1243
1370
  *
@@ -1391,11 +1518,26 @@ export declare class BridgeManager {
1391
1518
  */
1392
1519
  chatTranscripts(dshSessionId: string, afterSeq?: number): ChatTranscriptView[];
1393
1520
  /**
1394
- * Switch one chat-bound session's ACP mode. A released session is resumed
1395
- * first (`session/load`), matching the "send to recover" promise — the mode
1396
- * switch is itself a reason to bring the agent back.
1521
+ * Switch one chat-bound session's ACP mode.
1522
+ *
1523
+ * A released session is deliberately NOT resumed for it (user decision,
1524
+ * 2026-10-30 — the composer's axes are "what the next turn should use", and
1525
+ * the process comes back only when the user sends a message or hits the
1526
+ * released banner's resume button). The pick is recorded and
1527
+ * `applyChatChoice` applies it on resume, exactly like `setChatSessionModel`
1528
+ * / `setChatSessionEffort` / `setChatSessionConfig`; only a LIVE session is
1529
+ * told immediately (`session/set_mode`).
1397
1530
  */
1398
1531
  setChatSessionMode(dshSessionId: string, modeId: string, agentId?: string): Promise<void>;
1532
+ /**
1533
+ * Bring one chat conversation's released session back on a fresh process on
1534
+ * the USER's explicit gesture (the "恢复进程" banner) — the same work the
1535
+ * next prompt performs lazily (`getOrCreateDshSession`'s released branch),
1536
+ * minus the create path: a banner button must never materialize a session
1537
+ * the conversation has not had yet. A live target passes through untouched,
1538
+ * so a double click (or a click racing the next message) spawns nothing.
1539
+ */
1540
+ resumeChatSession(dshSessionId: string): Promise<void>;
1399
1541
  /**
1400
1542
  * Wire the harness's preset roster (`ctx.agentPresets`) into `presetStatus`.
1401
1543
  *
@@ -1451,7 +1593,30 @@ export declare class BridgeManager {
1451
1593
  */
1452
1594
  private loadPersistedChoices;
1453
1595
  private persistAgents;
1596
+ /**
1597
+ * Restore chat session records from disk as PARKED sessions.
1598
+ *
1599
+ * Every process died with the host, so nothing comes back live — but the
1600
+ * agent-side sessions did not. Restoring each record as `released` is what
1601
+ * makes the released banner, its resume button and the "next message
1602
+ * resumes it" promise survive a restart (the alternative is the next
1603
+ * message quietly starting a NEW, context-less session — constraint 10's
1604
+ * "released ≠ destroyed" lost at the process boundary). Tolerant, one row
1605
+ * at a time: a single corrupt entry must not cost every other conversation
1606
+ * its context. Records whose agent is no longer configured are dropped —
1607
+ * there is nothing to resume ON, and the next write omits them.
1608
+ */
1609
+ private loadPersistedChatSessions;
1454
1610
  private readonly catalogs;
1611
+ /**
1612
+ * Each agent's LAST KNOWN `sessionCapabilities.fork` bit, persisted with the
1613
+ * model catalog. A live `initialize` result outranks it whenever one exists
1614
+ * (see {@link canForkNow}) — this copy answers when the process is gone
1615
+ * (a released session's branch button must not flash into "supported" just
1616
+ * because `initResult` died with it, nor read `view().agentCapabilities`,
1617
+ * which a release clears).
1618
+ */
1619
+ private readonly forkCaps;
1455
1620
  /**
1456
1621
  * The slash commands each agent declared in its most recent session, cached
1457
1622
  * per agent and persisted alongside the model catalog.
@@ -1520,6 +1685,21 @@ export declare class BridgeManager {
1520
1685
  * restatement would make every open composer re-read `agent/catalog`.
1521
1686
  */
1522
1687
  private absorbCommands;
1688
+ /**
1689
+ * Record a fresh `initialize`'s `sessionCapabilities.fork` verdict (the
1690
+ * bridge reports it right after the handshake). Persisted with the model
1691
+ * catalog; no broadcast — the browser learns it through `session/chat-list`
1692
+ * rows, and its branch button deliberately runs on a last-known basis (the
1693
+ * agent's own next `initialize` is the refresh).
1694
+ */
1695
+ private noteForkCapability;
1696
+ /**
1697
+ * Whether an agent's fork can be attempted RIGHT NOW: a live `initialize`
1698
+ * answer wins (the process is up, its handshake is current); only when the
1699
+ * process is gone does the LAST KNOWN persisted bit answer. `false` covers
1700
+ * "never seen" — an unknown agent simply never gets a fork attempt.
1701
+ */
1702
+ private canForkNow;
1523
1703
  /**
1524
1704
  * Append models this observation newly introduced to the agent's selection.
1525
1705
  *
@@ -1736,6 +1916,18 @@ export declare class BridgeManager {
1736
1916
  /** The agent tag's halo preference (composer display, plugin-private). */
1737
1917
  tagHalo(): AcpTagHalo;
1738
1918
  setTagHalo(halo: AcpTagHalo): Promise<void>;
1919
+ /**
1920
+ * Declare the deployment baseline for the idle-release window — the value
1921
+ * `cordis.patch.yml` names, passed RAW (only when it names one).
1922
+ *
1923
+ * Kept separate from the validated tuning for the same reason the
1924
+ * deadline keeps its baseline apart: "the file says 10 minutes" and
1925
+ * "the file says nothing, so 30 minutes applies" are different facts.
1926
+ * The first is the reset target and shows as the fact being overridden;
1927
+ * the second is a built-in default. Called once by the host entry,
1928
+ * before any bridge exists.
1929
+ */
1930
+ setConfiguredIdleRelease(idleTimeoutMs: number | undefined): void;
1739
1931
  /**
1740
1932
  * Declare the deployment baseline for the approval deadline — the values
1741
1933
  * `cordis.patch.yml` names, passed RAW (only the keys it actually contains).
@@ -1758,6 +1950,37 @@ export declare class BridgeManager {
1758
1950
  * stay silent about a plain default.
1759
1951
  */
1760
1952
  approvalDeadlines(): AcpApprovalDeadlines;
1953
+ /**
1954
+ * The idle-release window in force, plus the facts that explain a value the
1955
+ * user did not pick: the deployment baseline an override is outranking.
1956
+ *
1957
+ * `defaultIdleTimeoutMs` is the window a RESET returns to — the deployment
1958
+ * baseline when the config file names one, else the built-in default. It is
1959
+ * NOT a hardcoded constant: a deployment that names 10 minutes must reset
1960
+ * to 10 minutes, not to the built-in 30.
1961
+ *
1962
+ * The config file's own number is reported only when the file names one, so
1963
+ * the settings card can stay silent about a plain default.
1964
+ */
1965
+ idleRelease(): AcpIdleRelease;
1966
+ /**
1967
+ * Write the idle-release window, or drop the persisted override with
1968
+ * `reset` so the deployment baseline applies again.
1969
+ *
1970
+ * HOT: the rewrite reaches every live bridge, so a user changing the window
1971
+ * never has to kill a running session to see it take effect.
1972
+ */
1973
+ setIdleRelease(idleTimeoutMs: number | undefined, reset?: boolean): Promise<void>;
1974
+ /**
1975
+ * Recompute the idle-release window from override → deployment baseline, and
1976
+ * push the result to every live bridge.
1977
+ */
1978
+ private applyIdleReleaseTuning;
1979
+ /**
1980
+ * The idle-release window that runs, in ms: the settings page's pick, else
1981
+ * the deployment baseline.
1982
+ */
1983
+ private idleTimeoutMs;
1761
1984
  /**
1762
1985
  * Write the approval deadline, or the mode (omitted keys keep their value),
1763
1986
  * or drop the persisted override with `reset` so the config-file baseline —
@@ -81,6 +81,26 @@ export interface AcpChatInjected {
81
81
  acp: AcpStore;
82
82
  /** Resolve the session's command/projection face; `undefined` before the session materializes. */
83
83
  faceFor(sessionId: string): AcpSessionFace | undefined;
84
+ /**
85
+ * The runtime's `sessions.fork` face (the host's own branching primitive,
86
+ * resolved structurally per call); `undefined` when the host half lacks it —
87
+ * the branch button then renders disabled (`hostUnavailable`), never hidden.
88
+ */
89
+ hostFork?: () => AcpHostForkFace | undefined;
90
+ /** The workspace's `openSession` face (the branch click's final step). */
91
+ openSession?: (sessionId: string) => void;
92
+ }
93
+ /** The slice of the client runtime's session service the branch button needs. */
94
+ export interface AcpHostForkFace {
95
+ /**
96
+ * Fork the DSH session tree at the latest completed turn. Resolves the CHILD
97
+ * session id; the host owns the cut-point snapping, history copy, `(1)`
98
+ * title and workspace attribution — the plugin rewrites none of it.
99
+ */
100
+ fork(options: {
101
+ sessionId: string;
102
+ increaseTitle?: boolean;
103
+ }): Promise<unknown>;
84
104
  }
85
105
  /**
86
106
  * The slice of the client runtime's `ConversationSnapshot` our composer entries
@@ -233,6 +253,13 @@ export interface AcpStagedSession {
233
253
  /**
234
254
  * Which session the composer is currently staged on.
235
255
  *
256
+ * The renderer's own current scope binding is the first answer when the caller
257
+ * passes it (`currentSessionId`, read from `uiSession.adapter.current` in the
258
+ * browser entry): that is the exact session the host has selected, while the
259
+ * list can retain more than one row in a multi-view layout. Falling back to
260
+ * the list is still required — a host without that scope adapter, and the old
261
+ * snapshot shape below, have no better source.
262
+ *
236
263
  * Old runtimes answer from `snapshot.current` (with `currentAddress` as the
237
264
  * subagent marker). rc.2 has no `current`: the staged session is the row the
238
265
  * main view retains (`retainedBy.mainView > 0`) — the exact rule
@@ -240,10 +267,15 @@ export interface AcpStagedSession {
240
267
  * row with `origin === "subagent"` is an addressed subagent route. `undefined`
241
268
  * when nothing is staged yet (the list has not loaded, or no row holds the main
242
269
  * view): callers must stand down rather than guess.
270
+ *
271
+ * A current id the list has no row for is ALSO `undefined`: the selection may
272
+ * be a subagent address, and guessing "not a subagent" there would let the
273
+ * gate install ACP chrome on a route the plugin must not drive. The next list
274
+ * notification retries.
243
275
  */
244
- export declare function stagedSessionOf(snapshot: AcpSessionsSnapshot | undefined): AcpStagedSession | undefined;
276
+ export declare function stagedSessionOf(snapshot: AcpSessionsSnapshot | undefined, currentSessionId?: string): AcpStagedSession | undefined;
245
277
  /** Whether the staged session runs the ACP agent preset. */
246
- export declare function stagedIsAcpMode(snapshot: AcpSessionsSnapshot | undefined, acpMode?: (sessionId: string) => boolean | undefined): boolean;
278
+ export declare function stagedIsAcpMode(snapshot: AcpSessionsSnapshot | undefined, acpMode?: (sessionId: string) => boolean | undefined, currentSessionId?: string): boolean;
247
279
  /**
248
280
  * The ACP-mode verdict a committed `agent-preset/selected` frame carries. The
249
281
  * frame is `[sessionId, agentPreset]` and the runtime broadcasts it the moment
@@ -12,7 +12,7 @@
12
12
  * spawns. Everything it shows is host state.
13
13
  */
14
14
  import type { AcpChatInjected } from './acp-ui.tsx';
15
- import type { AgentView, ChatSessionView } from '../protocol.ts';
15
+ import type { AgentView, ChatSessionView, SessionView } from '../protocol.ts';
16
16
  /** Props of the ACP view entry (injected face + the frame's session id). */
17
17
  export interface AcpViewProps extends AcpChatInjected {
18
18
  sessionId: string;
@@ -43,6 +43,17 @@ export declare function diffRows(oldText: string | null, newText: string): DiffR
43
43
  * the new content — the degenerate case where there is no edit at all.
44
44
  */
45
45
  export declare function isNewFileDiff(oldText: string | null, newText: string): boolean;
46
+ /**
47
+ * Whether an empty stream means "history exists but is not shown yet": the
48
+ * record was restored from disk after a host restart (the agent's replay has
49
+ * not landed), or a resume cleared the buffer ahead of the replay. The view
50
+ * then promises the replay instead of claiming the conversation never
51
+ * happened — "还没有消息" to someone whose chat plane still shows yesterday's
52
+ * turns is exactly the kind of contradiction this file's copy rules exist to
53
+ * prevent. In-memory released sessions keep their buffer, so this only fires
54
+ * when the buffer really is absent.
55
+ */
56
+ export declare function historyAwaitsReplay(session: Pick<SessionView, 'released' | 'hasHistory'>): boolean;
46
57
  /**
47
58
  * Everything the view can read that says "this conversation is set up on an
48
59
  * agent": a live or parked ACP session, the host's binding (`session/chat-list`'s
@@ -101,5 +112,5 @@ export declare function acpEmptyCopy(facts: AcpEmptyFacts, t: Translate): AcpEmp
101
112
  * The ACP view tab body: who serves this conversation and how it is doing,
102
113
  * then the agent's own stream.
103
114
  */
104
- export declare function AcpConversationView(props: AcpViewProps): import("react").JSX.Element;
115
+ export declare function AcpConversationView(props: AcpViewProps): import("react").JSX.Element | null;
105
116
  export {};
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The NTR branch button (slot `conversation.chat.assistant-actions`): in an
3
+ * ACP conversation this is the ONLY branch entry — the host's like / dislike /
4
+
5
+ * branch icons are hidden under `html[data-ntr-acp]` (see acp-ui.tsx), and
6
+ * this button takes the host branch's place with the same icon, size and
7
+ * row position (order 20, after the hidden feedback entry's order 10).
8
+ *
9
+ * Clicking runs the fixed two-stage branch: the HOST forks the DSH session
10
+ * tree first (`sessions.fork`, structurally resolved), then the plugin adopts
11
+ * the ACP side (`session/chat-fork-adopt` → the agent's own `session/fork`),
12
+ * so the child conversation's agent actually remembers the parent context —
13
+ * see the NTR message-actions plan, §D5/§D7.
14
+ *
15
+ * Availability is the store's `forkStateOf` six-state mapping over facts the
16
+ * button never derives itself: the host's per-row `fork` verdict
17
+ * (`session/chat-list`), the SSE running flag, the host-fork structural probe,
18
+ * and the DOM tail verdict. The tail check reads the host's OWN
19
+ * `[data-turn-tail]` marker (`data-actions-reveal === "always"` = the latest
20
+ * completed turn) and fails CLOSED: a missing marker disables the button
21
+ * rather than risking a fork cut at a point the user is not looking at.
22
+ */
23
+ import type { ComponentType } from 'react';
24
+ import type { AcpChatInjected } from './acp-ui.tsx';
25
+ /**
26
+ * The host's own branch-button icon, picked per host generation. The icon
27
+ * exports MOVED between shipped hosts — desktop 0.2.1-rc.2 has
28
+ * `IconBranchOutlineRegular` / `…Medium` but NOT `…16`, newer packages export
29
+ * `…16` — and a hard import of one name compiles to a plain property read
30
+ * (`primitives.IconBranchOutline16`), so the missing export is `undefined`
31
+ * and `<undefined />` throws "Element type is invalid". The slot's error
32
+ * boundary then abdicates the whole entry: no button, no tooltip, nothing in
33
+ * any log — the exact silent disappearance this picker exists to prevent.
34
+ * Rendering the button never depends on the icon: with all three absent the
35
+ * click still works, only the 28px glyph is missing.
36
+ */
37
+ export declare function branchIconOf(source: Record<string, unknown>): ComponentType<Record<string, never>> | undefined;
38
+ export declare function AcpBranchAction(props: AcpChatInjected & {
39
+ sessionId: string;
40
+ }): import("react").JSX.Element | null;
@@ -50,4 +50,32 @@ export interface ComposerModeProps {
50
50
  * gone and this select is the mode switch the user actually has.
51
51
  */
52
52
  export declare function agentModeTitle(agentId: string | undefined, acp: AcpStore): string;
53
+ /**
54
+ * Whether this conversation is one the agent-mode control may serve.
55
+ *
56
+ * An EXPLICIT non-ACP verdict outranks a lingering session row: switching a
57
+ * conversation off the NTR preset does not close its ACP session, so "a row
58
+ * exists" alone would keep this control on an ordinary conversation — the same
59
+ * precedence the agent tag, the context ring and the released banner already
60
+ * apply. Only while the host has not answered (`undefined`) does a bound
61
+ * session count as proof, which is what keeps the control available before the
62
+ * host's first `session/chat-list` answer lands.
63
+ */
64
+ export declare function agentModeVisible(acpMode: boolean | undefined, hasBoundSession: boolean): boolean;
65
+ /**
66
+ * The mode the select shows as in force.
67
+ *
68
+ * A LIVE session is the authority (it is what the agent runs right now). A
69
+ * RELEASED row only describes the mode the agent was parked ON, so a newer
70
+ * pick — which `applyChatChoice` will apply on the next resume — outranks it
71
+ * (and the parked row still beats the catalog default). Same precedence as
72
+ * the model and thought level in `describeRoute`: a pre-switch on a parked
73
+ * session must be visible or the control reads as dead.
74
+ */
75
+ export declare function modeInForce(target: {
76
+ released?: boolean;
77
+ modes?: {
78
+ currentModeId?: string;
79
+ };
80
+ } | undefined, persisted: string | undefined, fallback?: string): string;
53
81
  export declare function AcpComposerMode({ acp, sessionId, useSession }: ComposerModeProps): import("react").JSX.Element | null;