weir-harness 1.1.6 → 1.2.0

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.
@@ -53,33 +53,43 @@ window.__ModuleLoader__.load({
53
53
  const CLASSIFIER_JEV = Object.freeze({ key: "intentGateClassifier", equals: "jev" });
54
54
  const WINDOWS_ONLY = Object.freeze({ env: "platform", in: Object.freeze(["win32"]) });
55
55
  const ROBASH_WINDOWS = Object.freeze({ all: Object.freeze([enabledWhen("robashEnabled"), WINDOWS_ONLY]) });
56
- // The GROUPS field table. Every node: { field?, kind, values?, display?,
57
- // when?, parent?, folded?, slot? }. kind: boolean | enum | number | text |
58
- // custom. An enum node may set `display: "select"` to render a compact
59
- // native dropdown instead of the segmented control (long option labels
60
- // squeeze the row label). `custom` nodes carry a render `slot` identifier
61
- // and share
62
- // parent/when with plain rows; a custom node with a `field` is
63
- // text-backed at the form layer. `folded` fields stay in the form but
64
- // render inside another row (the model picker folds intentGateModel /
65
- // intentGateReasoningEffort into the intentGateProvider row; their
66
- // `when` matches the carrier row by declaration). Field-less custom
67
- // nodes (maintenance/permission panels) hang in the same tree.
56
+ const CLASSIFIER_LLM_OR_JEV = Object.freeze({ any: Object.freeze([CLASSIFIER_LLM, CLASSIFIER_JEV]) });
57
+ // The GROUPS field table. Every group: { id, page?, fields }. page is
58
+ // "main" (default when absent) or "routing" — the model-routing groups
59
+ // form the separate routing settings page (WeirCard/WeirCardController
60
+ // filter by it; validateLayout rejects any other value). Every node:
61
+ // { field?, kind, values?, display?, when?, parent?, folded?, slot? }.
62
+ // kind: boolean | enum | number | text | custom. An enum node may set
63
+ // `display: "select"` to render a compact native dropdown instead of the
64
+ // segmented control (long option labels squeeze the row label). `custom`
65
+ // nodes carry a render `slot` identifier and share parent/when with
66
+ // plain rows; a custom node with a `field` is text-backed at the form
67
+ // layer. `folded` fields stay in the form but render inside another row
68
+ // (the model picker folds intentGateModel / intentGateReasoningEffort
69
+ // into the intentGateProvider row; their `when` matches the carrier row
70
+ // by declaration). Field-less custom nodes (maintenance/permission
71
+ // panels) hang in the same tree. Keys marked page:false in
72
+ // src/settings/sections.js (CONFIG_FACE_ONLY_KEYS) never appear here —
73
+ // config-face-only by declaration, pinned by test.
68
74
  const GROUPS = [
69
75
  { id: "intent", fields: [
70
76
  { field: "intentGateClassifier", kind: "enum", values: ["regex", "llm", "jev"] },
71
77
  { field: "intentGateProvider", kind: "custom", slot: "modelPicker", when: CLASSIFIER_LLM },
72
78
  { field: "intentGateModel", kind: "text", folded: true, when: CLASSIFIER_LLM },
73
79
  { field: "intentGateReasoningEffort", kind: "text", folded: true, when: CLASSIFIER_LLM },
74
- { field: "intentGateTimeoutMs", kind: "number" },
80
+ { field: "intentGateTimeoutMs", kind: "number", when: CLASSIFIER_LLM_OR_JEV },
75
81
  { field: "jevEndpoint", kind: "text", when: CLASSIFIER_JEV },
76
82
  { field: "jevModel", kind: "text", when: CLASSIFIER_JEV },
77
83
  { field: "jevApiKeyEnv", kind: "text", when: CLASSIFIER_JEV }
78
84
  ] },
79
- { id: "delegate", fields: [
85
+ { id: "routingCategories", page: "routing", fields: [
80
86
  { field: "delegateCategoryChains", kind: "custom", slot: "chainEditor" },
81
- { field: "delegateAgentChains", kind: "custom", slot: "agentChainEditor" },
82
- { field: "delegateDisabledCategories", kind: "custom", slot: "disabledCategoriesEditor" },
87
+ { field: "delegateDisabledCategories", kind: "custom", slot: "disabledCategoriesEditor" }
88
+ ] },
89
+ { id: "routingAgents", page: "routing", fields: [
90
+ { field: "delegateAgentChains", kind: "custom", slot: "agentChainEditor" }
91
+ ] },
92
+ { id: "routingSupervision", page: "routing", fields: [
83
93
  { field: "supervisionMaxRetries", kind: "number" },
84
94
  { field: "supervisionInitialBackoffMs", kind: "number" },
85
95
  { field: "supervisionMaxBackoffMs", kind: "number" }
@@ -87,9 +97,7 @@ window.__ModuleLoader__.load({
87
97
  { id: "todo", fields: [
88
98
  { field: "todoEnabled", kind: "boolean" },
89
99
  { field: "todoMaxConsecutive", kind: "number", parent: "todoEnabled", when: enabledWhen("todoEnabled") },
90
- { field: "todoErrorRetryMax", kind: "number", parent: "todoEnabled", when: enabledWhen("todoEnabled") },
91
- { field: "todoErrorBackoffBaseMs", kind: "number", parent: "todoEnabled", when: enabledWhen("todoEnabled") },
92
- { field: "todoErrorBackoffCapMs", kind: "number", parent: "todoEnabled", when: enabledWhen("todoEnabled") }
100
+ { field: "todoErrorRetryMax", kind: "number", parent: "todoEnabled", when: enabledWhen("todoEnabled") }
93
101
  ] },
94
102
  { id: "guard", fields: [
95
103
  { field: "guardEnabled", kind: "boolean" },
@@ -101,11 +109,6 @@ window.__ModuleLoader__.load({
101
109
  { field: "editLockEnabled", kind: "boolean" },
102
110
  { field: "editLockAutoResume", kind: "boolean", parent: "editLockEnabled", when: enabledWhen("editLockEnabled") },
103
111
  { field: "editLockStaleSweep", kind: "boolean", parent: "editLockEnabled", when: enabledWhen("editLockEnabled") },
104
- { field: "editLockHoldDefaultMinutes", kind: "number", parent: "editLockEnabled", when: enabledWhen("editLockEnabled") },
105
- { field: "editLockHoldSingleMaxMinutes", kind: "number", parent: "editLockEnabled", when: enabledWhen("editLockEnabled") },
106
- { field: "editLockHoldCumulativeMaxMinutes", kind: "number", parent: "editLockEnabled", when: enabledWhen("editLockEnabled") },
107
- { field: "editLockNudgeAttempts", kind: "number", parent: "editLockEnabled", when: enabledWhen("editLockEnabled") },
108
- { field: "editLockNudgeFallback", kind: "text", parent: "editLockEnabled", when: enabledWhen("editLockEnabled") },
109
112
  // Diagnostics must remain reachable when enforcement is off.
110
113
  { kind: "custom", slot: "editLockMaintenance" }
111
114
  ] },
@@ -127,9 +130,6 @@ window.__ModuleLoader__.load({
127
130
  ] },
128
131
  { id: "lsp", fields: [
129
132
  { field: "lspEnabled", kind: "boolean" },
130
- { field: "lspIdleMs", kind: "number", parent: "lspEnabled", when: enabledWhen("lspEnabled") },
131
- { field: "lspRequestTimeoutMs", kind: "number", parent: "lspEnabled", when: enabledWhen("lspEnabled") },
132
- { field: "lspDiagnosticsWaitMs", kind: "number", parent: "lspEnabled", when: enabledWhen("lspEnabled") },
133
133
  // The raw lspServers JSON row is folded into the manager row: one
134
134
  // custom node carrying the field (the robashList pattern —
135
135
  // text-backed at the form layer via specFor), so the panel owns the
@@ -149,19 +149,25 @@ window.__ModuleLoader__.load({
149
149
  // confirms its platform (self-gating), so it stays unconditional
150
150
  // here even as a custom node in the tree.
151
151
  { kind: "custom", slot: "notifyPermissions" }
152
- ] },
153
- // Session blackboard (2.4a): the write-token TTL leaves
154
- // config-face-only and rides its own group. A single live number row
155
- // (volatile config — no restart marker, no master switch).
156
- { id: "blackboard", fields: [
157
- { field: "blackboardWriteTokenTtlMinutes", kind: "number" }
158
152
  ] }
159
153
  ];
160
154
  // Form fields: every node that names a flat settings key (custom
161
- // panels without a field render only). Order matches the pre-overhaul
162
- // flat table exactly — test pins this list.
155
+ // panels without a field render only), in GROUPS declaration order
156
+ // across both pages — test pins this list.
163
157
  const FIELDS = GROUPS.flatMap((group) => group.fields).filter((descriptor) => typeof descriptor.field === "string");
164
158
  const FIELD_BY_NAME = Object.fromEntries(FIELDS.map((descriptor) => [descriptor.field, descriptor]));
159
+ // The page one field's group belongs to ("main" when the group declares
160
+ // no page): the controller's form specs and projection cover exactly the
161
+ // page's fields, and WeirCard renders only the page's groups.
162
+ const FIELD_PAGE = new Map();
163
+ for (const group of GROUPS) {
164
+ for (const node of group.fields) {
165
+ if (typeof node.field === "string") FIELD_PAGE.set(node.field, group.page ?? "main");
166
+ }
167
+ }
168
+ function fieldsForPage(page) {
169
+ return FIELDS.filter((descriptor) => FIELD_PAGE.get(descriptor.field) === page);
170
+ }
165
171
  /** Product defaults of every flat settings key with a concrete
166
172
  * default — hand-maintained mirror of FIELD_DEFAULTS in
167
173
  * src/settings/sections.js (same-package sync require is impossible
@@ -352,8 +358,14 @@ window.__ModuleLoader__.load({
352
358
  }
353
359
  /** Fail-loud declaration validation, run at module load: unknown or
354
360
  * cross-group parents, parent cycles, conditions on unknown keys or
355
- * unknown env facts — every error names the offending key. */
361
+ * unknown env facts, an invalid group page — every error names the
362
+ * offending key or group. */
356
363
  function validateLayout(groups) {
364
+ for (const group of groups) {
365
+ if (group.page !== void 0 && group.page !== "main" && group.page !== "routing") {
366
+ throw settingsLayoutError(`group '${String(group.id)}' declares an invalid page '${String(group.page)}'`);
367
+ }
368
+ }
357
369
  const keyToGroup = new Map();
358
370
  for (const group of groups) {
359
371
  for (const node of group.fields) {
@@ -409,7 +421,7 @@ window.__ModuleLoader__.load({
409
421
  for (const child of node.children) assignDepth(child, depth + 1);
410
422
  };
411
423
  for (const root of roots) assignDepth(root, 0);
412
- return { id: group.id, roots };
424
+ return { id: group.id, page: group.page ?? "main", roots };
413
425
  });
414
426
  }
415
427
  // Declaration-time validation (D4): a bad GROUPS table fails chunk load
@@ -455,7 +467,12 @@ window.__ModuleLoader__.load({
455
467
  readOnly: t("readOnly"),
456
468
  saveFailed: t("saveFailed"),
457
469
  save: t("save"),
458
- saving: t("saving")
470
+ saving: t("saving"),
471
+ discard: t("discardChanges"),
472
+ // The save bar's pending count interpolates per render, so a
473
+ // function label: the host locale's second argument is the
474
+ // interpolation VARS object (never a fallback string).
475
+ pendingChanges: (n) => t("pendingChanges", { n })
459
476
  };
460
477
  }
461
478
  // ---- environment facts (design D6) ----
@@ -479,14 +496,19 @@ window.__ModuleLoader__.load({
479
496
  var WeirCardController = class {
480
497
  form;
481
498
  store;
482
- constructor(scope, deps) {
499
+ constructor(scope, deps, page) {
500
+ // page: "main" (default) or "routing" — the form model tracks only
501
+ // that page's fields, so one scope hosts two independent forms (the
502
+ // routing page edits the same namespace, disjoint key set).
503
+ this.page = page === "routing" ? "routing" : "main";
504
+ this.fields = fieldsForPage(this.page);
483
505
  // Prop-injected dependencies: the entry-owned settings bus (save →
484
506
  // toggle re-check) and a getSession closure resolving the
485
507
  // remote.session domain lazily at call time (it may not be wired
486
508
  // yet during apply, S17). fetchEnv is the injectable environment
487
509
  // facts read (tests); production uses the fetch above.
488
510
  this.deps = deps;
489
- this.form = new primitives.SettingsFormModel(scope, FIELDS.map(specFor));
511
+ this.form = new primitives.SettingsFormModel(scope, this.fields.map(specFor));
490
512
  this.store = this.form.bind(() => this.projection());
491
513
  // Restart-required keys touched by the last landed save, in
492
514
  // registry order; null until such a save lands or after the
@@ -518,7 +540,7 @@ window.__ModuleLoader__.load({
518
540
  }
519
541
  projection() {
520
542
  const fields = {};
521
- for (const descriptor of FIELDS) {
543
+ for (const descriptor of this.fields) {
522
544
  // `saved` rides along so condition evaluation can fall back
523
545
  // to the saved value while an unparseable draft is staged
524
546
  // (D3) — the merged field text alone cannot express it.
@@ -527,6 +549,9 @@ window.__ModuleLoader__.load({
527
549
  return {
528
550
  ...this.form.shell(),
529
551
  fields,
552
+ // Staged-write count for the save bar's pending label —
553
+ // plan() is a pure read (the same read save() plans from).
554
+ pendingCount: this.form.plan().length,
530
555
  restartReminder: this.restartReminder,
531
556
  env: this.env
532
557
  };
@@ -755,10 +780,65 @@ window.__ModuleLoader__.load({
755
780
  if (picker === void 0 || picker === null) return manualFallback;
756
781
  return react_jsx_runtime.jsx(picker.ModelPickerBoundary, { fallback: manualFallback, children: pickerRow(picker) });
757
782
  }
783
+ // ---- floating save bar + local form shell ----
784
+ // The bar renders ONLY while the form is dirty — a clean page gets no
785
+ // new visual noise. It sits in the normal flow as the form's last
786
+ // element with position: sticky, so once the page scrolls it pins to
787
+ // the bottom edge of the settings scroll container. Chrome mirrors the
788
+ // host's Toast surface (dark in BOTH themes) instead of the group cards:
789
+ // a floating action bar must read as a surface ABOVE the rows, not as
790
+ // one more row. Colors only from theme tokens, geometry hardcoded.
791
+ const saveBarStyle = { position: "sticky", bottom: "12px", zIndex: 1, display: "flex", alignItems: "center", justifyContent: "space-between", gap: "12px", marginTop: "12px", padding: "10px 14px", background: "var(--dsw-alias-toast-bg)", border: "none", borderRadius: "var(--dsw-radius-lg, 12px)", boxShadow: "var(--dsw-shadow-lv3, 0 8px 24px rgba(0,0,0,.18))" };
792
+ const saveBarTextStyle = { display: "flex", alignItems: "center", gap: "8px", minWidth: 0, fontSize: "12px", lineHeight: "16px", color: "var(--dsw-alias-toast-label)" };
793
+ // warn-label (not warn-primary) is the token designed for dark surfaces.
794
+ const saveBarFailedStyle = { color: "var(--dsw-alias-state-warn-label)" };
795
+ // Discard as a plain text action (the host toast's action pattern): the
796
+ // outline Button variant is tuned for regular surfaces and reads poorly
797
+ // on the always-dark toast surface.
798
+ const saveBarDiscardStyle = { background: "none", border: "none", cursor: "pointer", fontSize: "12px", lineHeight: "16px", padding: "4px 2px", color: "var(--dsw-static-deepseek-400, var(--dsw-alias-brand-primary))" };
799
+ const saveBarActionsStyle = { display: "flex", alignItems: "center", gap: "8px", flexShrink: 0 };
800
+ // The form-shell notice lines (unavailable / readOnly) keep the host
801
+ // contract's role="status" lines in the page's hint styling.
802
+ const formNoticeStyle = { fontSize: "13px", lineHeight: "18px", color: "var(--dsw-alias-label-secondary)", margin: "0 0 12px" };
803
+ /** Local form shell replacing the host primitives.SettingsForm (whose
804
+ * footer sits at the bottom of a long page and cannot be restyled from
805
+ * outside). The host contract is preserved verbatim: staged edits are
806
+ * discarded on unmount (the ref follows the latest onDiscard; hooks run
807
+ * before the early return so the cleanup registers in every state), an
808
+ * unavailable namespace renders the notice line INSTEAD of the form,
809
+ * and a read-only deployment shows the readOnly notice above the
810
+ * controls. The save affordance is the sticky bar: pending count +
811
+ * failure text left, discard/save right; invalid or saving disables
812
+ * save (the bar existing already implies dirty). */
813
+ function WeirSettingsForm(props) {
814
+ const { state, labels } = props;
815
+ const discardRef = react.useRef(props.onDiscard);
816
+ discardRef.current = props.onDiscard;
817
+ react.useEffect(() => () => discardRef.current(), []);
818
+ if (!state.available) return react_jsx_runtime.jsx("p", { style: formNoticeStyle, role: "status", children: labels.unavailable });
819
+ return react_jsx_runtime.jsxs("div", { children: [
820
+ !state.writable ? react_jsx_runtime.jsx("p", { style: formNoticeStyle, role: "status", children: labels.readOnly, key: "read-only" }) : null,
821
+ props.children,
822
+ state.dirty ? react_jsx_runtime.jsxs("div", { style: saveBarStyle, "data-save-bar": "", children: [
823
+ react_jsx_runtime.jsxs("div", { style: saveBarTextStyle, children: [
824
+ react_jsx_runtime.jsx("span", { children: labels.pendingChanges(state.pendingCount ?? 0), key: "pending" }),
825
+ state.failed ? react_jsx_runtime.jsx("span", { style: saveBarFailedStyle, role: "status", children: labels.saveFailed, key: "failed" }) : null
826
+ ] }),
827
+ react_jsx_runtime.jsxs("div", { style: saveBarActionsStyle, children: [
828
+ react_jsx_runtime.jsx("button", { type: "button", style: saveBarDiscardStyle, onClick: props.onDiscard, children: labels.discard }),
829
+ react_jsx_runtime.jsx(primitives.Button, { variant: "primary", size: "sm", disabled: state.invalid || state.saving, onClick: props.onSave, children: state.saving ? labels.saving : labels.save })
830
+ ] })
831
+ ] }, "save-bar") : null
832
+ ] });
833
+ }
758
834
  function WeirCard(props) {
759
835
  const state = props.useWeirSettingsCard((snapshot) => snapshot);
760
836
  const { t } = props;
761
- if (props.view === "summary") return t("description");
837
+ // Dual-page card: props.page ("main" default, "routing") selects the
838
+ // summary text and the rendered group subset; conditions/restartTag/
839
+ // displayText are page-independent.
840
+ const page = props.page === "routing" ? "routing" : "main";
841
+ if (props.view === "summary") return t(page === "routing" ? "routingDescription" : "description");
762
842
  const disabled = !state.writable;
763
843
  // env three-state (D6): pending/failed hide env-gated rows only.
764
844
  const env = state.env ?? { status: "ready", facts: {} };
@@ -893,7 +973,7 @@ window.__ModuleLoader__.load({
893
973
  // Card sections: the tree walk prunes hidden subtrees wholesale (a
894
974
  // hidden parent hides every descendant), and children land directly
895
975
  // under their parent's subtree in DFS order.
896
- const cards = LAYOUT.map((groupLayout) => {
976
+ const cards = LAYOUT.filter((groupLayout) => groupLayout.page === page).map((groupLayout) => {
897
977
  const rows = [];
898
978
  const walk = (node, ancestorsVisible) => {
899
979
  const isVisible = ancestorsVisible && visible(node);
@@ -931,7 +1011,9 @@ window.__ModuleLoader__.load({
931
1011
  key: "restart-reminder"
932
1012
  }));
933
1013
  }
934
- return react_jsx_runtime.jsxs(primitives.SettingsForm, {
1014
+ // The local form shell (NOT the host primitives.SettingsForm): same
1015
+ // contract, with the save affordance as the sticky bar above.
1016
+ return react_jsx_runtime.jsxs(WeirSettingsForm, {
935
1017
  labels: formLabels(t),
936
1018
  state,
937
1019
  onSave: props.save,
@@ -945,9 +1027,12 @@ window.__ModuleLoader__.load({
945
1027
  gap: "var(--dsw-spacing-3, 12px)"
946
1028
  };
947
1029
  const ITEM_SLOT = "settings.weir.item";
948
- const WeirSection = ({ renderSlot }) => react_jsx_runtime.jsx("div", {
1030
+ // The section shell is page-agnostic: the composition root passes the
1031
+ // child item slot of the page being hosted (main by default, the
1032
+ // routing page's "settings.weir-routing.item" for the routing section).
1033
+ const WeirSection = ({ renderSlot, slot }) => react_jsx_runtime.jsx("div", {
949
1034
  style: columnStyle,
950
- children: renderSlot(ITEM_SLOT)
1035
+ children: renderSlot(slot ?? ITEM_SLOT)
951
1036
  });
952
1037
  exports.GROUPS = GROUPS;
953
1038
  exports.BOOLEAN_DEFAULTS = BOOLEAN_DEFAULTS;
@@ -957,6 +1042,7 @@ window.__ModuleLoader__.load({
957
1042
  exports.CATEGORY_NAMES = CATEGORY_NAMES;
958
1043
  exports.RESTART_FIELDS = RESTART_FIELDS;
959
1044
  exports.FIELDS = FIELDS;
1045
+ exports.fieldsForPage = fieldsForPage;
960
1046
  exports.ENV_FACTS = ENV_FACTS;
961
1047
  exports.evaluateCondition = evaluateCondition;
962
1048
  exports.conditionUsesEnv = conditionUsesEnv;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weir-harness",
3
- "version": "1.1.6",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "Weir \u2014 orchestrator-centric DSH agent preset: intent gate, category delegation, todo driver, pull-based background jobs, auto-compaction, hashline edit.",
6
6
  "license": "MIT",
@@ -186,6 +186,19 @@ export async function resolveDurableRoot(ctx, agent, runtime, mount = undefined)
186
186
  if (mount) mounts.add(mount)
187
187
  runtime.members.set(agent.id, { agent, root: id, mounts })
188
188
  }
189
+ // A fork (a depth-0 Session carrying parentSession) is its own board root,
190
+ // but its board starts as a one-time snapshot of the fork source's durable
191
+ // root board: seed before the first ensure can materialize an empty sidecar.
192
+ // Once per runtime; the no-sidecar disk check keeps it idempotent across
193
+ // restarts. Best-effort: a failed seed never blocks board availability.
194
+ const parentId = header.parentSession
195
+ if (typeof parentId === 'string' && parentId.length > 0) {
196
+ runtime.forkSeeded ??= new Set()
197
+ if (!runtime.forkSeeded.has(id)) {
198
+ runtime.forkSeeded.add(id)
199
+ try { await runtime.storage.seedFork(id, parentId, where.header.cwd) } catch (error) { ctx.logger?.warn?.(`blackboard fork seeding failed: ${String(error)}`) }
200
+ }
201
+ }
189
202
  runtime.kernel.bind(id, where.header.cwd)
190
203
  await runtime.kernel.ensure(id)
191
204
  if (wasLive && (runtime.revoked?.has(agent) || ctx.get?.('agents')?.get?.(agent.id) !== agent)) throw new Error('blackboard: Agent disposed while resolving its root')
@@ -346,6 +346,50 @@ export function createBlackboardPlugin({ kernel, resolveTtl = resolveWriteTokenT
346
346
  return id
347
347
  }
348
348
 
349
+ /**
350
+ * The conversation root for a BARE session id (the remote bridge's
351
+ * resolve face — the side panel asks for the board it should bind
352
+ * before any lookup-gated verb runs). Same root semantics as boardOf
353
+ * and resolveDurableRoot: a depth-0 session (including a fork that
354
+ * still carries a parentSession) is its own root; a delegated child
355
+ * ascends the parentSession chain. Each hop reads the live sessions
356
+ * registry first, then the durable header through
357
+ * sessionQuery.readSession (the parentOf seam — a child between
358
+ * turns has no live registry entry). A hop that yields no header, a
359
+ * missing parent, a cycle, or the hop cap degrades to the deepest
360
+ * known ancestor: boards never silently cross-contaminate
361
+ * conversations.
362
+ * @param {string} sessionId
363
+ * @returns {Promise<string>}
364
+ */
365
+ const rootOf = async (sessionId) => {
366
+ const headerOf = async (/** @type {string} */ id) => {
367
+ try {
368
+ const live = ctx.get?.('sessions')?.get?.(id)
369
+ if (live?.header && typeof live.header === 'object') return live.header
370
+ } catch { /* fall through to the durable lookup */ }
371
+ try {
372
+ // readSession returns { session, events } — the session record IS
373
+ // the durable header (id, parentSession, delegationDepth, ...).
374
+ const read = await ctx.get?.('sessionQuery')?.readSession?.(id)
375
+ if (read?.session && typeof read.session === 'object') return read.session
376
+ } catch { /* an unreadable log yields no header */ }
377
+ return undefined
378
+ }
379
+ let id = sessionId
380
+ const seen = new Set([id])
381
+ for (let hop = 0; hop < MAX_ANCESTORS; hop++) {
382
+ const header = await headerOf(id)
383
+ if (!header) break
384
+ if ((header.delegationDepth ?? 0) === 0) return id
385
+ const parentId = header.parentSession
386
+ if (typeof parentId !== 'string' || parentId.length === 0 || seen.has(parentId)) break
387
+ seen.add(parentId)
388
+ id = parentId
389
+ }
390
+ return id
391
+ }
392
+
349
393
  const call = (exec, action) => {
350
394
  if (lifecycle) return lifecycle.run(exec?.agent, root => { exec?.signal?.throwIfAborted?.(); return action(root) })
351
395
  const id = boardOf(exec?.agent)
@@ -382,7 +426,7 @@ export function createBlackboardPlugin({ kernel, resolveTtl = resolveWriteTokenT
382
426
  resolveMainAgent: agent => agent,
383
427
  logger: ctx.logger,
384
428
  })
385
- const offBridge = feedBlackboardRemoteBridge({ kernel: kernelInstance, boardOf, run: lifecycle?.run, ttlMs, requestPromotion: async (/** @type {any} */ agent) => {
429
+ const offBridge = feedBlackboardRemoteBridge({ kernel: kernelInstance, boardOf, rootOf, run: lifecycle?.run, ttlMs, requestPromotion: async (/** @type {any} */ agent) => {
386
430
  const root = await boardOf(agent)
387
431
  const main = root === agent.id ? agent : ctx.get?.('agents')?.get?.(root)
388
432
  if (!main) throw new Error('blackboard: root Agent is not live for promotion')
@@ -16,11 +16,12 @@
16
16
  // WIRE CONTRACT (pinned — the parallel panel lane builds against this exact
17
17
  // shape; see docs/features/blackboard.md 面板数据通道):
18
18
  //
19
- // namespace `weirBlackboard`; every method takes one JSON `args` object.
20
- // The acting session rides the `agent` lookup parameter (wire `agentId`,
21
- // scope context `agent`) — the panel page's current session injects it the
22
- // same way the stock `fileReferences` remote does, so the domain signatures
23
- // below need no session field:
19
+ // namespace `weirBlackboard`. The seven board verbs each take one JSON
20
+ // `args` object, and the acting session rides the `agent` lookup
21
+ // parameter (wire `agentId`, scope context `agent`) — the panel page's
22
+ // current session injects it the same way the stock `fileReferences`
23
+ // remote does, so the domain signatures below need no session field.
24
+ // The eighth method, resolve, is the deliberate exception named below.
24
25
  //
25
26
  // - list({ type?, query? }) → { entries: [{ key, entryType, summary,
26
27
  // readCount, subscribeCount, updatedAt, promoted? }] }
@@ -40,6 +41,16 @@
40
41
  // - markPromoted({ key, destination }) → { ok: true, destination }
41
42
  // | { ok: false, error } — makes the entry
42
43
  // read-only with the promoted marker
44
+ // - resolve → { sessionId } — the panel's root
45
+ // resolution (slice 4): NO scope, NO agent
46
+ // lookup. The gateway's agent lookup refuses
47
+ // subagent-owned sessions before this service
48
+ // can run (the subagent-panel 404 root cause),
49
+ // so the browsing session id crosses as a bare
50
+ // JSON `agentId` parameter (source 'json' — the
51
+ // capability-remote precedent) and the answer
52
+ // is the conversation root whose board the
53
+ // panel binds before any lookup-gated verb.
43
54
  //
44
55
  // `token` is an opaque per-acquisition identifier (the authority itself is
45
56
  // the acting session's live kernel token, re-checked at every mutation);
@@ -56,7 +67,7 @@
56
67
  export const BLACKBOARD_REMOTE_SERVICE_KEY = 'weirBlackboardRemote'
57
68
  /** The wire namespace: POST /api/weirBlackboard/<method>, client ctx.remote.weirBlackboard.*. */
58
69
  export const BLACKBOARD_REMOTE_NAMESPACE = 'weirBlackboard'
59
- /** The seven methods, each taking one JSON `args` parameter plus the acting-session lookup. */
70
+ /** The seven board verbs, each taking one JSON `args` parameter plus the acting-session lookup. The eighth wire method, resolve, is built separately in the contribution (no lookup, no scope). */
60
71
  export const BLACKBOARD_REMOTE_METHODS = Object.freeze(['list', 'read', 'apply', 'write', 'remove', 'requestPromotion', 'markPromoted'])
61
72
  /** The wire package identity (distinct from 'weir-harness' — the capability-remote contribution already owns that package face). */
62
73
  const BLACKBOARD_REMOTE_PACKAGE = 'weir-blackboard'
@@ -90,6 +101,7 @@ export class BlackboardRemoteError extends Error {
90
101
  * module-level slot pattern as the capability read bridge. Faces:
91
102
  * kernel() — the shared arbitration kernel (list/read/apply/write/deleteKey)
92
103
  * boardOf(agent) — conversation-root board id for an agent handle
104
+ * rootOf(sessionId) — conversation-root session id for a BARE session id
93
105
  * ttlMs() — the settings-driven write-token TTL in milliseconds
94
106
  */
95
107
  let bridgeFaces = null
@@ -97,7 +109,7 @@ let bridgeFaces = null
97
109
  /**
98
110
  * Feed the bridge (blackboard plugin apply). Returns the unregister the plugin
99
111
  * disposes with its own lifecycle.
100
- * @param {{ kernel: any, boardOf: (agent: any) => string | undefined, ttlMs: () => number }} faces
112
+ * @param {{ kernel: any, boardOf: (agent: any) => string | undefined, rootOf?: (sessionId: string) => Promise<string>, ttlMs: () => number }} faces
101
113
  */
102
114
  export function feedBlackboardRemoteBridge(faces) {
103
115
  bridgeFaces = faces
@@ -155,7 +167,7 @@ export function createBlackboardRemoteService(dependencies = {}) {
155
167
  * @param {any} agent
156
168
  * @returns {{ sessionId: string, boardId: string, faces: { kernel: any, boardOf: Function, ttlMs: Function } }}
157
169
  */
158
- async function resolve(agent) {
170
+ async function resolveActing(agent) {
159
171
  if (!ensure()) throw new BlackboardRemoteError('channel-unavailable', 'blackboard remote is not registered with the gateway')
160
172
  const faces = bridgeFor()
161
173
  if (!faces) throw new BlackboardRemoteError('bridge-absent', 'blackboard remote is not offered (non-Weir preset or unmounted bridge)')
@@ -187,7 +199,7 @@ export function createBlackboardRemoteService(dependencies = {}) {
187
199
  /** Aggregate rows: key + entryType + summary + counts, never content. */
188
200
  async list(agent, args) {
189
201
  return payloadWrap('list', async () => {
190
- const { boardId, faces } = await resolve(agent)
202
+ const { boardId, faces } = await resolveActing(agent)
191
203
  const rows = await faces.kernel.list(boardId, { entryType: args?.type, query: args?.query })
192
204
  return { entries: rows.map((/** @type {any} */ entry) => wireEntry(entry)) }
193
205
  })
@@ -198,7 +210,7 @@ export function createBlackboardRemoteService(dependencies = {}) {
198
210
  * the agent-tool path's reuse evidence, not a page-view metric. */
199
211
  async read(agent, args) {
200
212
  return payloadWrap('read', async () => {
201
- const { boardId, faces } = await resolve(agent)
213
+ const { boardId, faces } = await resolveActing(agent)
202
214
  const result = await faces.kernel.read(boardId, args?.keys ?? [], { count: false })
203
215
  return { entries: result.found.map((/** @type {any} */ entry) => wireEntry(entry, { content: true })) }
204
216
  })
@@ -207,7 +219,7 @@ export function createBlackboardRemoteService(dependencies = {}) {
207
219
  /** Acquire the one-shot write authority for one key (auto-subscribes on contention; a promoted key refuses explicitly). */
208
220
  async apply(agent, args) {
209
221
  return payloadWrap('apply', async () => {
210
- const { sessionId, boardId, faces } = await resolve(agent)
222
+ const { sessionId, boardId, faces } = await resolveActing(agent)
211
223
  const result = await faces.kernel.apply(boardId, { holderId: sessionId, key: args?.key, ttlMs: faces.ttlMs() })
212
224
  if (result.status === 'contended') return { acquired: false, holder: result.holder, subscribed: true }
213
225
  if (result.status === 'promoted') return { acquired: false, promoted: true, destination: result.destination }
@@ -218,7 +230,7 @@ export function createBlackboardRemoteService(dependencies = {}) {
218
230
  /** Create or update one entry under the acting session's live write authority. */
219
231
  async write(agent, args) {
220
232
  try {
221
- const { sessionId, boardId, faces } = await resolve(agent)
233
+ const { sessionId, boardId, faces } = await resolveActing(agent)
222
234
  const result = await faces.kernel.write(boardId, {
223
235
  holderId: sessionId,
224
236
  key: args?.key,
@@ -249,7 +261,7 @@ export function createBlackboardRemoteService(dependencies = {}) {
249
261
  /** Delete one entry under the acting session's live write authority. */
250
262
  async remove(agent, args) {
251
263
  try {
252
- const { sessionId, boardId, faces } = await resolve(agent)
264
+ const { sessionId, boardId, faces } = await resolveActing(agent)
253
265
  const result = await faces.kernel.deleteKey(boardId, { holderId: sessionId, key: args?.key })
254
266
  if (result.status === 'missing') return { ok: false, error: `blackboard key "${result.key}" does not exist` }
255
267
  if (result.status === 'promoted') {
@@ -271,7 +283,7 @@ export function createBlackboardRemoteService(dependencies = {}) {
271
283
  /** Request the promotion-evaluation brief into the session's main agent (slice 3). Folds failures like the mutations. */
272
284
  async requestPromotion(agent, args) {
273
285
  try {
274
- const { faces } = await resolve(agent)
286
+ const { faces } = await resolveActing(agent)
275
287
  if (typeof faces.requestPromotion !== 'function') {
276
288
  return { ok: false, error: 'the blackboard bridge does not offer promotion requests' }
277
289
  }
@@ -288,7 +300,7 @@ export function createBlackboardRemoteService(dependencies = {}) {
288
300
  /** Mark one entry promoted under the user-adjudicated destination (slice 3); marking is an annotation, not a payload mutation. */
289
301
  async markPromoted(agent, args) {
290
302
  try {
291
- const { boardId, faces } = await resolve(agent)
303
+ const { boardId, faces } = await resolveActing(agent)
292
304
  const result = await faces.kernel.markPromoted(boardId, { key: args?.key, destination: args?.destination })
293
305
  if (result.status === 'missing') return { ok: false, error: `blackboard key "${result.key}" does not exist` }
294
306
  if (result.status === 'already-promoted') return { ok: true, destination: result.destination, alreadyPromoted: true }
@@ -300,6 +312,28 @@ export function createBlackboardRemoteService(dependencies = {}) {
300
312
  return { ok: false, error }
301
313
  }
302
314
  },
315
+
316
+ /**
317
+ * The panel's root resolution (slice 4): the browsing session id
318
+ * crosses as a bare JSON value — NO agent lookup, NO scope — because
319
+ * the gateway's lookup refuses subagent-owned sessions before this
320
+ * service can run. Answers the conversation root whose board the
321
+ * panel binds; a bridge without the rootOf face (an older blackboard
322
+ * plugin) or a face returning an unusable id degrades to the
323
+ * browsing id itself, which is exactly the pre-resolve behavior.
324
+ */
325
+ async resolve(sessionId) {
326
+ return payloadWrap('resolve', async () => {
327
+ if (!ensure()) throw new BlackboardRemoteError('channel-unavailable', 'blackboard remote is not registered with the gateway')
328
+ const faces = bridgeFor()
329
+ if (!faces) throw new BlackboardRemoteError('bridge-absent', 'blackboard remote is not offered (non-Weir preset or unmounted bridge)')
330
+ const id = typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : undefined
331
+ if (id === undefined) throw new BlackboardRemoteError('unknown-session', 'blackboard remote resolve: the browsing session identity is unavailable')
332
+ if (typeof faces.rootOf !== 'function') return { sessionId: id }
333
+ const root = await faces.rootOf(id)
334
+ return { sessionId: typeof root === 'string' && root.length > 0 ? root : id }
335
+ })
336
+ },
303
337
  }
304
338
  }
305
339
 
@@ -321,7 +355,8 @@ export function blackboardRemoteContribution() {
321
355
  face: 'host',
322
356
  schemas: [],
323
357
  model: { services: [], events: [], objects: [] },
324
- invocations: BLACKBOARD_REMOTE_METHODS.map(method => ({
358
+ invocations: [
359
+ ...BLACKBOARD_REMOTE_METHODS.map(method => ({
325
360
  id: `${BLACKBOARD_REMOTE_PACKAGE}.${BLACKBOARD_REMOTE_NAMESPACE}.${method}`,
326
361
  service: BLACKBOARD_REMOTE_SERVICE_KEY,
327
362
  namespace: BLACKBOARD_REMOTE_NAMESPACE,
@@ -334,6 +369,24 @@ export function blackboardRemoteContribution() {
334
369
  ],
335
370
  result: { mode: 'src-json' },
336
371
  })),
372
+ // The eighth invocation, resolve, deliberately breaks the shared
373
+ // shape: NO agent-lookup parameter, NO scope projection — the
374
+ // gateway's agent lookup refuses subagent-owned sessions before
375
+ // this service can run (the subagent-panel 404 root cause), so the
376
+ // browsing session id crosses as a bare JSON value on the same
377
+ // `agentId` wire field (the capability-remote precedent, src-json).
378
+ {
379
+ id: `${BLACKBOARD_REMOTE_PACKAGE}.${BLACKBOARD_REMOTE_NAMESPACE}.resolve`,
380
+ service: BLACKBOARD_REMOTE_SERVICE_KEY,
381
+ namespace: BLACKBOARD_REMOTE_NAMESPACE,
382
+ method: 'resolve',
383
+ invocation: { kind: 'direct' },
384
+ parameters: [
385
+ { name: 'sessionId', wire: 'agentId', source: 'json', codec: { mode: 'src-json' } },
386
+ ],
387
+ result: { mode: 'src-json' },
388
+ },
389
+ ],
337
390
  }
338
391
  }
339
392