@treeseed/dsh-chapters 0.2.8 → 0.2.9

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 {
@@ -319,6 +327,58 @@ window.__ModuleLoader__.load({
319
327
  return /^\s*model:\s*unset\b/m.test(text);
320
328
  }
321
329
  /**
330
+ * A pushed payload becomes the SAME events the pull path dispatches — one fold, one reducer, no
331
+ * second implementation to drift (#59). Null when the payload is not ours or not trustworthy: an
332
+ * unrecognised push is ignored, never guessed at, and the pull schedule underneath keeps running
333
+ * until a payload this function accepts actually arrives.
334
+ */
335
+ function pushEventsOf(payload) {
336
+ if (payload === null || payload === void 0 || typeof payload !== "object") return null;
337
+ const p = payload;
338
+ if (p.v !== 1 || typeof p.status !== "string") return null;
339
+ const status = p.status;
340
+ if (status !== "off" && status !== "idle" && status !== "analyzing" && status !== "ready" && status !== "failed") return null;
341
+ return {
342
+ polled: {
343
+ t: "polled",
344
+ status,
345
+ ...status === "ready" ? {
346
+ text: typeof p.text === "string" ? p.text : "",
347
+ turnSeq: p.turnSeq ?? null,
348
+ intent: p.intent ?? "worker-step"
349
+ } : {},
350
+ ...status === "failed" ? { error: p.error ?? "the suggestion call failed" } : {}
351
+ },
352
+ autorun: {
353
+ t: "autorun",
354
+ on: p.autorun === "armed"
355
+ }
356
+ };
357
+ }
358
+ /**
359
+ * The running signal's resolution order (scalar prop → `useSession` snapshot via `deriveRunning` →
360
+ * unknown) lives in the dock as `props.running ?? deriveRunning(sessionSnapshot)` — ONE expression,
361
+ * pinned by a source-scan row, folded by the deriveRunning rows that already cover every shape.
362
+ * A second pure helper for the same decision would be exactly the drift #59 forbids; the draft of
363
+ * #194 shipped one and this comment is its headstone.
364
+ */
365
+ /** The staleness-confirm's one question: did a push arrive after the edge that scheduled it? */
366
+ function pushIsFresher(lastPushAt, edgeAt) {
367
+ return lastPushAt !== null && lastPushAt > edgeAt;
368
+ }
369
+ /**
370
+ * #218: a DEAD-PUSH HEARTBEAT, not a staleness confirm — 20s fired mid-turn before the turn's
371
+ * record could exist (turns outlast 20s), which was confirm spam wearing a safety hat. 5 minutes:
372
+ * during normal work a record arrives every turn and the push effect retires the pending timer, so
373
+ * a healthy push NEVER fires it; only a push silent for a whole 5 minutes pulls once — a frozen
374
+ * bar is the failure this bounds, and it is worth one node every 5 minutes, not one every 20
375
+ * seconds.
376
+ * within moments (the runner writes it BEFORE calling the model). If no push arrived this long
377
+ * after the edge, the push is presumed dead and ONE confirm pull runs — a silently dead push
378
+ * degrades to one pull per turn, never to a frozen bar.
379
+ */
380
+ const PUSH_CONFIRM_GRACE_MS = 3e5;
381
+ /**
322
382
  * #211 — the edge kick's delay, measured on the maintainer's host the hard way: some hosts publish
323
383
  * `running` at STEP granularity (it flickers at every tool call and thinking block), and an
324
384
  * immediate kick per falling edge was a rendered `chapters-prompt` node per tool call — all waste,
@@ -457,11 +517,19 @@ window.__ModuleLoader__.load({
457
517
  }
458
518
  function PromptDock(props) {
459
519
  const { sessionId, inputActions, exec } = props;
520
+ const sessionSnapshot = (props.useSession ?? (() => void 0))((snapshot) => snapshot);
521
+ const running = props.running ?? deriveRunning(sessionSnapshot);
522
+ const pushed = (props.useProjection ?? (() => void 0))(PROMPT_PROJECTION_KEY);
460
523
  const [state, dispatch] = (0, react.useReducer)(promptBarReducer, initialBarState(), (s) => {
524
+ const pushEvs = pushEventsOf(pushed);
525
+ if (pushEvs !== null) return {
526
+ ...promptBarReducer(promptBarReducer(s, pushEvs.polled), pushEvs.autorun),
527
+ running: running ?? true
528
+ };
461
529
  const c = sessionId !== void 0 ? stateCache.get(sessionId) : void 0;
462
530
  return {
463
531
  ...c !== void 0 ? stateFromCache(c.ev, c.autorun) : s,
464
- running: props.running ?? true
532
+ running: running ?? true
465
533
  };
466
534
  });
467
535
  const pulling = (0, react.useRef)(false);
@@ -475,6 +543,8 @@ window.__ModuleLoader__.load({
475
543
  const kick = (0, react.useRef)(void 0);
476
544
  const lastPullAt = (0, react.useRef)(null);
477
545
  const edgeTimer = (0, react.useRef)(void 0);
546
+ const pushLiveRef = (0, react.useRef)(false);
547
+ const lastPushAt = (0, react.useRef)(null);
478
548
  const answered = (0, react.useRef)(false);
479
549
  const replyStatus = (0, react.useRef)("unknown");
480
550
  const stateRef = (0, react.useRef)(initialBarState());
@@ -517,6 +587,18 @@ window.__ModuleLoader__.load({
517
587
  pulling.current = false;
518
588
  }
519
589
  }, [exec]);
590
+ (0, react.useEffect)(() => {
591
+ const evs = pushEventsOf(pushed);
592
+ if (evs === null) return;
593
+ pushLiveRef.current = true;
594
+ lastPushAt.current = Date.now();
595
+ dispatch(evs.polled);
596
+ dispatch(evs.autorun);
597
+ if (edgeTimer.current !== void 0) {
598
+ clearTimeout(edgeTimer.current);
599
+ edgeTimer.current = void 0;
600
+ }
601
+ }, [pushed]);
520
602
  (0, react.useEffect)(() => {
521
603
  ensureCss();
522
604
  if (pulled.current) return;
@@ -540,6 +622,7 @@ window.__ModuleLoader__.load({
540
622
  });
541
623
  };
542
624
  const tick = async (opts) => {
625
+ if (pushLiveRef.current && opts?.force !== true) return;
543
626
  if (featureAbsent) return;
544
627
  if (effectiveStatus() === "off") {
545
628
  if (!(opts?.mount === true && duePull())) return;
@@ -571,9 +654,9 @@ window.__ModuleLoader__.load({
571
654
  if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
572
655
  };
573
656
  }, [refresh]);
574
- const prevRunning = (0, react.useRef)(props.running ?? true);
657
+ const prevRunning = (0, react.useRef)(running ?? true);
575
658
  (0, react.useEffect)(() => {
576
- const next = props.running ?? true;
659
+ const next = running ?? true;
577
660
  const prev = prevRunning.current;
578
661
  prevRunning.current = next;
579
662
  dispatch({
@@ -583,17 +666,20 @@ window.__ModuleLoader__.load({
583
666
  if (prev && !next) {
584
667
  if (edgeTimer.current !== void 0) clearTimeout(edgeTimer.current);
585
668
  const since = lastPullAt.current === null ? Number.MAX_SAFE_INTEGER : Date.now() - lastPullAt.current;
669
+ const delay = pushLiveRef.current ? Math.max(PUSH_CONFIRM_GRACE_MS, edgeKickDelayMs(since)) : edgeKickDelayMs(since);
670
+ const edgeAt = Date.now();
586
671
  edgeTimer.current = setTimeout(() => {
587
672
  edgeTimer.current = void 0;
673
+ if (pushLiveRef.current && pushIsFresher(lastPushAt.current, edgeAt)) return;
588
674
  kick.current?.();
589
- }, edgeKickDelayMs(since));
675
+ }, delay);
590
676
  } else if (!prev && next) {
591
677
  if (edgeTimer.current !== void 0) {
592
678
  clearTimeout(edgeTimer.current);
593
679
  edgeTimer.current = void 0;
594
680
  }
595
681
  }
596
- }, [props.running]);
682
+ }, [running]);
597
683
  const run = (0, react.useCallback)(() => {
598
684
  if (state.text === null || state.text === "" || exec === void 0) return;
599
685
  setBusy(true);
@@ -621,8 +707,7 @@ window.__ModuleLoader__.load({
621
707
  if (!shouldAutoFire(state)) return;
622
708
  run();
623
709
  }, [state, run]);
624
- const sessionSnapshot = (props.useSession ?? (() => void 0))((snapshot) => snapshot);
625
- const autorunDisabled = (props.running ?? deriveRunning(sessionSnapshot)) === void 0;
710
+ const autorunDisabled = running === void 0;
626
711
  if (sessionId === void 0 || !barVisible(state)) return null;
627
712
  const populate = () => {
628
713
  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 +791,7 @@ window.__ModuleLoader__.load({
706
791
  t: "autorun",
707
792
  on: arm
708
793
  });
709
- await refresh();
794
+ if (!pushLiveRef.current) await refresh();
710
795
  })();
711
796
  }
712
797
  }), (0, react.createElement)(Action, {
@@ -1155,12 +1240,18 @@ window.__ModuleLoader__.load({
1155
1240
  }, { ...uiWorkspace !== void 0 ? { navigator: uiWorkspace } : {} }),
1156
1241
  component: makeDoorAction(door)
1157
1242
  }));
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)));
1243
+ console.info(`[client:chapters] injecting ${String(rows.length)} assistant-actions door(s)`);
1244
+ const disposeDoors = slots.inject("conversation.chat.assistant-actions", () => {
1245
+ const effects = rows.map(({ door, run, component }) => slots.register({
1246
+ name: "conversation.chat.assistant-actions",
1247
+ id: door.id,
1248
+ label: door.label,
1249
+ inject: (sessionId) => ({ run: () => run(sessionId) })
1250
+ }, component));
1251
+ console.info(`[client:chapters] door factory ran: ${String(effects.length)} register effect(s), types: ${effects.map((e) => typeof e).join(",")}`);
1252
+ return effects;
1253
+ });
1254
+ console.info(`[client:chapters] assistant-actions inject returned ${typeof disposeDoors}`);
1164
1255
  slots.inject("conversation.input.dock", () => slots.register({
1165
1256
  name: "conversation.input.dock",
1166
1257
  id: "chapters-prompt",