weir-harness 1.1.1 → 1.1.3

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.
@@ -8,7 +8,9 @@ window.__ModuleLoader__.load({
8
8
  let react = require("react");
9
9
  let react_jsx_runtime = require("react/jsx-runtime");
10
10
  let primitives = require("@deepseek-ai/dsh-client-ui-primitives");
11
- let modelPicker = require("weir-model-picker");
11
+ // The model picker is a sibling package-local chunk: the composition root
12
+ // loads it on its own arrival state and hands it down as `props.modelPicker`
13
+ // (absent = still in flight or failed; the row degrades to plain fields).
12
14
  // Weir settings page: one form over the `weir-settings` namespace
13
15
  // (the shared SettingsFormModel only addresses flat fields), organized
14
16
  // as card sections with conditional rows (`when`) and nested
@@ -666,13 +668,13 @@ window.__ModuleLoader__.load({
666
668
  // instead of blanking the settings page.
667
669
  function renderModelPickerNode(node, state, props, disabled, t) {
668
670
  const pickerOverridden = state.fields.intentGateProvider.overridden || state.fields.intentGateModel.overridden || state.fields.intentGateReasoningEffort.overridden;
669
- const pickerRow = react_jsx_runtime.jsxs("div", { style: rowStyle, children: [
671
+ const pickerRow = (picker) => react_jsx_runtime.jsxs("div", { style: rowStyle, children: [
670
672
  react_jsx_runtime.jsxs("div", { style: labelGroupStyle, children: [
671
673
  labelWithTag(node, t, labelStyle),
672
674
  react_jsx_runtime.jsx("span", { style: hintStyle, children: t(`${node.field}Hint`) })
673
675
  ] }),
674
676
  react_jsx_runtime.jsxs("div", { style: { display: "flex", flexDirection: "column", alignItems: "flex-end", gap: "4px" }, children: [
675
- react_jsx_runtime.jsx(modelPicker.ModelPickerField, {
677
+ react_jsx_runtime.jsx(picker.ModelPickerField, {
676
678
  value: {
677
679
  provider: state.fields.intentGateProvider.text,
678
680
  model: state.fields.intentGateModel.text,
@@ -697,53 +699,56 @@ window.__ModuleLoader__.load({
697
699
  ] }) : null
698
700
  ] })
699
701
  ] });
700
- return react_jsx_runtime.jsx(modelPicker.ModelPickerBoundary, {
701
- fallback: react_jsx_runtime.jsxs("div", { style: { display: "flex", flexDirection: "column", gap: "4px" }, children: [
702
- react_jsx_runtime.jsx(primitives.SettingsValueField, {
703
- id: "plugin-config-fallback-intentGateProvider",
704
- label: t("intentGateProvider"),
705
- hint: t("intentGateProviderHint"),
706
- overriddenLabel: t("overridden"),
707
- resetLabel: t("reset"),
708
- invalidLabel: t("invalidValue"),
709
- disabled,
710
- text: state.fields.intentGateProvider.text,
711
- invalid: state.fields.intentGateProvider.invalid,
712
- overridden: state.fields.intentGateProvider.overridden,
713
- onChange: (text) => props.edit("intentGateProvider", text),
714
- onReset: () => props.resetField("intentGateProvider")
715
- }),
716
- react_jsx_runtime.jsx(primitives.SettingsValueField, {
717
- id: "plugin-config-fallback-intentGateModel",
718
- label: t("intentGateModel"),
719
- hint: t("intentGateModelHint"),
720
- overriddenLabel: t("overridden"),
721
- resetLabel: t("reset"),
722
- invalidLabel: t("invalidValue"),
723
- disabled,
724
- text: state.fields.intentGateModel.text,
725
- invalid: state.fields.intentGateModel.invalid,
726
- overridden: state.fields.intentGateModel.overridden,
727
- onChange: (text) => props.edit("intentGateModel", text),
728
- onReset: () => props.resetField("intentGateModel")
729
- }),
730
- react_jsx_runtime.jsx(primitives.SettingsValueField, {
731
- id: "plugin-config-fallback-intentGateReasoningEffort",
732
- label: t("intentGateReasoningEffort"),
733
- hint: t("intentGateReasoningEffortHint"),
734
- overriddenLabel: t("overridden"),
735
- resetLabel: t("reset"),
736
- invalidLabel: t("invalidValue"),
737
- disabled,
738
- text: state.fields.intentGateReasoningEffort.text,
739
- invalid: state.fields.intentGateReasoningEffort.invalid,
740
- overridden: state.fields.intentGateReasoningEffort.overridden,
741
- onChange: (text) => props.edit("intentGateReasoningEffort", text),
742
- onReset: () => props.resetField("intentGateReasoningEffort")
743
- })
744
- ] }),
745
- children: pickerRow
746
- });
702
+ // Manual-entry fallback: what the row renders before the picker chunk
703
+ // arrives, when its load failed, and what the picker's own error boundary
704
+ // swaps in when the picker component throws.
705
+ const manualFallback = react_jsx_runtime.jsxs("div", { style: { display: "flex", flexDirection: "column", gap: "4px" }, children: [
706
+ react_jsx_runtime.jsx(primitives.SettingsValueField, {
707
+ id: "plugin-config-fallback-intentGateProvider",
708
+ label: t("intentGateProvider"),
709
+ hint: t("intentGateProviderHint"),
710
+ overriddenLabel: t("overridden"),
711
+ resetLabel: t("reset"),
712
+ invalidLabel: t("invalidValue"),
713
+ disabled,
714
+ text: state.fields.intentGateProvider.text,
715
+ invalid: state.fields.intentGateProvider.invalid,
716
+ overridden: state.fields.intentGateProvider.overridden,
717
+ onChange: (text) => props.edit("intentGateProvider", text),
718
+ onReset: () => props.resetField("intentGateProvider")
719
+ }),
720
+ react_jsx_runtime.jsx(primitives.SettingsValueField, {
721
+ id: "plugin-config-fallback-intentGateModel",
722
+ label: t("intentGateModel"),
723
+ hint: t("intentGateModelHint"),
724
+ overriddenLabel: t("overridden"),
725
+ resetLabel: t("reset"),
726
+ invalidLabel: t("invalidValue"),
727
+ disabled,
728
+ text: state.fields.intentGateModel.text,
729
+ invalid: state.fields.intentGateModel.invalid,
730
+ overridden: state.fields.intentGateModel.overridden,
731
+ onChange: (text) => props.edit("intentGateModel", text),
732
+ onReset: () => props.resetField("intentGateModel")
733
+ }),
734
+ react_jsx_runtime.jsx(primitives.SettingsValueField, {
735
+ id: "plugin-config-fallback-intentGateReasoningEffort",
736
+ label: t("intentGateReasoningEffort"),
737
+ hint: t("intentGateReasoningEffortHint"),
738
+ overriddenLabel: t("overridden"),
739
+ resetLabel: t("reset"),
740
+ invalidLabel: t("invalidValue"),
741
+ disabled,
742
+ text: state.fields.intentGateReasoningEffort.text,
743
+ invalid: state.fields.intentGateReasoningEffort.invalid,
744
+ overridden: state.fields.intentGateReasoningEffort.overridden,
745
+ onChange: (text) => props.edit("intentGateReasoningEffort", text),
746
+ onReset: () => props.resetField("intentGateReasoningEffort")
747
+ })
748
+ ] });
749
+ const picker = props.modelPicker;
750
+ if (picker === void 0 || picker === null) return manualFallback;
751
+ return react_jsx_runtime.jsx(picker.ModelPickerBoundary, { fallback: manualFallback, children: pickerRow(picker) });
747
752
  }
748
753
  function WeirCard(props) {
749
754
  const state = props.useWeirSettingsCard((snapshot) => snapshot);
@@ -773,6 +778,7 @@ window.__ModuleLoader__.load({
773
778
  if (node.slot === "chainEditor") {
774
779
  return wrap(react_jsx_runtime.jsx(props.editors.ChainEditorField, {
775
780
  field: "delegateCategoryChains",
781
+ modelPicker: props.modelPicker,
776
782
  text: state.fields.delegateCategoryChains.text,
777
783
  overridden: state.fields.delegateCategoryChains.overridden,
778
784
  edit: (name, text) => props.edit(name, text),
@@ -787,6 +793,7 @@ window.__ModuleLoader__.load({
787
793
  // visual editor, agent lanes and agent dictionary stems.
788
794
  return wrap(react_jsx_runtime.jsx(props.editors.ChainEditorField, {
789
795
  field: "delegateAgentChains",
796
+ modelPicker: props.modelPicker,
790
797
  rows: CURATED_AGENT_NAMES,
791
798
  rowLabelPrefix: "chainAgent_",
792
799
  panelHintKey: "chainAgentPanelHint",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weir-harness",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
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",
@@ -93,6 +93,8 @@ function fsyncPath(path) {
93
93
 
94
94
  /** fsync one directory entry change (mirrors the fs-adapter fsyncDir step). @param {string} dir */
95
95
  function fsyncDirectory(dir) {
96
+ // Directory fsync is POSIX-only (EPERM on win32 — same gate as fs-adapter.js).
97
+ if (process.platform !== 'darwin' && process.platform !== 'linux') return
96
98
  const fd = openSync(dir, 'r')
97
99
  try {
98
100
  fsyncSync(fd)
@@ -26,6 +26,7 @@ declare module 'node:os' {
26
26
  }
27
27
  declare var process: {
28
28
  pid: number;
29
+ platform: string;
29
30
  kill(pid: number, signal?: number): void;
30
31
  };
31
32
 
@@ -18,6 +18,35 @@ import { link, mkdir, open, readFile, readdir, rename, unlink } from 'node:fs/pr
18
18
  * }} StoreFs
19
19
  */
20
20
 
21
+ // Directory fsync is a POSIX crash-durability idiom: on Windows the directory
22
+ // handle opens but sync fails EPERM (verified on Node v24, NTFS). The win32
23
+ // store row (SUPPORTED_PLATFORMS in store.js) therefore commits with
24
+ // file-level fsync + atomic rename only — the same platform stance the
25
+ // edit-lock reservation already takes (reservation.js SYNC_SUPPORTED). The
26
+ // durability tradeoff is documented in docs/features/session-capability-manager.md.
27
+ const DIR_SYNC_SUPPORTED = process.platform === 'darwin' || process.platform === 'linux'
28
+
29
+ // Windows file-locking etiquette: antivirus and the search indexer briefly
30
+ // hold brand-new files, so unlink/rename/link can fail transiently with
31
+ // EPERM/EACCES/EBUSY (observed in the cross-process suite). POSIX rows never
32
+ // see those codes from these operations; the win32 row retries a few times
33
+ // before surfacing the error.
34
+ const WINDOWS = process.platform === 'win32'
35
+ const TRANSIENT_LOCK = new Set(['EPERM', 'EACCES', 'EBUSY'])
36
+
37
+ /** @param {() => Promise<unknown>} op @returns {Promise<void>} */
38
+ async function retryTransientLock(op) {
39
+ for (let attempt = 0; ; attempt++) {
40
+ try {
41
+ await op()
42
+ return
43
+ } catch (error) {
44
+ if (!WINDOWS || !TRANSIENT_LOCK.has(/** @type {{ code?: string }} */ (error)?.code ?? '') || attempt >= 4) throw error
45
+ await new Promise(resolve => setTimeout(resolve, 10 * (attempt + 1)))
46
+ }
47
+ }
48
+ }
49
+
21
50
  /** @returns {StoreFs} */
22
51
  export function createNodeFs() {
23
52
  return {
@@ -38,14 +67,15 @@ export function createNodeFs() {
38
67
  await handle.close()
39
68
  }
40
69
  },
41
- link: (from, to) => link(from, to),
42
- rename: (from, to) => rename(from, to),
43
- unlink: path => unlink(path),
70
+ link: (from, to) => retryTransientLock(() => link(from, to)),
71
+ rename: (from, to) => retryTransientLock(() => rename(from, to)),
72
+ unlink: path => retryTransientLock(() => unlink(path)),
44
73
  readdir: path => readdir(path),
45
74
  async mkdirp(path) {
46
75
  await mkdir(path, { recursive: true, mode: 0o700 })
47
76
  },
48
77
  async fsyncDir(path) {
78
+ if (!DIR_SYNC_SUPPORTED) return
49
79
  const handle = await open(path, 'r')
50
80
  try {
51
81
  await handle.sync()
@@ -3,7 +3,7 @@
3
3
  // belongs to a process with a different OS start time (pid reuse), or the pid
4
4
  // is ours but carries another boot nonce. Everything else is 'alive' or
5
5
  // 'unknown', and neither may ever be reclaimed automatically.
6
- import { execFile } from 'node:child_process'
6
+ import { execFile as nodeExecFile } from 'node:child_process'
7
7
  import { randomBytes } from 'node:crypto'
8
8
  import { hostname } from 'node:os'
9
9
 
@@ -24,18 +24,47 @@ const PROCESS_BOOT_NONCE = randomBytes(8).toString('hex')
24
24
  /** @param {number} pid @returns {Promise<string|null>} */
25
25
  export function psStartTime(pid) {
26
26
  return new Promise(resolve => {
27
- execFile('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', timeout: 5000 }, (error, stdout) => {
27
+ nodeExecFile('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', timeout: 5000 }, (error, stdout) => {
28
28
  const text = error ? '' : String(stdout).trim()
29
29
  resolve(text.length > 0 ? text : null)
30
30
  })
31
31
  })
32
32
  }
33
33
 
34
+ // Windows has no ps(1): the process start time comes from Windows PowerShell,
35
+ // present on every supported Windows host (verified: powershell.exe Get-Process
36
+ // StartTime.ToFileTimeUtc(), ~0.5s warm). The probe fails closed to null — an
37
+ // unreadable start time is 'unknown', never 'dead'. The pid is interpolated
38
+ // only after the integer check below and the callers' own validation
39
+ // (parseOwnerDoc requires a safe integer; state() re-checks before probing).
40
+ /**
41
+ * @param {number} pid
42
+ * @param {((file: string, args: string[], options: object, callback: (error: Error | null, stdout: string) => void) => void)} [execFileImpl]
43
+ * @returns {Promise<string|null>}
44
+ */
45
+ export function windowsStartTime(pid, execFileImpl = /** @type {any} */ (nodeExecFile)) {
46
+ return new Promise(resolve => {
47
+ if (!Number.isSafeInteger(pid) || pid <= 0) { resolve(null); return }
48
+ execFileImpl('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `(Get-Process -Id ${pid}).StartTime.ToFileTimeUtc()`], { encoding: 'utf8', timeout: 5000, windowsHide: true }, (error, stdout) => {
49
+ const text = error ? '' : String(stdout).trim()
50
+ resolve(/^[0-9]+$/.test(text) ? text : null)
51
+ })
52
+ })
53
+ }
54
+
55
+ /** Module-level self start-identity: the own-process start time never changes,
56
+ * so the default configuration pays the start-time probe once per process (on
57
+ * win32 that probe is a subprocess) instead of once per store instance.
58
+ * Injected pids, boot nonces or probes bypass this cache.
59
+ * @type {Promise<StartIdentity>|undefined} */
60
+ let selfIdentity
61
+
34
62
  /**
35
63
  * @param {{
36
64
  * pid?: number,
37
65
  * host?: string,
38
66
  * bootNonce?: string,
67
+ * platform?: string,
39
68
  * kill?: (pid: number) => void,
40
69
  * startTime?: (pid: number) => Promise<string|null>,
41
70
  * }} [options]
@@ -46,7 +75,8 @@ export function createLiveness(options = {}) {
46
75
  const host = options.host ?? hostname()
47
76
  const bootNonce = options.bootNonce ?? PROCESS_BOOT_NONCE
48
77
  const kill = options.kill ?? (target => { process.kill(target, 0) })
49
- const startTime = options.startTime ?? psStartTime
78
+ const platform = options.platform ?? process.platform
79
+ const startTime = options.startTime ?? (platform === 'win32' ? windowsStartTime : psStartTime)
50
80
  /** @type {Promise<StartIdentity>|undefined} */
51
81
  let self
52
82
 
@@ -64,6 +94,10 @@ export function createLiveness(options = {}) {
64
94
  pid,
65
95
  host,
66
96
  identity() {
97
+ if (pid === process.pid && bootNonce === PROCESS_BOOT_NONCE && options.startTime === undefined) {
98
+ selfIdentity ??= startTime(pid).then(osStart => ({ osStart, bootNonce }))
99
+ return selfIdentity
100
+ }
67
101
  self ??= startTime(pid).then(osStart => ({ osStart, bootNonce }))
68
102
  return self
69
103
  },
@@ -9,8 +9,13 @@ import { createLockProtocol } from './lock.js'
9
9
  import { isFileSegment, isSegment, resolveStoreRoot, unitLayout } from './paths.js'
10
10
  import { decodeRecord, digestOf, encodeRecord } from './record.js'
11
11
 
12
- /** Platforms whose rename/fsync/link matrix row is EXECUTED (C-1.10 §6). */
13
- export const SUPPORTED_PLATFORMS = Object.freeze(['darwin'])
12
+ /**
13
+ * Platforms whose rename/fsync/link matrix row is EXECUTED (C-1.10 §6 darwin
14
+ * row; win32 row: openspec windows-platform-adaptation — hard-link EEXIST,
15
+ * rename-replace, file fsync and kill(pid,0) verified on NTFS with Node v24.
16
+ * Directory fsync is skipped on win32, see fs-adapter.js).
17
+ */
18
+ export const SUPPORTED_PLATFORMS = Object.freeze(['darwin', 'win32'])
14
19
 
15
20
  /**
16
21
  * @typedef {import('./paths.js').Unit} Unit
@@ -41,8 +41,9 @@ async function boundedRead(path, pins) {
41
41
  verify(pins)
42
42
  const before = lstatSync(path)
43
43
  check(before.isFile() && !before.isSymbolicLink() && before.nlink === 1 && before.size <= MAX_RECOVERY_BYTES, 'Bounded single-link regular authority file required')
44
- check(constants.O_NOFOLLOW && constants.O_NONBLOCK, 'Safe file IO unsupported')
45
- const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
44
+ // win32: O_NOFOLLOW/O_NONBLOCK are undefined; the lstat above refused a symlink
45
+ // leaf and the post-open identity check below bounds the swap (read-authority.js).
46
+ const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
46
47
  try {
47
48
  const stat = await file.stat()
48
49
  check(same(before, stat) && stat.isFile() && stat.nlink === 1 && stat.size <= MAX_RECOVERY_BYTES, 'Opened file identity or bound changed')
@@ -60,6 +61,8 @@ async function boundedRead(path, pins) {
60
61
  } finally { await file.close() }
61
62
  }
62
63
  async function syncDirectory(directory) {
64
+ // Directory fsync is POSIX-only (EPERM on win32, reservation.js SYNC_SUPPORTED).
65
+ if (process.platform !== 'darwin' && process.platform !== 'linux') return
63
66
  const handle = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
64
67
  try { await handle.sync() } finally { await handle.close() }
65
68
  }
@@ -123,7 +126,9 @@ export async function recoverAuthority(input, testing = {}) {
123
126
  check(savedBackup.equals(bytes), 'Existing backup does not match exact authority bytes')
124
127
  parseSnapshot(savedBackup, input.root)
125
128
  // Also sync an existing backup when retrying a crash before its first fsync.
126
- const backupHandle = await open(join(directory, backup.file), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
129
+ // fsync needs a writable handle on Windows (EPERM on O_RDONLY): open the
130
+ // existing backup read-write there so the re-sync stays a real fsync.
131
+ const backupHandle = await open(join(directory, backup.file), process.platform === 'win32' ? 'r+' : constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
127
132
  try { await backupHandle.sync() } finally { await backupHandle.close() }
128
133
  await syncDirectory(directory)
129
134
  await testing.checkpoint?.('backup-durable')
@@ -1,4 +1,4 @@
1
- import { posix } from 'node:path'
1
+ import { posix, win32 } from 'node:path'
2
2
 
3
3
  /** Candidates describe effects, not merely explicit API arguments. Session-wide
4
4
  * operations must enumerate affected ownership at their FIFO position.
@@ -11,9 +11,19 @@ import { posix } from 'node:path'
11
11
  * Opaque kernel test IDs remain valid without fences but cannot prove non-overlap.
12
12
  * @param {string} resourceId */
13
13
  function canonicalResource(resourceId) {
14
- return typeof resourceId === 'string' && resourceId.startsWith('/') &&
15
- !resourceId.includes('\0') && resourceId !== '/' &&
14
+ if (typeof resourceId !== 'string' || resourceId.includes('\0')) return false
15
+ // Shape check only, NOT filesystem proof. The POSIX arm is every POSIX row
16
+ // plus the platform-neutral synthetic ids historical records may carry; the
17
+ // win32 arm is the NTFS-native shape the resource-identity win32 row
18
+ // produces. Real producers emit exactly one shape per platform; relative,
19
+ // root, trailing-slash and non-normalized spellings stay refused.
20
+ const posixCanonical = resourceId.startsWith('/') && resourceId !== '/' &&
16
21
  !resourceId.endsWith('/') && posix.normalize(resourceId) === resourceId
22
+ if (process.platform !== 'win32') return posixCanonical
23
+ return posixCanonical ||
24
+ (/^[A-Za-z]:\\/.test(resourceId) && resourceId.length > 3 &&
25
+ !resourceId.endsWith('\\') && !resourceId.includes('/') &&
26
+ win32.normalize(resourceId) === resourceId)
17
27
  }
18
28
 
19
29
  /** Pure denial check. Historical closeout assertions grant no authority.
@@ -166,8 +166,18 @@ async function runCommand(domain, agent, raw, commandId, limits) {
166
166
  if (observation.kind !== 'file') throw new Error('unlock requires an existing regular file or an exact locked resource id')
167
167
  resourceId = observation.resourceId
168
168
  }
169
- const result = await domain.unlock(agent, resourceId, generation)
170
- return `Unlocked ${result.resourceId} (generation ${result.generation}, owner ${result.owner}). Content was not validated.`
169
+ try {
170
+ const result = await domain.unlock(agent, resourceId, generation)
171
+ return `Unlocked ${result.resourceId} (generation ${result.generation}, owner ${result.owner}). Content was not validated.`
172
+ } catch (error) {
173
+ // Actionable guidance (recovery UX D4): a fence refusal must name the
174
+ // resolution path, never leave a bare error code. The guidance is
175
+ // template-layer copy and stays English per the text discipline.
176
+ if (/** @type {any} */ (error)?.admissionRefusal === true) {
177
+ throw Object.assign(new Error(`${/** @type {any} */ (error).message}. Settle it with the administrative recovery in the Edit Lock maintenance panel (Settings → Edit Lock maintenance): it clears the unresolved publication fence and releases the owner's locks in one step.`), { admissionRefusal: true })
178
+ }
179
+ throw error
180
+ }
171
181
  }
172
182
  throw new Error('Usage: /edit-lock [status|locks|hold [minutes]|release <path>|stop|resume|confirm <path>|confirm --all|unlock <path> <generation>]')
173
183
  }
@@ -77,7 +77,8 @@ export function createNegotiation(options) {
77
77
  requester: { ...requester }, state: 'pending', expiresAt: now() + timeoutMs }
78
78
  requests.set(request.requestId, request)
79
79
  request.timer = setTimeout(() => settle(request, 'expired',
80
- `Edit Lock request ${request.requestId} for ${resourceId} expired without a holder reply; ownership is unchanged.`), timeoutMs)
80
+ `Edit Lock request ${request.requestId} for ${resourceId} expired without a holder reply; ownership is unchanged. ` +
81
+ `If the holder cannot reply, the Edit Lock maintenance panel (Settings → Edit Lock maintenance) offers the administrative force release for the stuck lock.`), timeoutMs)
81
82
  request.timer.unref?.()
82
83
  // An interrupted holder is never woken; its request simply expires.
83
84
  if (session && !session.interrupted) {
@@ -100,7 +100,7 @@ export function validateOperations(state) {
100
100
  if (op.binding.target.kind === 'update') valid(op.outcome.resourceId === op.binding.target.resourceId && op.outcome.generation === op.binding.target.generation, 'update outcome binding')
101
101
  if (op.phase === 'created' && op.fence?.kind === 'subtree') {
102
102
  const suffix = relative(op.fence.ancestor, op.outcome.resourceId)
103
- valid(suffix.length > 0 && suffix !== '..' && !suffix.startsWith('../') && !isAbsolute(suffix), 'created containment')
103
+ valid(suffix.length > 0 && suffix !== '..' && !suffix.startsWith('../') && !suffix.startsWith('..\\') && !isAbsolute(suffix), 'created containment')
104
104
  }
105
105
  }
106
106
  }
@@ -137,7 +137,7 @@ function validateFence(op) {
137
137
  else {
138
138
  valid(fence.basis === 'conservative-ancestor', 'scope basis')
139
139
  const suffix = relative(fence.ancestor, target.ancestor)
140
- valid(suffix !== '..' && !suffix.startsWith('../') && !isAbsolute(suffix), 'conservative containment')
140
+ valid(suffix !== '..' && !suffix.startsWith('../') && !suffix.startsWith('..\\') && !isAbsolute(suffix), 'conservative containment')
141
141
  }
142
142
  } else {
143
143
  shape(fence, ['kind', 'basis'])
@@ -72,8 +72,11 @@ export function readSnapshotBytes(authorityDir) {
72
72
  return { presence: 'no-snapshot' }
73
73
  }
74
74
  if (!leaf.isFile() || leaf.isSymbolicLink()) return { presence: 'not-a-file' }
75
- if (!constants.O_NOFOLLOW) throw new Error('safe inspection unsupported on this platform')
76
- fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
75
+ // Windows exposes no O_NOFOLLOW/O_NONBLOCK (constants undefined): the leaf
76
+ // lstat above already refused a symlink and the post-open fstat identity
77
+ // check below bounds the swap window (symlink creation needs privilege on
78
+ // Windows; the residual TOCTOU gap is documented in edit-lock.md).
79
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
77
80
  const opened = fstatSync(fd)
78
81
  if (!opened.isFile() || !sameNode(leaf, opened)) throw new Error('opened snapshot identity changed')
79
82
  verifyPins(parents)
@@ -7,17 +7,29 @@ import { createEditLockPeer } from './peer.js'
7
7
  import { serveEditLockPeer } from './peer-transport.js'
8
8
  import { createEditLockPeerClient } from './peer-client.js'
9
9
 
10
- /** Deterministic per-user endpoint for one canonical authority directory. The
11
- * OS temp directory is user-private on supported hosts; any same-user process
12
- * that can reach it is inside the cooperative trust boundary, not attested.
13
- * @param {string} directory */
14
- export function endpointFor(directory) {
10
+ /** Deterministic per-user endpoint for one canonical authority directory. On
11
+ * POSIX hosts the OS temp directory is user-private and the endpoint is a
12
+ * domain-socket file; on Windows it is a named pipe (a kernel object with no
13
+ * filesystem node, so there is no stale-node sweep and no 100-byte path
14
+ * budget — only the 256-char pipe-name limit) whose name carries no
15
+ * backslashes beyond the \\.\pipe\ prefix (verified on Node v24). Any
16
+ * same-user process that can reach the endpoint is inside the cooperative
17
+ * trust boundary, not attested.
18
+ * @param {string} directory @param {string} [platform] */
19
+ export function endpointFor(directory, platform = process.platform) {
15
20
  const authority = realpathSync.native(directory)
16
- const path = join(tmpdir(), `weir-edit-lock-${createHash('sha256').update(authority).digest('hex').slice(0, 24)}.sock`)
21
+ const hash = createHash('sha256').update(authority).digest('hex').slice(0, 24)
22
+ if (platform === 'win32') return `\\\\.\\pipe\\weir-edit-lock-${hash}`
23
+ const path = join(tmpdir(), `weir-edit-lock-${hash}.sock`)
17
24
  if (Buffer.byteLength(path) > 100) throw new Error(`edit lock endpoint path too long: ${path}`)
18
25
  return path
19
26
  }
20
27
 
28
+ /** True for Windows named-pipe endpoints: no filesystem node exists, so the
29
+ * stale-socket sweep and the close-time unlink are POSIX-only concerns.
30
+ * @param {string} endpoint */
31
+ const namedPipe = endpoint => endpoint.startsWith('\\\\.\\pipe\\') || endpoint.startsWith('\\\\?\\pipe\\')
32
+
21
33
  /** Publisher side: one session channel per connection. The first frame must be
22
34
  * `open {sessionId}`; the channel agent is fixed afterwards and EOF revokes it.
23
35
  * Call only while holding the publisher reservation (stale socket removal).
@@ -25,10 +37,12 @@ export function endpointFor(directory) {
25
37
  * @param {string} endpoint
26
38
  * @param {WeakMap<object, (event: unknown) => void>} sinks remote agent → notice sink */
27
39
  export async function serveEditLockEndpoint(lifecycle, endpoint, sinks) {
28
- try {
29
- if (!lstatSync(endpoint).isSocket()) throw new Error(`unexpected node at edit lock endpoint ${endpoint}`)
30
- unlinkSync(endpoint)
31
- } catch (error) { if (/** @type {any} */ (error)?.code !== 'ENOENT') throw error }
40
+ if (!namedPipe(endpoint)) {
41
+ try {
42
+ if (!lstatSync(endpoint).isSocket()) throw new Error(`unexpected node at edit lock endpoint ${endpoint}`)
43
+ unlinkSync(endpoint)
44
+ } catch (error) { if (/** @type {any} */ (error)?.code !== 'ENOENT') throw error }
45
+ }
32
46
  /** @type {Set<{close: () => Promise<unknown>}>} */
33
47
  const channels = new Set()
34
48
  let closed = false
@@ -63,7 +77,9 @@ export async function serveEditLockEndpoint(lifecycle, endpoint, sinks) {
63
77
  closed = true
64
78
  await new Promise(resolve => server.close(() => resolve(undefined)))
65
79
  const results = await Promise.allSettled([...channels].map(channel => channel.close()))
66
- try { unlinkSync(endpoint) } catch { /* already gone */ }
80
+ if (!namedPipe(endpoint)) {
81
+ try { unlinkSync(endpoint) } catch { /* already gone */ }
82
+ }
67
83
  const errors = results.filter(result => result.status === 'rejected').map(result => /** @type {any} */ (result).reason)
68
84
  if (errors.length) throw new AggregateError(errors, 'edit lock endpoint revocation failed')
69
85
  },
@@ -48,8 +48,12 @@ function writeOwner(path, doc) {
48
48
  renameSync(temporary, join(path, OWNER_FILE))
49
49
  }
50
50
 
51
+ // fsync on a directory handle is a POSIX crash-durability idiom: on Windows
52
+ // the handle opens but fsync fails EPERM (verified on Node v24, NTFS), so the
53
+ // win32 row skips it — the owner temp+rename stands on NTFS journaling.
51
54
  /** @param {string} directory */
52
55
  function syncParent(directory) {
56
+ if (process.platform !== 'darwin' && process.platform !== 'linux') return
53
57
  const handle = openSync(directory, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0))
54
58
  try { fsyncSync(handle) } finally { closeSync(handle) }
55
59
  }