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 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":"3889bcb48de6ff185d0b8d2ee457f51269710e8a748fa5de85fb9983a3ad5655","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":"4cf79e1231c8f0eab6692c986c11ef4e363b2f5968458f3c14e396a3aade7ee3"}
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 permanently revoked",
187
- editLockRevokedHint: "An administrative recovery permanently revoked this session's editing authority. It cannot be continued; open a new conversation to edit this workspace.",
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: badge look, warning tint on awaiting/base-moved, click opens the panel. */
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
- const color = attention ? WARN : summary.mode ? BUSINESS : "var(--dsw-alias-label-tertiary)";
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
- display: "inline-flex", alignItems: "center", gap: "5px", padding: "1px 7px",
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(Dot, { color }, "dot"), jsx("span", { children: parts.join(" · ") }, "label")],
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.5",
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
- export const revokedOwner = (state, owner) => state.adminRecoveries?.some(row => row.owner === owner) === true
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(), owners = 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) && !owners.has(row.owner), 'identity')
38
- ids.add(row.recoveryId); owners.add(row.owner)
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?.interrupted && natural(row.revokedEpoch) && session.executionEpoch >= row.revokedEpoch && !state.locks.some(l => l.owner === row.owner), 'revoked owner')
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
- requireThat(state.operations.filter(o => o.sessionId === row.owner && (o.phase === 'unknown' || o.phase === 'publishing' || o.phase === 'prepared')).every(o => opIds.has(o.operationId)), 'complete unresolved owner scope')
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
- const unresolved = before.state.operations.filter(op => op.sessionId === input.owner && ['prepared', 'publishing', 'unknown'].includes(op.phase))
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 }
@@ -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; never reopens permanently revoked authority. @param {object} agent */
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)
@@ -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 ? 'The user can run /edit-lock retry to retry the connection; this does not resume editing.' : 'Inspect Edit Lock maintenance before continuing.'}` }
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 permanently revoked by an administrative recovery; resume is impossible. Start a new conversation to edit this workspace.')
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}). The user can run /edit-lock retry after the holder evidence changes. Retry connects only; do not replay the failed edit or remove a live publisher reservation.`)
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. Recovery: restart DeepSeek Harness or start a new conversation.`) } catch { /* the audit record is the durable account */ }
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' ? 'Recovery: use /edit-lock retry after the original holder disconnects or its process shutdown is confirmed. Retry does not resume editing; never remove a live publisher reservation.' : 'Inspect Edit Lock maintenance before continuing.'}`) } catch { /* the panel record remains */ }
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
- if (domain && turn && lastTurn.get(agent) !== turn) {
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
- // A queued automatic-recovery summary (design D3) reaches exactly one
941
- // agent as an ordinary mid-turn notice — the same proven delivery point
942
- // as the settling and expiry notices.
943
- const recoveryNotice = recoveryNotices.get(registry.rootOf(agent) ?? '')
944
- if (recoveryNotice) {
945
- recoveryNotices.delete(registry.rootOf(agent) ?? '')
946
- try { deliverLocal(agent, recoveryNotice) } catch { /* the audit record is the durable account */ }
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
- // A genuine user message arrived while this session was stopped: resume
949
- // it and confirm its retained files BEFORE the turn's first step, so the
950
- // edits the turn is about to request are admitted (message-driven
951
- // auto-resume D1). A failure only warns and the turn proceeds denied —
952
- // the panel keeps showing the stopped state (D6).
953
- if (pendingAutoResume.get(agent) === true) {
954
- pendingAutoResume.delete(agent)
955
- if (domain.blocks(agent)) await autoResume(domain, agent, message => ctx.logger?.warn?.(message))
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; revoked owners remain terminal.
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
- if (sessionStatus(sessionId).session?.revoked) throw new Error('Edit Lock: this session was permanently revoked; binding retry cannot restore authority.')
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
- if (session?.revoked) throw new Error('Edit Lock: editing authority in this session was permanently revoked by an administrative recovery and cannot be resumed. Start a new conversation to edit this workspace again.')
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.
@@ -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
- checkNotRevoked(captured.sessionId)
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
- checkNotRevoked(captured.sessionId)
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()
@@ -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 terminal and
67
- // no action (resume, confirm, release) can ever apply to this session again.
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
- * terminal revoked, its own locks and the domain's locks, retention settled
121
- * against the read instant — never a reasonless "starting". The `cold` marker
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
@@ -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 confirms its retained files (default on)' },
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)' },