weir-harness 1.2.2 → 2.0.0

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.
Files changed (89) hide show
  1. package/cordis.patch.yml +7 -18
  2. package/lib/client.file-lease-badge.js +307 -0
  3. package/lib/client.js +20 -30
  4. package/lib/client.settings-page.js +10 -34
  5. package/package.json +2 -2
  6. package/src/capabilities/store/checked-contract.d.ts +2 -2
  7. package/src/capabilities/store/fs-adapter.js +2 -3
  8. package/src/context-guard/index.js +2 -2
  9. package/src/delegate/supervision-mount.js +5 -5
  10. package/src/delegate/tool.js +2 -10
  11. package/src/file-lease/client.js +1066 -0
  12. package/src/file-lease/index.js +30 -0
  13. package/src/file-lease/lineage.js +207 -0
  14. package/src/file-lease/node-contracts.d.ts +42 -0
  15. package/src/file-lease/plugin.js +789 -0
  16. package/src/file-lease/singleton.js +155 -0
  17. package/src/file-lease/spawn.js +62 -0
  18. package/src/file-lease/tools.js +459 -0
  19. package/src/file-lease/view.js +144 -0
  20. package/src/hashline-edit/index.js +76 -42
  21. package/src/hashline-edit/write-tool.js +90 -0
  22. package/src/intent-gate/matcher.js +27 -0
  23. package/src/lease-broker/entry.js +98 -0
  24. package/src/lease-broker/node-contracts.d.ts +63 -0
  25. package/src/lease-broker/protocol.js +236 -0
  26. package/src/lease-broker/server.js +399 -0
  27. package/src/lease-broker/state.js +425 -0
  28. package/src/lsp/admin.js +24 -13
  29. package/src/lsp/index.js +5 -2
  30. package/src/lsp/registry.js +83 -17
  31. package/src/lsp/tools.js +137 -27
  32. package/src/notify/commands.js +15 -2
  33. package/src/notify/index.js +48 -10
  34. package/src/notify/web-channel.js +26 -3
  35. package/src/process/index.js +11 -0
  36. package/src/settings/index.js +2 -44
  37. package/src/settings/sections.js +29 -28
  38. package/src/shared/audit.js +6 -1
  39. package/src/todo-driver/index.js +1 -6
  40. package/src/worktree/cards.js +6 -21
  41. package/src/worktree/lane-core.js +1 -19
  42. package/src/worktree/lane-workflows.js +11 -32
  43. package/lib/client.edit-lock-maintenance.js +0 -592
  44. package/lib/client.edit-lock-panel.js +0 -405
  45. package/src/edit-lock/adapter.js +0 -57
  46. package/src/edit-lock/admin-ledger.js +0 -149
  47. package/src/edit-lock/admin-recovery.js +0 -151
  48. package/src/edit-lock/admission.js +0 -82
  49. package/src/edit-lock/binding-attestation.js +0 -29
  50. package/src/edit-lock/binding-guidance.js +0 -72
  51. package/src/edit-lock/binding-machine.js +0 -312
  52. package/src/edit-lock/call-context.js +0 -65
  53. package/src/edit-lock/derived-facts.js +0 -67
  54. package/src/edit-lock/domain.js +0 -165
  55. package/src/edit-lock/domains.js +0 -170
  56. package/src/edit-lock/host.js +0 -149
  57. package/src/edit-lock/incarnations.js +0 -150
  58. package/src/edit-lock/index.js +0 -1303
  59. package/src/edit-lock/inspect.js +0 -219
  60. package/src/edit-lock/lifecycle.js +0 -579
  61. package/src/edit-lock/limits.js +0 -61
  62. package/src/edit-lock/maintenance.js +0 -310
  63. package/src/edit-lock/manager.js +0 -875
  64. package/src/edit-lock/negotiation.js +0 -132
  65. package/src/edit-lock/operation-history.js +0 -214
  66. package/src/edit-lock/peer-client.js +0 -86
  67. package/src/edit-lock/peer-transport.js +0 -76
  68. package/src/edit-lock/peer.js +0 -87
  69. package/src/edit-lock/publisher.js +0 -191
  70. package/src/edit-lock/read-authority.js +0 -101
  71. package/src/edit-lock/recovery.js +0 -139
  72. package/src/edit-lock/remote.js +0 -362
  73. package/src/edit-lock/request-binding.js +0 -42
  74. package/src/edit-lock/request-data.js +0 -40
  75. package/src/edit-lock/reservation.js +0 -301
  76. package/src/edit-lock/reserved-runtime.js +0 -45
  77. package/src/edit-lock/resource-identity.js +0 -130
  78. package/src/edit-lock/runtime.js +0 -145
  79. package/src/edit-lock/settle.js +0 -144
  80. package/src/edit-lock/snapshot.js +0 -175
  81. package/src/edit-lock/stale-sweep.js +0 -120
  82. package/src/edit-lock/state.js +0 -462
  83. package/src/edit-lock/store.js +0 -327
  84. package/src/edit-lock/tool-scope.js +0 -29
  85. package/src/edit-lock/view.js +0 -170
  86. package/src/edit-lock/write-scopes.js +0 -48
  87. package/src/edit-lock/write-tool.js +0 -45
  88. package/src/shared/delegate-edit-lifecycle.js +0 -36
  89. package/src/worktree/authority.js +0 -56
package/src/lsp/tools.js CHANGED
@@ -1,12 +1,14 @@
1
1
  // The LSP semantic tools: four read-only queries plus `lsp_rename`, the one
2
2
  // rewriting tool (cross-file WorkspaceEdit application through the fs version
3
- // guard). Pure-object definitions with object-rooted schemas; 1-based
4
- // positions in, structured text out; every LSP failure becomes an ordinary
5
- // tool error result, never a broken turn.
3
+ // guard, under ONE batch file lease). Pure-object definitions with
4
+ // object-rooted schemas; 1-based positions in, structured text out; every LSP
5
+ // failure becomes an ordinary tool error result, never a broken turn.
6
6
  import { familyForLanguageId, languageIdForFile } from './registry.js'
7
7
  import { uriToPath } from './uri.js'
8
8
  import { applyTextEdits, detectLineEndings, extractChanges, normalizeLineEndings, restoreLineEndings } from './rename.js'
9
9
  import { unifiedDiff } from '../hashline-edit/diff.js'
10
+ import { createEditorLease } from '../file-lease/singleton.js'
11
+ import { isLeaseConflict } from '../file-lease/client.js'
10
12
 
11
13
  const SEVERITY = { 1: 'error', 2: 'warning', 3: 'information', 4: 'hint' }
12
14
 
@@ -34,9 +36,105 @@ function asLocationArray(result) {
34
36
  .filter(Boolean)
35
37
  }
36
38
 
37
- /** Build the five tool definitions for one calling agent's enable. */
38
- export function createLspTools({ manager, ctx, agent }) {
39
- const editLock = ctx.get?.('weirEditLock')
39
+ /**
40
+ * The sandbox denial marker the host backend raises, by prefix only: every
41
+ * effective mode ends with its own mode name, and a denial never moved bytes.
42
+ */
43
+ const SANDBOX_DENIAL_MARKER = '[sandbox: file access denied under'
44
+
45
+ /** @param {unknown} error @returns {boolean} */
46
+ function isSandboxDenial(error) {
47
+ return String(/** @type {any} */ (error)?.message ?? '').includes(SANDBOX_DENIAL_MARKER)
48
+ }
49
+
50
+ /** @param {unknown} error @returns {string} */
51
+ function messageOf(error) {
52
+ return String(/** @type {any} */ (error)?.message ?? error)
53
+ }
54
+
55
+ /**
56
+ * The three-way truth of a failed rename (design D12): which targets are on
57
+ * disk, which PROVABLY are not, and which cannot be proven either way.
58
+ *
59
+ * Only these three failures prove that no byte of a target landed: a refused
60
+ * claim (nothing ran), the filesystem version guard (the write was rejected
61
+ * before it moved bytes) and a sandbox denial (the backend refused the
62
+ * operation). Anything else — an I/O error, an abort, a backend that threw
63
+ * after a partial write — is UNPROVEN, and claiming it was not written would be
64
+ * a lie the model would act on.
65
+ *
66
+ * @param {unknown} error
67
+ * @returns {boolean}
68
+ */
69
+ function isProvablyUnwritten(error) {
70
+ if (isLeaseConflict(error)) return true
71
+ const value = /** @type {any} */ (error)
72
+ if (String(value?.code ?? '').includes('FS_STALE_VERSION')) return true
73
+ return String(value?.message ?? '').includes('FS_STALE_VERSION') || isSandboxDenial(error)
74
+ }
75
+
76
+ /**
77
+ * The honest failure report: three lists and no rollback claim. The identity of
78
+ * the caller's claim is stated truthfully too — a batch refused before the
79
+ * claim was granted holds nothing, while a batch that failed mid-write keeps its
80
+ * leases until the turn ends (design D2/D10).
81
+ *
82
+ * @param {{ failure: unknown, remaining: any[], written: any[], entered: boolean }} detail
83
+ * @returns {Error}
84
+ */
85
+ function renameFailure({ failure, remaining, written, entered }) {
86
+ const list = (plans) => `[${plans.map((plan) => JSON.stringify(plan.target.displayPath)).join(', ')}]`
87
+ const proven = !entered || isProvablyUnwritten(failure)
88
+ const notWritten = proven ? remaining : []
89
+ const uncertain = proven ? [] : remaining
90
+ const failedPath = entered ? JSON.stringify(remaining[0]?.target.displayPath ?? '') : undefined
91
+ /** @type {Error & { cause?: unknown }} */
92
+ const error = new Error([
93
+ failedPath === undefined
94
+ ? `lsp_rename: the rename was refused before any write: ${messageOf(failure)}`
95
+ : `lsp_rename: write failed for ${failedPath}: ${messageOf(failure)}`,
96
+ `filesAlreadyWritten: ${list(written)};`,
97
+ `filesNotWritten: ${list(notWritten)};`,
98
+ `filesUncertain: ${list(uncertain)};`,
99
+ entered
100
+ ? 'ownership retained (no rollback)'
101
+ : 'no claim was taken for this batch (no rollback)',
102
+ ].join('\n'))
103
+ error.cause = failure
104
+ return error
105
+ }
106
+
107
+ /**
108
+ * The optional `leaseClient` seam on a scope that may not declare it.
109
+ *
110
+ * A cordis context THROWS on reading a property it does not inject
111
+ * ("cannot get property \"leaseClient\" without inject"), so probing it directly
112
+ * makes the seam a trap: `lsp` injects `tools`/`subprocess`/`fs`, never a lease
113
+ * client, and every `lsp_rename` would fail at tool construction. An explicit
114
+ * argument (unit tests, an embedder) always wins; the probe only ever answers
115
+ * `undefined` when the composition does not carry one.
116
+ * @param {any} scope
117
+ * @returns {any}
118
+ */
119
+ function optionalLeaseClient(scope) {
120
+ try {
121
+ return scope?.leaseClient
122
+ } catch {
123
+ return undefined
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Build the five tool definitions for one calling agent's enable.
129
+ *
130
+ * `leaseClient` is the injection seam (explicit argument or a carrying ctx),
131
+ * because this module's caller forwards only `{manager, ctx, agent}`. Nothing
132
+ * else in the row reads it: no service is published or consumed for leases
133
+ * (design D11) — the default is the process-wide client, resolved on the first
134
+ * managed write.
135
+ */
136
+ export function createLspTools({ manager, ctx, agent, leaseClient }) {
137
+ const lease = createEditorLease({ leaseClient: leaseClient ?? optionalLeaseClient(ctx), ctx })
40
138
  // Optional sandbox policy capture (S23, mirrors hashline-edit): the
41
139
  // sandboxed fs backend enforces the session policy only when the caller
42
140
  // passes it per call. Absent service (headless test compositions, other
@@ -179,7 +277,7 @@ export function createLspTools({ manager, ctx, agent }) {
179
277
  },
180
278
  {
181
279
  name: 'lsp_rename',
182
- description: `Rename the symbol at a 1-based position across the workspace: the language server computes every edit location and the resulting WorkspaceEdit is applied to disk through the filesystem version guard. Every target file is preflighted before anything is written (a bad edit range aborts the call with zero writes); then files are written one by one and a mid-write failure (file changed on disk, sandbox denial, I/O) stops immediately, reporting exactly which files were already written and which were not. The original line-ending style (LF/CRLF) of each file is preserved.`,
280
+ description: `Rename the symbol at a 1-based position across the workspace: the language server computes every edit location and the resulting WorkspaceEdit is applied to disk through the filesystem version guard, under one file-lease claim covering every preflighted target. Every target file is preflighted before anything is written (a bad edit range aborts the call with zero writes); then files are written one by one and a mid-write failure (file changed on disk, sandbox denial, I/O) stops immediately, reporting exactly which files were already written, which were not written, and which could not be proven either way. Nothing is rolled back. The original line-ending style (LF/CRLF) of each file is preserved.`,
183
281
  parameters: {
184
282
  type: 'object',
185
283
  properties: {
@@ -254,25 +352,39 @@ export function createLspTools({ manager, ctx, agent }) {
254
352
  return { text: `lsp_rename: no-op — the server's edits leave every file unchanged; nothing was written.` }
255
353
  }
256
354
 
257
- // Phase 2 — write pass: one atomic replaceIfVersion write per file.
258
- // A failure stops the pass immediately and names both file lists.
355
+ // Phase 2 — write pass under ONE batch claim (design D2): the complete
356
+ // preflighted target set is acquired atomically before the first byte, so
357
+ // a conflict anywhere refuses the whole call with nothing written and no
358
+ // implicit takeover. The claim is NOT released here: it lives until the
359
+ // turn ends, like every other managed edit claim (D10), so the session's
360
+ // own later edits of these files cannot conflict with itself.
259
361
  const written = []
260
- if (editLock) {
261
- await editLock.publishBatch(exec, {cwd: resolveCwd, effectivePolicy: policy, args,
262
- plans: writePlans.map(plan => ({filePath: plan.filePath, content: plan.after, version: plan.version}))})
263
- written.push(...writePlans)
264
- } else for (const plan of writePlans) {
265
- try {
266
- await ctx.fs.writeText(plan.target, plan.after, { kind: 'replaceIfVersion', version: plan.version }, exec.signal, policy)
267
- written.push(plan)
268
- } catch (error) {
269
- const notWritten = writePlans.slice(written.length).map((plan) => plan.target.displayPath)
270
- const already = written.map((plan) => plan.target.displayPath)
271
- throw new Error(
272
- `lsp_rename: write failed for ${plan.target.displayPath}: ${error?.message ?? error}\n` +
273
- `Rename partially applied. filesAlreadyWritten: [${already.join(', ')}]; filesNotWritten: [${notWritten.join(', ')}]`,
274
- )
275
- }
362
+ let entered = false
363
+ /** @type {unknown} */
364
+ let failure = null
365
+ try {
366
+ await lease.client().withLeases(
367
+ lease.ownerOf(exec.agent),
368
+ plans.map((plan) => plan.target.displayPath),
369
+ lease.scopeOf(exec.agent, resolveCwd),
370
+ async () => {
371
+ entered = true
372
+ for (const plan of writePlans) {
373
+ await ctx.fs.writeText(plan.target, plan.after, { kind: 'replaceIfVersion', version: plan.version }, exec.signal, policy)
374
+ written.push(plan)
375
+ }
376
+ },
377
+ )
378
+ } catch (error) {
379
+ failure = error
380
+ }
381
+ if (failure !== null) {
382
+ // The failure report is the whole contract here (design D12): which
383
+ // files are provably on disk, which provably are not, which cannot be
384
+ // proven, and never a rollback claim. `written` holds exactly the
385
+ // files that returned from writeText, so the failing target is the
386
+ // head of the remaining slice.
387
+ throw renameFailure({ failure, remaining: writePlans.slice(written.length), written, entered })
276
388
  }
277
389
 
278
390
  const editCount = writePlans.reduce((sum, plan) => sum + plan.edits, 0)
@@ -284,8 +396,6 @@ export function createLspTools({ manager, ctx, agent }) {
284
396
  },
285
397
  },
286
398
  ]
287
- // Only a definition built with the captured service is admitted by Edit Lock.
288
- if (editLock) for (const tool of tools) if (tool.name === 'lsp_rename') editLock.claim?.(tool)
289
399
  return tools
290
400
  }
291
401
 
@@ -54,7 +54,10 @@ function linux(note, sound) {
54
54
  }
55
55
  }
56
56
 
57
- /** PowerShell source of the Windows toast; reads title/body from the environment. */
57
+ /**
58
+ * PowerShell source of the Windows toast; reads title/body from the environment
59
+ * and reports an OS-level block through stderr plus a non-zero exit.
60
+ */
58
61
  const WINDOWS_SCRIPT = [
59
62
  "$ErrorActionPreference = 'Stop'",
60
63
  '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null',
@@ -66,7 +69,17 @@ const WINDOWS_SCRIPT = [
66
69
  '$xml.LoadXml("<toast><visual><binding template=\'ToastGeneric\'><text>$title</text><text>$body</text></binding></visual>$audio</toast>")',
67
70
  '$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)',
68
71
  "$appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe'",
69
- '[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast)',
72
+ // `Show()` has no synchronous failure return, and the asynchronous
73
+ // `ToastNotification.Failed` event cannot be awaited by a process that exits
74
+ // right after firing, so `ToastNotifier.Setting` is the synchronous proxy: a
75
+ // non-`Enabled` value (disabled for the app, the user, by group policy or by
76
+ // manifest) becomes a non-zero exit whose stderr reaches the warn-once path
77
+ // instead of vanishing into silence. Explicit variables, not a chained
78
+ // expression: PowerShell 5.1 chaining behaviour must not be relied on.
79
+ '$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId)',
80
+ '$setting = $notifier.Setting',
81
+ 'if ("$setting" -ne \'Enabled\') { [Console]::Error.WriteLine("Windows toast notifications are unavailable: $setting"); exit 3 }',
82
+ '$notifier.Show($toast)',
70
83
  ].join('\n')
71
84
 
72
85
  /**
@@ -30,6 +30,32 @@ const STALE_EVENT_MS = 60_000
30
30
  /** Longest parent chain walked to find the session the user actually sees. */
31
31
  const MAX_ANCESTORS = 8
32
32
 
33
+ /**
34
+ * Platforms whose page can both DISPLAY a notification and REPORT whether it
35
+ * appeared, so handing the note to the page first is sound there: macOS and
36
+ * Linux. Windows is deliberately absent — the page's web `Notification` can
37
+ * neither display nor report failure there. Electron only shows a toast for an
38
+ * app that owns a Start Menu shortcut with an AppUserModelID and a
39
+ * ToastActivatorCLSID (the DSH desktop bundle declares no app identity), so
40
+ * Chromium drops it with nothing but a debug log; and in a secure context with
41
+ * permission granted, Blink never fails the constructor and never fires
42
+ * `error`. The page could therefore only answer `shown`/`pending`, both
43
+ * terminal, which would suppress the fallback forever. So Windows goes
44
+ * straight to the platform command, whose AUMID points at an OS-registered
45
+ * shortcut. The cost: Windows attributes the toast to "Windows PowerShell".
46
+ */
47
+ export const PAGE_DELIVERY_PLATFORMS = Object.freeze(['darwin', 'linux'])
48
+
49
+ /**
50
+ * Whether the page leg can carry a delivery on this platform.
51
+ *
52
+ * @param {string} platform - a `process.platform` value
53
+ * @returns {boolean}
54
+ */
55
+ export function usesPageDelivery(platform) {
56
+ return PAGE_DELIVERY_PLATFORMS.includes(platform)
57
+ }
58
+
33
59
  /**
34
60
  * Resolve the effective options NOW (the settings service is live: a
35
61
  * volatile commit takes effect on the next event, no remount). Unusable
@@ -238,26 +264,38 @@ export function wire(ctx, config, { notifier, channel, now = Date.now, setTimer,
238
264
  }
239
265
  }
240
266
 
241
- /** @param {any} ctx @param {object} [config] */
242
- function apply(ctx, config = {}) {
243
- const notifier = createNotifier({ logger: ctx.logger })
244
- if (!notifier.supported) ctx.logger?.warn?.(`notify: system notifications are not supported on platform "${process.platform}"`)
245
- // The page delivers (as DeepSeek Harness); the system command is the fallback.
246
- const channel = createWebChannel({ fallback: (note, options) => notifier.send(note, options), logger: ctx.logger })
267
+ /**
268
+ * @param {any} ctx
269
+ * @param {object} [config]
270
+ * @param {{ platform?: string }} [options] - test seam; cordis calls (ctx, config) only
271
+ */
272
+ function apply(ctx, config = {}, { platform = process.platform } = {}) {
273
+ const notifier = createNotifier({ platform, logger: ctx.logger })
274
+ if (!notifier.supported) ctx.logger?.warn?.(`notify: system notifications are not supported on platform "${platform}"`)
275
+ // The page delivers (as DeepSeek Harness) where it can also confirm; on the
276
+ // other platforms the system command is the only leg (PAGE_DELIVERY_PLATFORMS).
277
+ const channel = usesPageDelivery(platform)
278
+ ? createWebChannel({ fallback: (note, options) => notifier.send(note, options), logger: ctx.logger })
279
+ : undefined
247
280
  const offWire = wire(ctx, config, { notifier, channel })
248
281
  // Page endpoints (pull/ack) and the macOS permission panel's endpoints. They
249
282
  // register from this row, whose `./notify` subpath already exists, so no new
250
283
  // package subpath is needed (S19); services come through ctx.inject (S20).
251
284
  const offEndpoints = wireNotifyPermissions(ctx, {
285
+ platform,
252
286
  channel,
253
- // The permission panel's test goes through the real path, and shows even
254
- // with the window in front (the user is looking at the settings page).
255
- sendTest: () => channel.send({ ...TEST_NOTE, tag: tagFor('test', 'test'), renotify: RENOTIFY, sound: true, foreground: 'always' }),
287
+ // The permission panel's test goes through whichever path the platform
288
+ // uses, and shows even with the window in front (the user is looking at
289
+ // the settings page).
290
+ sendTest: () =>
291
+ channel
292
+ ? channel.send({ ...TEST_NOTE, tag: tagFor('test', 'test'), renotify: RENOTIFY, sound: true, foreground: 'always' })
293
+ : notifier.send(TEST_NOTE, { sound: true }),
256
294
  })
257
295
  return () => {
258
296
  offWire()
259
297
  offEndpoints()
260
- channel.close()
298
+ channel?.close()
261
299
  }
262
300
  }
263
301
 
@@ -9,8 +9,13 @@
9
9
  //
10
10
  // Safety contract (spec "待发通知通道对失败与异常页面安全"):
11
11
  // - a note is handed to exactly ONE puller;
12
- // - a note is delivered at most once: a page that showed it, or suppressed it
13
- // on purpose (window in the foreground), is final — no fallback;
12
+ // - a note is delivered at most once: a page that showed it, suppressed it on
13
+ // purpose (window in the foreground), or handed it off without confirmation
14
+ // (`pending`) is final — no fallback;
15
+ // - `pending` is final but is NOT confirmation: it means the page could not
16
+ // tell whether the notification appeared, so it is reported once per cause
17
+ // through the logger instead of falling back — a silent platform drop would
18
+ // otherwise be invisible to the user and to the log;
14
19
  // - a note is never lost: no puller, a failed page, or a missing ack all fall
15
20
  // back to the system command;
16
21
  // - nothing here touches a session. Plain ESM, ctx-free; clock and timers are
@@ -25,11 +30,16 @@ export const ACK_TIMEOUT_MS = 4_000
25
30
 
26
31
  /**
27
32
  * What the page may report. `shown`/`pending`/`suppressed` are final (no
28
- * fallback); everything else falls back.
33
+ * fallback); everything else falls back. `pending` is final without being
34
+ * confirmation — the page handed the note off and could not tell whether it
35
+ * appeared — so it is reported through the logger instead of falling back.
29
36
  */
30
37
  export const FINAL_RESULTS = Object.freeze(['shown', 'pending', 'suppressed'])
31
38
  export const FALLBACK_RESULTS = Object.freeze(['blocked', 'error', 'threw', 'unsupported'])
32
39
 
40
+ /** Reported once per channel when a page hands a note off but never confirms it. */
41
+ const UNCONFIRMED = 'the page sent a notification but could not confirm it appeared'
42
+
33
43
  /**
34
44
  * @typedef {{ title: string, body: string, urgent: boolean }} Note
35
45
  * @typedef {Note & { tag: string, renotify: boolean, sound: boolean, foreground: 'skip' | 'always' }} WebNote
@@ -63,6 +73,15 @@ export function createWebChannel({ fallback, now = Date.now, setTimer, clearTime
63
73
  let nextId = 1
64
74
  let closed = false
65
75
 
76
+ /** Distinct warnings already emitted (one per cause, like the notifier). @type {Set<string>} */
77
+ const reported = new Set()
78
+ /** @param {string} cause */
79
+ const warnOnce = (cause) => {
80
+ if (reported.has(cause)) return
81
+ reported.add(cause)
82
+ logger?.warn?.(`notify: ${cause}`)
83
+ }
84
+
66
85
  const hasPuller = () => waiters.length > 0 || now() - lastPullAt < PULLER_FRESH_MS
67
86
 
68
87
  /** @param {WebNote} note */
@@ -165,6 +184,10 @@ export function createWebChannel({ fallback, now = Date.now, setTimer, clearTime
165
184
  inFlight.delete(id)
166
185
  stop(entry.timer)
167
186
  if (!FINAL_RESULTS.includes(result)) useFallback(entry.note)
187
+ // `pending` is final (never fall back) but it is not confirmation: the
188
+ // page handed the note off and could not tell whether it appeared. Say so
189
+ // once per cause, or a silent platform drop stays invisible.
190
+ else if (result === 'pending') warnOnce(UNCONFIRMED)
168
191
  return true
169
192
  },
170
193
 
@@ -39,6 +39,8 @@ export function extraBinDirectories(env = process.env, platform = process.platfo
39
39
  // describe, not which separator to spell, so a test on any host can point
40
40
  // the seam at a real fixture directory.
41
41
  const roaming = env.APPDATA ?? join(home, 'AppData', 'Roaming')
42
+ const localAppData = env.LOCALAPPDATA ?? join(home, 'AppData', 'Local')
43
+ const programFiles = env.ProgramFiles ?? 'C:\\Program Files'
42
44
  return [
43
45
  // npm's own global prefix: the .cmd/.ps1 shims live directly here.
44
46
  join(roaming, 'npm'),
@@ -51,6 +53,15 @@ export function extraBinDirectories(env = process.env, platform = process.platfo
51
53
  join(home, 'go', 'bin'),
52
54
  join(home, 'scoop', 'shims'),
53
55
  join(roaming, 'npm', 'node_modules', '.bin'),
56
+ // LLVM's NSIS installer skips the "add to PATH" step in silent mode, so
57
+ // these two are the ONLY way a winget-installed clangd resolves (D4).
58
+ join(programFiles, 'LLVM', 'bin'),
59
+ join(localAppData, 'Programs', 'LLVM', 'bin'),
60
+ // WinGet portable packages expose their command aliases here (LuaLS).
61
+ join(localAppData, 'Microsoft', 'WinGet', 'Links'),
62
+ // winget.exe itself lives here; a long-running GUI's PATH can predate the
63
+ // App Installer update, so the directory scan must cover it too.
64
+ join(localAppData, 'Microsoft', 'WindowsApps'),
54
65
  ]
55
66
  }
56
67
  const nvmBins = []
@@ -14,32 +14,14 @@ import { configValue } from './volatile.js'
14
14
  import { computeSections } from './sections.js'
15
15
  import { wireLspAdmin } from '../lsp/admin.js'
16
16
  import { wireEnvAdmin } from './env-admin.js'
17
- import { createEditLockEvidence, wireEditLockMaintenance } from '../edit-lock/maintenance.js'
18
- import { managementRootFor } from '../edit-lock/domains.js'
19
- import { createAudit, AUDIT_TYPES } from '../shared/audit.js'
20
-
21
- // Host lifetime, not settings-row lifetime: remounting settings must not forget
22
- // still-live preset rows. Weak keys isolate hosts and release stopped hosts.
23
- const evidenceByHost = new WeakMap()
24
17
 
25
18
  export { Config } from './sections.js'
26
19
 
27
20
  const name = 'weir-settings'
28
21
  const inject = []
29
22
 
30
- // Explicit resolver seam keeps root-discovery tests away from ancestor authorities.
31
- export function maintenanceRoots(agents, resolveRoot = managementRootFor) {
32
- const roots = new Set()
33
- for (const agent of agents?.roots?.() ?? []) {
34
- const cwd = agent.session?.header?.cwd
35
- if (typeof cwd !== 'string') continue
36
- try { roots.add(resolveRoot(cwd)) } catch { /* unavailable root */ }
37
- }
38
- return [...roots]
39
- }
40
- /** @param {{ resolveRoot?: typeof managementRootFor }} [dependencies] */
41
- export function createSettingsPlugin({ resolveRoot = managementRootFor } = {}) {
42
23
  // Arrow apply: cordis must retain the returned endpoint disposer, not construct it.
24
+ export function createSettingsPlugin() {
43
25
  return (ctx, config = {}) => {
44
26
  // The whitelist defaults cache: the defaults file is read once per process,
45
27
  // and an explicit reload entry (robashDefaultsReload) clears it. Nothing here
@@ -55,21 +37,9 @@ return (ctx, config = {}) => {
55
37
  // Compute eagerly: malformed categoryChains/lspServers/robash lists fail
56
38
  // activation loud.
57
39
  compute()
58
- const host = ctx.root ?? ctx
59
- let editLockEvidence = evidenceByHost.get(host)
60
- if (!editLockEvidence) {
61
- editLockEvidence = createEditLockEvidence()
62
- evidenceByHost.set(host, editLockEvidence)
63
- }
64
- const candidateRoots = () => maintenanceRoots(ctx.get?.('agents'), resolveRoot)
65
- const savedEnabled = () => compute().editLock?.enabled === true
66
- const audit = createAudit(ctx)
67
- let lastEnabled = savedEnabled()
68
-
69
40
  const listeners = new Set()
70
41
  // Host-plane service: preset modules read their override section on demand.
71
42
  ctx.reflect.provide('weirSettings', {
72
- editLockEvidence,
73
43
  get(key) {
74
44
  return compute()[key]
75
45
  },
@@ -97,17 +67,6 @@ return (ctx, config = {}) => {
97
67
  observeReloadEntry()
98
68
  ctx.on('loader/volatile-update', () => {
99
69
  observeReloadEntry()
100
- const enabled = savedEnabled()
101
- if (enabled !== lastEnabled) {
102
- lastEnabled = enabled
103
- // Profile intent has no session log. Mirror only when a server-derived
104
- // workspace exists; no cwd fallback to the developer's current directory.
105
- const data = { kind: enabled ? 'enable-requested' : 'disable-requested', scope: 'profile', appliesAfterRestart: true }
106
- let roots = []
107
- try { roots = candidateRoots() } catch { /* emit remains available */ }
108
- if (roots.length) for (const root of roots) audit(null, AUDIT_TYPES.editLockMaintenance, data, { root })
109
- else audit(null, AUDIT_TYPES.editLockMaintenance, data)
110
- }
111
70
  for (const callback of listeners) callback()
112
71
  })
113
72
 
@@ -126,11 +85,10 @@ return (ctx, config = {}) => {
126
85
  // built-in catalog.
127
86
  const offLspAdmin = wireLspAdmin(ctx, () => compute().lsp?.servers)
128
87
 
129
- const offMaintenance = wireEditLockMaintenance(ctx, { savedEnabled, evidence: editLockEvidence, candidateRoots })
130
88
  // Environment facts endpoint (settings-page `when: { env }` rows): same
131
89
  // settings-row wiring as the LSP endpoints above (S19).
132
90
  const offEnvAdmin = wireEnvAdmin(ctx)
133
- return () => { offMaintenance(); offLspAdmin(); offEnvAdmin(); listeners.clear() }
91
+ return () => { offLspAdmin(); offEnvAdmin(); listeners.clear() }
134
92
  }
135
93
  }
136
94
  const apply = createSettingsPlugin()
@@ -43,17 +43,6 @@ const FIELDS = [
43
43
  { key: 'guardSoftThreshold', section: 'contextGuard', field: 'softThreshold', type: 'number', description: 'Soft pressure threshold (live advisory; 0 < soft < hard < 1)' },
44
44
  { key: 'guardHardThreshold', section: 'contextGuard', field: 'hardThreshold', type: 'number', description: 'Hard pressure threshold (live maintenance request; not a completion guarantee)' },
45
45
  { key: 'hashlineHideStockEdit', section: 'hashlineEdit', field: 'hideStockEdit', type: 'boolean', description: 'Hide the stock edit tool (hash_edit only)', restart: true },
46
- { key: 'editLockEnabled', section: 'editLock', field: 'enabled', type: 'boolean', description: 'Edit Lock cross-session file ownership (default on; explicit off applies after restart)', restart: true },
47
- // Retention (design D1/D2): one lock batch shares one cumulative allowance, so a
48
- // session reads one expiry instead of N countdowns. These keys are policy, not
49
- // mechanism: the kernel never sees them, the lifecycle resolves them per call.
50
- { key: 'editLockHoldDefaultMinutes', section: 'editLock', field: 'holdDefaultMinutes', type: 'number', description: 'Edit Lock: retention minutes used when a session asks to keep its locks without giving a period (default 30)', page: false },
51
- { key: 'editLockHoldSingleMaxMinutes', section: 'editLock', field: 'holdSingleMaxMinutes', type: 'number', description: 'Edit Lock: most minutes one retention request may ask for (default 30)', page: false },
52
- { 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)', page: false },
53
- { 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)', page: false },
54
- { 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)', page: false },
55
- { 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)' },
56
- { 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)' },
57
46
  { key: 'robashEnabled', section: 'robash', field: 'enabled', type: 'boolean', description: 'Guarded read-only bash for curated agents (master switch)' },
58
47
  { 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)' },
59
48
  { key: 'robashGitAllow', section: 'robash', field: 'gitAllow', type: 'string', list: true, description: 'JSON array of git subcommands to APPEND to the product-default git allow list (empty adds nothing; the defaults are always in effect)' },
@@ -72,7 +61,7 @@ const FIELDS = [
72
61
  { key: 'worktreeAutoSetup', section: 'worktree', field: 'autoSetup', type: 'boolean', description: 'Install dependencies in a new lane automatically (configured setup, else lockfile-derived)' },
73
62
  { key: 'worktreeWatchTimeoutMinutes', section: 'worktree', field: 'watchTimeoutMinutes', type: 'number', description: 'Minutes a lane-state watch (worktree_watch) lives before expiring with one notice (default 360 = 6 hours, minimum 1); frozen per watch at subscribe time' },
74
63
  { key: 'worktreeAutoApprove', section: 'worktree', field: 'autoApprove', type: { union: ['manual', 'auto-keep', 'auto-clean'] }, description: 'Worktree auto-approve mode: manual shows every decision card; auto-keep auto-approves merges and abandons, removing the worktree but keeping the branch; auto-clean also deletes the branch. The initial mode for sessions without a /worktree approve override (default auto-clean); a session override wins over this global value' },
75
- { key: 'lspServers', section: 'lsp', field: 'servers', type: 'string', description: 'JSON map of custom language servers: family → { command, args?, manifests?, installHint?, install? }' },
64
+ { key: 'lspServers', section: 'lsp', field: 'servers', type: 'string', description: 'JSON map of custom language servers: family → { command, args?, manifests?, installHint?, platforms?, install? }; a platforms map ({ darwin?, linux?, win32?, default? }) overrides only the fields its row declares for that platform' },
76
65
  { key: 'notifyEnabled', section: 'notify', field: 'enabled', type: 'boolean', description: 'System notifications master switch: tell the user when a session needs them or a turn finishes' },
77
66
  { key: 'notifyOnComplete', section: 'notify', field: 'onComplete', type: 'boolean', description: 'Notify when a turn finishes (only turns longer than notifyMinTurnSeconds)' },
78
67
  { key: 'notifyOnAttention', section: 'notify', field: 'onAttention', type: 'boolean', description: 'Notify when a session needs the user: approval, question, plan review, a failed or stopped turn' },
@@ -98,8 +87,8 @@ export { FIELDS }
98
87
 
99
88
  /** Product defaults of the flat settings keys, mirrored from the consuming
100
89
  * modules' code defaults (intent-gate classifier, delegate supervision,
101
- * todo driver, context guard, edit-lock retention, robash, lsp, worktree,
102
- * notify). This is the canonical declaration: the cordis.patch.yml
90
+ * todo driver, context guard, robash, lsp, worktree, notify). This is the
91
+ * canonical declaration: the cordis.patch.yml
103
92
  * weir-settings row carries the same values so saved profiles resolve
104
93
  * them, and the settings page mirrors this table client-side to SHOW the
105
94
  * effective default of an unset field (both directions pinned by test).
@@ -121,14 +110,6 @@ export const FIELD_DEFAULTS = Object.freeze({
121
110
  guardSoftThreshold: 0.72,
122
111
  guardHardThreshold: 0.88,
123
112
  hashlineHideStockEdit: true,
124
- editLockEnabled: true,
125
- editLockHoldDefaultMinutes: 30,
126
- editLockHoldSingleMaxMinutes: 30,
127
- editLockHoldCumulativeMaxMinutes: 120,
128
- editLockNudgeAttempts: 2,
129
- editLockNudgeFallback: 'release',
130
- editLockAutoResume: true,
131
- editLockStaleSweep: true,
132
113
  robashEnabled: true,
133
114
  lspEnabled: false,
134
115
  lspIdleMs: 600000,
@@ -227,19 +208,42 @@ function chainsValidator(key) {
227
208
  return validate
228
209
  }
229
210
 
230
- /** Parse + validate the lspServers JSON map (bad input fails activation loud). */
211
+ /** True for a JSON object literal (arrays and null are not plain objects). */
212
+ function isPlainObject(value) {
213
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
214
+ }
215
+
216
+ /** Parse + validate the lspServers JSON map (bad input fails activation loud).
217
+ * A family may declare `platforms: { darwin?, linux?, win32?, default? }`,
218
+ * each row shaped like an entry with every field optional; a malformed row
219
+ * names the family and field so activation failure is diagnosable (D6). */
231
220
  function validateLspServers(raw) {
232
221
  const parsed = JSON.parse(raw)
233
- if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
222
+ if (!isPlainObject(parsed)) {
234
223
  throw new Error('weir-settings: lspServers must be a JSON object map')
235
224
  }
236
225
  for (const [family, entry] of Object.entries(parsed)) {
237
- if (!entry || typeof entry !== 'object' || typeof entry.command !== 'string' || entry.command.trim().length === 0) {
226
+ if (!isPlainObject(entry) || typeof entry.command !== 'string' || entry.command.trim().length === 0) {
238
227
  throw new Error(`weir-settings: lspServers.${family} needs a { command } entry`)
239
228
  }
240
229
  if (entry.args !== undefined && !Array.isArray(entry.args)) {
241
230
  throw new Error(`weir-settings: lspServers.${family}.args must be an array`)
242
231
  }
232
+ if (entry.platforms === undefined) continue
233
+ if (!isPlainObject(entry.platforms)) {
234
+ throw new Error(`weir-settings: lspServers.${family}.platforms must be a JSON object map`)
235
+ }
236
+ for (const [platform, row] of Object.entries(entry.platforms)) {
237
+ if (!isPlainObject(row)) {
238
+ throw new Error(`weir-settings: lspServers.${family}.platforms.${platform} must be a JSON object`)
239
+ }
240
+ if (row.command !== undefined && (typeof row.command !== 'string' || row.command.trim().length === 0)) {
241
+ throw new Error(`weir-settings: lspServers.${family}.platforms.${platform}.command must be a non-empty string`)
242
+ }
243
+ if (row.args !== undefined && !Array.isArray(row.args)) {
244
+ throw new Error(`weir-settings: lspServers.${family}.platforms.${platform}.args must be an array`)
245
+ }
246
+ }
243
247
  }
244
248
  return parsed
245
249
  }
@@ -337,9 +341,6 @@ export function computeSections(config, { readDefaults } = {}) {
337
341
  }
338
342
  }
339
343
  }
340
- // A saved profile row replaces the bundle row wholesale. An omitted key
341
- // must still activate protection; a saved false remains an explicit opt-out.
342
- if (key === 'editLock' && out.enabled === undefined) out.enabled = FIELD_DEFAULTS.editLockEnabled
343
344
  if (key === 'contextGuard') guardOptions(out)
344
345
  if (key === 'lsp' && typeof out.servers === 'string' && out.servers.trim().length > 0) {
345
346
  out.servers = parseJsonField(validateLspServers, out.servers)
@@ -23,7 +23,6 @@ export const AUDIT_TYPES = Object.freeze({
23
23
  compaction: 'compaction',
24
24
  supervision: 'supervision',
25
25
  worktree: 'worktree',
26
- editLockMaintenance: 'edit-lock-maintenance',
27
26
  // Session capability manager (tasks 4.2+): one record per durable Apply
28
27
  // acceptance or receipt-query recovery; never commit evidence by itself.
29
28
  capabilityApply: 'capability-apply',
@@ -48,6 +47,12 @@ export const AUDIT_TYPES = Object.freeze({
48
47
  // spawn carrying the composition the child's creation window applies, so a
49
48
  // host restart can replay it — the shim itself is runtime-only state.
50
49
  delegate: 'delegate',
50
+ // File-lease degraded managed writes (edit-lock-rebuild D7): one record per
51
+ // degradation, carrying the kind, the owner (session and lineage), the path
52
+ // COUNT and the failure reason — never path contents. The lease client owns
53
+ // the per-process rate limit (one record per window); the emit site adds no
54
+ // throttle of its own and a failed emit never blocks a write.
55
+ fileLease: 'file-lease',
51
56
  })
52
57
  /**
53
58
  * Known dynamic sub-event kinds: `<type>/<kind>` events emitted beside a base