dsh-ntr 0.3.1 → 0.3.3

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 (57) hide show
  1. package/README.en.md +3 -1
  2. package/README.md +3 -1
  3. package/cordis.patch.yml +17 -1
  4. package/lib/bridge.js +2436 -82
  5. package/lib/client/acp-ui.js +103 -3
  6. package/lib/client/acp-view.js +24 -4
  7. package/lib/client/activity-pill.js +156 -0
  8. package/lib/client/branch-action.js +149 -0
  9. package/lib/client/composer-mode.js +48 -13
  10. package/lib/client/context-meter.js +86 -0
  11. package/lib/client/index.js +279 -16
  12. package/lib/client/model-seat.js +8 -2
  13. package/lib/client/panel.js +96 -29
  14. package/lib/client/released-banner.js +66 -0
  15. package/lib/client/session-controls.js +38 -13
  16. package/lib/client/slash-isolation.js +2 -2
  17. package/lib/client/store.js +380 -13
  18. package/lib/client.js +1364 -70
  19. package/lib/client.js.map +1 -1
  20. package/lib/impl.js +1229 -8
  21. package/lib/index.js +34 -0
  22. package/lib/protocol.js +19 -0
  23. package/lib/tps.js +32 -0
  24. package/lib/types/bridge.d.ts +850 -18
  25. package/lib/types/client/acp-ui.d.ts +34 -2
  26. package/lib/types/client/acp-view.d.ts +13 -2
  27. package/lib/types/client/activity-pill.d.ts +81 -0
  28. package/lib/types/client/branch-action.d.ts +47 -0
  29. package/lib/types/client/composer-mode.d.ts +28 -0
  30. package/lib/types/client/context-meter.d.ts +51 -0
  31. package/lib/types/client/model-seat.d.ts +21 -0
  32. package/lib/types/client/released-banner.d.ts +34 -0
  33. package/lib/types/client/slash-isolation.d.ts +1 -1
  34. package/lib/types/client/store.d.ts +108 -1
  35. package/lib/types/impl.d.ts +94 -7
  36. package/lib/types/protocol.d.ts +145 -0
  37. package/lib/types/tps.d.ts +17 -0
  38. package/package.json +2 -1
  39. package/src/bridge.ts +2754 -86
  40. package/src/client/acp-ui.tsx +123 -2
  41. package/src/client/acp-view.tsx +30 -5
  42. package/src/client/activity-pill.tsx +201 -0
  43. package/src/client/branch-action.tsx +178 -0
  44. package/src/client/composer-mode.tsx +59 -13
  45. package/src/client/context-meter.tsx +128 -0
  46. package/src/client/index.tsx +298 -15
  47. package/src/client/model-seat.tsx +28 -3
  48. package/src/client/panel.tsx +157 -40
  49. package/src/client/primitives.d.ts +27 -0
  50. package/src/client/released-banner.tsx +97 -0
  51. package/src/client/session-controls.tsx +34 -11
  52. package/src/client/slash-isolation.ts +2 -1
  53. package/src/client/store.ts +412 -14
  54. package/src/impl.ts +1251 -13
  55. package/src/index.ts +33 -0
  56. package/src/protocol.ts +145 -0
  57. package/src/tps.ts +40 -0
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
@@ -269,6 +277,31 @@ export function apply(ctx: Context, config?: unknown): void {
269
277
  })
270
278
  }
271
279
 
280
+ const wireAttachments = (injectedCtx?: Context): void => {
281
+ const c = injectedCtx ?? ctx
282
+ let attachments: unknown
283
+ try {
284
+ if (typeof c.get === 'function') {
285
+ attachments = c.get('attachments') ?? c.get('attachmentService')
286
+ }
287
+ } catch {}
288
+ try {
289
+ if (attachments === undefined) {
290
+ attachments = (c as unknown as Record<string, unknown>).attachments ?? (c as unknown as Record<string, unknown>).attachmentService
291
+ }
292
+ } catch {}
293
+ if (attachments !== undefined) {
294
+ manager.setAttachmentResolver(attachments)
295
+ } else {
296
+ manager.setAttachmentResolver(c)
297
+ }
298
+ }
299
+ wireAttachments()
300
+ if (typeof ctx.inject === 'function') {
301
+ ctx.inject(['attachments'], injectedCtx => { wireAttachments(injectedCtx) })
302
+ ctx.inject(['attachmentService'], injectedCtx => { wireAttachments(injectedCtx) })
303
+ }
304
+
272
305
  /** Registration handle: callable disposer, plus `replace` to re-announce routes. */
273
306
  interface LlmAdapterHandle {
274
307
  (): void
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
@@ -446,6 +458,18 @@ export type AcpEvent =
446
458
  */
447
459
  | { type: 'permission/synced'; requestIds: string[] }
448
460
  | { type: 'turn/start'; sessionId: string; turnId: string; text: string }
461
+ | {
462
+ type: 'turn/metrics'
463
+ /** ACP session id (the store keys live burst state by it). */
464
+ sessionId: string
465
+ /** DSH chat session id (routing/context only). */
466
+ dshSessionId: string
467
+ turnId: string
468
+ /** Session aggregate INCLUDING the current turn when measurable. */
469
+ outputTokens: number
470
+ burstMs: number
471
+ final: boolean
472
+ }
449
473
  | { type: 'turn/end'; sessionId: string; turnId: string; stopReason?: string; error?: string }
450
474
  /**
451
475
  * A DSH chat conversation was locked to its agent at the start of its first
@@ -469,6 +493,14 @@ export interface PermissionOptionView {
469
493
 
470
494
  export type PermissionOutcome = { outcome: 'selected'; optionId: string } | { outcome: 'cancelled' }
471
495
 
496
+ /** Plugin-side measurable burst aggregate for one ACP session (not host turn timings). */
497
+ export interface SessionBurst {
498
+ /** Sum of estimated output tokens over measurable turns. */
499
+ outputTokens: number
500
+ /** Sum of within-burst chunk gaps over those turns; tool/approval/long pauses excluded. */
501
+ burstMs: number
502
+ }
503
+
472
504
  /** Derived session snapshot for replay/reconnect (source of truth stays in the transcript). */
473
505
  export interface SessionView {
474
506
  sessionId: string
@@ -482,6 +514,8 @@ export interface SessionView {
482
514
  availableCommands?: Array<{ name: string; description?: string; [key: string]: unknown }>
483
515
  plan?: unknown[]
484
516
  usage?: { used: number; size: number; cost?: { amount: number; currency: string } }
517
+ /** Measurable burst aggregate over this session's turns; absent until measured. */
518
+ burst?: SessionBurst
485
519
  running: boolean
486
520
  closed: boolean
487
521
  /**
@@ -490,6 +524,14 @@ export interface SessionView {
490
524
  * recover") when the agent declares the `loadSession` capability.
491
525
  */
492
526
  released?: boolean
527
+ /**
528
+ * The agent-side session has history while the local buffer is empty: the
529
+ * record was restored from disk after a host restart (the `session/load`
530
+ * replay has not landed), or a resume cleared the buffer ahead of the
531
+ * replay. The ACP view then says history replays on restore instead of
532
+ * claiming "nothing yet".
533
+ */
534
+ hasHistory?: boolean
493
535
  /** Highest transcript seq included in this snapshot (for gap detection). */
494
536
  seq: number
495
537
  }
@@ -543,8 +585,38 @@ export interface ChatSessionView {
543
585
  * agent can never see.
544
586
  */
545
587
  availableCommands?: SessionView['availableCommands']
588
+ /**
589
+ * Context occupancy the agent last reported (`usage_update`): prompt-side
590
+ * used/size, not request tokens. Absent until the agent reports one; the
591
+ * browser live-patches this row from the update frames (see AGENTS/ui.md).
592
+ */
593
+ usage?: SessionView['usage']
594
+ /** Measurable burst aggregate over this session's turns; absent until measured. */
595
+ burst?: SessionView['burst']
546
596
  /** The agent process died; the session awaits a `session/load` resume. */
547
597
  released?: boolean
598
+ /** The agent is running a turn on this session right now. */
599
+ running?: boolean
600
+ /**
601
+ * Whether this session's ACP agent can be forked (`session/fork`) into a
602
+ * child conversation, as the host computed it. The browser does not derive
603
+ * this itself — the capability is host knowledge (the agent's
604
+ * `initialize`), and the row is the one place it is delivered.
605
+ * `supported: false` carries the reason; no reason on `false` means the
606
+ * host half predates the field (treat as unsupported).
607
+ */
608
+ fork?: ChatSessionForkInfo
609
+ }
610
+ /**
611
+ * The host's per-row verdict on forking one chat session's ACP session
612
+ * (`ChatSessionView.fork`). Computed from the agent's LAST KNOWN
613
+ * `sessionCapabilities.fork` (persisted with the model catalog) plus the
614
+ * session's own state — the browser never reads capability bits itself.
615
+ */
616
+ export interface ChatSessionForkInfo {
617
+ supported: boolean
618
+ /** Why {@link supported} is false: the agent cannot fork, or the session cannot be forked right now. */
619
+ reason?: 'unsupported' | 'released' | 'busy'
548
620
  }
549
621
  export type ChatSessionsListResult = { sessions: ChatSessionView[] }
550
622
 
@@ -668,6 +740,63 @@ export interface TagHaloSetPayload { halo: AcpTagHalo }
668
740
  /** The approval deadline the settings page can edit, by field name. ONE window. */
669
741
  export type ApprovalDeadlineKey = 'permissionTimeoutMs'
670
742
 
743
+ /**
744
+ * The idle-release windows the settings page offers, in MILLISECONDS.
745
+ *
746
+ * ONE ladder, shared by both halves: the browser snaps its slider to these
747
+ * values and the host accepts only these values, so a window the slider can
748
+ * produce is always a window the host runs. The first rung is 1 minute (an
749
+ * aggressive release), the rest are 5-minute steps up to an hour.
750
+ */
751
+ export const IDLE_RELEASE_MS: readonly number[] = [
752
+ 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,
753
+ ]
754
+
755
+ /**
756
+ * The built-in idle-release window for chat processes: 30 minutes.
757
+ *
758
+ * Lives beside the ladder it is a rung of, so the two cannot drift: a
759
+ * deployment that names no window of its own runs this, and the settings
760
+ * page's reset returns to it.
761
+ */
762
+ export const DEFAULT_IDLE_TIMEOUT_MS = 1_800_000
763
+
764
+ /**
765
+ * The idle-release window for chat processes, as the settings page edits it.
766
+ *
767
+ * The window is the HOST default: an agent that names its own
768
+ * {@link AcpAgentDef.idleTimeoutMs} keeps it, and a probe-only process (one
769
+ * that never hosted a session) still honors the short probe window — see
770
+ * `AGENTS/protocol.md` §5.
771
+ */
772
+ export interface AcpIdleRelease {
773
+ /** The window in force, in ms. */
774
+ idleTimeoutMs: number
775
+ /**
776
+ * The window a RESET returns to: the deployment baseline when the config
777
+ * file names one, else the built-in default (30 minutes).
778
+ */
779
+ defaultIdleTimeoutMs: number
780
+ /**
781
+ * The window `cordis.patch.yml` names, when it names one. Reported only
782
+ * when the file actually names one: the settings card must not say "the
783
+ * file asks for 30 minutes" about a file that says nothing.
784
+ */
785
+ configuredIdleTimeoutMs?: number
786
+ }
787
+ /** `ui/idle-release`: read the idle-release window in force. */
788
+ export type IdleReleaseResult = { idleRelease?: AcpIdleRelease }
789
+ /**
790
+ * `ui/idle-release-set`: write the idle-release window, or drop the persisted
791
+ * override with `reset` so the built-in default applies again.
792
+ */
793
+ export interface IdleReleaseSetPayload {
794
+ /** The window to run, in ms; omitted when `reset` is true. */
795
+ idleTimeoutMs?: number
796
+ /** Drop the persisted override so the built-in default applies again. */
797
+ reset?: boolean
798
+ }
799
+
671
800
  /**
672
801
  * What an EXPIRED approval deadline does. Three modes, and only three — an
673
802
  * unanswered ask either gets answered for you, keeps waiting, or is refused.
@@ -752,6 +881,22 @@ export interface ChatEffortSetPayload { dshSessionId: string; effort?: string; a
752
881
  /** `session/chat-config-set`: set one of the agent's extra config options. */
753
882
  export interface ChatConfigSetPayload { dshSessionId: string; configId: string; value: string | boolean; agentId?: string }
754
883
 
884
+ /**
885
+ * `session/chat-fork-adopt`: bind the DSH chat session a host `sessions.fork`
886
+ * just created to the SAME agent process, through the agent's own
887
+ * `session/fork` of the parent conversation's ACP session — the second half of
888
+ * the two-stage branch (host builds the DSH tree first, the plugin then adopts
889
+ * the ACP side; see the NTR message-actions plan, §D5/D7).
890
+ *
891
+ * On success the child conversation's next message reaches an agent that
892
+ * actually remembers the parent context. On failure NOTHING is bound: the
893
+ * child stays an ordinary copied-history conversation and its first message
894
+ * starts a fresh `session/new` (today's behavior).
895
+ */
896
+ export interface ChatForkAdoptPayload { dshSessionId: string; parentDshSessionId: string }
897
+ /** `session/chat-fork-adopt` success: the adopted ACP session's ids. */
898
+ export type ChatForkAdoptResult = { sessionId: string; acpSessionId: string }
899
+
755
900
  /** Host-detected agent presence and tested defaults. */
756
901
  export interface DetectedAgentInfo {
757
902
  id: string
package/src/tps.ts ADDED
@@ -0,0 +1,40 @@
1
+ /** ACP `usage_update` is context occupancy, not request tokens; this is the plugin's 4-chars/token OUTPUT estimate (tool args excluded). */
2
+ export const OUTPUT_CHARS_PER_TOKEN = 4
3
+ export const BURST_GAP_MS = 1000
4
+
5
+ export interface TurnTpsSnapshot {
6
+ outputTokens: number
7
+ burstMs: number
8
+ }
9
+
10
+ export class TurnTpsTracker {
11
+ private readonly gapMs: number
12
+ private lastProseAt: number | undefined
13
+ private outputChars = 0
14
+ private burstMs = 0
15
+
16
+ constructor(gapMs = BURST_GAP_MS) {
17
+ this.gapMs = gapMs
18
+ }
19
+
20
+ onProse(text: string, at: number): void {
21
+ if (text === '') return
22
+ if (this.lastProseAt !== undefined) {
23
+ const gap = at - this.lastProseAt
24
+ if (gap <= this.gapMs) this.burstMs += gap
25
+ }
26
+ this.lastProseAt = at
27
+ this.outputChars += text.length
28
+ }
29
+
30
+ onBreak(): void {
31
+ this.lastProseAt = undefined
32
+ }
33
+
34
+ snapshot(): TurnTpsSnapshot {
35
+ return {
36
+ outputTokens: Math.round(this.outputChars / OUTPUT_CHARS_PER_TOKEN),
37
+ burstMs: this.burstMs,
38
+ }
39
+ }
40
+ }