weir-harness 1.1.5 → 1.1.6
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.edit-lock-panel.js +3 -2
- package/lib/client.js +129 -8
- package/lib/client.worktree-view.js +23 -8
- package/package.json +4 -2
- package/src/edit-lock/admin-ledger.js +42 -6
- package/src/edit-lock/admin-recovery.js +4 -2
- package/src/edit-lock/domain.js +2 -1
- package/src/edit-lock/index.js +66 -28
- package/src/edit-lock/lifecycle.js +20 -4
- package/src/edit-lock/manager.js +8 -3
- package/src/edit-lock/view.js +8 -5
- package/src/settings/sections.js +1 -1
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>
|
|
@@ -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,
|
package/lib/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Weir client chunks: {"client.blackboard-model.js":"66fd9fc5438477d6d7ff156631f810a647ce352612a16051e987e8cd449bc4c9","client.blackboard-panel.js":"f961e0242dad7bf311829a25e265bf091925711b2fb9c19d2141997fbdf792b5","client.capability-badge.js":"6ffd703f498e0aa5cc8ac78f244402ec4ceee56d22d2a0e74c246799565776cc","client.capability-manager.js":"d8d1076e1b624c1dfc0834ad393c05fd7d9ee35004cd2fb34e5e5ba83f44c902","client.capability-model.js":"d0ad248c4a6defbc90f79c8c0e7904639779d47927874b8663c15c6cecb9d54a","client.capability-presets.js":"12ac8da3568d013abd76b67cfe473a329f4af2feaac9cd55a82d8881e5bc1017","client.chain-editor.js":"1e9a33bc68cabc43bc27b91ef83eab5028a33256596c147e15722d684687765b","client.chain-model.js":"81cdd04626900a63c38ccb3d9c4d117686c6f8a5da2ce9131b7aaf2f089dea77","client.disabled-categories-editor.js":"d4606a3172b47b25caf76fd64c8420a0079cd5f008bc67a9c71b3f146724e393","client.edit-lock-maintenance.js":"f5dca1a7658e2f536b52d265c49ab37bb5d2df6bcd6cf21826b843c9554311a8","client.edit-lock-panel.js":"
|
|
1
|
+
// Weir client chunks: {"client.blackboard-model.js":"66fd9fc5438477d6d7ff156631f810a647ce352612a16051e987e8cd449bc4c9","client.blackboard-panel.js":"f961e0242dad7bf311829a25e265bf091925711b2fb9c19d2141997fbdf792b5","client.capability-badge.js":"6ffd703f498e0aa5cc8ac78f244402ec4ceee56d22d2a0e74c246799565776cc","client.capability-manager.js":"d8d1076e1b624c1dfc0834ad393c05fd7d9ee35004cd2fb34e5e5ba83f44c902","client.capability-model.js":"d0ad248c4a6defbc90f79c8c0e7904639779d47927874b8663c15c6cecb9d54a","client.capability-presets.js":"12ac8da3568d013abd76b67cfe473a329f4af2feaac9cd55a82d8881e5bc1017","client.chain-editor.js":"1e9a33bc68cabc43bc27b91ef83eab5028a33256596c147e15722d684687765b","client.chain-model.js":"81cdd04626900a63c38ccb3d9c4d117686c6f8a5da2ce9131b7aaf2f089dea77","client.disabled-categories-editor.js":"d4606a3172b47b25caf76fd64c8420a0079cd5f008bc67a9c71b3f146724e393","client.edit-lock-maintenance.js":"f5dca1a7658e2f536b52d265c49ab37bb5d2df6bcd6cf21826b843c9554311a8","client.edit-lock-panel.js":"54f7a21b79adc2b91803c61d92b0dad72d95d5d320fdf35433719cefd0a6c46d","client.hash-edit-model.js":"99df87cc3cc261abd160b83124b5ce726d0708f3c87eba9d58ad9e5a1f41bae9","client.hash-edit-view.js":"fc0f4dabddab9a48527553ad4232b3715e46e6863aab75e02ae2dcd6687265c2","client.lsp-model.js":"6b5bead2900289427a1ad723b9c7bea85af4c5547029e486c620ff94ece24775","client.lsp-panel.js":"39211da7658e0810abc0984b8bcbbfb4ee6a6299fedfd21649ded1ab39d1f291","client.lsp-toggle.js":"4d960984ccf1045c617be6d282665513a9cf636779563a276973b55036a59fb6","client.model-picker.js":"57f96826e00b37ae6d6474a6750f31b4e128e5fdb37e302875a41cdb2e18f14a","client.notify-permissions.js":"c1c103ce5976a4032b62e12b3b3ecb907a574159bf89512539c68c4886ea37a7","client.notify-web.js":"b2ccf831524be4e94c532324cb5495793d4424e3b78d6014d2a3f3d20e9c5871","client.robash-editor.js":"cd6a283b72cb02598e32ca4ce03a1b8de5f328ecdd1f5f920377524f3bdcbe1c","client.robash-model.js":"356ee7a679223bcd351720b2bc9e7300e390d97a60d1c8d75998706b4644923b","client.settings-page.js":"3f33da4ce1676721e38e5437653a2d706948ddb3f2afc6507e03d79a596339b9","client.worktree-model.js":"819f1b00650aa0bb01140690b29b4a1f09b9d26e6bb73adc7efbb3a08e215766","client.worktree-view.js":"356c3f5f683b8509ae8679243a6460ca02957fd15e057a629b2ac4fdd12ed057"}
|
|
2
2
|
window.__ModuleLoader__.load({
|
|
3
3
|
id: "weir-harness",
|
|
4
4
|
factory: (require) => {
|
|
@@ -183,8 +183,10 @@ window.__ModuleLoader__.load({
|
|
|
183
183
|
editLockState_confirm: "Waiting for you to continue",
|
|
184
184
|
editLockState_stopped: "Editing stopped",
|
|
185
185
|
editLockState_attention: "Needs your attention",
|
|
186
|
-
editLockState_revoked: "Editing
|
|
187
|
-
editLockRevokedHint: "An administrative recovery
|
|
186
|
+
editLockState_revoked: "Editing revoked by an administrator",
|
|
187
|
+
editLockRevokedHint: "An administrative recovery revoked this session's editing authority and released its locks. Continue editing here, or send any message — editing resumes automatically in a new execution epoch; the revocation record is preserved.",
|
|
188
|
+
editLockColdRevokedAuto: "An administrative recovery revoked this session's editing authority and released its locks. Send any message in this session — the binding and editing resume automatically.",
|
|
189
|
+
editLockColdRevokedManual: "An administrative recovery revoked this session's editing authority and released its locks. Send any message to activate the session, then continue editing from this panel.",
|
|
188
190
|
editLockColdResumeAuto: "This session was restored after a restart and is stopped. Send any message — editing resumes automatically, with the files below confirmed again.",
|
|
189
191
|
editLockColdResumeManual: "This session was restored after a restart and is stopped. Send any message to activate the session, then continue editing from this panel.",
|
|
190
192
|
editLockState_unavailable: "Edit Lock is starting",
|
|
@@ -725,6 +727,7 @@ window.__ModuleLoader__.load({
|
|
|
725
727
|
blackboardTabLabel: "Blackboard",
|
|
726
728
|
blackboardGuideTitle: "Session blackboard",
|
|
727
729
|
blackboardGuideDescription: "Findings shared across this session: entries, usage counts, arbitrated editing.",
|
|
730
|
+
blackboardPillTitle: "Open the session blackboard",
|
|
728
731
|
"blackboard.title": "Blackboard",
|
|
729
732
|
"blackboard.entries": "{n} entries",
|
|
730
733
|
"blackboard.loading": "Loading the board…",
|
|
@@ -942,8 +945,10 @@ window.__ModuleLoader__.load({
|
|
|
942
945
|
editLockState_confirm: "等你确认继续",
|
|
943
946
|
editLockState_stopped: "编辑已停止",
|
|
944
947
|
editLockState_attention: "需要你处理",
|
|
945
|
-
editLockState_revoked: "
|
|
946
|
-
editLockRevokedHint: "
|
|
948
|
+
editLockState_revoked: "编辑权已被管理员吊销",
|
|
949
|
+
editLockRevokedHint: "管理员恢复已吊销本会话的编辑权并释放其锁。点击「继续编辑」或发送任意消息,即在新执行纪元恢复编辑;吊销记录保留。",
|
|
950
|
+
editLockColdRevokedAuto: "管理员恢复已吊销本会话的编辑权并释放其锁。在本会话发送任意消息——连接与编辑会自动恢复。",
|
|
951
|
+
editLockColdRevokedManual: "管理员恢复已吊销本会话的编辑权并释放其锁。发送任意消息激活会话,然后回到此面板点「继续编辑」。",
|
|
947
952
|
editLockColdResumeAuto: "重启后恢复的会话,编辑已停止。发送任意消息即自动恢复编辑(下列文件会一并确认)。",
|
|
948
953
|
editLockColdResumeManual: "重启后恢复的会话,编辑已停止。发送任意消息激活会话,然后回到此面板点「继续编辑」。",
|
|
949
954
|
editLockState_unavailable: "编辑锁启动中",
|
|
@@ -1481,6 +1486,7 @@ window.__ModuleLoader__.load({
|
|
|
1481
1486
|
blackboardTabLabel: "黑板",
|
|
1482
1487
|
blackboardGuideTitle: "会话黑板",
|
|
1483
1488
|
blackboardGuideDescription: "本会话共享的发现:条目、用量计数与仲裁编辑。",
|
|
1489
|
+
blackboardPillTitle: "打开会话黑板",
|
|
1484
1490
|
"blackboard.title": "黑板",
|
|
1485
1491
|
"blackboard.entries": "{n} 条",
|
|
1486
1492
|
"blackboard.loading": "正在加载黑板…",
|
|
@@ -1639,6 +1645,88 @@ window.__ModuleLoader__.load({
|
|
|
1639
1645
|
if (!arrival?.chunks) return null;
|
|
1640
1646
|
return react_jsx_runtime.jsx(arrival.chunks.EditLockPanel, props);
|
|
1641
1647
|
}
|
|
1648
|
+
// ---- Tab glyphs (ui-entries-and-icons) ----
|
|
1649
|
+
// Three 16x16 stroke glyphs drawn in currentColor, matching the host
|
|
1650
|
+
// CompassGlyph/CubeGlyph style (no strokeWidth, rounded caps/joins).
|
|
1651
|
+
// The guide capsule icon and the tab chip title are registered eagerly
|
|
1652
|
+
// at apply time, so the glyph components MUST live inline in this
|
|
1653
|
+
// entry — a lazy chunk would flash the host's placeholder cube.
|
|
1654
|
+
/** @param {object} props @param {Array} children */
|
|
1655
|
+
function glyphSvg(props, children) {
|
|
1656
|
+
const size = props.size ?? 16;
|
|
1657
|
+
return react_jsx_runtime.jsx("svg", {
|
|
1658
|
+
width: size, height: size, viewBox: "0 0 16 16", fill: "none",
|
|
1659
|
+
"aria-hidden": "true", className: props.className, style: props.style,
|
|
1660
|
+
stroke: "currentColor", strokeLinecap: "round", strokeLinejoin: "round",
|
|
1661
|
+
children
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
/** Worktree: git-branch fork (same geometry as the panel chunk's branch icon). */
|
|
1665
|
+
function WorktreeGlyph(props) {
|
|
1666
|
+
return glyphSvg(props, [
|
|
1667
|
+
react_jsx_runtime.jsx("circle", { cx: 4.5, cy: 3, r: 1.8 }, "c1"),
|
|
1668
|
+
react_jsx_runtime.jsx("circle", { cx: 4.5, cy: 13, r: 1.8 }, "c2"),
|
|
1669
|
+
react_jsx_runtime.jsx("circle", { cx: 11.5, cy: 5.5, r: 1.8 }, "c3"),
|
|
1670
|
+
react_jsx_runtime.jsx("path", { d: "M4.5 4.8v6.4M4.5 11.2c2.8 0 7-.8 7-3.9" }, "p")
|
|
1671
|
+
]);
|
|
1672
|
+
}
|
|
1673
|
+
/** Capabilities: two horizontal sliders. */
|
|
1674
|
+
function CapabilityGlyph(props) {
|
|
1675
|
+
return glyphSvg(props, [
|
|
1676
|
+
react_jsx_runtime.jsx("path", { d: "M2.4 5.2h11.2M2.4 10.8h11.2" }, "l"),
|
|
1677
|
+
react_jsx_runtime.jsx("circle", { cx: 6, cy: 5.2, r: 1.7 }, "c1"),
|
|
1678
|
+
react_jsx_runtime.jsx("circle", { cx: 10, cy: 10.8, r: 1.7 }, "c2")
|
|
1679
|
+
]);
|
|
1680
|
+
}
|
|
1681
|
+
/** Blackboard: a board rectangle with list lines. */
|
|
1682
|
+
function BlackboardGlyph(props) {
|
|
1683
|
+
return glyphSvg(props, [
|
|
1684
|
+
react_jsx_runtime.jsx("rect", { x: 2, y: 2.6, width: 12, height: 10.8, rx: 1.6 }, "r"),
|
|
1685
|
+
react_jsx_runtime.jsx("path", { d: "M4.6 5.8h4.2M4.6 8h6.8M4.6 10.2h5.4" }, "l")
|
|
1686
|
+
]);
|
|
1687
|
+
}
|
|
1688
|
+
// Tab chip title: icon + the host-provided tab title (the host's own
|
|
1689
|
+
// GuideTitle renders glyph + tab.title the same way). Icon styling is
|
|
1690
|
+
// inline — the shell's CSS-module classes are unreachable from a bundle.
|
|
1691
|
+
const tabChipIconStyle = { marginRight: "5px", color: "var(--dsw-alias-label-tertiary)", flex: "none", display: "inline-block", verticalAlign: "-2px" };
|
|
1692
|
+
const tabChipTitle = (Glyph) => {
|
|
1693
|
+
function WeirTabChipTitle({ useTabInfo }) {
|
|
1694
|
+
return react_jsx_runtime.jsxs(react_jsx_runtime.Fragment, { children: [
|
|
1695
|
+
react_jsx_runtime.jsx(Glyph, { size: 12, style: tabChipIconStyle }, "icon"),
|
|
1696
|
+
useTabInfo().tab.title
|
|
1697
|
+
] });
|
|
1698
|
+
}
|
|
1699
|
+
return WeirTabChipTitle;
|
|
1700
|
+
};
|
|
1701
|
+
// Blackboard session-header quick entry (D2): a bordered badge pill — glyph
|
|
1702
|
+
// plus the localized tab label — so it reads as a control next to the
|
|
1703
|
+
// worktree pill. No live lane state to poll, so no chunk and no model; hover
|
|
1704
|
+
// paint follows the capability badge's inline-style pattern.
|
|
1705
|
+
const blackboardPillStyle = {
|
|
1706
|
+
display: "inline-flex", alignItems: "center", gap: "5px",
|
|
1707
|
+
padding: "1px 7px", fontSize: "11px", lineHeight: "16px", fontWeight: 600,
|
|
1708
|
+
color: "var(--dsw-alias-label-secondary)", cursor: "pointer", background: "none",
|
|
1709
|
+
border: "1px solid var(--dsw-alias-border-l3, rgba(127,127,127,.35))",
|
|
1710
|
+
borderRadius: "var(--dsw-radius-sm)"
|
|
1711
|
+
};
|
|
1712
|
+
function BlackboardPill(props) {
|
|
1713
|
+
const title = typeof props.t === "function" ? props.t("blackboardPillTitle") : "Open the session blackboard";
|
|
1714
|
+
const label = typeof props.t === "function" ? props.t("blackboardTabLabel") : "Blackboard";
|
|
1715
|
+
return react_jsx_runtime.jsxs("button", {
|
|
1716
|
+
type: "button",
|
|
1717
|
+
style: blackboardPillStyle,
|
|
1718
|
+
onClick: () => props.openPanel?.(),
|
|
1719
|
+
onMouseEnter: (event) => { event.currentTarget.style.background = "var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.10))"; },
|
|
1720
|
+
onMouseLeave: (event) => { event.currentTarget.style.background = "none"; },
|
|
1721
|
+
title,
|
|
1722
|
+
"aria-label": title,
|
|
1723
|
+
"data-weir-blackboard-pill": "",
|
|
1724
|
+
children: [
|
|
1725
|
+
react_jsx_runtime.jsx(BlackboardGlyph, { size: 11 }, "icon"),
|
|
1726
|
+
react_jsx_runtime.jsx("span", { children: label }, "label")
|
|
1727
|
+
]
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1642
1730
|
// ---- Worktree lanes surfaces ----
|
|
1643
1731
|
// One model chunk plus one view chunk carry every lane surface; the
|
|
1644
1732
|
// composition root binds them to the ctx closures below. A session needs
|
|
@@ -1924,7 +2012,8 @@ window.__ModuleLoader__.load({
|
|
|
1924
2012
|
id: "worktree",
|
|
1925
2013
|
order: 30,
|
|
1926
2014
|
title: () => sidebarScope.locale.bind(NS)("worktreeGuideTitle"),
|
|
1927
|
-
description: () => sidebarScope.locale.bind(NS)("worktreeGuideDescription")
|
|
2015
|
+
description: () => sidebarScope.locale.bind(NS)("worktreeGuideDescription"),
|
|
2016
|
+
icon: WorktreeGlyph
|
|
1928
2017
|
}]
|
|
1929
2018
|
}), "ui-weir-settings: worktree tab type");
|
|
1930
2019
|
sidebarScope.effect(() => sidebarScope.slots.register({
|
|
@@ -1939,6 +2028,11 @@ window.__ModuleLoader__.load({
|
|
|
1939
2028
|
commandsList: () => worktreeCommandsList(sessionId)
|
|
1940
2029
|
} : {})
|
|
1941
2030
|
}, WorktreePanelWrapper), "ui-weir-settings: worktree panel body");
|
|
2031
|
+
sidebarScope.effect(() => sidebarScope.slots.register({
|
|
2032
|
+
name: "sidebar.right.pane.tab.title",
|
|
2033
|
+
key: tabId,
|
|
2034
|
+
locale: NS
|
|
2035
|
+
}, tabChipTitle(WorktreeGlyph)), "ui-weir-settings: worktree tab title");
|
|
1942
2036
|
});
|
|
1943
2037
|
// Session capability Badge in the composer bar (12.1): order 95, next
|
|
1944
2038
|
// to the LSP toggle (order 100). The wrapper registers synchronously
|
|
@@ -2141,7 +2235,8 @@ window.__ModuleLoader__.load({
|
|
|
2141
2235
|
id: "capabilities",
|
|
2142
2236
|
order: 40,
|
|
2143
2237
|
title: () => sidebarScope.locale.bind(NS)("capabilityGuideTitle"),
|
|
2144
|
-
description: () => sidebarScope.locale.bind(NS)("capabilityGuideDescription")
|
|
2238
|
+
description: () => sidebarScope.locale.bind(NS)("capabilityGuideDescription"),
|
|
2239
|
+
icon: CapabilityGlyph
|
|
2145
2240
|
}]
|
|
2146
2241
|
}), "ui-weir-settings: capability tab type");
|
|
2147
2242
|
sidebarScope.effect(() => sidebarScope.slots.register({
|
|
@@ -2150,6 +2245,11 @@ window.__ModuleLoader__.load({
|
|
|
2150
2245
|
locale: NS,
|
|
2151
2246
|
inject: (sessionId) => (sessionId ? capabilityVerbs(sessionId) : {})
|
|
2152
2247
|
}, CapabilityPanelWrapper), "ui-weir-settings: capability panel body");
|
|
2248
|
+
sidebarScope.effect(() => sidebarScope.slots.register({
|
|
2249
|
+
name: "sidebar.right.pane.tab.title",
|
|
2250
|
+
key: tabId,
|
|
2251
|
+
locale: NS
|
|
2252
|
+
}, tabChipTitle(CapabilityGlyph)), "ui-weir-settings: capability tab title");
|
|
2153
2253
|
});
|
|
2154
2254
|
// ---- Session blackboard panel (slice 2, tasks 2.3/2.4) ----
|
|
2155
2255
|
// The board data channel is the plugin-owned typert remote
|
|
@@ -2201,7 +2301,8 @@ window.__ModuleLoader__.load({
|
|
|
2201
2301
|
id: "blackboard",
|
|
2202
2302
|
order: 50,
|
|
2203
2303
|
title: () => sidebarScope.locale.bind(NS)("blackboardGuideTitle"),
|
|
2204
|
-
description: () => sidebarScope.locale.bind(NS)("blackboardGuideDescription")
|
|
2304
|
+
description: () => sidebarScope.locale.bind(NS)("blackboardGuideDescription"),
|
|
2305
|
+
icon: BlackboardGlyph
|
|
2205
2306
|
}]
|
|
2206
2307
|
}), "ui-weir-settings: blackboard tab type");
|
|
2207
2308
|
sidebarScope.effect(() => sidebarScope.slots.register({
|
|
@@ -2210,7 +2311,27 @@ window.__ModuleLoader__.load({
|
|
|
2210
2311
|
locale: NS,
|
|
2211
2312
|
inject: (sessionId) => (sessionId ? { sessionId, callBoard: blackboardRawCall } : {})
|
|
2212
2313
|
}, BlackboardPanelWrapper), "ui-weir-settings: blackboard panel body");
|
|
2314
|
+
sidebarScope.effect(() => sidebarScope.slots.register({
|
|
2315
|
+
name: "sidebar.right.pane.tab.title",
|
|
2316
|
+
key: tabId,
|
|
2317
|
+
locale: NS
|
|
2318
|
+
}, tabChipTitle(BlackboardGlyph)), "ui-weir-settings: blackboard tab title");
|
|
2213
2319
|
});
|
|
2320
|
+
// Blackboard quick entry in the session header utilities row (D2):
|
|
2321
|
+
// order 20, after the worktree pill (order 10). A shell without the
|
|
2322
|
+
// sidebar right package keeps the button; openPanel then no-ops.
|
|
2323
|
+
ctx.effect(() => ctx.slots.inject("conversation.session.header.utilities", () => ctx.slots.register({
|
|
2324
|
+
name: "conversation.session.header.utilities",
|
|
2325
|
+
id: "weir-blackboard-pill",
|
|
2326
|
+
order: 20,
|
|
2327
|
+
locale: NS,
|
|
2328
|
+
inject: (sessionId) => (sessionId ? {
|
|
2329
|
+
sessionId,
|
|
2330
|
+
openPanel: () => {
|
|
2331
|
+
try { ctx.get("sidebarRight")?.openTab?.("weir-blackboard"); } catch { /* no sidebar in this shell */ }
|
|
2332
|
+
}
|
|
2333
|
+
} : {})
|
|
2334
|
+
}, BlackboardPill)), "ui-weir-settings: blackboard header pill");
|
|
2214
2335
|
// Top-level Settings section (same place as dsh-web-kimi and the
|
|
2215
2336
|
// built-in General/Models sections), with a nested item slot
|
|
2216
2337
|
// hosting the form — the single Weir settings entry.
|
|
@@ -260,7 +260,16 @@ window.__ModuleLoader__.load({
|
|
|
260
260
|
],
|
|
261
261
|
});
|
|
262
262
|
}
|
|
263
|
-
/** U2 — session header status pill:
|
|
263
|
+
/** U2 — session header status pill: the same bordered badge as the blackboard
|
|
264
|
+
* header entry (branch glyph + label) on a neutral base; warning tone on
|
|
265
|
+
* awaiting/base-moved, click opens the panel. */
|
|
266
|
+
const statusPillStyle = {
|
|
267
|
+
display: "inline-flex", alignItems: "center", gap: "5px",
|
|
268
|
+
padding: "1px 7px", fontSize: "11px", lineHeight: "16px", fontWeight: 600,
|
|
269
|
+
color: "var(--dsw-alias-label-secondary)", cursor: "pointer", background: "none",
|
|
270
|
+
border: "1px solid var(--dsw-alias-border-l3, rgba(127,127,127,.35))",
|
|
271
|
+
borderRadius: "var(--dsw-radius-sm)"
|
|
272
|
+
};
|
|
264
273
|
function WorktreeStatusPill(props) {
|
|
265
274
|
const available = useCommandPresence(props);
|
|
266
275
|
const enabled = available === true;
|
|
@@ -274,19 +283,25 @@ window.__ModuleLoader__.load({
|
|
|
274
283
|
if (summary.active > 0) parts.push(t("pillCount").replace("{n}", String(summary.active)));
|
|
275
284
|
if (summary.awaiting > 0) parts.push(t("pillAwaiting").replace("{n}", String(summary.awaiting)));
|
|
276
285
|
const attention = summary.baseMoved || summary.awaiting > 0;
|
|
277
|
-
|
|
286
|
+
// Attention keeps the documented warning signal — text, border and the
|
|
287
|
+
// 12% tint — so a base-moved lane or a pending approval still reads at a
|
|
288
|
+
// glance; every other state carries mode/business purely in the label.
|
|
289
|
+
const tone = attention ? { color: WARN, background: tint(WARN, 12), border: `1px solid ${WARN}` } : null;
|
|
290
|
+
// Hover paints the neutral base only: an attention pill keeps its tint
|
|
291
|
+
// instead of losing it on mouse-leave.
|
|
292
|
+
const hover = attention ? {} : {
|
|
293
|
+
onMouseEnter: (event) => { event.currentTarget.style.background = "var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.10))"; },
|
|
294
|
+
onMouseLeave: (event) => { event.currentTarget.style.background = "none"; },
|
|
295
|
+
};
|
|
278
296
|
return jsxs("button", {
|
|
279
297
|
type: "button",
|
|
280
|
-
style: {
|
|
281
|
-
|
|
282
|
-
fontSize: "11px", lineHeight: "16px", fontWeight: 600, color, cursor: "pointer",
|
|
283
|
-
background: tint(color, 12), border: `1px solid ${color}`, borderRadius: "var(--dsw-radius-sm)",
|
|
284
|
-
},
|
|
298
|
+
style: { ...statusPillStyle, ...tone },
|
|
299
|
+
...hover,
|
|
285
300
|
onClick: () => props.openPanel?.(),
|
|
286
301
|
title: summary.baseMoved ? t("pillBaseMoved") : t("pillTitle"),
|
|
287
302
|
"data-weir-worktree-pill": "",
|
|
288
303
|
"data-weir-worktree-state": summary.baseMoved ? "base-moved" : summary.mode ? "on" : "off",
|
|
289
|
-
children: [jsx(
|
|
304
|
+
children: [jsx(Icon, { name: "branch", size: 11 }, "icon"), jsx("span", { children: parts.join(" · ") }, "label")],
|
|
290
305
|
});
|
|
291
306
|
}
|
|
292
307
|
/** One lane card in the panel. */
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weir-harness",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.6",
|
|
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",
|
|
7
|
+
"icon": "icon.svg",
|
|
7
8
|
"author": "Lim-Young",
|
|
8
9
|
"homepage": "https://github.com/WeirLabs/weir#readme",
|
|
9
10
|
"bugs": "https://github.com/WeirLabs/weir/issues",
|
|
@@ -24,7 +25,8 @@
|
|
|
24
25
|
"lib",
|
|
25
26
|
"skills",
|
|
26
27
|
"cordis.patch.yml",
|
|
27
|
-
"whitelist-defaults.json"
|
|
28
|
+
"whitelist-defaults.json",
|
|
29
|
+
"icon.svg"
|
|
28
30
|
],
|
|
29
31
|
"peerDependencies": {
|
|
30
32
|
"@deepseek-ai/dsh": ">=0.2.0-rc.2 <0.3.0"
|
|
@@ -13,7 +13,16 @@ export const ONLINE_RECOVERY_ACTOR = 'online-administrator'
|
|
|
13
13
|
const ACTORS = ['authenticated-settings-administrator', AUTOMATIC_RECOVERY_ACTOR, ONLINE_RECOVERY_ACTOR]
|
|
14
14
|
export const digest = value => createHash('sha256').update(canonical(value)).digest('hex')
|
|
15
15
|
export const dispositionFor = (state, op) => state.adminRecoveries?.find(row => row.owner === op.sessionId && row.operations.some(item => item.operationId === op.operationId))
|
|
16
|
-
|
|
16
|
+
/** Epoch-scoped revocation: a disposition revokes its owner's execution
|
|
17
|
+
* authority exactly at the recorded revokedEpoch. The owner stays revoked
|
|
18
|
+
* while its recorded epoch is within it; a trusted resume advances the epoch
|
|
19
|
+
* past every revokedEpoch and ends the revoked state atomically, without any
|
|
20
|
+
* ledger change (append-only: dispositions are never removed or altered). */
|
|
21
|
+
export const revokedOwner = (state, owner) => {
|
|
22
|
+
const session = state.sessions?.find(s => s.sessionId === owner)
|
|
23
|
+
if (!session) return false
|
|
24
|
+
return state.adminRecoveries?.some(row => row.owner === owner && session.executionEpoch <= row.revokedEpoch) === true
|
|
25
|
+
}
|
|
17
26
|
|
|
18
27
|
function requireThat(condition, message) { if (!condition) throw new Error(`invalid administrative recovery: ${message}`) }
|
|
19
28
|
function keys(value, names) {
|
|
@@ -28,21 +37,41 @@ export function validateAdminLedger(state) {
|
|
|
28
37
|
// The v5 ledger exists only once an override lands; a v6 image may carry an
|
|
29
38
|
// empty one (registry upgrade before any administrative recovery).
|
|
30
39
|
if (state.adminRecoveries.length === 0) { requireThat(state.version === 6, 'nonempty v5 ledger'); return }
|
|
31
|
-
const ids = new Set()
|
|
40
|
+
const ids = new Set()
|
|
41
|
+
// An owner may be revoked again after a repair: its rows must carry strictly
|
|
42
|
+
// increasing revokedEpochs in ledger order (the epoch only ever advances).
|
|
43
|
+
const lastRevokedEpoch = new Map()
|
|
44
|
+
// Operations bound by an owner's earlier rows are already disposed; a later
|
|
45
|
+
// row of the same owner never re-covers them.
|
|
46
|
+
const covered = new Map()
|
|
47
|
+
// "Still revoked" is judged against the owner's LATEST row at image time.
|
|
48
|
+
const finalRevokedEpoch = new Map()
|
|
49
|
+
for (const row of state.adminRecoveries) {
|
|
50
|
+
if (natural(row?.revokedEpoch) && typeof row?.owner === 'string') {
|
|
51
|
+
finalRevokedEpoch.set(row.owner, Math.max(row.revokedEpoch, finalRevokedEpoch.get(row.owner) ?? -1))
|
|
52
|
+
}
|
|
53
|
+
}
|
|
32
54
|
// Rows committed in one transaction share one expected/committed pair;
|
|
33
55
|
// across transactions both revisions are non-decreasing.
|
|
34
56
|
let lastExpected = -1, lastCommitted = -1
|
|
35
57
|
for (const row of state.adminRecoveries) {
|
|
36
58
|
keys(row, ['recoveryId', 'root', 'owner', 'expectedRevision', 'committedRevision', 'at', 'actor', 'reason', 'risk', 'confirmation', 'backup', 'operations', 'releasedLocks', 'revokedEpoch'])
|
|
37
|
-
requireThat(text(row.recoveryId, 128) && !ids.has(row.recoveryId) && text(row.root, 4096) && text(row.owner)
|
|
38
|
-
ids.add(row.recoveryId)
|
|
59
|
+
requireThat(text(row.recoveryId, 128) && !ids.has(row.recoveryId) && text(row.root, 4096) && text(row.owner), 'identity')
|
|
60
|
+
ids.add(row.recoveryId)
|
|
61
|
+
requireThat(natural(row.revokedEpoch) && row.revokedEpoch > (lastRevokedEpoch.get(row.owner) ?? -1), 'revocation epoch progression')
|
|
62
|
+
lastRevokedEpoch.set(row.owner, row.revokedEpoch)
|
|
39
63
|
requireThat(natural(row.expectedRevision) && natural(row.committedRevision) && row.committedRevision === row.expectedRevision + 1 && row.expectedRevision >= lastExpected && row.committedRevision >= lastCommitted && natural(row.at) && ACTORS.includes(row.actor) && text(row.reason, 2000), 'audit')
|
|
40
64
|
lastExpected = row.expectedRevision; lastCommitted = row.committedRevision
|
|
41
65
|
requireThat(row.risk === LATE_WRITER_RISK && /^[a-f0-9]{64}$/.test(row.confirmation), 'risk acknowledgement')
|
|
42
66
|
keys(row.backup, ['file', 'sha256', 'bytes'])
|
|
43
67
|
requireThat(row.backup.file === `admin-backup-${digest([row.root, row.recoveryId])}.json` && /^[a-f0-9]{64}$/.test(row.backup.sha256) && natural(row.backup.bytes) && row.backup.bytes > 0, 'backup')
|
|
44
68
|
const session = state.sessions.find(s => s.sessionId === row.owner)
|
|
45
|
-
requireThat(session
|
|
69
|
+
requireThat(session && session.executionEpoch >= row.revokedEpoch, 'revoked owner epoch')
|
|
70
|
+
// The row-level invariants of an unsettled revocation bind only an owner
|
|
71
|
+
// still revoked at image time; a repaired owner's historical rows stand on
|
|
72
|
+
// their append-only bindings alone.
|
|
73
|
+
const stillRevoked = session.executionEpoch <= (finalRevokedEpoch.get(row.owner) ?? -1)
|
|
74
|
+
requireThat(!stillRevoked || (session.interrupted && !state.locks.some(l => l.owner === row.owner)), 'revoked owner')
|
|
46
75
|
requireThat(Array.isArray(row.operations) && row.operations.length > 0, 'operations')
|
|
47
76
|
const opIds = new Set()
|
|
48
77
|
for (const item of row.operations) {
|
|
@@ -52,7 +81,14 @@ export function validateAdminLedger(state) {
|
|
|
52
81
|
const op = state.operations.find(o => o.sessionId === row.owner && o.operationId === item.operationId)
|
|
53
82
|
requireThat(op?.phase === 'unknown' && digest(op) === item.sha256, 'immutable operation binding')
|
|
54
83
|
}
|
|
55
|
-
|
|
84
|
+
// The row's responsibility is exactly the owner's unresolved operations
|
|
85
|
+
// originating at or before its revokedEpoch (that epoch never ran, so none
|
|
86
|
+
// originate AT it), excluding operations its earlier rows already cover.
|
|
87
|
+
const disposed = covered.get(row.owner) ?? new Set()
|
|
88
|
+
requireThat(state.operations.filter(o => o.sessionId === row.owner && (o.phase === 'unknown' || o.phase === 'publishing' || o.phase === 'prepared')
|
|
89
|
+
&& (typeof o.origin?.executionEpoch !== 'number' || o.origin.executionEpoch <= row.revokedEpoch)
|
|
90
|
+
&& !disposed.has(o.operationId)).every(o => opIds.has(o.operationId)), 'complete unresolved owner scope')
|
|
91
|
+
covered.set(row.owner, new Set([...disposed, ...opIds]))
|
|
56
92
|
requireThat(row.confirmation === digest({ root: row.root, owner: row.owner, expectedRevision: row.expectedRevision, operationIds: [...opIds].sort(), risk: LATE_WRITER_RISK }), 'scoped confirmation')
|
|
57
93
|
requireThat(Array.isArray(row.releasedLocks), 'released locks')
|
|
58
94
|
const resources = new Set()
|
|
@@ -6,7 +6,7 @@ import { createHash } from 'node:crypto'
|
|
|
6
6
|
import { reservePublisher } from './reservation.js'
|
|
7
7
|
import { openEditLockStore } from './store.js'
|
|
8
8
|
import { canonical, parseSnapshot, validateImage } from './snapshot.js'
|
|
9
|
-
import { administrativeState, digest, LATE_WRITER_RISK } from './admin-ledger.js'
|
|
9
|
+
import { administrativeState, digest, dispositionFor, LATE_WRITER_RISK } from './admin-ledger.js'
|
|
10
10
|
|
|
11
11
|
export const MAX_RECOVERY_BYTES = 16 * 1024 * 1024
|
|
12
12
|
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex')
|
|
@@ -101,7 +101,9 @@ export async function recoverAuthority(input, testing = {}) {
|
|
|
101
101
|
if (before.revision !== input.expectedRevision) fail('revision-conflict', 'Authority revision changed; inspect and confirm again')
|
|
102
102
|
const owner = before.state.sessions.find(s => s.sessionId === input.owner)
|
|
103
103
|
if (!owner || !owner.interrupted) throw new Error('Only an interrupted historical owner can be overridden')
|
|
104
|
-
|
|
104
|
+
// Operations an earlier disposition of the same owner already covers stay
|
|
105
|
+
// disposed: a re-revocation after repair names only genuinely new unknowns.
|
|
106
|
+
const unresolved = before.state.operations.filter(op => op.sessionId === input.owner && ['prepared', 'publishing', 'unknown'].includes(op.phase) && !dispositionFor(before.state, op))
|
|
105
107
|
check(unresolved.length > 0 && unresolved.every(op => op.phase === 'unknown'), 'Recover to stable unknown history before administrative override')
|
|
106
108
|
check(canonical(unresolved.map(op => op.operationId).sort()) === canonical([...input.operationIds].sort()), 'Confirm every unresolved operation for this owner')
|
|
107
109
|
const backup = { file: `admin-backup-${digest([input.root, input.recoveryId])}.json`, sha256: sha256(bytes), bytes: bytes.length }
|
package/src/edit-lock/domain.js
CHANGED
|
@@ -9,7 +9,8 @@ export function localDomain(lifecycle, endpoint) {
|
|
|
9
9
|
service: lifecycle.service,
|
|
10
10
|
/** @param {object} agent */
|
|
11
11
|
start: agent => endpoint?.startLocal ? endpoint.startLocal(agent) : lifecycle.start(agent),
|
|
12
|
-
/** Trusted binding retry;
|
|
12
|
+
/** Trusted binding retry; binds a revoked session as interrupted without
|
|
13
|
+
* restoring authority (the trusted resume path does that). @param {object} agent */
|
|
13
14
|
retryStart: async agent => {
|
|
14
15
|
lifecycle.assertStartable(agent)
|
|
15
16
|
return endpoint?.startLocal ? endpoint.startLocal(agent, true) : lifecycle.start(agent)
|
package/src/edit-lock/index.js
CHANGED
|
@@ -258,7 +258,7 @@ function registerLockTools(ctx, service) {
|
|
|
258
258
|
output: text('message'),
|
|
259
259
|
async execute(/** @type {any} */ _args, /** @type {any} */ exec) {
|
|
260
260
|
const unavailable = service.startupFailure(exec.agent)
|
|
261
|
-
if (unavailable) return { ...unavailable, message: `Edit Lock unavailable: ${unavailable.reason}. ${unavailable.bindingFailure?.retryable ? '
|
|
261
|
+
if (unavailable) return { ...unavailable, message: `Edit Lock unavailable: ${unavailable.reason}. ${unavailable.bindingFailure?.retryable ? 'Do not retry failed edits: the binding is retried automatically when the user next sends a message, and /edit-lock retry also retries the connection; neither resumes editing by itself.' : 'Inspect Edit Lock maintenance before continuing.'}` }
|
|
262
262
|
const locks = await service.locks(exec)
|
|
263
263
|
const lines = locks.map((/** @type {any} */ lock) => `- ${lock.resourceId} owner=${lock.mine ? 'this session' : lock.owner} status=${lock.status} generation=${lock.generation}${lock.reason ? ` reason=${lock.reason}` : ''}`)
|
|
264
264
|
// The reservation is part of what the holder needs to know: without it the
|
|
@@ -294,7 +294,7 @@ function replyToolDefinition(service) {
|
|
|
294
294
|
/** @param {any} status */
|
|
295
295
|
function describe(status) {
|
|
296
296
|
const lines = [`Edit Lock session ${status.sessionId}: ${status.state}${status.interrupted ? ' (interrupted)' : ''}, epoch ${status.executionEpoch ?? '-'}`]
|
|
297
|
-
if (status.revoked) lines.push('Editing authority was
|
|
297
|
+
if (status.revoked) lines.push('Editing authority was revoked by an administrative recovery and its locks were released. Continue editing or send any message to restore editing in a new execution epoch; the revocation record is preserved.')
|
|
298
298
|
for (const lock of status.locks) lines.push(`- ${lock.resourceId} [${lock.status}${lock.reason ? `: ${lock.reason}` : ''}] generation ${lock.generation}`)
|
|
299
299
|
if (status.locks.length === 0) lines.push('- no locks held')
|
|
300
300
|
const recovery = status.recovery
|
|
@@ -464,6 +464,13 @@ return (ctx, config = {}) => {
|
|
|
464
464
|
recoveryId: info.record.recoveryId, revision: info.revision, idempotent: info.idempotent,
|
|
465
465
|
operations: info.record.operations.length,
|
|
466
466
|
}, { root }),
|
|
467
|
+
// Revocation repair audit: publisher-side, after the durable resume
|
|
468
|
+
// commit advanced the owner's epoch past its revokedEpoch. The ledger
|
|
469
|
+
// record itself never changes; the repair is a new-epoch event.
|
|
470
|
+
onRevocationRepair: info => audit(null, AUDIT_TYPES.editLockMaintenance, {
|
|
471
|
+
kind: 'revocation-repaired', root, owner: info.owner, trigger: info.trigger,
|
|
472
|
+
fromEpoch: info.fromEpoch ?? null, toEpoch: info.toEpoch,
|
|
473
|
+
}, { root })
|
|
467
474
|
})
|
|
468
475
|
let server
|
|
469
476
|
try { server = await serveEditLockEndpoint(lifecycle, endpoint(directory), sinks, attestBinding,
|
|
@@ -513,7 +520,7 @@ return (ctx, config = {}) => {
|
|
|
513
520
|
await setupInFlight.get(agent)
|
|
514
521
|
if (settled.has(agent)) return settled.get(agent)
|
|
515
522
|
const failure = startFailures.get(agent)
|
|
516
|
-
if (failure && bindingFailures.has(agent)) throw new Error(`Edit Lock binding is unavailable (${failure}).
|
|
523
|
+
if (failure && bindingFailures.has(agent)) throw new Error(`Edit Lock binding is unavailable (${failure}). Do not retry the failed edit: the binding is retried automatically when the user next sends a message, after which editing works normally. Never remove a live publisher reservation.`)
|
|
517
524
|
if (failure) throw new Error(`this session has no Edit Lock domain: setup failed under the current preset generation (${failure}); the automatic re-bind was already attempted. Recovery: restart DeepSeek Harness or start a new conversation.`)
|
|
518
525
|
// No fence match (foreign preset) or unmount: keep the stock refusal.
|
|
519
526
|
return registry.forAgent(agent)
|
|
@@ -751,7 +758,7 @@ return (ctx, config = {}) => {
|
|
|
751
758
|
startFailures.set(agent, reason)
|
|
752
759
|
try { evidence?.recordSessionFailure?.(sessionOf(agent), reason) } catch { /* reporting only */ }
|
|
753
760
|
audit(agent?.session ?? null, AUDIT_TYPES.editLockMaintenance, { kind: 'setup-failed', sessionId: sessionOf(agent) ?? null, reason })
|
|
754
|
-
try { deliverLocal(agent, `Edit Lock is unavailable for this session (${reason}); managed file editing is disabled.
|
|
761
|
+
try { deliverLocal(agent, `Edit Lock is unavailable for this session (${reason}); managed file editing is disabled and the failed edit must not be retried. This failure is not repaired automatically: the user can restart DeepSeek Harness or start a new conversation.`) } catch { /* the audit record is the durable account */ }
|
|
755
762
|
ctx.logger?.warn?.(`edit lock scope failed; edit tools denied: ${reason}`)
|
|
756
763
|
return
|
|
757
764
|
}
|
|
@@ -783,7 +790,7 @@ return (ctx, config = {}) => {
|
|
|
783
790
|
if (failure?.code === 'binding-duplicate') bindingFailures.set(agent, failure)
|
|
784
791
|
else bindingFailures.delete(agent)
|
|
785
792
|
try { evidence?.recordSessionFailure?.(sessionOf(agent), reason) } catch { /* reporting only */ }
|
|
786
|
-
try { deliverLocal(agent, `Edit Lock could not start this session's edit domain (${reason}); managed file editing is unavailable. ${failure?.code === 'binding-duplicate' ? '
|
|
793
|
+
try { deliverLocal(agent, `Edit Lock could not start this session's edit domain (${reason}); managed file editing is temporarily unavailable. ${failure?.code === 'binding-duplicate' ? 'Do not retry the failed edit: the binding is retried automatically when the user next sends a message, after which editing works normally. Never remove a live publisher reservation.' : 'Inspect Edit Lock maintenance before continuing.'}`) } catch { /* the panel record remains */ }
|
|
787
794
|
ctx.logger?.warn?.(`edit lock registration failed for ${sessionOf(agent)}: ${reason}`)
|
|
788
795
|
}
|
|
789
796
|
}
|
|
@@ -927,39 +934,65 @@ return (ctx, config = {}) => {
|
|
|
927
934
|
* Boolean, consumed once by the next turn's first pre-step (message-driven
|
|
928
935
|
* auto-resume D1/D6); WeakMap keys die with their agent. @type {WeakMap<object, boolean>} */
|
|
929
936
|
const pendingAutoResume = new WeakMap()
|
|
937
|
+
/** A genuine user message also arms ONE bounded binding retry when this agent
|
|
938
|
+
* has a recorded duplicate-binding failure (message-driven self-heal, design
|
|
939
|
+
* D5): consumed at the next turn's first pre-step by awaiting the existing
|
|
940
|
+
* trusted retryBinding — same attestation guards, same in-flight join,
|
|
941
|
+
* fail-closed while a holder serves. @type {WeakMap<object, boolean>} */
|
|
942
|
+
const pendingBindingRetry = new WeakMap()
|
|
930
943
|
/** @type {WeakSet<AbortSignal>} */
|
|
931
944
|
const watched = new WeakSet()
|
|
932
945
|
ctx.on('agent/pre-step', async (/** @type {any} */ event, /** @type {() => Promise<any>} */ next) => {
|
|
933
946
|
const { agent, signal, turn } = event ?? {}
|
|
934
|
-
const domain = agent && settled.get(agent)
|
|
935
947
|
// `pre-step` fires once per STEP, not per turn, so "a new turn started" has to
|
|
936
948
|
// be detected on turn identity. Doing it on every step would clear the
|
|
937
|
-
// reservation a holder just asked for, one step later.
|
|
938
|
-
|
|
949
|
+
// reservation a holder just asked for, one step later. The detection runs
|
|
950
|
+
// even without a settled domain: a message-armed binding retry (D5) exists
|
|
951
|
+
// exactly while no domain is settled and must precede the turn's first step.
|
|
952
|
+
if (agent && turn && lastTurn.get(agent) !== turn) {
|
|
939
953
|
lastTurn.set(agent, turn)
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
954
|
+
if (pendingBindingRetry.get(agent) === true) {
|
|
955
|
+
pendingBindingRetry.delete(agent)
|
|
956
|
+
const cause = bindingFailures.get(agent)
|
|
957
|
+
try {
|
|
958
|
+
await retryBinding(agent)
|
|
959
|
+
// The genuine message that armed this retry is also this turn's
|
|
960
|
+
// Continue receipt: re-arm what setupAgentOnce(retry) cleared, then
|
|
961
|
+
// fall through to the ordinary auto-resume consumption below.
|
|
962
|
+
pendingAutoResume.set(agent, true)
|
|
963
|
+
audit(agent?.session ?? null, AUDIT_TYPES.editLockMaintenance, {
|
|
964
|
+
kind: 'binding-auto-retry', sessionId: sessionOf(agent) ?? null,
|
|
965
|
+
code: cause?.code ?? 'binding-duplicate', result: 'recovered',
|
|
966
|
+
}, { root: registry.rootOf(agent) ?? undefined })
|
|
967
|
+
} catch { /* a safe refusal keeps the recorded cause; the turn proceeds with edits denied */ }
|
|
947
968
|
}
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
969
|
+
const domain = settled.get(agent)
|
|
970
|
+
if (domain) {
|
|
971
|
+
// A queued automatic-recovery summary (design D3) reaches exactly one
|
|
972
|
+
// agent as an ordinary mid-turn notice — the same proven delivery point
|
|
973
|
+
// as the settling and expiry notices.
|
|
974
|
+
const recoveryNotice = recoveryNotices.get(registry.rootOf(agent) ?? '')
|
|
975
|
+
if (recoveryNotice) {
|
|
976
|
+
recoveryNotices.delete(registry.rootOf(agent) ?? '')
|
|
977
|
+
try { deliverLocal(agent, recoveryNotice) } catch { /* the audit record is the durable account */ }
|
|
978
|
+
}
|
|
979
|
+
// A genuine user message arrived while this session was stopped: resume
|
|
980
|
+
// it and confirm its retained files BEFORE the turn's first step, so the
|
|
981
|
+
// edits the turn is about to request are admitted (message-driven
|
|
982
|
+
// auto-resume D1). A failure only warns and the turn proceeds denied —
|
|
983
|
+
// the panel keeps showing the stopped state (D6).
|
|
984
|
+
if (pendingAutoResume.get(agent) === true) {
|
|
985
|
+
pendingAutoResume.delete(agent)
|
|
986
|
+
if (domain.blocks(agent)) await autoResume(domain, agent, message => ctx.logger?.warn?.(message))
|
|
987
|
+
}
|
|
988
|
+
// The holder is working again: every lock it holds leaves the holding state at
|
|
989
|
+
// once, without waiting for the reservation to run out (design D4), and the
|
|
990
|
+
// notice budget for this batch restarts.
|
|
991
|
+
settle.turnStarted(agent)
|
|
992
|
+
void Promise.resolve(domain.turnStarted(agent)).catch((/** @type {any} */ error) => ctx.logger?.warn?.(`edit lock turn start failed: ${error?.message ?? error}`))
|
|
956
993
|
}
|
|
957
|
-
// The holder is working again: every lock it holds leaves the holding state at
|
|
958
|
-
// once, without waiting for the reservation to run out (design D4), and the
|
|
959
|
-
// notice budget for this batch restarts.
|
|
960
|
-
settle.turnStarted(agent)
|
|
961
|
-
void Promise.resolve(domain.turnStarted(agent)).catch((/** @type {any} */ error) => ctx.logger?.warn?.(`edit lock turn start failed: ${error?.message ?? error}`))
|
|
962
994
|
}
|
|
995
|
+
const domain = agent && settled.get(agent)
|
|
963
996
|
if (domain && signal && !watched.has(signal)) {
|
|
964
997
|
watched.add(signal)
|
|
965
998
|
const latch = () => { if (abortedByUser(signal)) domain.latch(agent) }
|
|
@@ -1004,6 +1037,11 @@ return (ctx, config = {}) => {
|
|
|
1004
1037
|
}
|
|
1005
1038
|
if (ctx.get?.('weirSettings')?.get?.('editLock')?.autoResume === false) return
|
|
1006
1039
|
pendingAutoResume.set(agent, true)
|
|
1040
|
+
// Message-driven binding self-heal (D5): the same genuine message arms ONE
|
|
1041
|
+
// bounded retry of a recorded duplicate-binding failure, executed through
|
|
1042
|
+
// the trusted retryBinding path at the next turn's first pre-step. Injected
|
|
1043
|
+
// messages, timers, write calls and status reads never reach this branch.
|
|
1044
|
+
if (bindingFailures.get(agent)?.code === 'binding-duplicate') pendingBindingRetry.set(agent, true)
|
|
1007
1045
|
})
|
|
1008
1046
|
|
|
1009
1047
|
// Classification and settling read only the durable turn/end reason
|
|
@@ -9,7 +9,7 @@ import { isMissingTarget, STALE_SWEEP_COOLDOWN_MS } from './stale-sweep.js'
|
|
|
9
9
|
* Resume and confirm are trusted human ingress only; never expose them as tools.
|
|
10
10
|
* @param {Awaited<ReturnType<typeof import('./runtime.js').openEditLockRuntime>>} runtime
|
|
11
11
|
* @param {(agent: object) => string | undefined} sessionForAgent
|
|
12
|
-
* @param {{deliver?: (agent: object, text: string, wake?: boolean) => void, onPending?: (agent: object, pending: number) => void, negotiationTimeoutMs?: number, onStaleRelease?: (row: {resourceId: string, owner: string, generation: number}, triggerSessionId: string | null) => void, onAdminRecovery?: (info: {revision: number, idempotent: boolean, record: any, trigger: string | null}) => void, warn?: (message: string) => void, sweepCooldownMs?: number, sweepNow?: () => number, servingRuntime?: (sessionId: string) => object | undefined}} [options] */
|
|
12
|
+
* @param {{deliver?: (agent: object, text: string, wake?: boolean) => void, onPending?: (agent: object, pending: number) => void, negotiationTimeoutMs?: number, onStaleRelease?: (row: {resourceId: string, owner: string, generation: number}, triggerSessionId: string | null) => void, onAdminRecovery?: (info: {revision: number, idempotent: boolean, record: any, trigger: string | null}) => void, onRevocationRepair?: (info: {owner: string, fromEpoch: number | undefined, toEpoch: number, trigger: string}) => void, warn?: (message: string) => void, sweepCooldownMs?: number, sweepNow?: () => number, servingRuntime?: (sessionId: string) => object | undefined}} [options] */
|
|
13
13
|
export function createEditLockLifecycle(runtime, sessionForAgent, options = {}) {
|
|
14
14
|
/** @typedef {{sessionId:string, state:'starting'|'active'|'recovering'|'stopped'|'resuming', attempt:number, ready:Promise<'active'|'interrupted'>, stop?:Promise<unknown>}} Entry */
|
|
15
15
|
/** @type {Map<object, Entry>} */
|
|
@@ -169,13 +169,18 @@ export function createEditLockLifecycle(runtime, sessionForAgent, options = {})
|
|
|
169
169
|
}
|
|
170
170
|
return result
|
|
171
171
|
}
|
|
172
|
-
/** Read-only startup admission
|
|
172
|
+
/** Read-only startup admission. A revoked session is startable: it binds as
|
|
173
|
+
* interrupted and its revocation ends only through the trusted resume path
|
|
174
|
+
* (the epoch advance is the repair; binding alone grants no authority).
|
|
173
175
|
* @param {object} agent */
|
|
174
176
|
function assertStartable(agent) {
|
|
175
177
|
if (closed) throw new Error('edit lifecycle closed')
|
|
176
178
|
const sessionId = sessionForAgent(agent)
|
|
177
179
|
if (!sessionId) throw new Error('unregistered lifecycle agent')
|
|
178
|
-
|
|
180
|
+
// Health probe: a poisoned manager refuses the retry here, before any
|
|
181
|
+
// holder is touched. A revoked session passes — it binds as interrupted
|
|
182
|
+
// and only the trusted resume path ends the revocation.
|
|
183
|
+
sessionStatus(sessionId)
|
|
179
184
|
}
|
|
180
185
|
/** Register once before publishing an agent's editing tools. A session the
|
|
181
186
|
* manager already knows (restart, re-created agent) is never re-armed: it
|
|
@@ -314,7 +319,12 @@ export function createEditLockLifecycle(runtime, sessionForAgent, options = {})
|
|
|
314
319
|
const attempt = ++entry.attempt
|
|
315
320
|
try {
|
|
316
321
|
const { status, session } = sessionStatus(entry.sessionId)
|
|
317
|
-
|
|
322
|
+
// A revoked session resumes exactly like a stopped one: the
|
|
323
|
+
// disposition already released its locks, so resume confirms the empty
|
|
324
|
+
// set and the epoch advance past revokedEpoch ends the revocation.
|
|
325
|
+
const wasRevoked = session?.revoked === true
|
|
326
|
+
const fromEpoch = session?.executionEpoch
|
|
327
|
+
if (!session?.interrupted) throw new Error('session is not durably interrupted')
|
|
318
328
|
if (!session?.interrupted) throw new Error('session is not durably interrupted')
|
|
319
329
|
const execution = {managerIncarnation: status.managerIncarnation, sessionId: entry.sessionId, executionEpoch: session.executionEpoch}
|
|
320
330
|
const receipt = await runtime.control.issueExecutionReceipt(execution, requestId)
|
|
@@ -324,6 +334,12 @@ export function createEditLockLifecycle(runtime, sessionForAgent, options = {})
|
|
|
324
334
|
}
|
|
325
335
|
entry.state = 'active'
|
|
326
336
|
host.attach(agent, resumed)
|
|
337
|
+
if (wasRevoked) {
|
|
338
|
+
// Repair of an administrative revocation is audited through the same
|
|
339
|
+
// shared channel as the recovery itself — never the session log.
|
|
340
|
+
const trigger = requestId.startsWith('auto:user-message:') ? 'auto' : 'manual'
|
|
341
|
+
try { options.onRevocationRepair?.({ owner: entry.sessionId, fromEpoch, toEpoch: resumed.executionEpoch, trigger }) } catch { /* audit is log-only */ }
|
|
342
|
+
}
|
|
327
343
|
return statusOf(agent)
|
|
328
344
|
} catch (error) {
|
|
329
345
|
// Anything that may have resumed without attaching is revoked again.
|
package/src/edit-lock/manager.js
CHANGED
|
@@ -42,6 +42,9 @@ async function automaticSettlementRecords(previous, base, { liveness, root, proc
|
|
|
42
42
|
const unknownByOwner = new Map()
|
|
43
43
|
for (const operation of base.operations) {
|
|
44
44
|
if (operation.phase !== 'unknown') continue
|
|
45
|
+
// An operation an earlier disposition already covers stays disposed; a
|
|
46
|
+
// re-revocation after repair binds only genuinely new unknowns.
|
|
47
|
+
if (dispositionFor(base, operation)) continue
|
|
45
48
|
const ops = unknownByOwner.get(operation.sessionId) ?? []
|
|
46
49
|
ops.push(operation)
|
|
47
50
|
unknownByOwner.set(operation.sessionId, ops)
|
|
@@ -370,7 +373,8 @@ function managerCore(store, kernel, { processIdentity = null, root = null, write
|
|
|
370
373
|
const captured = { ...execution }
|
|
371
374
|
return transact(draft => {
|
|
372
375
|
if (cancelled.has(captured.sessionId)) throw new Error('session cancelled')
|
|
373
|
-
|
|
376
|
+
// A revoked owner is NOT refused here: receipt issuance is half of the
|
|
377
|
+
// trusted repair path; the epoch binding still fences stale authority.
|
|
374
378
|
return draft.authority.issueExecutionReceipt(captured, requestId)
|
|
375
379
|
}, { kind: 'none' }).then(async receipt => {
|
|
376
380
|
const cancellation = cancelled.get(captured.sessionId)
|
|
@@ -389,7 +393,8 @@ function managerCore(store, kernel, { processIdentity = null, root = null, write
|
|
|
389
393
|
const captured = { ...execution }
|
|
390
394
|
return transact(draft => {
|
|
391
395
|
if (cancelled.has(captured.sessionId)) throw new Error('session cancelled')
|
|
392
|
-
|
|
396
|
+
// A revoked owner is NOT refused here: the resume commits the epoch
|
|
397
|
+
// advance that atomically ends the revocation (no ledger change).
|
|
393
398
|
return draft.operations.resume(captured, requestId, receipt)
|
|
394
399
|
}, () => sessionResources(captured.sessionId)).then(async resumed => {
|
|
395
400
|
const cancellation = cancelled.get(captured.sessionId)
|
|
@@ -664,7 +669,7 @@ function managerCore(store, kernel, { processIdentity = null, root = null, write
|
|
|
664
669
|
if (revokedOwner(state, input.owner)) refuse('invalid-recovery', 'owner already revoked by administrative recovery')
|
|
665
670
|
const session = state.sessions.find((/** @type {any} */ row) => row.sessionId === input.owner)
|
|
666
671
|
if (!session || !session.interrupted) refuse('invalid-recovery', 'Only an interrupted historical owner can be overridden')
|
|
667
|
-
const unresolved = state.operations.filter((/** @type {any} */ op) => op.sessionId === input.owner && ['prepared', 'publishing', 'unknown'].includes(op.phase))
|
|
672
|
+
const unresolved = state.operations.filter((/** @type {any} */ op) => op.sessionId === input.owner && ['prepared', 'publishing', 'unknown'].includes(op.phase) && !dispositionFor(state, op))
|
|
668
673
|
if (!(unresolved.length > 0 && unresolved.every((/** @type {any} */ op) => op.phase === 'unknown'))) refuse('invalid-recovery', 'Recover to stable unknown history before administrative override')
|
|
669
674
|
if (canonical(unresolved.map((/** @type {any} */ op) => op.operationId).sort()) !== canonical(scoped.operationIds)) refuse('invalid-recovery', 'Confirm every unresolved operation for this owner')
|
|
670
675
|
const bytes = store.currentBytes()
|
package/src/edit-lock/view.js
CHANGED
|
@@ -63,8 +63,9 @@ export function buildView({ status, locks, recoveryState, cwd, root, mode, now }
|
|
|
63
63
|
? recoveryState.pausedUntil : null
|
|
64
64
|
/** @type {typeof VIEW_STATES[number]} */
|
|
65
65
|
let state
|
|
66
|
-
// Revoked wins over everything: administrative revocation is
|
|
67
|
-
//
|
|
66
|
+
// Revoked wins over everything: an administrative revocation is a distinct,
|
|
67
|
+
// durable fact the user must see. It is repairable, not terminal: the panel
|
|
68
|
+
// offers the same continue-editing path as for a stopped session.
|
|
68
69
|
if (status.revoked === true) state = 'revoked'
|
|
69
70
|
else if (status.state === 'stopped' || status.state === 'resuming') state = 'stopped'
|
|
70
71
|
else if (status.state === 'recovering' || own.some(lock => lock.status === 'abnormal')) state = 'attention'
|
|
@@ -117,8 +118,10 @@ export function unavailableView(reason = null, bindingFailure = null) {
|
|
|
117
118
|
* Cold-session view (design D2): map one validated authority image into the
|
|
118
119
|
* same buildView input a live status read would produce, so a restored session
|
|
119
120
|
* shows its TRUE recorded state — interrupted as stopped, an ADMIN OVERRIDE as
|
|
120
|
-
*
|
|
121
|
-
*
|
|
121
|
+
* repairable revoked (epoch-scoped: the owner is revoked only while its
|
|
122
|
+
* recorded epoch is within the disposition's revokedEpoch), its own locks and
|
|
123
|
+
* the domain's locks, retention settled against the read instant — never a
|
|
124
|
+
* reasonless "starting". The `cold` marker
|
|
122
125
|
* tells the panel to replace live-only actions with activation guidance, and
|
|
123
126
|
* the auto-resume gate travels with the view so the guidance matches the
|
|
124
127
|
* committed setting. Pure like buildView: no IO, no clock, no ctx — the caller
|
|
@@ -141,7 +144,7 @@ export function buildColdView({ image, sessionId, cwd, root, autoResume = true,
|
|
|
141
144
|
const session = sessions.find(row => row?.sessionId === sessionId) ?? null
|
|
142
145
|
// The manager derives the revoked flag from the administrative ledger at
|
|
143
146
|
// status time (revokedOwner); the cold read applies the same rule to the image.
|
|
144
|
-
const revoked = adminRecoveries.some(row => row?.owner === sessionId)
|
|
147
|
+
const revoked = session != null && adminRecoveries.some(row => row?.owner === sessionId && Number.isSafeInteger(row?.revokedEpoch) && session.executionEpoch <= row.revokedEpoch)
|
|
145
148
|
// Read-time retention settlement, byte-equivalent to the kernel's settleHold:
|
|
146
149
|
// a hold counts only while the session owns a lock, and the remaining budget
|
|
147
150
|
// is computed against the read instant, so a missed expiry timer can never
|
package/src/settings/sections.js
CHANGED
|
@@ -49,7 +49,7 @@ const FIELDS = [
|
|
|
49
49
|
{ key: 'editLockHoldCumulativeMaxMinutes', section: 'editLock', field: 'holdCumulativeMaxMinutes', type: 'number', description: 'Edit Lock: cumulative retention minutes one batch of locks may use; asks beyond it are refused and only release remains (default 120)' },
|
|
50
50
|
{ key: 'editLockNudgeAttempts', section: 'editLock', field: 'nudgeAttempts', type: 'number', description: 'Edit Lock: how many times a finished turn is continued to ask for still-held locks to be released or retained (default 2)' },
|
|
51
51
|
{ key: 'editLockNudgeFallback', section: 'editLock', field: 'nudgeFallback', type: { union: ['release', 'abnormal'] }, description: 'Edit Lock: disposition of locks still held after those notices are used up; release frees the files for other sessions, abnormal keeps them for the user (default release)' },
|
|
52
|
-
{ key: 'editLockAutoResume', section: 'editLock', field: 'autoResume', type: 'boolean', description: 'Edit Lock: a genuine user message automatically resumes a stopped session and
|
|
52
|
+
{ key: 'editLockAutoResume', section: 'editLock', field: 'autoResume', type: 'boolean', description: 'Edit Lock: a genuine user message automatically resumes a stopped or administratively revoked session and retries a duplicate-binding failure (default on)' },
|
|
53
53
|
{ key: 'editLockStaleSweep', section: 'editLock', field: 'staleSweep', type: 'boolean', description: 'Edit Lock: a genuine user message silently releases locks whose target file no longer exists (default on; read per message, applies immediately)' },
|
|
54
54
|
{ key: 'robashEnabled', section: 'robash', field: 'enabled', type: 'boolean', description: 'Guarded read-only bash for curated agents (master switch)' },
|
|
55
55
|
{ key: 'robashAllow', section: 'robash', field: 'allow', type: 'string', list: true, description: 'JSON array of command names to APPEND to the product-default bash allow list (empty adds nothing; the defaults are always in effect)' },
|