weir-harness 1.1.5 → 1.1.7
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/icon.svg +1 -0
- package/lib/client.blackboard-model.js +35 -2
- package/lib/client.blackboard-panel.js +61 -9
- package/lib/client.edit-lock-panel.js +3 -2
- package/lib/client.js +382 -230
- package/lib/client.settings-page.js +132 -46
- package/lib/client.worktree-view.js +23 -8
- package/package.json +4 -2
- package/src/blackboard/durable.js +13 -0
- package/src/blackboard/index.js +45 -1
- package/src/blackboard/remote.js +69 -16
- package/src/blackboard/storage.js +56 -0
- package/src/edit-lock/admin-ledger.js +42 -6
- package/src/edit-lock/admin-recovery.js +4 -2
- package/src/edit-lock/binding-guidance.js +15 -4
- package/src/edit-lock/domain.js +14 -1
- package/src/edit-lock/index.js +194 -46
- package/src/edit-lock/lifecycle.js +74 -6
- package/src/edit-lock/manager.js +8 -3
- package/src/edit-lock/remote.js +23 -4
- package/src/edit-lock/view.js +8 -5
- package/src/settings/sections.js +26 -17
package/icon.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg viewBox="0 0 160 160" xmlns="http://www.w3.org/2000/svg"><rect width="160" height="160" rx="36" fill="#0A0A0B"/><g transform="translate(80 80) scale(.72) translate(-80 -84)"><polygon points="16,48 30,48 56,126 80,56 104,126 130,48 144,48 144,144 16,144" fill="#ECECE6"/><rect x="16" y="24" width="128" height="10" fill="#CCFF00"/></g></svg>
|
|
@@ -13,8 +13,13 @@ window.__ModuleLoader__.load({
|
|
|
13
13
|
// /api/weirBlackboard/<method> payload shape.
|
|
14
14
|
//
|
|
15
15
|
// WIRE CONTRACT (pinned by src/blackboard/remote.js): namespace
|
|
16
|
-
// weirBlackboard
|
|
17
|
-
// `agentId` lookup parameter plus one JSON `args` object. The
|
|
16
|
+
// weirBlackboard. The seven board verbs each take the acting session as
|
|
17
|
+
// the `agentId` lookup parameter plus one JSON `args` object. The eighth
|
|
18
|
+
// method, resolve, is the deliberate exception: the browsing session id
|
|
19
|
+
// crosses as a BARE JSON `agentId` (no lookup, no scope, no inner args)
|
|
20
|
+
// because the gateway's agent lookup refuses subagent-owned sessions
|
|
21
|
+
// before the service can run — resolve is how a subagent session page
|
|
22
|
+
// learns the conversation root whose board it binds. The channel
|
|
18
23
|
// rides the RAW gateway carrier exactly like the capability reads (the
|
|
19
24
|
// hand-written src-json contribution cannot mount through
|
|
20
25
|
// ctx.remote.$mount — its strict-codec gate rejects src-json codecs),
|
|
@@ -99,6 +104,33 @@ window.__ModuleLoader__.load({
|
|
|
99
104
|
};
|
|
100
105
|
}
|
|
101
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The panel's root resolution (slice 4): POST weirBlackboard/resolve
|
|
109
|
+
* with the browsing session id as a bare JSON `agentId` — NO inner
|
|
110
|
+
* args object, NO agent lookup (the gateway's lookup would refuse a
|
|
111
|
+
* subagent-owned session before the service runs). ok with a non-empty
|
|
112
|
+
* value.sessionId folds to { ok: true, sessionId }; every other shape
|
|
113
|
+
* (failure envelope, unusable payload, carrier throw) folds to
|
|
114
|
+
* { ok: false, error } so the panel can fall back to the browsing id.
|
|
115
|
+
*/
|
|
116
|
+
function resolveBoardSession(rawCall, sessionId) {
|
|
117
|
+
let pending;
|
|
118
|
+
try {
|
|
119
|
+
pending = Promise.resolve(rawCall("weirBlackboard/resolve", { args: { agentId: sessionId } }));
|
|
120
|
+
} catch (cause) {
|
|
121
|
+
return Promise.resolve({ ok: false, error: { code: "call-threw", message: cause instanceof Error ? cause.message : String(cause) } });
|
|
122
|
+
}
|
|
123
|
+
return pending.then((result) => {
|
|
124
|
+
const folded = foldRemoteResult(result);
|
|
125
|
+
if (folded.ok === true) {
|
|
126
|
+
const root = folded.value?.sessionId;
|
|
127
|
+
if (typeof root === "string" && root !== "") return { ok: true, sessionId: root };
|
|
128
|
+
return { ok: false, error: { code: "bad-payload", message: "resolve returned no usable session id" } };
|
|
129
|
+
}
|
|
130
|
+
return { ok: false, error: folded.error };
|
|
131
|
+
}, (cause) => ({ ok: false, error: { code: "call-threw", message: cause instanceof Error ? cause.message : String(cause) } }));
|
|
132
|
+
}
|
|
133
|
+
|
|
102
134
|
// ---- entries ----
|
|
103
135
|
|
|
104
136
|
/** Defensive normalization of one wire row (list or read shape; content only present on reads). */
|
|
@@ -358,6 +390,7 @@ window.__ModuleLoader__.load({
|
|
|
358
390
|
exports.WATCH_GIVE_UP_AFTER = WATCH_GIVE_UP_AFTER;
|
|
359
391
|
exports.foldRemoteResult = foldRemoteResult;
|
|
360
392
|
exports.createBlackboardChannel = createBlackboardChannel;
|
|
393
|
+
exports.resolveBoardSession = resolveBoardSession;
|
|
361
394
|
exports.entryRowOf = entryRowOf;
|
|
362
395
|
exports.listOutcomeOf = listOutcomeOf;
|
|
363
396
|
exports.readOutcomeOf = readOutcomeOf;
|
|
@@ -168,6 +168,13 @@ window.__ModuleLoader__.load({
|
|
|
168
168
|
minWidth: "16px",
|
|
169
169
|
textAlign: "center",
|
|
170
170
|
flex: "none"
|
|
171
|
+
},
|
|
172
|
+
parentNote: {
|
|
173
|
+
fontSize: "11px",
|
|
174
|
+
lineHeight: "16px",
|
|
175
|
+
color: MUTED,
|
|
176
|
+
flex: "none",
|
|
177
|
+
padding: "0 2px 4px"
|
|
171
178
|
}
|
|
172
179
|
},
|
|
173
180
|
row: {
|
|
@@ -481,20 +488,35 @@ window.__ModuleLoader__.load({
|
|
|
481
488
|
const [typeFilter, setTypeFilter] = react.useState("");
|
|
482
489
|
const [query, setQuery] = react.useState("");
|
|
483
490
|
const [promoting, setPromoting] = react.useState(false);
|
|
491
|
+
// The board the panel binds: null while resolveBoardSession runs, then
|
|
492
|
+
// the conversation root resolve names (a subagent session page cannot
|
|
493
|
+
// ride the gateway's agent lookup) or the browsing id itself when
|
|
494
|
+
// resolve fails — exactly the pre-resolve behavior.
|
|
495
|
+
const [boardSessionId, setBoardSessionId] = react.useState(null);
|
|
484
496
|
// Mirrors for async continuations (polls and call settles read the
|
|
485
497
|
// freshest state without stale-closure races).
|
|
486
498
|
const detailRef = react.useRef(null);
|
|
487
499
|
detailRef.current = detail;
|
|
488
500
|
const watchRef = react.useRef(null);
|
|
489
501
|
watchRef.current = watch;
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
502
|
+
// The channel keys on the RESOLVED board session, never the browsing
|
|
503
|
+
// id: while resolution runs it does not exist, and every list/poll/
|
|
504
|
+
// detail effect below gates on it being non-null.
|
|
505
|
+
const channel = react.useMemo(
|
|
506
|
+
() => (boardSessionId === null ? null : model.createBlackboardChannel(props.callBoard, boardSessionId)),
|
|
507
|
+
[props.callBoard, boardSessionId],
|
|
508
|
+
);
|
|
509
|
+
const refreshList = () => {
|
|
510
|
+
if (channel === null) return Promise.resolve();
|
|
511
|
+
return channel.list().then((result) => {
|
|
512
|
+
setBoard(model.listOutcomeOf(result));
|
|
513
|
+
});
|
|
514
|
+
};
|
|
494
515
|
const setDetailFor = (key, next) => {
|
|
495
516
|
if (detailRef.current?.key === key) setDetail(next);
|
|
496
517
|
};
|
|
497
518
|
const loadDetail = (key) => {
|
|
519
|
+
if (channel === null) return;
|
|
498
520
|
// Record the request synchronously: a fast-settling read must not
|
|
499
521
|
// be dropped by the setDetailFor guard before the next render
|
|
500
522
|
// re-mirrors the ref.
|
|
@@ -514,14 +536,32 @@ window.__ModuleLoader__.load({
|
|
|
514
536
|
setRoute({ name: "detail", key });
|
|
515
537
|
loadDetail(key);
|
|
516
538
|
};
|
|
517
|
-
//
|
|
539
|
+
// Root resolution precedes the channel: a subagent session page cannot
|
|
540
|
+
// pass the gateway's agent lookup, so the panel first asks resolve for
|
|
541
|
+
// the conversation root and binds THAT board; a failed resolve keeps
|
|
542
|
+
// the browsing id (the pre-resolve behavior). While the answer is
|
|
543
|
+
// pending the board resets to the loading state; the alive flag drops
|
|
544
|
+
// a superseded answer after a session switch.
|
|
545
|
+
react.useEffect(() => {
|
|
546
|
+
let alive = true;
|
|
547
|
+
setBoardSessionId(null);
|
|
548
|
+
setBoard(null);
|
|
549
|
+
model.resolveBoardSession(props.callBoard, props.sessionId).then((result) => {
|
|
550
|
+
if (!alive) return;
|
|
551
|
+
setBoardSessionId(result.ok === true ? result.sessionId : props.sessionId);
|
|
552
|
+
});
|
|
553
|
+
return () => { alive = false; };
|
|
554
|
+
}, [props.callBoard, props.sessionId]);
|
|
555
|
+
// Initial listing + (re)bind: runs once the resolved channel exists
|
|
556
|
+
// and again whenever the answer swapped it.
|
|
518
557
|
react.useEffect(() => {
|
|
558
|
+
if (channel === null) return undefined;
|
|
519
559
|
let alive = true;
|
|
520
560
|
channel.list().then((result) => {
|
|
521
561
|
if (alive) setBoard(model.listOutcomeOf(result));
|
|
522
562
|
});
|
|
523
563
|
return () => { alive = false; };
|
|
524
|
-
}, [
|
|
564
|
+
}, [channel]);
|
|
525
565
|
// Page visibility: polls pause while hidden (no server push in this
|
|
526
566
|
// slice — the interval is the only refresh channel).
|
|
527
567
|
react.useEffect(() => {
|
|
@@ -531,15 +571,15 @@ window.__ModuleLoader__.load({
|
|
|
531
571
|
return () => document.removeEventListener("visibilitychange", onVisibility);
|
|
532
572
|
}, []);
|
|
533
573
|
react.useEffect(() => {
|
|
534
|
-
if (!visible || !(pollMs > 0)) return undefined;
|
|
574
|
+
if (!visible || !(pollMs > 0) || channel === null) return undefined;
|
|
535
575
|
const timer = setInterval(refreshList, pollMs);
|
|
536
576
|
return () => clearInterval(timer);
|
|
537
|
-
}, [visible, pollMs,
|
|
577
|
+
}, [visible, pollMs, channel]);
|
|
538
578
|
// The contention watch: poll apply until the key frees (an acquired
|
|
539
579
|
// flip means THIS session now holds the authority — the one-shot
|
|
540
580
|
// subscription's client-side half).
|
|
541
581
|
react.useEffect(() => {
|
|
542
|
-
if (!visible || watch?.phase !== "watching" || !(watchMs > 0)) return undefined;
|
|
582
|
+
if (!visible || watch?.phase !== "watching" || !(watchMs > 0) || channel === null) return undefined;
|
|
543
583
|
const timer = setInterval(() => {
|
|
544
584
|
channel.apply(watch.key).then((result) => {
|
|
545
585
|
const next = model.watchAfterPoll(watchRef.current, model.applyOutcomeOf(result));
|
|
@@ -587,6 +627,7 @@ window.__ModuleLoader__.load({
|
|
|
587
627
|
setRoute({ name: "editor", mode: "create" });
|
|
588
628
|
};
|
|
589
629
|
const startEdit = (entry) => {
|
|
630
|
+
if (channel === null) return;
|
|
590
631
|
if (entry.promoted) {
|
|
591
632
|
setNotice({ tone: "warn", text: t("blackboard.promotedReadonly", "This entry is promoted and read-only.") });
|
|
592
633
|
return;
|
|
@@ -611,6 +652,7 @@ window.__ModuleLoader__.load({
|
|
|
611
652
|
};
|
|
612
653
|
// ---- promotion request (3.1): the button pushes the evaluation brief into the main agent ----
|
|
613
654
|
const requestPromotionNow = () => {
|
|
655
|
+
if (channel === null) return;
|
|
614
656
|
if (promoting) return;
|
|
615
657
|
setNotice(null);
|
|
616
658
|
setPromoting(true);
|
|
@@ -626,6 +668,7 @@ window.__ModuleLoader__.load({
|
|
|
626
668
|
});
|
|
627
669
|
};
|
|
628
670
|
const reacquire = () => {
|
|
671
|
+
if (channel === null) return;
|
|
629
672
|
if (editor?.mode !== "update") return;
|
|
630
673
|
const key = editor.key;
|
|
631
674
|
setEditor({ ...editor, phase: "applying", error: null, contended: null });
|
|
@@ -643,6 +686,7 @@ window.__ModuleLoader__.load({
|
|
|
643
686
|
});
|
|
644
687
|
};
|
|
645
688
|
const saveEditor = () => {
|
|
689
|
+
if (channel === null) return;
|
|
646
690
|
if (editor === null || editor.draft === null || editor.phase === "saving" || editor.phase === "applying") return;
|
|
647
691
|
const errors = model.editorErrorsOf(editor.draft);
|
|
648
692
|
if (Object.keys(errors).length > 0) {
|
|
@@ -694,6 +738,7 @@ window.__ModuleLoader__.load({
|
|
|
694
738
|
writeNow();
|
|
695
739
|
};
|
|
696
740
|
const startDelete = (key) => {
|
|
741
|
+
if (channel === null) return;
|
|
697
742
|
setNotice(null);
|
|
698
743
|
setConfirmDelete({ key, phase: "applying", error: null });
|
|
699
744
|
channel.apply(key).then((result) => {
|
|
@@ -711,6 +756,7 @@ window.__ModuleLoader__.load({
|
|
|
711
756
|
});
|
|
712
757
|
};
|
|
713
758
|
const confirmDeleteNow = () => {
|
|
759
|
+
if (channel === null) return;
|
|
714
760
|
if (confirmDelete?.phase !== "confirm") return;
|
|
715
761
|
const key = confirmDelete.key;
|
|
716
762
|
setConfirmDelete({ key, phase: "removing", error: null });
|
|
@@ -1181,6 +1227,11 @@ window.__ModuleLoader__.load({
|
|
|
1181
1227
|
]
|
|
1182
1228
|
})
|
|
1183
1229
|
: null;
|
|
1230
|
+
// The parent-board note: the resolved root differs from the browsing
|
|
1231
|
+
// session — this page is a subagent session showing its parent's board.
|
|
1232
|
+
const parentBoardNote = route.name === "list" && boardSessionId !== null && boardSessionId !== props.sessionId
|
|
1233
|
+
? react_jsx_runtime.jsx("div", { style: S.list.parentNote, children: t("blackboard.parentBoard", "Showing the parent conversation's board.") })
|
|
1234
|
+
: null;
|
|
1184
1235
|
const scrollBody = route.name === "editor"
|
|
1185
1236
|
? react_jsx_runtime.jsx("div", { style: S.form.box, children: editorBody })
|
|
1186
1237
|
: route.name === "detail"
|
|
@@ -1189,6 +1240,7 @@ window.__ModuleLoader__.load({
|
|
|
1189
1240
|
return react_jsx_runtime.jsxs("div", { style: S.frame, children: [
|
|
1190
1241
|
header,
|
|
1191
1242
|
searchRow,
|
|
1243
|
+
parentBoardNote,
|
|
1192
1244
|
scrollBody,
|
|
1193
1245
|
footerChips.length > 0 ? react_jsx_runtime.jsx("div", { style: S.footer.bar, children: footerChips }) : null
|
|
1194
1246
|
] });
|
|
@@ -88,7 +88,7 @@ window.__ModuleLoader__.load({
|
|
|
88
88
|
// Continue editing is one human action: restore authority, then confirm the
|
|
89
89
|
// retained files (each through the ordinary per-file check).
|
|
90
90
|
// confirm --all only when something waits, so no empty "Confirmed 0 of 0" is recorded.
|
|
91
|
-
if (state === "stopped") return { verbs: view.files.some((file) => file.mine && file.status === "user-interrupted") ? ["resume", "confirm --all"] : ["resume"], label: "editLockResume" };
|
|
91
|
+
if (state === "stopped" || state === "revoked") return { verbs: view.files.some((file) => file.mine && file.status === "user-interrupted") ? ["resume", "confirm --all"] : ["resume"], label: "editLockResume" };
|
|
92
92
|
if (state === "confirm") return { verb: "confirm --all", label: "editLockConfirmAll" };
|
|
93
93
|
if (state === "holding" && view.ownCount > 0) return { verbs: view.files.filter((file) => file.mine && file.action === "release").map(rowCommand), label: "editLockReleaseAll" };
|
|
94
94
|
return null;
|
|
@@ -209,7 +209,8 @@ window.__ModuleLoader__.load({
|
|
|
209
209
|
] }, "head"),
|
|
210
210
|
view?.reason && !bindingRefusal ? jsx("div", { style: { ...errorStyle, wordBreak: "break-word" }, "data-weir-edit-lock-reason": "", children: view.reason }, "reason") : null,
|
|
211
211
|
view?.bindingFailure ? jsx("div", { style: mutedStyle, "data-weir-edit-lock-binding": "", children: t(`editLockBinding_${view.bindingFailure.holderKind ?? "unknown"}`) }, "binding") : null,
|
|
212
|
-
state === "revoked" ? jsx("div", { style: { ...mutedStyle, marginTop: "4px" }, "data-weir-edit-lock-revoked": "", children: t("editLockRevokedHint") }, "revoked") : null,
|
|
212
|
+
state === "revoked" && !cold ? jsx("div", { style: { ...mutedStyle, marginTop: "4px" }, "data-weir-edit-lock-revoked": "", children: t("editLockRevokedHint") }, "revoked") : null,
|
|
213
|
+
cold && state === "revoked" ? jsx("div", { style: { ...mutedStyle, marginTop: "4px" }, "data-weir-edit-lock-cold": "", children: t(view.autoResume === false ? "editLockColdRevokedManual" : "editLockColdRevokedAuto") }, "cold") : null,
|
|
213
214
|
cold && state === "stopped" ? jsx("div", { style: { ...mutedStyle, marginTop: "4px" }, "data-weir-edit-lock-cold": "", children: t(view.autoResume === false ? "editLockColdResumeManual" : "editLockColdResumeAuto") }, "cold") : null,
|
|
214
215
|
// Usage is historical; only the live driver's explicit deadline signals a pause.
|
|
215
216
|
// Follow-up locale keys: editLockCleanupOnly, editLockRecoveryUsage,
|