weir-harness 1.1.2 → 1.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weir-harness",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
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.
@@ -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,99 @@ 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
+
33
+ /** Channel-open arbitration for one endpoint (duplicate-binding recovery,
34
+ * design D1/D2/D4). One sessionId has at most one registered channel at any
35
+ * moment:
36
+ *
37
+ * - a prior binding whose socket is PROVEN dead (synchronous state bits only:
38
+ * `destroyed`, `closed` or `errored` — never an active probe) is reclaimed
39
+ * through the SAME disconnect path a processed EOF takes (revoke + forget;
40
+ * no lock is ever released), then the new open proceeds;
41
+ * - a prior binding that is alive — or whose state is in any way unclear — is
42
+ * refused, fail-closed, and is never disconnected;
43
+ * - with no prior channel binding, a refusal from `lifecycle.start` itself
44
+ * (a local agent or another mount generation holds the session) passes
45
+ * through untouched: a local binding can never be preempted from here.
46
+ *
47
+ * Opens of one sessionId are serialized through a per-session promise queue,
48
+ * because the reclaim-check-register sequence awaits; different sessionIds
49
+ * stay fully concurrent. Exported for the unit tests; production wiring is
50
+ * the endpoint below. */
51
+ export function createChannelBindingArbitration() {
52
+ /** @typedef {{destroyed?: boolean, closed?: boolean, errored?: unknown}} ChannelSocket */
53
+ /** @typedef {{disconnect: () => Promise<unknown>}} ChannelPeer */
54
+ /** @type {Map<string, {socket: ChannelSocket, peer: ChannelPeer}>} */
55
+ const bound = new Map()
56
+ /** @type {Map<string, Promise<unknown>>} */
57
+ const opening = new Map()
58
+ /** @param {string} sessionId
59
+ * @param {ChannelSocket} socket the NEW connection's socket
60
+ * @param {() => Promise<{peer: ChannelPeer}>} bind establish the channel
61
+ * (create the peer and start the lifecycle); its result is returned verbatim
62
+ * @returns {Promise<any>} */
63
+ function open(sessionId, socket, bind) {
64
+ const queued = opening.get(sessionId) ?? Promise.resolve()
65
+ const run = queued.then(step, step)
66
+ opening.set(sessionId, run)
67
+ const settled = () => { if (opening.get(sessionId) === run) opening.delete(sessionId) }
68
+ run.then(settled, settled)
69
+ return run
70
+ async function step() {
71
+ const prior = bound.get(sessionId)
72
+ if (prior) {
73
+ const dead = prior.socket.destroyed === true || prior.socket.closed === true || prior.socket.errored != null
74
+ if (!dead) throw new Error('session already bound')
75
+ try { await prior.peer.disconnect() } catch {
76
+ // Reclaim outcome unknown: stay fail-closed and KEEP the mapping, so
77
+ // the next open retries the reclaim instead of wedging behind a
78
+ // lifecycle-level refusal.
79
+ throw new Error('session already bound')
80
+ }
81
+ if (bound.get(sessionId) === prior) bound.delete(sessionId)
82
+ }
83
+ const established = await bind()
84
+ // Registered even when this socket already died during registration: the
85
+ // transport's own close handling still revokes it, and the next open
86
+ // reclaims the entry — a lost EOF can never wedge the session again.
87
+ bound.set(sessionId, { socket, peer: established.peer })
88
+ return established
89
+ }
90
+ }
91
+ /** Index hygiene on transport close: drop the entry only while it still
92
+ * names THIS connection's socket — a reclaimed binding must never be removed
93
+ * by the late close of the dead socket it replaced. Revocation itself runs
94
+ * on the transport's own disconnect path, not here.
95
+ * @param {string} sessionId @param {ChannelSocket} socket */
96
+ function release(sessionId, socket) {
97
+ const prior = bound.get(sessionId)
98
+ if (prior && prior.socket === socket) bound.delete(sessionId)
99
+ }
100
+ return Object.freeze({ open, release })
101
+ }
102
+
21
103
  /** Publisher side: one session channel per connection. The first frame must be
22
104
  * `open {sessionId}`; the channel agent is fixed afterwards and EOF revokes it.
23
105
  * Call only while holding the publisher reservation (stale socket removal).
@@ -25,33 +107,46 @@ export function endpointFor(directory) {
25
107
  * @param {string} endpoint
26
108
  * @param {WeakMap<object, (event: unknown) => void>} sinks remote agent → notice sink */
27
109
  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 }
110
+ if (!namedPipe(endpoint)) {
111
+ try {
112
+ if (!lstatSync(endpoint).isSocket()) throw new Error(`unexpected node at edit lock endpoint ${endpoint}`)
113
+ unlinkSync(endpoint)
114
+ } catch (error) { if (/** @type {any} */ (error)?.code !== 'ENOENT') throw error }
115
+ }
32
116
  /** @type {Set<{close: () => Promise<unknown>}>} */
33
117
  const channels = new Set()
34
118
  let closed = false
119
+ const arbitration = createChannelBindingArbitration()
35
120
  const server = createServer(socket => {
36
121
  if (closed) { socket.destroy(); return }
37
122
  /** @type {ReturnType<typeof createEditLockPeer> | undefined} */
38
123
  let inner
124
+ /** @type {string | undefined} */
125
+ let openedSessionId
39
126
  const router = {
40
127
  /** @param {any} message */
41
128
  async receive(message) {
42
129
  if (inner) return inner.receive(message)
43
130
  const sessionId = message?.request?.sessionId
44
131
  if (message?.kind !== 'open' || typeof sessionId !== 'string' || !sessionId || sessionId.length > 256) throw new Error('channel must open a session first')
45
- const agent = Object.freeze({ id: sessionId, remote: true })
46
- sinks.set(agent, event => transport.notify(event))
47
- inner = createEditLockPeer(lifecycle, agent)
48
- return { state: await lifecycle.start(agent) }
132
+ openedSessionId = sessionId
133
+ const opened = await arbitration.open(sessionId, socket, async () => {
134
+ const agent = Object.freeze({ id: sessionId, remote: true })
135
+ sinks.set(agent, event => transport.notify(event))
136
+ inner = createEditLockPeer(lifecycle, agent)
137
+ const state = await lifecycle.start(agent)
138
+ return { peer: inner, state }
139
+ })
140
+ return { state: opened.state }
49
141
  },
50
142
  disconnect() { return inner ? inner.disconnect() : Promise.resolve() },
51
143
  }
52
144
  const transport = serveEditLockPeer(socket, router)
53
145
  channels.add(transport)
54
- socket.once('close', () => channels.delete(transport))
146
+ socket.once('close', () => {
147
+ channels.delete(transport)
148
+ if (openedSessionId !== undefined) arbitration.release(openedSessionId, socket)
149
+ })
55
150
  })
56
151
  await new Promise((resolve, reject) => {
57
152
  server.once('error', reject)
@@ -63,7 +158,9 @@ export async function serveEditLockEndpoint(lifecycle, endpoint, sinks) {
63
158
  closed = true
64
159
  await new Promise(resolve => server.close(() => resolve(undefined)))
65
160
  const results = await Promise.allSettled([...channels].map(channel => channel.close()))
66
- try { unlinkSync(endpoint) } catch { /* already gone */ }
161
+ if (!namedPipe(endpoint)) {
162
+ try { unlinkSync(endpoint) } catch { /* already gone */ }
163
+ }
67
164
  const errors = results.filter(result => result.status === 'rejected').map(result => /** @type {any} */ (result).reason)
68
165
  if (errors.length) throw new AggregateError(errors, 'edit lock endpoint revocation failed')
69
166
  },
@@ -83,14 +180,25 @@ export function createRemoteEditLockDomain(endpoint, hooks) {
83
180
  const all = new Set()
84
181
  const never = new AbortController().signal
85
182
  let closed = false
86
- /** The publisher may not yet have processed an earlier channel's EOF; only
87
- * channel establishment is retried, never a call.
183
+ /** Failure classes are split by WHERE the open failed, never by guessing at
184
+ * message text (design D3): a transport that never came up — or an open
185
+ * request that never got an answer — is an unreachable publisher and carries
186
+ * the reservation hint; an open the publisher answered and refused is a live
187
+ * publisher rejecting the binding, surfaced with its own guidance after the
188
+ * duplicate-binding retry budget runs out. Only that answered refusal is
189
+ * retried: the publisher may not yet have processed (or even seen) the
190
+ * earlier channel's death. Never a call.
88
191
  * @param {any} agent @returns {Promise<Channel>} */
89
192
  async function channelFor(agent) {
90
193
  for (let attempt = 0; ; attempt++) {
91
194
  try { return await openChannel(agent) } catch (error) {
92
- if (attempt >= 20 || !/session already bound/.test(String(/** @type {any} */ (error)?.message))) throw error
93
- await new Promise(resolve => setTimeout(resolve, 50))
195
+ if (/** @type {any} */ (error)?.answeredRefusal !== true) throw error
196
+ if (/session already bound/.test(String(/** @type {any} */ (error)?.message)) && attempt < 20) {
197
+ await new Promise(resolve => setTimeout(resolve, 50))
198
+ continue
199
+ }
200
+ const reason = String(/** @type {any} */ (error)?.message ?? error)
201
+ throw new Error(`edit lock duplicate session binding: session ${agent.id} is already active in the DeepSeek Harness window that publishes this project. Use the session in that window, close it there, or quit that Harness process. Do NOT remove the publisher reservation (.weir/.edit-lock.publisher-reservation) while that process is alive — the reservation belongs to it, and removing it would split the edit authority. (publisher answered: ${reason})`)
94
202
  }
95
203
  }
96
204
  }
@@ -108,17 +216,35 @@ export function createRemoteEditLockDomain(endpoint, hooks) {
108
216
  })
109
217
  channels.set(agent, channel)
110
218
  all.add(channel)
219
+ /** The publisher genuinely did not answer. @param {unknown} cause */
220
+ const unreachable = cause => new Error(`edit lock publisher unreachable at ${endpoint}: ${/** @type {any} */ (cause)?.message ?? cause}${hooks.unreachableHint ? `. ${hooks.unreachableHint}` : ''}`)
111
221
  channel.ready = (async () => {
112
- await new Promise((resolve, reject) => {
113
- socket.once('connect', resolve)
114
- socket.once('error', reject)
115
- })
116
- const opened = await channel.client.request('open', 'open', { sessionId: agent.id }, never)
222
+ try {
223
+ await new Promise((resolve, reject) => {
224
+ socket.once('connect', resolve)
225
+ socket.once('error', reject)
226
+ })
227
+ } catch (error) {
228
+ // The transport never came up: the publisher process is not answering.
229
+ throw unreachable(error)
230
+ }
231
+ let opened
232
+ try {
233
+ opened = await channel.client.request('open', 'open', { sessionId: agent.id }, never)
234
+ } catch (error) {
235
+ const message = String(/** @type {any} */ (error)?.message ?? error)
236
+ // No answer ever arrived — the channel died mid-open or the request
237
+ // was never dispatched: the same failure class as a connect error.
238
+ if (message.startsWith('edit publication outcome UNKNOWN:') || message.startsWith('edit peer unavailable')) throw unreachable(error)
239
+ // The publisher answered and refused the open. channelFor owns the
240
+ // retry budget and the final message for answered refusals.
241
+ throw Object.assign(error instanceof Error ? error : new Error(message), { answeredRefusal: true })
242
+ }
117
243
  if (!channel.closed) channel.state = opened.state
118
244
  })()
119
245
  try { await channel.ready } catch (error) {
120
246
  channel.client.close()
121
- throw new Error(`edit lock publisher unreachable at ${endpoint}: ${/** @type {any} */ (error)?.message ?? error}${hooks.unreachableHint ? `. ${hooks.unreachableHint}` : ''}`)
247
+ throw error
122
248
  }
123
249
  return channel
124
250
  }
@@ -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
  }
@@ -10,14 +10,23 @@ import { dirname, isAbsolute } from 'node:path'
10
10
  */
11
11
 
12
12
  /**
13
- * Read-only POSIX observations, not ownership or publication authority.
13
+ * Read-only filesystem observations, not ownership or publication authority.
14
14
  * Filesystem calls are not an atomic snapshot: callers must coordinate mutation.
15
15
  * dev/ino continuity cannot detect inode reuse or change-and-restore (ABA).
16
+ *
17
+ * Verified platform rows: darwin/linux (POSIX spellings) and win32
18
+ * (openspec windows-platform-adaptation: drive-letter absolute spellings with
19
+ * both separators normalized to '/'; NTFS dev/ino/nlink verified real on Node
20
+ * v24; junctions are reparse points and surface as symbolic links). UNC and
21
+ * device paths are rejected on win32 — network shares are outside the
22
+ * supported envelope (edit-lock.md).
23
+ * @param {{ platform?: string }} [options]
16
24
  */
17
- export function createResourceIdentity() {
18
- if (process.platform !== 'darwin' && process.platform !== 'linux') {
19
- throw new Error(`unsupported platform: ${process.platform}`)
25
+ export function createResourceIdentity({ platform = process.platform } = {}) {
26
+ if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win32') {
27
+ throw new Error(`unsupported platform: ${platform}`)
20
28
  }
29
+ const win = platform === 'win32'
21
30
  /** @type {WeakMap<object, Evidence>} */
22
31
  const observations = new WeakMap()
23
32
  /** @param {string} filePath @param {{ cwd: string }} options */
@@ -25,7 +34,8 @@ export function createResourceIdentity() {
25
34
  if (typeof cwd !== 'string' || !isAbsolute(cwd)) throw new TypeError('expected absolute cwd')
26
35
  if (typeof filePath !== 'string' || filePath.length === 0) throw new TypeError('expected non-empty path')
27
36
  if (filePath.includes('\0') || cwd.includes('\0')) throw new TypeError('NUL in path')
28
- const result = inspect(isAbsolute(filePath) ? filePath : `${cwd}/${filePath}`)
37
+ const joined = isAbsolute(filePath) ? filePath : `${cwd}/${filePath}`
38
+ const result = inspect(win ? normalizeWin(joined) : joined, win)
29
39
  const observation = Object.freeze(result.value)
30
40
  observations.set(observation, { filePath, cwd, kind: observation.kind, witnesses: result.witnesses, links: result.links })
31
41
  return observation
@@ -48,11 +58,22 @@ export function createResourceIdentity() {
48
58
  }
49
59
  }
50
60
 
61
+ /** Canonical spelling for the win32 walk: single forward slashes under a
62
+ * drive-letter root. UNC/device roots are rejected, never reinterpreted.
63
+ * @param {string} spelling */
64
+ function normalizeWin(spelling) {
65
+ const normalized = spelling.replace(/\\/g, '/').replace(/\/{2,}/g, '/')
66
+ if (!/^[A-Za-z]:\//.test(normalized)) throw new Error(`unsupported path root (drive-letter absolute paths only): ${spelling.slice(0, 64)}`)
67
+ return normalized
68
+ }
69
+
51
70
  /** Walk original prefixes without lexically collapsing symlink/.. or missing/..
52
- * @param {string} spelling @returns {Inspection} */
53
- function inspect(spelling) {
71
+ * The ancestor chain terminates at the filesystem root of the spelling —
72
+ * '/' on POSIX, '<drive>:/' on win32 — detected as dirname's fixed point.
73
+ * @param {string} spelling @param {boolean} win @returns {Inspection} */
74
+ function inspect(spelling, win) {
54
75
  const parts = spelling.split('/')
55
- let ancestor = '/'
76
+ let ancestor = win ? /** @type {string} */ (parts[0]) : '/'
56
77
  const witnesses = []
57
78
  let links = 0
58
79
  for (let index = 1; index < parts.length; index++) {
@@ -71,11 +92,11 @@ function inspect(spelling) {
71
92
  ancestor = realpathSync.native(prefix)
72
93
  const node = statSync(ancestor, { bigint: true })
73
94
  witnesses.push(`${ancestor}:${node.dev}:${node.ino}:${entry.dev}:${entry.ino}`)
74
- if (entry.isSymbolicLink()) traceLink(physical, witnesses, 0)
95
+ if (entry.isSymbolicLink()) traceLink(physical, witnesses, 0, win)
75
96
  for (let parent = dirname(ancestor); ; parent = dirname(parent)) {
76
97
  const directory = statSync(parent, { bigint: true })
77
98
  witnesses.push(`${parent}:${directory.dev}:${directory.ino}`)
78
- if (parent === '/') break
99
+ if (parent === dirname(parent)) break
79
100
  }
80
101
  if (index < parts.length - 1) {
81
102
  if (!node.isDirectory()) throw new Error('non-directory ancestor')
@@ -90,18 +111,20 @@ function inspect(spelling) {
90
111
  * the physical cursor avoids recursively replaying the original parent spelling.
91
112
  * Resolve each component only AFTER tracing it: lexical .. collapse or resolving
92
113
  * the whole target up front would hide nested links and their topology changes.
93
- * @param {string} path @param {string[]} witnesses @param {number} depth */
94
- function traceLink(path, witnesses, depth) {
114
+ * On win32 readlink returns backslash spellings; they are normalized before the
115
+ * walk so component splitting matches the POSIX row.
116
+ * @param {string} path @param {string[]} witnesses @param {number} depth @param {boolean} win */
117
+ function traceLink(path, witnesses, depth, win) {
95
118
  if (depth >= 40) throw new Error('unsupported symlink depth')
96
- const target = readlinkSync(path)
119
+ const target = win ? readlinkSync(path).replace(/\\/g, '/') : readlinkSync(path)
97
120
  witnesses.push(`link:${path}:${target}`)
98
- let parent = isAbsolute(target) ? '/' : dirname(path)
121
+ let parent = isAbsolute(target) ? (win ? /** @type {string} */ (target.split('/')[0]) : '/') : dirname(path)
99
122
  const parts = isAbsolute(target) ? target.split('/').slice(1) : target.split('/')
100
123
  for (const part of parts) {
101
124
  const prefix = `${parent}/${part}`
102
125
  const node = lstatSync(prefix, { bigint: true })
103
126
  witnesses.push(`${prefix}:${node.dev}:${node.ino}`)
104
- if (node.isSymbolicLink()) traceLink(prefix, witnesses, depth + 1)
127
+ if (node.isSymbolicLink()) traceLink(prefix, witnesses, depth + 1, win)
105
128
  parent = realpathSync.native(prefix)
106
129
  }
107
130
  }
@@ -17,6 +17,9 @@ async function writeAuthorityFile(directory, file, bytes) {
17
17
  const handle = await open(temporary, 'wx', 0o600)
18
18
  try { await handle.writeFile(bytes); await handle.sync() } finally { await handle.close() }
19
19
  await rename(temporary, join(directory, file))
20
+ // Directory fsync is POSIX-only (EPERM on win32): the file fsync above and
21
+ // the atomic rename stand alone there (reservation.js SYNC_SUPPORTED stance).
22
+ if (process.platform !== 'darwin' && process.platform !== 'linux') return
20
23
  const dir = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
21
24
  try { await dir.sync() } finally { await dir.close() }
22
25
  }
@@ -3,6 +3,7 @@
3
3
  // the scheduler's job, clock and timer are injected so the composition and
4
4
  // the unit tests share one core.
5
5
  import { lstatSync } from 'node:fs'
6
+ import { dirname } from 'node:path'
6
7
 
7
8
  /** Missing-target classification for one stored canonical resource id. ONLY
8
9
  * ENOENT counts: a successful lstat — including of a dangling symlink — is
@@ -16,7 +17,23 @@ export function isMissingTarget(resourceId) {
16
17
  lstatSync(resourceId)
17
18
  return false
18
19
  } catch (error) {
19
- return /** @type {any} */ (error)?.code === 'ENOENT'
20
+ const code = /** @type {any} */ (error)?.code
21
+ if (code !== 'ENOENT') return false
22
+ // Windows reports a path through a regular-file component as ENOENT as
23
+ // well (there is no ENOTDIR): preserve the conservative POSIX
24
+ // classification by walking up — a non-directory ancestor means the path
25
+ // is BLOCKED (skip the row), not missing.
26
+ if (process.platform === 'win32') {
27
+ for (let parent = dirname(resourceId); parent !== dirname(parent); parent = dirname(parent)) {
28
+ let stat
29
+ try { stat = lstatSync(parent) } catch (ancestorError) {
30
+ if (/** @type {any} */ (ancestorError)?.code === 'ENOENT') continue
31
+ return false
32
+ }
33
+ return stat.isDirectory()
34
+ }
35
+ }
36
+ return true
20
37
  }
21
38
  }
22
39
 
@@ -6,6 +6,11 @@ import { validateOperationTransitions } from './operation-history.js'
6
6
  import { canonical, parseSnapshot, validateImage } from './snapshot.js'
7
7
  import { validateAdminTransition } from './admin-ledger.js'
8
8
 
9
+ // Directory fsync is POSIX-only (EPERM on win32, Node v24 — same stance as
10
+ // reservation.js SYNC_SUPPORTED): the win32 row persists with file fsync +
11
+ // atomic rename and skips the directory sync steps.
12
+ const DIR_SYNC_SUPPORTED = process.platform === 'darwin' || process.platform === 'linux'
13
+
9
14
  /**
10
15
  * Unmounted historical Edit Lock snapshot storage, NOT a kernel restore interface.
11
16
  * The caller MUST already hold an externally proven exclusive directory lifecycle
@@ -79,11 +84,14 @@ export async function openEditLockStore({ directory, domainId, mode, maxSnapshot
79
84
  await io('file-sync', async () => { await openedFile.sync() })
80
85
  await io('file-close', async () => { await openedFile.close(); file = undefined })
81
86
  await io('rename', async () => { renameStarted = true; await rename(temporary, target) })
82
- await io('directory-open', async () => { dir = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW) })
83
- valid(dir, 'opened directory descriptor')
87
+ // The directory steps fire on every platform — checkpoint cadence is a
88
+ // contract; only the real directory fsync is POSIX-only (EPERM on win32,
89
+ // same stance as reservation.js SYNC_SUPPORTED).
90
+ await io('directory-open', async () => { dir = DIR_SYNC_SUPPORTED ? await open(directory, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | (constants.O_NOFOLLOW ?? 0)) : undefined })
91
+ if (DIR_SYNC_SUPPORTED) valid(dir, 'opened directory descriptor')
84
92
  const openedDirectory = dir
85
- await io('directory-sync', async () => { await openedDirectory.sync() })
86
- await io('directory-close', async () => { await openedDirectory.close(); dir = undefined })
93
+ await io('directory-sync', async () => { await openedDirectory?.sync() })
94
+ await io('directory-close', async () => { await openedDirectory?.close(); dir = undefined })
87
95
  committedBytes = Buffer.from(bytes, 'utf8')
88
96
  } catch (cause) {
89
97
  // Never remove/promote a temp or roll back a rename. Once rename was
@@ -108,7 +116,9 @@ export async function openEditLockStore({ directory, domainId, mode, maxSnapshot
108
116
  await persist(current)
109
117
  } else {
110
118
  valid((await lstat(target)).isFile(), 'snapshot must be regular file')
111
- const file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
119
+ // win32: O_NOFOLLOW/O_NONBLOCK are undefined — the lstat above and the post-open
120
+ // stat below carry the identity checks (same stance as read-authority.js).
121
+ const file = await open(target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
112
122
  try {
113
123
  valid((await file.stat()).isFile(), 'snapshot must be regular file')
114
124
  let bytes
@@ -7,8 +7,12 @@
7
7
  // bundled npm only exists when the bundled Node directory actually contains
8
8
  // an `npm`; yarn and bun have no bundled offer. The returned command
9
9
  // prepends the resolved Node/bin directory to PATH so install lifecycle
10
- // scripts can spawn node, and `exec`s the manager so its exit code passes
11
- // straight through. User-CONFIGURED setup strings never enter this module.
10
+ // scripts can spawn node, and hands the process over to the manager so its
11
+ // exit code passes straight through. The command text matches the host
12
+ // shell: POSIX sh (`export PATH=...; exec ...`) everywhere except win32,
13
+ // where the host shell is PowerShell (`$env:PATH = ...; & ...; exit
14
+ // $LASTEXITCODE`) — `export`/`exec` are not cmdlets and fail there.
15
+ // User-CONFIGURED setup strings never enter this module.
12
16
  // Pure module (no ctx, no node: imports) — every fact arrives through seams.
13
17
  /** Install arguments per package manager (frozen-lockfile semantics kept). */
14
18
  export const MANAGER_INSTALL = Object.freeze({
@@ -42,10 +46,27 @@ export function quoteSh(word) {
42
46
  return `'${text.replace(/'/g, `'\\''`)}'`
43
47
  }
44
48
 
45
- /** POSIX dirname of a '/'-separated path. @param {string} path */
49
+ /**
50
+ * Single-quote a word for PowerShell: a single-quoted PowerShell string has
51
+ * no escape semantics — the only special character inside is ' itself,
52
+ * doubled. Every word is wrapped (there is no safe-word pass-through).
53
+ * @param {string} word
54
+ */
55
+ export function quotePs(word) {
56
+ return `'${String(word).replace(/'/g, `''`)}'`
57
+ }
58
+
59
+ /**
60
+ * dirname of a '/'- or '\'-separated path. POSIX edge semantics are kept
61
+ * (root is '/', no separator is '.'); a win32 drive root keeps its
62
+ * separator: dirOf('C:\\x') is 'C:\\', never 'C:'.
63
+ * @param {string} path
64
+ */
46
65
  function dirOf(path) {
47
- const at = path.lastIndexOf('/')
48
- return at > 0 ? path.slice(0, at) : at === 0 ? '/' : '.'
66
+ const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
67
+ if (at <= 0) return at === 0 ? '/' : '.'
68
+ if (at === 2 && /^[A-Za-z]:/.test(path)) return path.slice(0, 3)
69
+ return path.slice(0, at)
49
70
  }
50
71
 
51
72
  /**
@@ -55,6 +76,7 @@ function dirOf(path) {
55
76
  * @property {(path: string) => boolean} isFile
56
77
  * @property {string | undefined} dshHome - the deployment's DSH home
57
78
  * @property {string} [home] - user home, used as `<home>/.dsh` when dshHome is unset
79
+ * @property {string} [platform] - host platform ('win32' switches the command form, dirname separators, and the bundled node file name); defaults to process.platform
58
80
  */
59
81
 
60
82
  /**
@@ -66,6 +88,10 @@ function dirOf(path) {
66
88
  export function resolveDerivedSetup(manager, seams) {
67
89
  const args = MANAGER_INSTALL[manager]
68
90
  if (!args) return { ok: false, manager }
91
+ // Defaulting to the HOST platform is the fail-safe choice: emitting POSIX
92
+ // text on a Windows host is exactly the failure this seam exists to
93
+ // prevent. Tests pin the platform explicitly.
94
+ const platform = seams.platform ?? process.platform
69
95
  const system = seams.foundOnPath(manager)
70
96
  if (system) {
71
97
  const node = seams.foundOnPath('node')
@@ -77,7 +103,7 @@ export function resolveDerivedSetup(manager, seams) {
77
103
  // Node's directory first: the manager's shim and every install
78
104
  // lifecycle script resolve node through PATH, and the manager's own
79
105
  // directory (e.g. ~/.npm-global/bin) need not contain one.
80
- command: wrap([dirOf(node), dirOf(system)], [system, ...args]),
106
+ command: wrap([dirOf(node), dirOf(system)], [system, ...args], platform),
81
107
  }
82
108
  }
83
109
  // A manager whose interpreter is not on PATH cannot run either — fall
@@ -86,6 +112,8 @@ export function resolveDerivedSetup(manager, seams) {
86
112
  const offer = BUNDLED_OFFER[/** @type {'pnpm' | 'npm'} */ (manager)]
87
113
  const home = seams.dshHome ?? (seams.home ? `${seams.home}/.dsh` : undefined)
88
114
  if (offer && home) {
115
+ // The bundled Node executable carries the platform's file name.
116
+ const nodeRel = platform === 'win32' ? `${BUNDLED_REL.node}.exe` : BUNDLED_REL.node
89
117
  let runtimes = []
90
118
  try {
91
119
  runtimes = seams.listDirs(`${home}/dsh-runtimes`)
@@ -94,7 +122,7 @@ export function resolveDerivedSetup(manager, seams) {
94
122
  }
95
123
  for (const runtime of [...runtimes].sort()) {
96
124
  const root = `${home}/dsh-runtimes/${runtime}`
97
- const node = `${root}/${BUNDLED_REL.node}`
125
+ const node = `${root}/${nodeRel}`
98
126
  const tool = `${root}/${offer}`
99
127
  if (!seams.isFile(node) || !seams.isFile(tool)) continue
100
128
  const argv = manager === 'pnpm' ? [node, tool, ...args] : [tool, ...args]
@@ -102,7 +130,7 @@ export function resolveDerivedSetup(manager, seams) {
102
130
  ok: true,
103
131
  source: 'bundled',
104
132
  display: argv.map((part) => (part === node ? 'node' : part)).join(' '),
105
- command: wrap([`${root}/dependencies/node/bin`], argv),
133
+ command: wrap([`${root}/dependencies/node/bin`], argv, platform),
106
134
  }
107
135
  }
108
136
  }
@@ -118,8 +146,19 @@ export function setupMissingReason(manager) {
118
146
  return `setup needs ${manager}, but it was found neither on PATH nor in the DSH bundled runtime. Install ${manager} yourself, set "setup" in .weir/worktrees/.config.json, or skip with /worktree setup <lane> --skip`
119
147
  }
120
148
 
121
- /** @param {string[]} binDirs @param {string[]} argv */
122
- function wrap(binDirs, argv) {
149
+ /**
150
+ * Build the derived setup command text for the host shell. POSIX keeps the
151
+ * sh form (`export PATH=...:"$PATH"; exec ...`) byte for byte. win32 targets
152
+ * PowerShell: the PATH prefix is one single-quoted ';'-joined string
153
+ * prepended to $env:PATH, the argv runs through the `&` call operator with
154
+ * every element single-quoted, and `exit $LASTEXITCODE` passes the manager's
155
+ * exit code through (the role `exec` plays on POSIX).
156
+ * @param {string[]} binDirs @param {string[]} argv @param {string} platform
157
+ */
158
+ function wrap(binDirs, argv, platform) {
159
+ if (platform === 'win32') {
160
+ return `$env:PATH = ${quotePs(`${binDirs.join(';')};`)} + $env:PATH; & ${argv.map(quotePs).join(' ')}; exit $LASTEXITCODE`
161
+ }
123
162
  const path = binDirs.map(quoteSh).join(':')
124
163
  return `export PATH=${path}:"$PATH"; exec ${argv.map(quoteSh).join(' ')}`
125
164
  }
@@ -102,10 +102,10 @@ export function createGitRunner(subprocess) {
102
102
  * manager first (the LSP module's extended resolver — S21), then the DSH
103
103
  * bundled runtime under the deployment's DSH home. Pure decision in
104
104
  * pkgmgr.js; this is only the seam wiring.
105
- * @param {{ subprocess: any, env?: Record<string, string | undefined>, home?: string, findExecutable?: (name: string) => Promise<string | undefined> }} options - `findExecutable` exists so tests can pin the lookup without fighting the host's real PATH scan
105
+ * @param {{ subprocess: any, env?: Record<string, string | undefined>, home?: string, findExecutable?: (name: string) => Promise<string | undefined>, platform?: string }} options - `findExecutable` exists so tests can pin the lookup without fighting the host's real PATH scan; `platform` defaults to process.platform and exists so tests can pin it
106
106
  * @returns {(manager: string) => Promise<ReturnType<typeof resolveDerivedSetup>>}
107
107
  */
108
- export function createSetupResolver({ subprocess, env = process.env, home = homedir(), findExecutable }) {
108
+ export function createSetupResolver({ subprocess, env = process.env, home = homedir(), findExecutable, platform = process.platform }) {
109
109
  const find = findExecutable ?? ((name) => resolveExecutable(subprocess, name).catch(() => undefined))
110
110
  return async (manager) => {
111
111
  // Manager and node are resolved INDEPENDENTLY: the core asks the seam
@@ -130,6 +130,7 @@ export function createSetupResolver({ subprocess, env = process.env, home = home
130
130
  },
131
131
  dshHome: env.DSH_HOME,
132
132
  home,
133
+ platform,
133
134
  })
134
135
  }
135
136
  }