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/src/impl.ts CHANGED
@@ -7,10 +7,10 @@
7
7
 
8
8
  import { randomUUID } from './ids.ts'
9
9
  import type {
10
- AcpAgentCatalog, AcpAgentDef, AcpCatalogOption, AcpApprovalDeadlines, AcpConversationChoice, AcpEvent, AcpMcpServerDef, AcpPluginConfig, AcpPresetStatus, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult,
10
+ AcpAgentCatalog, AcpAgentDef, AcpCatalogOption, AcpApprovalDeadlines, AcpConversationChoice, AcpEvent, AcpIdleRelease, AcpMcpServerDef, AcpPluginConfig, AcpPresetStatus, AcpTagHalo, AgentCatalogResult, AgentProbeModelsResult, AgentTestResult,
11
11
  AgentView, ApprovalDeadlineKey, ApprovalDeadlinePatch, ChatSessionView, ChatTranscriptView, DetectedAgentInfo, PermissionOptionView, PermissionOutcome, PermissionTimeoutMode, PluginUpdateState, RpcResult,
12
12
  } from './protocol.ts'
13
- import { DEFAULT_TAG_HALO } from './protocol.ts'
13
+ import { DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_TAG_HALO, IDLE_RELEASE_MS } from './protocol.ts'
14
14
 
15
15
  export { randomUUID }
16
16
 
@@ -175,6 +175,21 @@ export function validateApprovalDeadlines(value: unknown): Partial<Record<Approv
175
175
  return out
176
176
  }
177
177
 
178
+ /**
179
+ * Validate the idle-release window the settings page writes.
180
+ *
181
+ * The window is expressed in MILLISECONDS on the shared ladder
182
+ * ({@link IDLE_RELEASE_MS}), so a value the slider can produce is always a
183
+ * value the host runs — the same one-ladder rule the approval deadline card
184
+ * follows.
185
+ */
186
+ export function validateIdleReleaseMs(raw: unknown, code: 'bad_config' | 'bad_payload'): number {
187
+ if (typeof raw !== 'number' || !Number.isInteger(raw) || !IDLE_RELEASE_MS.includes(raw)) {
188
+ throw new AcpError(code, `acp: idleTimeoutMs must be one of ${IDLE_RELEASE_MS.join(', ')}`)
189
+ }
190
+ return raw
191
+ }
192
+
178
193
  /**
179
194
  * Validate the mode a write asks for. Three values, and the list is the whole
180
195
  * vocabulary: an unanswered ask is either answered for you, kept waiting, or
@@ -523,6 +538,14 @@ export function validateConfig(raw: unknown): AcpPluginConfig {
523
538
  // `abort` a zero window would refuse every ask on arrival — the agent stops
524
539
  // at every gate — so it becomes the shortest real wait.
525
540
  const effectivePermissionTimeoutMs = normalizeApprovalWindow(permissionTimeoutMode, permissionTimeoutMs)
541
+ // The chat-process idle-release window (the settings page edits it).
542
+ // Same shape as the per-agent field: 0 = never release, otherwise an
543
+ // integer number of milliseconds.
544
+ const idleTimeoutMs = value.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
545
+ if (typeof idleTimeoutMs !== 'number' || !Number.isInteger(idleTimeoutMs)
546
+ || idleTimeoutMs > 86_400_000 || (idleTimeoutMs !== 0 && idleTimeoutMs < 1_000)) {
547
+ throw new AcpError('bad_config', 'acp: idleTimeoutMs must be 0 (never release) or an integer in [1000, 86400000]')
548
+ }
526
549
  const cancelFallbackMs = value.cancelFallbackMs ?? 15_000
527
550
  if (typeof cancelFallbackMs !== 'number' || !Number.isInteger(cancelFallbackMs) || cancelFallbackMs < 0 || cancelFallbackMs > 600_000) {
528
551
  throw new AcpError('bad_config', 'acp: cancelFallbackMs must be an integer in [0, 600000] (0 = disable the fallback)')
@@ -564,6 +587,7 @@ export function validateConfig(raw: unknown): AcpPluginConfig {
564
587
  turnStallMs,
565
588
  turnStallToolMs,
566
589
  turnStallTickMs,
590
+ idleTimeoutMs,
567
591
  }
568
592
  }
569
593
 
@@ -1789,6 +1813,19 @@ export interface AcpManager {
1789
1813
  * applies again. Applies to the running bridges without a restart.
1790
1814
  */
1791
1815
  setApprovalDeadlines?(patch: ApprovalDeadlinePatch, reset?: boolean): Promise<void>
1816
+ /**
1817
+ * The idle-release window in force for chat processes, plus the built-in
1818
+ * default a reset returns to. `undefined` on a host half from before the
1819
+ * card existed, which hides the settings tab instead of showing a number
1820
+ * this browser invented.
1821
+ */
1822
+ idleRelease?(): AcpIdleRelease | undefined
1823
+ /**
1824
+ * Write the idle-release window (in ms), or drop the persisted
1825
+ * override with `reset` so the built-in default applies again. Applies to
1826
+ * the running bridges without a restart.
1827
+ */
1828
+ setIdleRelease?(idleTimeoutMs: number | undefined, reset?: boolean): Promise<void>
1792
1829
  detectAgents?(): Promise<DetectedAgentInfo[]>
1793
1830
  saveAgent?(def: AcpAgentDef): Promise<void>
1794
1831
  deleteAgent?(agentId: string): Promise<void>
@@ -1861,6 +1898,21 @@ export interface AcpManager {
1861
1898
  * without it the live (non-released) session is picked.
1862
1899
  */
1863
1900
  setChatSessionMode?(dshSessionId: string, modeId: string, agentId?: string): Promise<void>
1901
+ /**
1902
+ * Bring one chat conversation's released session back on a fresh process on
1903
+ * the user's explicit gesture (`session/chat-resume`, the released-session
1904
+ * banner's button). Same recovery the next prompt performs lazily, minus
1905
+ * the create path; no-op when the session is already live.
1906
+ */
1907
+ resumeChatSession?(dshSessionId: string): Promise<void>
1908
+ /**
1909
+ * Adopt the ACP side of a branched conversation (`session/chat-fork-adopt`):
1910
+ * the browser has already asked the HOST to fork the DSH session tree, and
1911
+ * this runs the agent's own `session/fork` of the parent's live ACP session,
1912
+ * then binds the child to it. All-or-nothing — nothing is bound on failure,
1913
+ * so the child stays an ordinary copied-history conversation.
1914
+ */
1915
+ adoptForkedSession?(childDshSessionId: string, parentDshSessionId: string): Promise<{ sessionId: string; acpSessionId: string }>
1864
1916
  /**
1865
1917
  * Point one DSH chat conversation at an ACP agent — the plugin's own
1866
1918
  * conversation↔agent mapping, never a DSH model route (see
@@ -2055,6 +2107,26 @@ export async function dispatchRpc(manager: AcpManager, endpoint: string, payload
2055
2107
  await manager.setChatSessionMode?.(dshSessionId, modeId, optionalStr(body, 'agentId', 64))
2056
2108
  return ok({ done: true })
2057
2109
  }
2110
+ case 'session/chat-resume': {
2111
+ // The released-session banner's explicit resume: the recovery the next
2112
+ // prompt performs lazily, on the user's gesture instead. Addressed by
2113
+ // DSH chat session id, like the rest of the conversation surfaces.
2114
+ const dshSessionId = str(body, 'dshSessionId', 128)
2115
+ await manager.resumeChatSession?.(dshSessionId)
2116
+ return ok({ done: true })
2117
+ }
2118
+ case 'session/chat-fork-adopt': {
2119
+ // The second half of a branched conversation: bind the DSH session the
2120
+ // host's `sessions.fork` just created to the agent's own `session/fork`
2121
+ // of the parent's live ACP session. Both ids are validated before the
2122
+ // optional call — the same shape rule as `session/chat-list` above.
2123
+ // The adopted ids ride the answer (the protocol's ChatForkAdoptResult);
2124
+ // a manager without the method keeps the plain `done` shape.
2125
+ const dshSessionId = str(body, 'dshSessionId', 128)
2126
+ const parentDshSessionId = str(body, 'parentDshSessionId', 128)
2127
+ const adopted = await manager.adoptForkedSession?.(dshSessionId, parentDshSessionId)
2128
+ return ok(adopted ?? { done: true })
2129
+ }
2058
2130
  case 'session/chat-bind': {
2059
2131
  // Point one chat conversation at an ACP agent. The plugin's own
2060
2132
  // conversation↔agent mapping — NOT a DSH model route (see
@@ -2141,6 +2213,16 @@ export async function dispatchRpc(manager: AcpManager, endpoint: string, payload
2141
2213
  await manager.setApprovalDeadlines?.(patch, body.reset === true)
2142
2214
  return ok({ deadlines: manager.approvalDeadlines?.() })
2143
2215
  }
2216
+ case 'ui/idle-release':
2217
+ // Absent on a host half from before the tab existed: the browser then
2218
+ // renders nothing rather than a number it cannot trust.
2219
+ return ok({ idleRelease: manager.idleRelease?.() })
2220
+ case 'ui/idle-release-set': {
2221
+ const reset = body.reset === true
2222
+ const idleTimeoutMs = reset ? undefined : validateIdleReleaseMs(body.idleTimeoutMs, 'bad_payload')
2223
+ await manager.setIdleRelease?.(idleTimeoutMs, reset)
2224
+ return ok({ idleRelease: manager.idleRelease?.() })
2225
+ }
2144
2226
  case 'agent/set-exposed-models': {
2145
2227
  const agentId = str(body, 'agentId', 64)
2146
2228
  const raw = body.exposedModels
package/src/index.ts CHANGED
@@ -91,6 +91,7 @@ export function apply(ctx: Context, config?: unknown): void {
91
91
  turnStallMs: resolved.turnStallMs,
92
92
  turnStallToolMs: resolved.turnStallToolMs,
93
93
  turnStallTickMs: resolved.turnStallTickMs,
94
+ idleTimeoutMs: resolved.idleTimeoutMs,
94
95
  },
95
96
  )
96
97
  const adapter = new AcpLlmAdapter(manager)
@@ -126,6 +127,13 @@ export function apply(ctx: Context, config?: unknown): void {
126
127
  ? rawConfig.permissionTimeoutMode
127
128
  : undefined
128
129
  manager.setConfiguredApprovalDeadlines(configuredDeadlines, configuredMode)
130
+
131
+ // The idle-release baseline the settings page's pick outranks. Only the
132
+ // raw value counts: `validateConfig` fills a default in, so the
133
+ // validated number cannot tell "the file says 10 minutes" from
134
+ // "the file says nothing, so 30 minutes applies".
135
+ const configuredIdleTimeoutMs = typeof rawConfig.idleTimeoutMs === 'number' ? rawConfig.idleTimeoutMs : undefined
136
+ manager.setConfiguredIdleRelease(configuredIdleTimeoutMs)
129
137
  // Boot hook kept only so this entry point's shape does not change:
130
138
  // `bootstrapCatalogs` is intentionally empty — boot never spawns an agent,
131
139
  // in any environment and for any `probeModelsOnBoot` value. Catalogs fill
package/src/protocol.ts CHANGED
@@ -187,6 +187,18 @@ export interface AcpPluginConfig {
187
187
  * deployment baseline that override falls back to. See {@link AcpApprovalDeadlines}.
188
188
  */
189
189
  permissionTimeoutMs?: number
190
+ /**
191
+ * How long a chat process may sit idle before it is released (parked, then
192
+ * resumed by the next message). In MILLISECONDS; default 1800000 (30 min).
193
+ *
194
+ * This is the HOST DEFAULT: an agent that names its own
195
+ * {@link AcpAgentDef.idleTimeoutMs} keeps its own. Editable from the
196
+ * settings page (「进程管理」 → `ui/idle-release-set`), which persists an
197
+ * override in the plugin's own state file and applies it to the running
198
+ * bridges without restarting anything; the value here is the deployment
199
+ * baseline that override falls back to. See {@link AcpIdleRelease}.
200
+ */
201
+ idleTimeoutMs?: number
190
202
  /**
191
203
  * LEGACY: the old conversation-route window, kept so an existing
192
204
  * `cordis.patch.yml` still says what it meant. Folded into
@@ -490,6 +502,14 @@ export interface SessionView {
490
502
  * recover") when the agent declares the `loadSession` capability.
491
503
  */
492
504
  released?: boolean
505
+ /**
506
+ * The agent-side session has history while the local buffer is empty: the
507
+ * record was restored from disk after a host restart (the `session/load`
508
+ * replay has not landed), or a resume cleared the buffer ahead of the
509
+ * replay. The ACP view then says history replays on restore instead of
510
+ * claiming "nothing yet".
511
+ */
512
+ hasHistory?: boolean
493
513
  /** Highest transcript seq included in this snapshot (for gap detection). */
494
514
  seq: number
495
515
  }
@@ -543,8 +563,36 @@ export interface ChatSessionView {
543
563
  * agent can never see.
544
564
  */
545
565
  availableCommands?: SessionView['availableCommands']
566
+ /**
567
+ * Context occupancy the agent last reported (`usage_update`): prompt-side
568
+ * used/size, not request tokens. Absent until the agent reports one; the
569
+ * browser live-patches this row from the update frames (see AGENTS/ui.md).
570
+ */
571
+ usage?: SessionView['usage']
546
572
  /** The agent process died; the session awaits a `session/load` resume. */
547
573
  released?: boolean
574
+ /** The agent is running a turn on this session right now. */
575
+ running?: boolean
576
+ /**
577
+ * Whether this session's ACP agent can be forked (`session/fork`) into a
578
+ * child conversation, as the host computed it. The browser does not derive
579
+ * this itself — the capability is host knowledge (the agent's
580
+ * `initialize`), and the row is the one place it is delivered.
581
+ * `supported: false` carries the reason; no reason on `false` means the
582
+ * host half predates the field (treat as unsupported).
583
+ */
584
+ fork?: ChatSessionForkInfo
585
+ }
586
+ /**
587
+ * The host's per-row verdict on forking one chat session's ACP session
588
+ * (`ChatSessionView.fork`). Computed from the agent's LAST KNOWN
589
+ * `sessionCapabilities.fork` (persisted with the model catalog) plus the
590
+ * session's own state — the browser never reads capability bits itself.
591
+ */
592
+ export interface ChatSessionForkInfo {
593
+ supported: boolean
594
+ /** Why {@link supported} is false: the agent cannot fork, or the session cannot be forked right now. */
595
+ reason?: 'unsupported' | 'released' | 'busy'
548
596
  }
549
597
  export type ChatSessionsListResult = { sessions: ChatSessionView[] }
550
598
 
@@ -668,6 +716,63 @@ export interface TagHaloSetPayload { halo: AcpTagHalo }
668
716
  /** The approval deadline the settings page can edit, by field name. ONE window. */
669
717
  export type ApprovalDeadlineKey = 'permissionTimeoutMs'
670
718
 
719
+ /**
720
+ * The idle-release windows the settings page offers, in MILLISECONDS.
721
+ *
722
+ * ONE ladder, shared by both halves: the browser snaps its slider to these
723
+ * values and the host accepts only these values, so a window the slider can
724
+ * produce is always a window the host runs. The first rung is 1 minute (an
725
+ * aggressive release), the rest are 5-minute steps up to an hour.
726
+ */
727
+ export const IDLE_RELEASE_MS: readonly number[] = [
728
+ 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,
729
+ ]
730
+
731
+ /**
732
+ * The built-in idle-release window for chat processes: 30 minutes.
733
+ *
734
+ * Lives beside the ladder it is a rung of, so the two cannot drift: a
735
+ * deployment that names no window of its own runs this, and the settings
736
+ * page's reset returns to it.
737
+ */
738
+ export const DEFAULT_IDLE_TIMEOUT_MS = 1_800_000
739
+
740
+ /**
741
+ * The idle-release window for chat processes, as the settings page edits it.
742
+ *
743
+ * The window is the HOST default: an agent that names its own
744
+ * {@link AcpAgentDef.idleTimeoutMs} keeps it, and a probe-only process (one
745
+ * that never hosted a session) still honors the short probe window — see
746
+ * `AGENTS/protocol.md` §5.
747
+ */
748
+ export interface AcpIdleRelease {
749
+ /** The window in force, in ms. */
750
+ idleTimeoutMs: number
751
+ /**
752
+ * The window a RESET returns to: the deployment baseline when the config
753
+ * file names one, else the built-in default (30 minutes).
754
+ */
755
+ defaultIdleTimeoutMs: number
756
+ /**
757
+ * The window `cordis.patch.yml` names, when it names one. Reported only
758
+ * when the file actually names one: the settings card must not say "the
759
+ * file asks for 30 minutes" about a file that says nothing.
760
+ */
761
+ configuredIdleTimeoutMs?: number
762
+ }
763
+ /** `ui/idle-release`: read the idle-release window in force. */
764
+ export type IdleReleaseResult = { idleRelease?: AcpIdleRelease }
765
+ /**
766
+ * `ui/idle-release-set`: write the idle-release window, or drop the persisted
767
+ * override with `reset` so the built-in default applies again.
768
+ */
769
+ export interface IdleReleaseSetPayload {
770
+ /** The window to run, in ms; omitted when `reset` is true. */
771
+ idleTimeoutMs?: number
772
+ /** Drop the persisted override so the built-in default applies again. */
773
+ reset?: boolean
774
+ }
775
+
671
776
  /**
672
777
  * What an EXPIRED approval deadline does. Three modes, and only three — an
673
778
  * unanswered ask either gets answered for you, keeps waiting, or is refused.
@@ -752,6 +857,22 @@ export interface ChatEffortSetPayload { dshSessionId: string; effort?: string; a
752
857
  /** `session/chat-config-set`: set one of the agent's extra config options. */
753
858
  export interface ChatConfigSetPayload { dshSessionId: string; configId: string; value: string | boolean; agentId?: string }
754
859
 
860
+ /**
861
+ * `session/chat-fork-adopt`: bind the DSH chat session a host `sessions.fork`
862
+ * just created to the SAME agent process, through the agent's own
863
+ * `session/fork` of the parent conversation's ACP session — the second half of
864
+ * the two-stage branch (host builds the DSH tree first, the plugin then adopts
865
+ * the ACP side; see the NTR message-actions plan, §D5/D7).
866
+ *
867
+ * On success the child conversation's next message reaches an agent that
868
+ * actually remembers the parent context. On failure NOTHING is bound: the
869
+ * child stays an ordinary copied-history conversation and its first message
870
+ * starts a fresh `session/new` (today's behavior).
871
+ */
872
+ export interface ChatForkAdoptPayload { dshSessionId: string; parentDshSessionId: string }
873
+ /** `session/chat-fork-adopt` success: the adopted ACP session's ids. */
874
+ export type ChatForkAdoptResult = { sessionId: string; acpSessionId: string }
875
+
755
876
  /** Host-detected agent presence and tested defaults. */
756
877
  export interface DetectedAgentInfo {
757
878
  id: string