weir-harness 1.1.7 → 1.2.1

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 (93) hide show
  1. package/lib/client.blackboard-model.js +172 -56
  2. package/lib/client.capability-manager.js +0 -1
  3. package/lib/client.capability-model.js +378 -31
  4. package/lib/client.chain-model.js +80 -0
  5. package/lib/client.disabled-categories-editor.js +22 -0
  6. package/lib/client.edit-lock-maintenance.js +239 -246
  7. package/lib/client.edit-lock-panel.js +110 -0
  8. package/lib/client.hash-edit-model.js +48 -0
  9. package/lib/client.js +267 -1549
  10. package/lib/client.lsp-model.js +92 -0
  11. package/lib/client.lsp-panel.js +11 -16
  12. package/lib/client.lsp-toggle.js +18 -0
  13. package/lib/client.notify-permissions.js +112 -20
  14. package/lib/client.notify-web.js +14 -11
  15. package/lib/client.robash-model.js +26 -0
  16. package/lib/client.settings-page.js +287 -17
  17. package/lib/client.worktree-model.js +225 -0
  18. package/package.json +1 -1
  19. package/src/blackboard/durable.js +12 -0
  20. package/src/blackboard/index.js +2 -2
  21. package/src/blackboard/kernel.js +23 -6
  22. package/src/blackboard/notify.js +10 -3
  23. package/src/blackboard/remote.js +31 -13
  24. package/src/blackboard/storage.js +13 -5
  25. package/src/capabilities/apply-engine.js +189 -101
  26. package/src/capabilities/defaults-transaction.js +136 -1
  27. package/src/capabilities/lifecycle-snapshot.js +83 -204
  28. package/src/capabilities/mcp-admission.js +1 -23
  29. package/src/capabilities/mcp-drain.js +30 -19
  30. package/src/capabilities/mcp-facade.js +23 -3
  31. package/src/capabilities/preset-library.js +247 -0
  32. package/src/capabilities/preset-package.js +212 -1
  33. package/src/capabilities/same-name-policy.js +3 -41
  34. package/src/capabilities/selection-notify.js +324 -21
  35. package/src/capabilities/skill-selection-plugin.js +315 -729
  36. package/src/capabilities/store/checked-contract.d.ts +5 -0
  37. package/src/capabilities/store/fs-adapter.js +52 -0
  38. package/src/capabilities/store/lock.js +86 -0
  39. package/src/capabilities/store/paths.js +5 -0
  40. package/src/capabilities/store/store.js +105 -5
  41. package/src/delegate/group-coordinator.js +1 -17
  42. package/src/delegate/index.js +4 -2
  43. package/src/delegate/rehydrate.js +2 -1
  44. package/src/delegate/robash-guard-core.js +2 -2
  45. package/src/delegate/supervision-mount.js +5 -4
  46. package/src/delegate/tool.js +26 -3
  47. package/src/{shared → delegate}/whitelist-defaults.js +2 -2
  48. package/src/edit-lock/admin-ledger.js +8 -11
  49. package/src/edit-lock/admin-recovery.js +2 -1
  50. package/src/edit-lock/admission.js +12 -2
  51. package/src/edit-lock/binding-guidance.js +28 -10
  52. package/src/edit-lock/binding-machine.js +312 -0
  53. package/src/edit-lock/derived-facts.js +67 -0
  54. package/src/edit-lock/domains.js +67 -0
  55. package/src/edit-lock/index.js +115 -261
  56. package/src/edit-lock/inspect.js +113 -1
  57. package/src/edit-lock/lifecycle.js +24 -7
  58. package/src/edit-lock/limits.js +61 -0
  59. package/src/edit-lock/maintenance.js +5 -2
  60. package/src/edit-lock/manager.js +6 -2
  61. package/src/edit-lock/operation-history.js +1 -1
  62. package/src/edit-lock/peer-client.js +9 -2
  63. package/src/edit-lock/peer-transport.js +7 -1
  64. package/src/edit-lock/remote.js +11 -4
  65. package/src/edit-lock/state.js +2 -4
  66. package/src/edit-lock/view.js +10 -11
  67. package/src/lsp/admin.js +2 -24
  68. package/src/lsp/index.js +1 -1
  69. package/src/lsp/manager.js +1 -1
  70. package/src/notify/permissions-admin.js +1 -1
  71. package/src/{lsp/child-process.js → process/index.js} +12 -4
  72. package/src/settings/env-admin.js +1 -1
  73. package/src/settings/index.js +1 -1
  74. package/src/settings/sections.js +0 -54
  75. package/src/shared/audit.js +5 -0
  76. package/src/shared/http-json.js +28 -0
  77. package/src/{delegate/audit-readers.js → shared/terminal-evidence.js} +34 -4
  78. package/src/worktree/errors.js +3 -0
  79. package/src/worktree/index.js +1 -2
  80. package/src/worktree/lane-core.js +641 -0
  81. package/src/worktree/lane-views.js +147 -0
  82. package/src/worktree/lane-workflows.js +853 -0
  83. package/src/worktree/lanes.js +24 -1513
  84. package/src/worktree/runner.js +5 -4
  85. package/src/capabilities/content-pin.js +0 -88
  86. package/src/capabilities/content-refresh.js +0 -331
  87. package/src/capabilities/distribution-manifest.js +0 -78
  88. package/src/capabilities/isolated-exec.js +0 -106
  89. package/src/capabilities/publisher.js +0 -129
  90. package/src/capabilities/skill-admission.js +0 -181
  91. package/src/capabilities/update-lifecycle.js +0 -106
  92. package/src/edit-lock/remote-service.js +0 -28
  93. package/src/worktree/contract-guidance.test.js +0 -100
@@ -195,11 +195,25 @@ export function createEditLockLifecycle(runtime, sessionForAgent, options = {})
195
195
  }
196
196
  /** Register once before publishing an agent's editing tools. A session the
197
197
  * manager already knows (restart, re-created agent) is never re-armed: it
198
- * starts interrupted and needs trusted resume. @param {object} agent
198
+ * starts interrupted and needs trusted resume. Presenting the SAME agent
199
+ * object again adopts the established entry instead of registering twice
200
+ * (change edit-lock-shared-domain-mounts, design D2). @param {object} agent
199
201
  * @returns {Promise<'active'|'interrupted'>} */
200
202
  function start(agent) {
201
203
  if (closed) return Promise.reject(new Error('edit lifecycle closed'))
202
- if (entries.has(agent)) return Promise.reject(new Error('agent already registered'))
204
+ const existing = entries.get(agent)
205
+ if (existing) {
206
+ // Same-agent binding adoption: a sibling mount's idempotent re-setup
207
+ // presents the same agent object and takes the established start result
208
+ // verbatim — no openSession, no revocation, no epoch advance, no state
209
+ // change. A stopped/revoked entry keeps its state: adoption returns its
210
+ // established result, and business ingress stays gated by
211
+ // denyStopped/business until a trusted resume. Identity continuity is
212
+ // required — a host that reused the agent object for a different
213
+ // session keeps the duplicate-registration refusal.
214
+ if (sessionForAgent(agent) === existing.sessionId) return existing.ready
215
+ return Promise.reject(new Error('agent already registered'))
216
+ }
203
217
  const sessionId = sessionForAgent(agent)
204
218
  if (!sessionId) return Promise.reject(new Error('unregistered lifecycle agent'))
205
219
  const prior = [...entries].find(([, entry]) => entry.sessionId === sessionId)
@@ -213,10 +227,12 @@ export function createEditLockLifecycle(runtime, sessionForAgent, options = {})
213
227
  ? (/** @type {any} */ (prior[0]).remote !== true && holderStatus(prior[0]) === 'idle' ? 'serving-idle' : 'serving')
214
228
  : 'unknown'
215
229
  const refusal = () => {
216
- const reason = bindingRefusalReason(/** @type {any} */ (prior[0]).remote === true ? undefined : process.pid, attestation)
230
+ const pid = /** @type {any} */ (prior[0]).remote === true ? undefined : process.pid
231
+ const reason = bindingRefusalReason(pid, attestation)
232
+ const failure = bindingFailure(pid, attestation)
217
233
  // Remote callers render after their retry budget; local callers have no wire.
218
- return Object.assign(new Error(/** @type {any} */ (agent).remote === true ? reason : `${reason}: ${bindingGuidance(sessionId, reason)}`),
219
- { bindingFailure: bindingFailure(/** @type {any} */ (prior[0]).remote === true ? undefined : process.pid, attestation) })
234
+ return Object.assign(new Error(/** @type {any} */ (agent).remote === true ? reason : `${reason}: ${bindingGuidance(sessionId, failure)}`),
235
+ { bindingFailure: failure })
220
236
  }
221
237
  // Remote entries are channel proxies, NOT runtime identities. Only the
222
238
  // endpoint's nonce attestation may reclaim them (including same-process).
@@ -280,8 +296,9 @@ export function createEditLockLifecycle(runtime, sessionForAgent, options = {})
280
296
  // re-attempts the transfer.
281
297
  return forget(prior[0]).then(() => start(agent), () => {
282
298
  const reason = bindingRefusalReason(process.pid, 'serving-idle')
283
- throw Object.assign(new Error(/** @type {any} */ (agent).remote === true ? reason : `${reason}: ${bindingGuidance(sessionId, reason)}`),
284
- { bindingFailure: bindingFailure(process.pid, 'serving-idle') })
299
+ const failure = bindingFailure(process.pid, 'serving-idle')
300
+ throw Object.assign(new Error(/** @type {any} */ (agent).remote === true ? reason : `${reason}: ${bindingGuidance(sessionId, failure)}`),
301
+ { bindingFailure: failure })
285
302
  })
286
303
  }
287
304
  return start(agent)
@@ -0,0 +1,61 @@
1
+ // Edit Lock retention policy: the defaults and the overlay resolver the
2
+ // lifecycle (edit-lock/index.js) consumes. Moved verbatim out of
3
+ // src/settings/sections.js (subsystem-seam-hygiene, D4) — the owner subsystem
4
+ // now holds the policy; settings' field-default parity consumes this module
5
+ // directly (the temporary sections.js re-export was removed by dead-export-cleanup).
6
+
7
+ /** Resolved Edit Lock retention policy. The flat keys are declared in the
8
+ * settings sections table; this is the ONE place that turns them into the
9
+ * values the lifecycle consumes, so defaults and validation cannot drift
10
+ * apart. */
11
+ export const EDIT_LOCK_DEFAULTS = Object.freeze({
12
+ holdDefaultMinutes: 30,
13
+ holdSingleMaxMinutes: 30,
14
+ holdCumulativeMaxMinutes: 120,
15
+ nudgeAttempts: 2,
16
+ nudgeFallback: 'release',
17
+ })
18
+
19
+ /** Overlay one Edit Lock section onto the defaults. A key the user never set
20
+ * falls back to its default; a key that is present but unusable (alone or in
21
+ * combination) throws with the flat settings key named, because silently clamping
22
+ * a retention cap would change how long other sessions stay blocked from those
23
+ * files. The Edit Lock composition refuses retention requests with that error and
24
+ * runs every other path (settling, status, release, stop, unlock) on defaults.
25
+ * @param {any} section */
26
+ export function editLockLimits(section) {
27
+ const value = section && typeof section === 'object' ? section : {}
28
+ /** @type {{holdDefaultMinutes: number, holdSingleMaxMinutes: number, holdCumulativeMaxMinutes: number, nudgeAttempts: number, nudgeFallback: 'release'|'abnormal'}} */
29
+ const limits = { ...EDIT_LOCK_DEFAULTS }
30
+ /** @param {'holdDefaultMinutes'|'holdSingleMaxMinutes'|'holdCumulativeMaxMinutes'} key @param {string} name */
31
+ const take = (key, name) => {
32
+ const input = value[key]
33
+ if (input === undefined) return
34
+ if (typeof input !== 'number' || !Number.isFinite(input) || input <= 0) {
35
+ throw new Error(`weir-settings: ${name} must be a positive number`)
36
+ }
37
+ limits[key] = input
38
+ }
39
+ take('holdDefaultMinutes', 'editLockHoldDefaultMinutes')
40
+ take('holdSingleMaxMinutes', 'editLockHoldSingleMaxMinutes')
41
+ take('holdCumulativeMaxMinutes', 'editLockHoldCumulativeMaxMinutes')
42
+ if (limits.holdDefaultMinutes > limits.holdSingleMaxMinutes) {
43
+ throw new Error('weir-settings: editLockHoldDefaultMinutes must not exceed editLockHoldSingleMaxMinutes')
44
+ }
45
+ if (limits.holdCumulativeMaxMinutes < limits.holdSingleMaxMinutes) {
46
+ throw new Error('weir-settings: editLockHoldCumulativeMaxMinutes must be at least editLockHoldSingleMaxMinutes')
47
+ }
48
+ if (value.nudgeAttempts !== undefined) {
49
+ if (!Number.isSafeInteger(value.nudgeAttempts) || value.nudgeAttempts < 0) {
50
+ throw new Error('weir-settings: editLockNudgeAttempts must be a non-negative integer')
51
+ }
52
+ limits.nudgeAttempts = value.nudgeAttempts
53
+ }
54
+ if (value.nudgeFallback !== undefined) {
55
+ if (value.nudgeFallback !== 'release' && value.nudgeFallback !== 'abnormal') {
56
+ throw new Error('weir-settings: editLockNudgeFallback must be "release" or "abnormal"')
57
+ }
58
+ limits.nudgeFallback = value.nudgeFallback
59
+ }
60
+ return Object.freeze(limits)
61
+ }
@@ -6,7 +6,7 @@ import { join } from 'node:path'
6
6
  import { AUTHORITY_DIR } from './domains.js'
7
7
  import { reservationPathFor } from './reservation.js'
8
8
  import { parseSnapshot } from './snapshot.js'
9
- import { maintenanceState, summarizeAuthorityImage } from './inspect.js'
9
+ import { maintenanceState, summarizeAuthorityImage, recoveryProposals } from './inspect.js'
10
10
  import { readSnapshotBytes, checkedPath, verifyPins, missing, errorMessage, MAX_SNAPSHOT_BYTES } from './read-authority.js'
11
11
  import { recoverAuthority } from './admin-recovery.js'
12
12
  import { createAudit, AUDIT_TYPES } from '../shared/audit.js'
@@ -141,7 +141,10 @@ export function inspectAuthority(root) {
141
141
  if (read.presence === 'not-a-file') return { ...base, presence: 'not-a-file' }
142
142
  if (read.presence !== 'valid') return { ...base, presence: 'unreadable', message: read.message }
143
143
  try {
144
- return { ...base, presence: 'valid', snapshot: summarizeAuthorityImage(parseSnapshot(read.bytes, root)) }
144
+ const parsed = parseSnapshot(read.bytes, root)
145
+ // Both projections derive from the SAME parsed image: the snapshot summary
146
+ // and the server-computed recovery proposals (additive, schemaVersion 1).
147
+ return { ...base, presence: 'valid', snapshot: summarizeAuthorityImage(parsed), recoveryProposals: recoveryProposals(root, parsed) }
145
148
  } catch (error) {
146
149
  return { ...base, presence: 'corrupt', message: errorMessage(error) }
147
150
  }
@@ -4,7 +4,8 @@ import { bindRequest } from './request-binding.js'
4
4
  import { lookupOperation } from './operation-history.js'
5
5
  import { canonicalRequestData } from './request-data.js'
6
6
  import { admitMutation as admitHistory, publicationCandidate } from './admission.js'
7
- import { administrativeState, AUTOMATIC_RECOVERY_ACTOR, digest, dispositionFor, LATE_WRITER_RISK, ONLINE_RECOVERY_ACTOR, revokedOwner } from './admin-ledger.js'
7
+ import { administrativeState, AUTOMATIC_RECOVERY_ACTOR, digest, LATE_WRITER_RISK, ONLINE_RECOVERY_ACTOR } from './admin-ledger.js'
8
+ import { dispositionFor, revokedOwner } from './derived-facts.js'
8
9
  import { canonical } from './snapshot.js'
9
10
  import { incarnationProcess, withIncarnation } from './incarnations.js'
10
11
 
@@ -395,8 +396,11 @@ function managerCore(store, kernel, { processIdentity = null, root = null, write
395
396
  if (cancelled.has(captured.sessionId)) throw new Error('session cancelled')
396
397
  // A revoked owner is NOT refused here: the resume commits the epoch
397
398
  // advance that atomically ends the revocation (no ledger change).
399
+ // Admission runs in the resume mode: the epoch advance publishes nothing,
400
+ // so unresolved resource-level fences never block the trusted Continue;
401
+ // the fenced resources stay barred to every other ingress.
398
402
  return draft.operations.resume(captured, requestId, receipt)
399
- }, () => sessionResources(captured.sessionId)).then(async resumed => {
403
+ }, () => sessionResources(captured.sessionId), 'resume').then(async resumed => {
400
404
  const cancellation = cancelled.get(captured.sessionId)
401
405
  if (cancellation) {
402
406
  await cancellation
@@ -1,5 +1,5 @@
1
1
  import { isAbsolute, relative } from 'node:path'
2
- import { dispositionFor } from './admin-ledger.js'
2
+ import { dispositionFor } from './derived-facts.js'
3
3
 
4
4
  /** Historical assertions only: no authentication, live permission or FS publication.
5
5
  * Digests MUST be computed by a future trusted publisher over canonical requests,
@@ -1,3 +1,4 @@
1
+ import { asBindingFailure } from './binding-guidance.js'
1
2
  import { canonicalRequestData } from './request-data.js'
2
3
  import { PEER_KINDS } from './peer.js'
3
4
 
@@ -39,15 +40,21 @@ export function createEditLockPeerClient(stream, hooks = {}) {
39
40
  try { hooks.onEvent?.(message.event) } catch { /* notices never imply consent */ }
40
41
  continue
41
42
  }
43
+ const frameKeys = !message || typeof message !== 'object' ? '' : Object.keys(message).sort().join(',')
44
+ // The error frame carries the structured binding refusal as an additive
45
+ // optional field (change edit-lock-deepening, design D2).
42
46
  if (!message || typeof message.callId !== 'string' || typeof message.ok !== 'boolean'
43
- || Object.keys(message).sort().join(',') !== (message.ok ? 'callId,ok,outcome' : 'callId,error,ok')
47
+ || (message.ok ? frameKeys !== 'callId,ok,outcome' : frameKeys !== 'callId,error,ok' && frameKeys !== 'bindingFailure,callId,error,ok')
44
48
  || (!message.ok && typeof message.error !== 'string')) { close(); return }
45
49
  const entry = pending.get(message.callId)
46
50
  if (!entry) { close(); return }
47
51
  pending.delete(message.callId)
48
52
  entry.cleanup()
49
53
  if (message.ok) entry.resolve(message.outcome)
50
- else entry.reject(new Error(message.error))
54
+ else {
55
+ const failure = asBindingFailure(message.bindingFailure)
56
+ entry.reject(Object.assign(new Error(message.error), failure ? { bindingFailure: failure } : {}))
57
+ }
51
58
  }
52
59
  }
53
60
  stream.on('data', receive)
@@ -1,3 +1,4 @@
1
+ import { asBindingFailure } from './binding-guidance.js'
1
2
  import { canonicalRequestData } from './request-data.js'
2
3
 
3
4
  /** Attach ONLY an authenticated, host-owned byte stream. Never accepts public
@@ -54,7 +55,12 @@ export function serveEditLockPeer(stream, peer) {
54
55
  const callId = request.callId
55
56
  void peer.receive(request).then(
56
57
  outcome => { try { send({callId, ok:true, outcome:outcome ?? null}) } catch { close() } },
57
- error => { try { send({callId, ok:false, error:String(error?.message ?? error).slice(0,4096)}) } catch { close() } },
58
+ error => {
59
+ // The wire carries the structured binding refusal additively (change
60
+ // edit-lock-deepening, design D2); the text stays for display.
61
+ const failure = asBindingFailure(/** @type {any} */ (error)?.bindingFailure)
62
+ try { send({ callId, ok: false, error: String(/** @type {any} */ (error)?.message ?? error).slice(0, 4096), ...(failure ? { bindingFailure: failure } : {}) }) } catch { close() }
63
+ },
58
64
  )
59
65
  }
60
66
  }
@@ -3,7 +3,7 @@ import { lstatSync, realpathSync, unlinkSync } from 'node:fs'
3
3
  import { createConnection, createServer } from 'node:net'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
- import { bindingGuidance, bindingRefusal } from './binding-guidance.js'
6
+ import { asBindingFailure, bindingGuidance, bindingRefusal } from './binding-guidance.js'
7
7
  import { createEditLockPeer } from './peer.js'
8
8
  import { serveEditLockPeer } from './peer-transport.js'
9
9
  import { createEditLockPeerClient } from './peer-client.js'
@@ -275,12 +275,16 @@ export function createRemoteEditLockDomain(endpoint, hooks) {
275
275
  for (let attempt = 0; ; attempt++) {
276
276
  try { return await openChannel(agent, retry) } catch (error) {
277
277
  if (/** @type {any} */ (error)?.answeredRefusal !== true) throw error
278
- if (!retry && /session already bound/.test(String(/** @type {any} */ (error)?.message)) && attempt < 20) {
278
+ // The retry budget classifies on the STRUCTURED refusal field, never on
279
+ // the display text (change edit-lock-deepening, design D2): only an
280
+ // answered, publisher-classified duplicate binding is retried — the
281
+ // publisher may not yet have processed the earlier channel's death.
282
+ if (!retry && /** @type {any} */ (error)?.bindingFailure?.code === 'binding-duplicate' && attempt < 20) {
279
283
  await new Promise(resolve => setTimeout(resolve, 50))
280
284
  continue
281
285
  }
282
286
  const reason = String(/** @type {any} */ (error)?.message ?? error)
283
- const guidance = bindingGuidance(agent.id, reason)
287
+ const guidance = bindingGuidance(agent.id, /** @type {any} */ (error)?.bindingFailure)
284
288
  throw Object.assign(new Error(`edit lock duplicate session binding: ${guidance} (publisher answered: ${reason})`), { bindingFailure: /** @type {any} */ (error)?.bindingFailure })
285
289
  }
286
290
  }
@@ -317,7 +321,10 @@ export function createRemoteEditLockDomain(endpoint, hooks) {
317
321
  let opened
318
322
  try {
319
323
  opened = await channel.client.request('open', 'open', { sessionId: agent.id, nonce, pid: process.pid, bindingFailures: true, retry }, never)
320
- if (opened?.state === 'unavailable' && opened.bindingFailure?.code === 'binding-duplicate') throw Object.assign(new Error(opened.reason), { bindingFailure: opened.bindingFailure })
324
+ // The answered refusal's structured field is validated on receipt;
325
+ // only the exact minted shape becomes decision input.
326
+ const refused = opened?.state === 'unavailable' ? asBindingFailure(opened.bindingFailure) : undefined
327
+ if (refused) throw Object.assign(new Error(opened.reason), { bindingFailure: refused })
321
328
  } catch (error) {
322
329
  const message = String(/** @type {any} */ (error)?.message ?? error)
323
330
  // No answer ever arrived — the channel died mid-open or the request
@@ -24,6 +24,7 @@
24
24
  * @typedef {{ now?: number, singleMaxMs: number, cumulativeMaxMs: number }} HoldCaps
25
25
  */
26
26
 
27
+ import { settleHoldFacts } from './derived-facts.js'
27
28
  /** Private transfer seam; no raw hydration is exported. */
28
29
  const cores = new WeakMap()
29
30
  /** @param {string} managerIncarnation */
@@ -165,10 +166,7 @@ export function createEditLockState(managerIncarnation) {
165
166
  const held = holds.get(sessionId)
166
167
  if (!held) return Object.freeze({ sessionId, held: false, expired: false, holdUntil: null, remainingMs: 0, holdCumulativeMs: 0 })
167
168
  const owns = [...locks.values()].some(lock => lock.owner === sessionId)
168
- const remainingMs = held.holdUntil === null ? 0 : Math.max(0, held.holdUntil - now)
169
- const holding = held.holding && owns
170
- return Object.freeze({ sessionId, held: holding, expired: holding && held.holdUntil !== null && remainingMs === 0,
171
- holdUntil: held.holdUntil, remainingMs, holdCumulativeMs: held.holdCumulativeMs })
169
+ return Object.freeze(settleHoldFacts(sessionId, held, owns, now))
172
170
  },
173
171
  }
174
172
 
@@ -10,6 +10,7 @@
10
10
  * in `technical` and per-lock `detail`, for the expandable detail only.
11
11
  */
12
12
  import { isAbsolute, relative, sep } from 'node:path'
13
+ import { revokedOwner, settleHoldFacts } from './derived-facts.js'
13
14
 
14
15
  /** Session states the panel renders, in precedence order. */
15
16
  export const VIEW_STATES = Object.freeze(['revoked', 'unavailable', 'stopped', 'attention', 'confirm', 'holding', 'editing', 'idle'])
@@ -140,21 +141,19 @@ export function buildColdView({ image, sessionId, cwd, root, autoResume = true,
140
141
  const locks = (Array.isArray(image?.locks) ? image.locks : []).filter(lock => lock !== null && typeof lock === 'object')
141
142
  const holds = Array.isArray(image?.holds) ? image.holds : []
142
143
  const recoveries = Array.isArray(image?.recovery) ? image.recovery : []
143
- const adminRecoveries = Array.isArray(image?.adminRecoveries) ? image.adminRecoveries : []
144
144
  const session = sessions.find(row => row?.sessionId === sessionId) ?? null
145
145
  // The manager derives the revoked flag from the administrative ledger at
146
- // status time (revokedOwner); the cold read applies the same rule to the image.
147
- const revoked = session != null && adminRecoveries.some(row => row?.owner === sessionId && Number.isSafeInteger(row?.revokedEpoch) && session.executionEpoch <= row.revokedEpoch)
148
- // Read-time retention settlement, byte-equivalent to the kernel's settleHold:
149
- // a hold counts only while the session owns a lock, and the remaining budget
150
- // is computed against the read instant, so a missed expiry timer can never
151
- // leave stale ownership behind a status read.
146
+ // status time (revokedOwner); the cold read applies the SAME shared rule in
147
+ // its deliberately tolerant partial mode (design D1).
148
+ const revoked = revokedOwner(image, sessionId, { partial: true })
149
+ // Read-time retention settlement through the shared derived fact (design D5,
150
+ // partial mode — byte-equivalent to the kernel's settleHold): a hold counts
151
+ // only while the session owns a lock, and the remaining budget is computed
152
+ // against the read instant, so a missed expiry timer can never leave stale
153
+ // ownership behind a status read. No hold row settles to no retention (null).
152
154
  const held = holds.find(row => row?.sessionId === sessionId) ?? null
153
155
  const owns = locks.some(lock => lock.owner === sessionId)
154
- const remainingMs = held?.holdUntil == null ? 0 : Math.max(0, held.holdUntil - now)
155
- const holding = held?.holding === true && owns
156
- const retention = held ? { sessionId, held: holding, expired: holding && held.holdUntil !== null && remainingMs === 0,
157
- holdUntil: held.holdUntil, remainingMs, holdCumulativeMs: held.holdCumulativeMs } : null
156
+ const retention = held ? settleHoldFacts(sessionId, held, owns, now, { partial: true }) : null
158
157
  const status = {
159
158
  sessionId,
160
159
  // Interrupted records a stopped session (a restarted host interrupts every
package/src/lsp/admin.js CHANGED
@@ -4,40 +4,18 @@
4
4
  // resolve through ctx.get so the module mounts harmlessly in compositions
5
5
  // without them (headless).
6
6
  import { buildRegistry, DEFAULT_SERVERS, displayInstallCommand, installSpecFor, languageIdsForFamily } from './registry.js'
7
- import { npmGlobalPrefix, resolveExecutable as extendedResolveExecutable, runBounded, spawnArgv } from './child-process.js'
7
+ import { npmGlobalPrefix, resolveExecutable as extendedResolveExecutable, runBounded, spawnArgv } from '../process/index.js'
8
+ import { jsonResponse, readJsonBody } from '../shared/http-json.js'
8
9
 
9
10
  const name = 'weir-lsp-admin'
10
11
  const inject = []
11
12
  const DEFAULT_INSTALL_TIMEOUT_MS = 600_000
12
13
  const VERSION_PROBE_TIMEOUT_MS = 8_000
13
14
 
14
- export function jsonResponse(payload, status = 200) {
15
- return new Response(JSON.stringify(payload), {
16
- status,
17
- headers: { 'content-type': 'application/json' },
18
- })
19
- }
20
-
21
15
  export function errorResponse(message, status = 400) {
22
16
  return jsonResponse({ ok: false, error: { code: `weir-lsp/${status === 400 ? 'invalid' : 'internal'}`, message } }, status)
23
17
  }
24
18
 
25
- /** Read the JSON body of a connection request (Request-like or stream). */
26
- export async function readJsonBody(request) {
27
- try {
28
- if (typeof request?.json === 'function') return await request.json()
29
- } catch {
30
- return null
31
- }
32
- try {
33
- const chunks = []
34
- for await (const chunk of request?.body ?? []) chunks.push(Buffer.from(chunk))
35
- return JSON.parse(Buffer.concat(chunks).toString('utf8'))
36
- } catch {
37
- return null
38
- }
39
- }
40
-
41
19
  /** Accept only lines that plausibly carry a version (stack traces and
42
20
  * paths from servers without --version support are dropped). */
43
21
  export function looksLikeVersion(line) {
package/src/lsp/index.js CHANGED
@@ -8,7 +8,7 @@
8
8
  // ESM, ctx-only.
9
9
  import { createLspManager, LSP_DEFAULTS } from './manager.js'
10
10
  import { buildRegistry } from './registry.js'
11
- import { resolveExecutable as extendedResolveExecutable } from './child-process.js'
11
+ import { resolveExecutable as extendedResolveExecutable } from '../process/index.js'
12
12
  import { createLspTools } from './tools.js'
13
13
 
14
14
  const name = 'weir-lsp'
@@ -2,7 +2,7 @@
2
2
  // lazy start, full-document sync, idle shutdown, per-session holder refcounts.
3
3
  import { createLspClient, handshake, shutdownClient } from './client.js'
4
4
  import { familyForLanguageId } from './registry.js'
5
- import { childEnvironment, spawnArgv } from './child-process.js'
5
+ import { childEnvironment, spawnArgv } from '../process/index.js'
6
6
  import { pathToUri } from './uri.js'
7
7
 
8
8
  export const LSP_DEFAULTS = {
@@ -4,7 +4,7 @@
4
4
  // connection service resolves from that scope (S20). The platform and the
5
5
  // process spawner are injected for tests.
6
6
  import { execFile as nodeExecFile } from 'node:child_process'
7
- import { jsonResponse, readJsonBody } from '../lsp/admin.js'
7
+ import { jsonResponse, readJsonBody } from '../shared/http-json.js'
8
8
  import { ACTIONS, readNotificationPermission, runPermissionAction, SENDER } from './permissions.js'
9
9
 
10
10
  const STATUS_PATH = '/api/weir-notify/permissions/status'
@@ -1,15 +1,21 @@
1
- // Executable resolution for LSP servers and installers. The desktop app is
1
+ // Generic process machinery: executable resolution, launch-shape
2
+ // adjudication, and deadline-bounded runs — one concept, three stages
3
+ // (resolve → launch shape → bounded run). The desktop app is
2
4
  // a GUI process launched by LaunchServices, so its PATH is the minimal
3
5
  // `/usr/bin:/bin:/usr/sbin:/sbin` — Homebrew (/opt/homebrew, /usr/local),
4
6
  // nvm, cargo, go, pipx and npm-global bins are all missing, and
5
7
  // ctx.subprocess.resolveExecutable() fails for them (S21). This helper
6
- // falls back to scanning well-known installation directories so servers and
7
- // their installers resolve the same way they do in the user's shell.
8
+ // falls back to scanning well-known installation directories so spawned
9
+ // tools resolve the same way they do in the user's shell.
8
10
  //
9
11
  // Windows resolves through the same helper with a different dialect: npm puts
10
12
  // its global shims in `%APPDATA%\npm` (not `~/.npm-global/bin`), bare names
11
13
  // carry a PATHEXT extension, and the PATH separator is `;`.
12
- // Renamed-and-expanded from executable.js (design D1): one concept, three stages — resolve → launch shape → bounded run.
14
+ //
15
+ // No LSP-specific semantics live here: the LSP subsystem (server lifecycle,
16
+ // version probe, panel installer) and the worktree git runner are peer
17
+ // consumers. Moved verbatim out of src/lsp/child-process.js
18
+ // (subsystem-seam-hygiene, D1); npm install POLICY stays in src/lsp/.
13
19
  import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs'
14
20
  import { homedir } from 'node:os'
15
21
  import { dirname, join, join as nodeJoin } from 'node:path'
@@ -304,6 +310,7 @@ function expandShimToken(token, text, shimDirectory, join) {
304
310
  function tokenizeShimLine(line) {
305
311
  const tokens = []
306
312
  let current = ''
313
+ /** @type {string | null} */
307
314
  let quote = null
308
315
  let escaped = false
309
316
  for (const character of line) {
@@ -495,6 +502,7 @@ export async function runBounded(subprocess, { argv, timeoutMs, unref = false })
495
502
  })
496
503
  let timedOut = false
497
504
  let timer
505
+ /** @type {Promise<void>} */
498
506
  const deadline = new Promise((resolve) => {
499
507
  timer = setTimeout(() => {
500
508
  timedOut = true
@@ -4,7 +4,7 @@
4
4
  // settings row through ctx.inject so no new package subpath is needed (S19),
5
5
  // same pattern as wireLspAdmin / wireNotifyPermissions. The platform is
6
6
  // injected for tests.
7
- import { jsonResponse } from '../lsp/admin.js'
7
+ import { jsonResponse } from '../shared/http-json.js'
8
8
 
9
9
  const ENV_PATH = '/api/weir-settings/env'
10
10
 
@@ -9,7 +9,7 @@
9
9
  // config contains only the values the user actually set, so the settings
10
10
  // service can overlay them over preset-row config without clobbering
11
11
  // bundle-shipped defaults (layering: module defaults ← row config ← service).
12
- import { createWhitelistDefaultsCache, DEFAULT_WHITELIST_PATH } from '../shared/whitelist-defaults.js'
12
+ import { createWhitelistDefaultsCache, DEFAULT_WHITELIST_PATH } from '../delegate/whitelist-defaults.js'
13
13
  import { configValue } from './volatile.js'
14
14
  import { computeSections } from './sections.js'
15
15
  import { wireLspAdmin } from '../lsp/admin.js'
@@ -149,60 +149,6 @@ export const FIELD_DEFAULTS = Object.freeze({
149
149
  blackboardWriteTokenTtlMinutes: 60,
150
150
  })
151
151
 
152
- /** Resolved Edit Lock retention policy. The keys are declared above; this is the
153
- * ONE place that turns them into the values the lifecycle consumes, so defaults
154
- * and validation cannot drift apart. */
155
- export const EDIT_LOCK_DEFAULTS = Object.freeze({
156
- holdDefaultMinutes: 30,
157
- holdSingleMaxMinutes: 30,
158
- holdCumulativeMaxMinutes: 120,
159
- nudgeAttempts: 2,
160
- nudgeFallback: 'release',
161
- })
162
-
163
- /** Overlay one Edit Lock section onto the defaults. A key the user never set
164
- * falls back to its default; a key that is present but unusable (alone or in
165
- * combination) throws with the flat settings key named, because silently clamping
166
- * a retention cap would change how long other sessions stay blocked from those
167
- * files. The Edit Lock composition refuses retention requests with that error and
168
- * runs every other path (settling, status, release, stop, unlock) on defaults.
169
- * @param {any} section */
170
- export function editLockLimits(section) {
171
- const value = section && typeof section === 'object' ? section : {}
172
- /** @type {{holdDefaultMinutes: number, holdSingleMaxMinutes: number, holdCumulativeMaxMinutes: number, nudgeAttempts: number, nudgeFallback: 'release'|'abnormal'}} */
173
- const limits = { ...EDIT_LOCK_DEFAULTS }
174
- /** @param {'holdDefaultMinutes'|'holdSingleMaxMinutes'|'holdCumulativeMaxMinutes'} key @param {string} name */
175
- const take = (key, name) => {
176
- const input = value[key]
177
- if (input === undefined) return
178
- if (typeof input !== 'number' || !Number.isFinite(input) || input <= 0) {
179
- throw new Error(`weir-settings: ${name} must be a positive number`)
180
- }
181
- limits[key] = input
182
- }
183
- take('holdDefaultMinutes', 'editLockHoldDefaultMinutes')
184
- take('holdSingleMaxMinutes', 'editLockHoldSingleMaxMinutes')
185
- take('holdCumulativeMaxMinutes', 'editLockHoldCumulativeMaxMinutes')
186
- if (limits.holdDefaultMinutes > limits.holdSingleMaxMinutes) {
187
- throw new Error('weir-settings: editLockHoldDefaultMinutes must not exceed editLockHoldSingleMaxMinutes')
188
- }
189
- if (limits.holdCumulativeMaxMinutes < limits.holdSingleMaxMinutes) {
190
- throw new Error('weir-settings: editLockHoldCumulativeMaxMinutes must be at least editLockHoldSingleMaxMinutes')
191
- }
192
- if (value.nudgeAttempts !== undefined) {
193
- if (!Number.isSafeInteger(value.nudgeAttempts) || value.nudgeAttempts < 0) {
194
- throw new Error('weir-settings: editLockNudgeAttempts must be a non-negative integer')
195
- }
196
- limits.nudgeAttempts = value.nudgeAttempts
197
- }
198
- if (value.nudgeFallback !== undefined) {
199
- if (value.nudgeFallback !== 'release' && value.nudgeFallback !== 'abnormal') {
200
- throw new Error('weir-settings: editLockNudgeFallback must be "release" or "abnormal"')
201
- }
202
- limits.nudgeFallback = value.nudgeFallback
203
- }
204
- return Object.freeze(limits)
205
- }
206
152
  const zs = z
207
153
 
208
154
  function fieldSchema(type) {
@@ -35,6 +35,11 @@ export const AUDIT_TYPES = Object.freeze({
35
35
  // identity, feed census and provider status, never skill names; the receipt
36
36
  // itself stays the only effective-state authority.
37
37
  capabilityRead: 'capability-read',
38
+ // The pending model-facing capability notice (durable-pending-notice D7):
39
+ // queue / degrade / deliver / clear outcomes of the durable notice unit.
40
+ // Counts only, never names — the same rule as capability-read above; the
41
+ // accepted selection record stays the only effective-state authority.
42
+ capabilityNotice: 'capability-notice',
38
43
  // Session blackboard release facts (design D3): one record per key release
39
44
  // that had waiting subscribers; the emission lives in the blackboard plugin
40
45
  // and never touches a session log.
@@ -0,0 +1,28 @@
1
+ // Generic HTTP JSON helpers for the host-side admin endpoints: Response
2
+ // shaping and request-body reading shared by the lsp, settings, and notify
3
+ // admin modules. Sunk verbatim out of src/lsp/admin.js
4
+ // (subsystem-seam-hygiene, D2); the LSP-specific error namespace
5
+ // (errorResponse) and endpoint assembly (wireLspAdmin) stay in src/lsp/.
6
+
7
+ export function jsonResponse(payload, status = 200) {
8
+ return new Response(JSON.stringify(payload), {
9
+ status,
10
+ headers: { 'content-type': 'application/json' },
11
+ })
12
+ }
13
+
14
+ /** Read the JSON body of a connection request (Request-like or stream). */
15
+ export async function readJsonBody(request) {
16
+ try {
17
+ if (typeof request?.json === 'function') return await request.json()
18
+ } catch {
19
+ return null
20
+ }
21
+ try {
22
+ const chunks = []
23
+ for await (const chunk of request?.body ?? []) chunks.push(Buffer.from(chunk))
24
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
25
+ } catch {
26
+ return null
27
+ }
28
+ }
@@ -1,9 +1,39 @@
1
- // Best-effort cold readers for supervision rebuild: the audit JSONL tail and a
2
- // child's final assistant text. These are the only node: consumers in the
3
- // delegate subsystem's cold path — the composition root stays node:-free.
1
+ // Terminal evidence — the single definition site for what proves a supervised
2
+ // child reached a terminal state: the STATUS/REPORT parser, the terminal-status
3
+ // predicate (its JSDoc carries the one blocked-vs-terminal rule every consumer
4
+ // refers to), and the two best-effort cold readers (the bounded audit-JSONL
5
+ // tail; a child log's final assistant text). Delegate supervision and worktree
6
+ // zombie-lane reconciliation both import from here, so neither subsystem
7
+ // reaches into the other for this concept.
8
+ //
9
+ // Load-bearing tolerance semantics (zombie-lane safety): readAuditTail's 256KB
10
+ // bounded window and error tolerance (missing/unreadable → [], a partial first
11
+ // line dropped, malformed lines skipped) make an aged-out fact degrade to "no
12
+ // evidence", never to a wrong release. Keep them byte-exact.
13
+ //
14
+ // Ports & adapters: the readers take their services as parameters (never ctx).
15
+ // node: imports are the src/shared precedent (audit.js);
16
+ // the node:-free constraint guards the delegate composition root, not shared.
4
17
  import { openSync, readSync, closeSync, statSync } from 'node:fs'
5
18
  import { join } from 'node:path'
6
- import { contentText } from '../shared/content-text.js'
19
+ import { contentText } from './content-text.js'
20
+
21
+ /** Parse a child's final assistant text into a terminal status, or null. */
22
+ export function parseTerminalStatus(text) {
23
+ if (typeof text !== 'string' || text.length === 0) return null
24
+ const match = /^STATUS:[ \t]*(completed|blocked)[ \t]*$/m.exec(text)
25
+ if (!match) return null
26
+ const reportMatch = /^REPORT:[ \t]*([\s\S]*)$/m.exec(text.slice(match.index))
27
+ return { status: match[1], report: (reportMatch?.[1] ?? '').trim() }
28
+ }
29
+
30
+ /**
31
+ * Single exit for the terminal-status predicate: statuses that count toward
32
+ * group completion (blocked waits for a resume, so it is NOT terminal here).
33
+ */
34
+ export function isTerminalStatus(status) {
35
+ return status === 'completed' || status === 'terminated'
36
+ }
7
37
 
8
38
  /** Audit JSONL location for one session (cold-safe channel; may not exist). */
9
39
  export function auditFilePathOf(session) {
@@ -18,6 +18,9 @@ export const WORKTREE_CODES = Object.freeze({
18
18
  UNKNOWN_LANE: 'UNKNOWN_LANE',
19
19
  ILLEGAL_TRANSITION: 'ILLEGAL_TRANSITION',
20
20
  LANE_NOT_DISPATCHABLE: 'LANE_NOT_DISPATCHABLE',
21
+ // The binding commit's reservation is gone (force-reclaim / reconcile
22
+ // --rebuild) or the lane left `working` before the commit landed.
23
+ LANE_RESERVATION_LOST: 'LANE_RESERVATION_LOST',
21
24
  LANE_BUSY: 'LANE_BUSY',
22
25
  NOT_LANDABLE: 'NOT_LANDABLE',
23
26
  STALE_LANDABLE: 'STALE_LANDABLE',