@treeseed/dsh-chapters 0.2.8 → 0.2.11

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/docs/contract.md CHANGED
@@ -706,6 +706,46 @@ version supplies, so Autorun reported itself unavailable on a host that exposes
706
706
  `{ captureInsertion, insertText, setDraft, addAttachments, removeAttachment, pruneAttachments, submit }`, and
707
707
  the composer-write limitation in #126 is measured against that same kit.
708
708
 
709
+ **The running signal has seats, and the scalar is only one of them (measured 2026-10-31, #194).**
710
+ `SessionSnapshot.running: boolean` (`packages/api/session-controller/src/client/contract/snapshot.ts:69-73`)
711
+ arrives through the dock's standard prop `useSession` — a `MaybeSnapshotSelectorHook<SessionSnapshot>`
712
+ (`useSession((s) => s.running)`), the same seat `ui-session` documents in `SessionStandardProps`.
713
+ Some builds ALSO hand a scalar `running` prop. The shipped dock declared both hook channels
714
+ (`useSessionStatus?`, `useSession?`) and consumed only the scalar — so on a hook-only host the #193
715
+ idle edge never fired and the cadence silently degraded to the conservative 10s-active fallback
716
+ (`props.running ?? true`). Resolution order, locked here: scalar `running` → `useSession((s) =>
717
+ s.running)` → unknown (conservative true, legacy cadence). Conditional invocation of an optional
718
+ hook prop is deliberate: the prop face is fixed per host build, so hook order is stable across
719
+ renders within any one deployment, which is the invariant React actually keeps.
720
+
721
+ **Push transport (interface lock, #194 — probe-verified on host 0.2.1-rc.5, `spikes/push-probe/FINDINGS.md`).**
722
+ Where the host has `appendPluginRecord` (0.2.1+; absent from 0.2.0-rc.2 — grep-verified on both tags),
723
+ the bar's state rides a plugin record and a projection, and the steady state costs ZERO command runs:
724
+
725
+ - Record type: `plugin:dsh-chapters/prompt`. Payload = the bar's durable facts, versioned:
726
+ `{ v: 1, status, text, turnSeq, error, intent, goalRevision, autorun, at }`.
727
+ - Append sites: every durable row write — the runner's `analyzing`/`ready`/`failed`/quiet-`off`
728
+ transitions (it holds the live session) and the bar-access mutations (`setAutorun`, `setOff`,
729
+ `setOn`, `runNow`'s consumption), which gain an optional live-session parameter for the purpose.
730
+ - The function comes ONLY from the host's own module instance — `createRequire(process.argv[1])
731
+ → resolve('@deepseek-ai/dsh-session') → await import(resolved)`. The plugin's OWN copy at the
732
+ IDENTICAL version throws `Receiver must be an instance of class Session` against a host session
733
+ (measured), and a bare import from a `link:` install resolves by realpath into the source tree —
734
+ on a dev machine, into a DIFFERENT host checkout than the running server. No plugin dependency is
735
+ declared for it; detection is `typeof appendPluginRecord === 'function'`, one try behind try/catch,
736
+ and any failure marks push unavailable for the process.
737
+ - Projection: key `chaptersPrompt`, registered ONLY when the writer resolved — registration is the
738
+ feature signal. `apply` folds records last-wins; `wire.view` passes the payload through.
739
+ - Client: the dock reads the standard prop `useProjection('chaptersPrompt')`. A live value renders
740
+ through the SAME reducer (one fold path); `undefined` ⇒ the #203/#208 pull schedule stands,
741
+ unchanged — the fallback is the floor. In push mode the only pulls are the bar's own actions and
742
+ ONE staleness-confirm per idle edge, scheduled at +20s and cancelled when a fresher record arrives
743
+ (the runner writes `analyzing` within moments of turn/end, so a healthy push cancels every confirm):
744
+ a silently dead push degrades to one pull per turn, never to a frozen bar.
745
+ - Records are `ignorable: true` with an unknown `plugin:` type — skipped by readers, rendered by
746
+ nothing; the projection value travels the host's own push channel. Verbs, state block, and the
747
+ pull schedule below are unchanged and remain the contract for pre-0.2.1 hosts.
748
+
709
749
  **Verbs (interface lock, #113).** `/chapters-prompt` accepts `model …|clear`, `off`, `on`, `state`, `autorun on|off`,
710
750
  and **`run`** — submit the offer currently on screen as a **human-authored user turn**, through the same follow-up
711
751
  path autorun uses (`agent.followup(suggestPromptMessage(…))`). Run goes through the command plane rather than the
package/lib/client.js CHANGED
@@ -64,6 +64,14 @@ window.__ModuleLoader__.load({
64
64
  function nativeTitle(label) {
65
65
  return resolveTooltip() === null ? { title: label } : {};
66
66
  }
67
+ // src/prompt-keys.ts
68
+ /**
69
+ * Identifiers shared by BOTH planes of the push transport (#194). One module, one spelling: the
70
+ * server registers the projection under this key and the client reads `useProjection(key)` — two
71
+ * literals in two planes would be exactly the drift #59 forbids. No node imports (the client
72
+ * compiles this), no DOM (the server imports this).
73
+ */
74
+ const PROMPT_PROJECTION_KEY = "chaptersPrompt";
67
75
  // src/client/prompt-bar.ts
68
76
  function initialBarState(autorun = "disarmed") {
69
77
  return {
@@ -176,11 +184,12 @@ window.__ModuleLoader__.load({
176
184
  const ACTIVE_POLL_MS = 1e4;
177
185
  const QUIET_POLL_MS = 6e4;
178
186
  function pollSpacingFor(s) {
187
+ if (s.turnEndPulse === true) return ACTIVE_POLL_MS;
179
188
  if (s.edges) {
180
189
  if (s.status === "analyzing" || s.autorun === "armed") return ACTIVE_POLL_MS;
181
190
  return null;
182
191
  }
183
- const active = s.status === "analyzing" || s.autorun === "armed" || s.running;
192
+ const active = s.status === "analyzing" || s.autorun === "armed";
184
193
  if (!active && s.hidden) return null;
185
194
  return active ? ACTIVE_POLL_MS : QUIET_POLL_MS;
186
195
  }
@@ -319,6 +328,58 @@ window.__ModuleLoader__.load({
319
328
  return /^\s*model:\s*unset\b/m.test(text);
320
329
  }
321
330
  /**
331
+ * A pushed payload becomes the SAME events the pull path dispatches — one fold, one reducer, no
332
+ * second implementation to drift (#59). Null when the payload is not ours or not trustworthy: an
333
+ * unrecognised push is ignored, never guessed at, and the pull schedule underneath keeps running
334
+ * until a payload this function accepts actually arrives.
335
+ */
336
+ function pushEventsOf(payload) {
337
+ if (payload === null || payload === void 0 || typeof payload !== "object") return null;
338
+ const p = payload;
339
+ if (p.v !== 1 || typeof p.status !== "string") return null;
340
+ const status = p.status;
341
+ if (status !== "off" && status !== "idle" && status !== "analyzing" && status !== "ready" && status !== "failed") return null;
342
+ return {
343
+ polled: {
344
+ t: "polled",
345
+ status,
346
+ ...status === "ready" ? {
347
+ text: typeof p.text === "string" ? p.text : "",
348
+ turnSeq: p.turnSeq ?? null,
349
+ intent: p.intent ?? "worker-step"
350
+ } : {},
351
+ ...status === "failed" ? { error: p.error ?? "the suggestion call failed" } : {}
352
+ },
353
+ autorun: {
354
+ t: "autorun",
355
+ on: p.autorun === "armed"
356
+ }
357
+ };
358
+ }
359
+ /**
360
+ * The running signal's resolution order (scalar prop → `useSession` snapshot via `deriveRunning` →
361
+ * unknown) lives in the dock as `props.running ?? deriveRunning(sessionSnapshot)` — ONE expression,
362
+ * pinned by a source-scan row, folded by the deriveRunning rows that already cover every shape.
363
+ * A second pure helper for the same decision would be exactly the drift #59 forbids; the draft of
364
+ * #194 shipped one and this comment is its headstone.
365
+ */
366
+ /** The staleness-confirm's one question: did a push arrive after the edge that scheduled it? */
367
+ function pushIsFresher(lastPushAt, edgeAt) {
368
+ return lastPushAt !== null && lastPushAt > edgeAt;
369
+ }
370
+ /**
371
+ * #218: a DEAD-PUSH HEARTBEAT, not a staleness confirm — 20s fired mid-turn before the turn's
372
+ * record could exist (turns outlast 20s), which was confirm spam wearing a safety hat. 5 minutes:
373
+ * during normal work a record arrives every turn and the push effect retires the pending timer, so
374
+ * a healthy push NEVER fires it; only a push silent for a whole 5 minutes pulls once — a frozen
375
+ * bar is the failure this bounds, and it is worth one node every 5 minutes, not one every 20
376
+ * seconds.
377
+ * within moments (the runner writes it BEFORE calling the model). If no push arrived this long
378
+ * after the edge, the push is presumed dead and ONE confirm pull runs — a silently dead push
379
+ * degrades to one pull per turn, never to a frozen bar.
380
+ */
381
+ const PUSH_CONFIRM_GRACE_MS = 3e5;
382
+ /**
322
383
  * #211 — the edge kick's delay, measured on the maintainer's host the hard way: some hosts publish
323
384
  * `running` at STEP granularity (it flickers at every tool call and thinking block), and an
324
385
  * immediate kick per falling edge was a rendered `chapters-prompt` node per tool call — all waste,
@@ -332,6 +393,26 @@ window.__ModuleLoader__.load({
332
393
  function edgeKickDelayMs(msSinceLastPull) {
333
394
  return Math.max(EDGE_DEBOUNCE_MS, EDGE_FLOOR_MS - msSinceLastPull);
334
395
  }
396
+ /**
397
+ * #221: the turn-end pulse, derived from the host's own turn-boundary projection — no plugin
398
+ * record API needed, so it works on 0.2.0 hosts (where appendPluginRecord is absent) exactly as
399
+ * on newer ones. The host maintains a `turnBoundary` projection (the agent loop reads its
400
+ * lastTurn for its own bookkeeping); reading what the host already folds costs zero writes and
401
+ * delivers reactively through the same standard useProjection seat as any other projection.
402
+ * Returns true when a NEW turn ended since the last observation; the dock calls it once per
403
+ * render and stores the returned stamp back into `seen`.
404
+ */
405
+ function turnEndPulse(lastTurn, seen) {
406
+ const cur = typeof lastTurn === "number" && Number.isFinite(lastTurn) ? lastTurn : 0;
407
+ if (cur > seen) return {
408
+ pulse: true,
409
+ seen: cur
410
+ };
411
+ return {
412
+ pulse: false,
413
+ seen
414
+ };
415
+ }
335
416
  // src/client/prompt-dock.ts
336
417
  /**
337
418
  * The Suggested Next Step dock row (#113) — thin by design.
@@ -457,11 +538,21 @@ window.__ModuleLoader__.load({
457
538
  }
458
539
  function PromptDock(props) {
459
540
  const { sessionId, inputActions, exec } = props;
541
+ const sessionSnapshot = (props.useSession ?? (() => void 0))((snapshot) => snapshot);
542
+ const running = props.running ?? deriveRunning(sessionSnapshot);
543
+ const useProjectionHook = props.useProjection ?? (() => void 0);
544
+ const pushed = useProjectionHook(PROMPT_PROJECTION_KEY);
545
+ const turnBoundary = useProjectionHook("turnBoundary");
460
546
  const [state, dispatch] = (0, react.useReducer)(promptBarReducer, initialBarState(), (s) => {
547
+ const pushEvs = pushEventsOf(pushed);
548
+ if (pushEvs !== null) return {
549
+ ...promptBarReducer(promptBarReducer(s, pushEvs.polled), pushEvs.autorun),
550
+ running: running ?? true
551
+ };
461
552
  const c = sessionId !== void 0 ? stateCache.get(sessionId) : void 0;
462
553
  return {
463
554
  ...c !== void 0 ? stateFromCache(c.ev, c.autorun) : s,
464
- running: props.running ?? true
555
+ running: running ?? true
465
556
  };
466
557
  });
467
558
  const pulling = (0, react.useRef)(false);
@@ -475,6 +566,9 @@ window.__ModuleLoader__.load({
475
566
  const kick = (0, react.useRef)(void 0);
476
567
  const lastPullAt = (0, react.useRef)(null);
477
568
  const edgeTimer = (0, react.useRef)(void 0);
569
+ const pushLiveRef = (0, react.useRef)(false);
570
+ const seenTurnRef = (0, react.useRef)(0);
571
+ const lastPushAt = (0, react.useRef)(null);
478
572
  const answered = (0, react.useRef)(false);
479
573
  const replyStatus = (0, react.useRef)("unknown");
480
574
  const stateRef = (0, react.useRef)(initialBarState());
@@ -517,19 +611,36 @@ window.__ModuleLoader__.load({
517
611
  pulling.current = false;
518
612
  }
519
613
  }, [exec]);
614
+ (0, react.useEffect)(() => {
615
+ const evs = pushEventsOf(pushed);
616
+ if (evs === null) return;
617
+ pushLiveRef.current = true;
618
+ lastPushAt.current = Date.now();
619
+ dispatch(evs.polled);
620
+ dispatch(evs.autorun);
621
+ if (edgeTimer.current !== void 0) {
622
+ clearTimeout(edgeTimer.current);
623
+ edgeTimer.current = void 0;
624
+ }
625
+ }, [pushed]);
520
626
  (0, react.useEffect)(() => {
521
627
  ensureCss();
522
628
  if (pulled.current) return;
523
629
  pulled.current = true;
524
630
  let timer;
525
631
  const effectiveStatus = () => replyStatus.current === "unknown" ? stateRef.current.status : replyStatus.current;
526
- const spacingNow = () => pollSpacingFor({
527
- status: effectiveStatus(),
528
- autorun: stateRef.current.autorun,
529
- running: stateRef.current.running,
530
- hidden: typeof document !== "undefined" && document.hidden === true,
531
- edges: edgesRef.current
532
- });
632
+ const spacingNow = () => {
633
+ const pe = turnEndPulse(turnBoundary?.lastTurn, seenTurnRef.current);
634
+ if (pe.pulse) seenTurnRef.current = pe.seen;
635
+ return pollSpacingFor({
636
+ status: effectiveStatus(),
637
+ autorun: stateRef.current.autorun,
638
+ running: stateRef.current.running,
639
+ hidden: typeof document !== "undefined" && document.hidden === true,
640
+ edges: edgesRef.current,
641
+ turnEndPulse: pe.pulse
642
+ });
643
+ };
533
644
  const duePull = () => {
534
645
  const c = sessionId !== void 0 ? stateCache.get(sessionId) : void 0;
535
646
  return mountPullDecision({
@@ -540,6 +651,7 @@ window.__ModuleLoader__.load({
540
651
  });
541
652
  };
542
653
  const tick = async (opts) => {
654
+ if (pushLiveRef.current && opts?.force !== true) return;
543
655
  if (featureAbsent) return;
544
656
  if (effectiveStatus() === "off") {
545
657
  if (!(opts?.mount === true && duePull())) return;
@@ -571,9 +683,9 @@ window.__ModuleLoader__.load({
571
683
  if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
572
684
  };
573
685
  }, [refresh]);
574
- const prevRunning = (0, react.useRef)(props.running ?? true);
686
+ const prevRunning = (0, react.useRef)(running ?? true);
575
687
  (0, react.useEffect)(() => {
576
- const next = props.running ?? true;
688
+ const next = running ?? true;
577
689
  const prev = prevRunning.current;
578
690
  prevRunning.current = next;
579
691
  dispatch({
@@ -583,17 +695,20 @@ window.__ModuleLoader__.load({
583
695
  if (prev && !next) {
584
696
  if (edgeTimer.current !== void 0) clearTimeout(edgeTimer.current);
585
697
  const since = lastPullAt.current === null ? Number.MAX_SAFE_INTEGER : Date.now() - lastPullAt.current;
698
+ const delay = pushLiveRef.current ? Math.max(PUSH_CONFIRM_GRACE_MS, edgeKickDelayMs(since)) : edgeKickDelayMs(since);
699
+ const edgeAt = Date.now();
586
700
  edgeTimer.current = setTimeout(() => {
587
701
  edgeTimer.current = void 0;
702
+ if (pushLiveRef.current && pushIsFresher(lastPushAt.current, edgeAt)) return;
588
703
  kick.current?.();
589
- }, edgeKickDelayMs(since));
704
+ }, delay);
590
705
  } else if (!prev && next) {
591
706
  if (edgeTimer.current !== void 0) {
592
707
  clearTimeout(edgeTimer.current);
593
708
  edgeTimer.current = void 0;
594
709
  }
595
710
  }
596
- }, [props.running]);
711
+ }, [running]);
597
712
  const run = (0, react.useCallback)(() => {
598
713
  if (state.text === null || state.text === "" || exec === void 0) return;
599
714
  setBusy(true);
@@ -621,8 +736,7 @@ window.__ModuleLoader__.load({
621
736
  if (!shouldAutoFire(state)) return;
622
737
  run();
623
738
  }, [state, run]);
624
- const sessionSnapshot = (props.useSession ?? (() => void 0))((snapshot) => snapshot);
625
- const autorunDisabled = (props.running ?? deriveRunning(sessionSnapshot)) === void 0;
739
+ const autorunDisabled = running === void 0;
626
740
  if (sessionId === void 0 || !barVisible(state)) return null;
627
741
  const populate = () => {
628
742
  console.info(`[client:prompt] populate expanded=${String(state.expanded)} hasInsertText=${String(typeof inputActions?.insertText)} hasSetDraft=${String(typeof inputActions?.setDraft)} textLen=${String(state.text?.length ?? -1)}`);
@@ -706,7 +820,7 @@ window.__ModuleLoader__.load({
706
820
  t: "autorun",
707
821
  on: arm
708
822
  });
709
- await refresh();
823
+ if (!pushLiveRef.current) await refresh();
710
824
  })();
711
825
  }
712
826
  }), (0, react.createElement)(Action, {
@@ -912,13 +1026,14 @@ window.__ModuleLoader__.load({
912
1026
  return tryClick();
913
1027
  }
914
1028
  function makeDoorRunner(remote, sessions, door, verifySwitched, opts) {
915
- return async function run(sessionId) {
1029
+ return async function run(sessionId, anchorSeq) {
916
1030
  try {
917
1031
  if (remote.commands?.execute === void 0) return {
918
1032
  ok: false,
919
1033
  message: "commands remote unavailable"
920
1034
  };
921
- const res = await remote.commands.execute(sessionId, door.command, []);
1035
+ const line = typeof anchorSeq === "number" && Number.isFinite(anchorSeq) ? `${door.command} ${String(Math.trunc(anchorSeq))}` : door.command;
1036
+ const res = await remote.commands.execute(sessionId, line, []);
922
1037
  if (res.ok !== true) return {
923
1038
  ok: false,
924
1039
  message: (res.error?.message ?? `the ${door.noun} call failed`).slice(0, 90)
@@ -1155,12 +1270,27 @@ window.__ModuleLoader__.load({
1155
1270
  }, { ...uiWorkspace !== void 0 ? { navigator: uiWorkspace } : {} }),
1156
1271
  component: makeDoorAction(door)
1157
1272
  }));
1158
- slots.inject("conversation.chat.assistant-actions", () => rows.map(({ door, run, component }) => slots.register({
1159
- name: "conversation.chat.assistant-actions",
1160
- id: door.id,
1161
- order: door.order,
1162
- inject: (sessionId) => ({ run: () => run(sessionId) })
1163
- }, component)));
1273
+ console.info(`[client:chapters] injecting ${String(rows.length)} assistant-actions door(s)`);
1274
+ const disposeDoors = slots.inject("conversation.chat.assistant-actions", () => {
1275
+ const effects = rows.map(({ door, run, component }) => slots.register({
1276
+ name: "conversation.chat.assistant-actions",
1277
+ id: door.id,
1278
+ label: door.label,
1279
+ inject: (sessionId, owner) => ({ run: async () => {
1280
+ let anchor = null;
1281
+ const mid = typeof owner?.messageId === "string" ? owner.messageId : "";
1282
+ if (mid !== "") try {
1283
+ const out = await runCommandText(remote, sessionId, `/chapters-anchor ${mid}`);
1284
+ const n = Number.parseInt(out.trim(), 10);
1285
+ if (Number.isFinite(n) && n > 0) anchor = n;
1286
+ } catch {}
1287
+ return run(sessionId, anchor);
1288
+ } })
1289
+ }, component));
1290
+ console.info(`[client:chapters] door factory ran: ${String(effects.length)} register effect(s), types: ${effects.map((e) => typeof e).join(",")}`);
1291
+ return effects;
1292
+ });
1293
+ console.info(`[client:chapters] assistant-actions inject returned ${typeof disposeDoors}`);
1164
1294
  slots.inject("conversation.input.dock", () => slots.register({
1165
1295
  name: "conversation.input.dock",
1166
1296
  id: "chapters-prompt",