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
@@ -17,7 +17,8 @@ import { openReservedEditLockRuntime } from './reserved-runtime.js'
17
17
  import { reservationPathFor } from './reservation.js'
18
18
  import { createEditLockLifecycle } from './lifecycle.js'
19
19
  import { installEditLockWriteScope } from './tool-scope.js'
20
- import { disposeWriteScope, writeScopesFor } from './write-scopes.js'
20
+ import { writeScopesFor } from './write-scopes.js'
21
+ import { createBindingMachine } from './binding-machine.js'
21
22
  import { createResourceIdentity } from './resource-identity.js'
22
23
  import { userTextMessage } from '../shared/user-message.js'
23
24
  import { isGenuineUserMessage } from '../shared/runtime-messages.js'
@@ -26,10 +27,10 @@ import { createStaleSweepScheduler } from './stale-sweep.js'
26
27
  import { createRemoteEditLockDomain, endpointFor, serveEditLockEndpoint } from './remote.js'
27
28
  import { registerBindingRuntime, createBindingAttester } from './binding-attestation.js'
28
29
  import { localDomain, remoteDomain } from './domain.js'
29
- import { AUTHORITY_DIR, createDomainRegistry, excludeFromGit, managementRootFor } from './domains.js'
30
+ import { AUTHORITY_DIR, createDomainRegistry, excludeFromGit, managementRootFor, sharedDomainRegistryFor } from './domains.js'
30
31
  import { createRecoveryDriver } from './recovery.js'
31
32
  import { createSettlementDriver } from './settle.js'
32
- import { editLockLimits } from '../settings/sections.js'
33
+ import { editLockLimits } from './limits.js'
33
34
  import { buildColdView, buildView, unavailableView } from './view.js'
34
35
  import { parseSnapshot } from './snapshot.js'
35
36
  import { readSnapshotBytes, errorMessage } from './read-authority.js'
@@ -41,6 +42,29 @@ const inject = ['tools', 'fs', 'agents']
41
42
  /** Tool names whose definitions must be Edit Lock routed when enabled. */
42
43
  export const GUARDED_TOOLS = Object.freeze(['write', 'edit', 'hash_edit', 'lsp_rename', 'str_replace_editor'])
43
44
 
45
+ /** Claimed managed-editor executions, shared by every mount of this bundle in
46
+ * the process (change edit-lock-shared-domain-mounts, design D3): membership
47
+ * is execute-object identity, so cross-host admission is impossible and an
48
+ * execution claimed by a disposed mount is harmless — no live guard reads it.
49
+ * Whichever mount's guard stage hears an execution, the verdict is the same.
50
+ * @type {WeakSet<Function>} */
51
+ const managed = new WeakSet()
52
+
53
+ /** Host-lifetime live weirEditLock services (design D3): every mount registers
54
+ * its service at apply and unregisters at dispose, so the endpoint currency
55
+ * proof can accept any live sibling mount's service identity.
56
+ * @type {WeakMap<object, Set<object>>} */
57
+ const liveServicesByHost = new WeakMap()
58
+ /** @param {object} host @returns {Set<object>} */
59
+ function liveServicesFor(host) {
60
+ let services = liveServicesByHost.get(host)
61
+ if (!services) {
62
+ services = new Set()
63
+ liveServicesByHost.set(host, services)
64
+ }
65
+ return services
66
+ }
67
+
44
68
  /** Row config wins over the settings section; `root` + `authorityDirectory`
45
69
  * pin one fixed domain (development compositions and probes).
46
70
  * @param {unknown} config @param {unknown} [settings] */
@@ -310,21 +334,9 @@ function describe(status) {
310
334
  return lines.join('\n')
311
335
  }
312
336
 
313
- /** In-session binding-failure notice copy (change edit-lock-binding-transfer,
314
- * design D4): the transferable class (same-process idle holder) is a calm
315
- * self-heal announcement rather than an editing-disabled alarm and never
316
- * carries the raw holder classification or the reservation warning; a mid-turn
317
- * holder names the real state and the retry moment. Template layer stays
318
- * English (AGENTS §3.7). @param {string} reason @param {any} failure */
319
- function bindingFailureNotice(reason, failure) {
320
- if (failure?.code === 'binding-duplicate' && failure.holderKind === 'transferable') {
321
- return "Edit Lock: this session's editing authority is held by an inactive attachment of this same session; it transfers automatically when you send a message."
322
- }
323
- if (failure?.code === 'binding-duplicate' && failure.holderKind === 'serving') {
324
- return "Edit Lock: this session is being edited in another attachment right now; managed file editing stays unavailable until that turn finishes. Do not retry the failed edit; use /edit-lock retry (or the panel's Retry connection) once it finishes — retry does not Continue editing."
325
- }
326
- return `Edit Lock could not start this session's edit domain (${reason}); managed file editing is temporarily unavailable. ${failure?.code === 'binding-duplicate' ? 'Do not retry the failed edit: the binding is retried automatically when the user next sends a message, after which editing works normally. Never remove a live publisher reservation.' : 'Inspect Edit Lock maintenance before continuing.'}`
327
- }
337
+ // The binding-failure notice copy moved with the binding state machine to
338
+ // binding-machine.js (change edit-lock-deepening, design D3) — it renders only
339
+ // inside setupAgentOnce's failure record.
328
340
 
329
341
  /** Read-only binding-unavailable guidance tail for the tool/service surfaces
330
342
  * (domainFor and edit_lock_status), split by the recorded holder class.
@@ -370,6 +382,10 @@ return (ctx, config = {}) => {
370
382
  evidence?.failed(platformFailure)
371
383
  ctx.logger?.warn?.(platformFailure.message)
372
384
  }
385
+ /** The shared-registry handle, assigned inside the mount try so a failed
386
+ * apply can release its count (design D1: a failed mount enters no count).
387
+ * @type {any} */
388
+ let shared = null
373
389
  try {
374
390
  /** @type {any} */
375
391
  let sandboxPolicyRef = null
@@ -377,6 +393,11 @@ return (ctx, config = {}) => {
377
393
  // Capture the original host filesystem once; the manager is its only writer.
378
394
  const fs = ctx.fs
379
395
  const audit = createAudit(ctx)
396
+ // The host-lifetime key shared by the write-scope store, the domain registry
397
+ // container and the live-service set (design D1/D3) — identical to the key
398
+ // expression the settings evidence store uses.
399
+ const host = ctx.root ?? ctx
400
+ const liveServices = liveServicesFor(host)
380
401
  /** Successful undefined means no live runtime; missing service means unknown.
381
402
  * @param {string} sessionId */
382
403
  const servingRuntime = sessionId => {
@@ -391,17 +412,18 @@ return (ctx, config = {}) => {
391
412
  warn: message => ctx.logger?.warn?.(message),
392
413
  setTimer: fn => (safeSetTimer(ctx) ?? globalThis.setTimeout)(fn, 0),
393
414
  })
394
- /** Agents disposed in this mount; their undispatched sweep work cancels.
395
- * @type {WeakSet<object>} */
396
- const disposedAgents = new WeakSet()
415
+ // The binding state machine's stores (disposedAgents, startFailures,
416
+ // bindingFailures, setupInFlight, boundAgents, dispositions,
417
+ // agentDispositions) live in binding-machine.js and arrive destructured
418
+ // below (change edit-lock-deepening, design D3).
397
419
  /** One queued automatic-recovery summary notice per management root (design
398
420
  * D3): delivered to the first agent that finishes domain start, then gone.
399
- * @type {Map<string, string>} */
400
- const recoveryNotices = new Map()
401
- // The tool registry may hand back a normalized copy of a definition; the
402
- // claimed execute function is what actually runs, so it is the identity.
403
- /** @type {WeakSet<Function>} */
404
- const managed = new WeakSet()
421
+ * Shared across sibling mounts with the domain registry (change
422
+ * edit-lock-shared-domain-mounts): the domain opens through the first
423
+ * mount's closure, so the queue must live in the shared entry for the
424
+ * mount that hears the first post-recovery turn to drain it. Assigned
425
+ * with `shared` below. @type {Map<string, string>} */
426
+ let recoveryNotices
405
427
  /** Managed write registrations outlive this mount: the tool is installed
406
428
  * into the agent's OWN scope layer, which a preset rebind never disposes.
407
429
  * Their disposers live in a host-lifetime store so any later generation can
@@ -410,21 +432,16 @@ return (ctx, config = {}) => {
410
432
  * tearing the managed write down mid-gap would expose the stock writers
411
433
  * until the next mount's per-agent re-install.
412
434
  * @type {WeakMap<object, import('./write-scopes.js').WriteScopeEntry>} */
413
- const writeScopes = writeScopesFor(ctx.root ?? ctx)
435
+ const writeScopes = writeScopesFor(host)
414
436
  let closed = false
415
437
  /** Remote channel agents on the publisher side → their notice sink.
416
438
  * @type {WeakMap<object, (event: unknown) => void>} */
417
439
  const sinks = new WeakMap()
418
- /** Why registration failed for an agent, for the panel. @type {WeakMap<object, string>} */
419
- const startFailures = new WeakMap()
420
- /** Publisher-classified failures, never inferred from text. @type {WeakMap<object, any>} */
421
- const bindingFailures = new WeakMap()
422
- /** Settled domain per bound agent, for synchronous gates. @type {WeakMap<object, any>} */
423
- const settled = new WeakMap()
424
- /** In-flight setup per agent, shared by every entry point (creation,
425
- * preset-switch, catch-up, lazy re-bind) so concurrent callers can never
426
- * double-register. @type {WeakMap<object, Promise<void>>} */
427
- const setupInFlight = new WeakMap()
440
+ /** Settled domain per bound agent, for synchronous gates. Shared across
441
+ * sibling mounts with the domain registry (design D3): whichever mount
442
+ * hears an event reads the same binding. Assigned with `shared` below.
443
+ * @type {WeakMap<object, any>} */
444
+ let settled
428
445
  /** `wake` is used only for an ownership request addressed to an idle holder:
429
446
  * inject alone would sit unread until the holder's next user message, which is
430
447
  * how a request expired unanswered. Interrupted holders are never woken.
@@ -524,8 +541,11 @@ return (ctx, config = {}) => {
524
541
  } else if (requireProof) return false
525
542
  // Fresh creation keeps the established missing/throwing-registry
526
543
  // fallback. A reclaim or retry never uses that fallback as proof.
544
+ // Currency proof (design D3): the agent's resolved service must be a
545
+ // LIVE service of this process — after a preset switch that is the
546
+ // sibling mount's service, never this mount's own necessarily.
527
547
  if (typeof presets?.serviceFor === 'function') {
528
- try { return presets.serviceFor(agent, 'weirEditLock') === service }
548
+ try { return liveServices.has(presets.serviceFor(agent, 'weirEditLock')) }
529
549
  catch { return !requireProof }
530
550
  }
531
551
  return true
@@ -541,12 +561,21 @@ return (ctx, config = {}) => {
541
561
  throw error
542
562
  }
543
563
  }
544
- const registry = createDomainRegistry(root => {
564
+ // One domain registry per host + effective domain configuration, shared by
565
+ // every same-process preset mount of this bundle (design D1): a sibling
566
+ // mount never degrades to a same-process remote client, and the LAST
567
+ // disposer closes all domains through the established drain discipline. The
568
+ // first mount's `open` closure serves every sharing mount — mounts are
569
+ // process-persistent, so the capture is safe (design D1 trade-off).
570
+ shared = sharedDomainRegistryFor(host, options.fixed ? `${options.fixed.root}\n${options.fixed.directory}` : '', () => createDomainRegistry(root => {
545
571
  const fixed = options.fixed
546
572
  if (fixed) return openDomain(fixed.root, fixed.directory, root)
547
573
  try { exclude(root) } catch (error) { ctx.logger?.warn?.(`edit lock: could not update .git/info/exclude: ${/** @type {any} */ (error)?.message ?? error}`) }
548
574
  return openDomain(root, join(root, AUTHORITY_DIR), root)
549
- }, resolveRoot)
575
+ }, resolveRoot))
576
+ const registry = shared.registry
577
+ settled = shared.settled
578
+ recoveryNotices = shared.recoveryNotices
550
579
  /** @param {any} agent */
551
580
  const domainOf = agent => registry.forAgent(agent)
552
581
  /** Lazy re-bind (design D2): an own-preset main agent with no binding —
@@ -635,6 +664,31 @@ return (ctx, config = {}) => {
635
664
  * lost timer (suspend, sleep) only delays the release, never the decision.
636
665
  * @type {WeakMap<object, {timer?: any}>} */
637
666
  const expiry = new WeakMap()
667
+ /** A genuine user message asked for the stopped session's automatic resume.
668
+ * Boolean, consumed once by the next turn's first pre-step (message-driven
669
+ * auto-resume D1/D6); WeakMap keys die with their agent. @type {WeakMap<object, boolean>} */
670
+ const pendingAutoResume = new WeakMap()
671
+ const delegateResumes = new Map()
672
+ const turnClassifications = new WeakMap()
673
+ // The per-agent binding state machine (change edit-lock-deepening, design
674
+ // D3): setup/retry/dispose/startup-diagnostic plus the binding stores,
675
+ // shared by every entry point below. The remaining wiring reads the same
676
+ // container objects through these destructured aliases.
677
+ const {
678
+ disposedAgents, startFailures, bindingFailures, setupInFlight,
679
+ boundAgents, dispositions, agentDispositions,
680
+ setupAgent, retryBinding, disposeAgent, startupFailure,
681
+ } = createBindingMachine({
682
+ ctx, sessionOf, GUARDED_TOOLS, options, registry, settled, writeScopes,
683
+ domainOf, servingRuntime,
684
+ // Late-bound wiring (ownAgent and the service object are declared below;
685
+ // the machine dereferences them only when an entry point actually calls).
686
+ ownAgent: (/** @type {any} */ agent) => ownAgent(agent),
687
+ installWriteScope: (/** @type {any} */ agent) => ({ owner: service, dispose: installEditLockWriteScope(agent, ctx, service, sandboxPolicyRef) }),
688
+ evidence, audit, deliverLocal,
689
+ pendingAutoResume, delegateResumes, turnClassifications, replyTools, expiry, settle,
690
+ isClosed: () => closed, platformFailure: () => platformFailure,
691
+ })
638
692
  /** @param {any} agent @param {any} domain */
639
693
  function armExpiry(agent, domain) {
640
694
  const state = expiry.get(agent) ?? {}
@@ -660,27 +714,6 @@ return (ctx, config = {}) => {
660
714
  state.timer?.unref?.()
661
715
  }).catch((/** @type {any} */ error) => ctx.logger?.warn?.(`edit lock expiry arm failed: ${error?.message ?? error}`))
662
716
  }
663
- /** Only this live Agent's diagnostic; no domain reads or startup side effects. @param {any} agent */
664
- function startupFailure(agent) {
665
- if (platformFailure) return { state: 'unavailable', reason: platformFailure.message, bindingFailure: null }
666
- if (!agent || settled.has(agent) || !startFailures.has(agent)) return null
667
- let eligible = false
668
- try {
669
- const serving = servingRuntime(agent.id)
670
- eligible = !closed && !disposedAgents.has(agent) && ownAgent(agent)
671
- && (serving === agent
672
- // The registry names ANOTHER live local agent — the dual-agent defect
673
- // this change heals (edit-lock-binding-transfer). The recorded
674
- // duplicate-binding failure is still THIS agent's honest panel state:
675
- // show it, with its Retry action, instead of hiding behind the ghost's
676
- // healthy binding. An empty registry stays hidden (unattestable).
677
- || (serving !== undefined && bindingFailures.get(agent)?.code === 'binding-duplicate'))
678
- } catch { /* no trusted live identity */ }
679
- if (!eligible) return null
680
- const failure = bindingFailures.get(agent)
681
- return { state: 'unavailable', reason: startFailures.get(agent),
682
- bindingFailure: failure ? { ...failure, inFlight: setupInFlight.has(agent) } : null }
683
- }
684
717
  const service = Object.freeze({
685
718
  startupFailure,
686
719
  publish: route('publish'),
@@ -734,6 +767,9 @@ return (ctx, config = {}) => {
734
767
  },
735
768
  })
736
769
  ctx.reflect.provide('weirEditLock', service)
770
+ // Any live sibling mount's service identity attests endpoint currency
771
+ // (design D3); unregister at dispose.
772
+ liveServices.add(service)
737
773
 
738
774
  const offTools = registerLockTools(ctx, service)
739
775
 
@@ -767,171 +803,11 @@ return (ctx, config = {}) => {
767
803
  if (failure) return { kind: 'deny', reason: `${exec.name} refused: this session has no Edit Lock domain under the current preset generation (setup failed: ${failure}); an automatic re-bind was attempted and did not recover it. Recovery: restart DeepSeek Harness or start a new conversation.` }
768
804
  return { kind: 'deny', reason: `${exec.name} is not routed through Edit Lock in this composition; unmanaged file mutation refused.` }
769
805
  })
770
- const delegateResumes = new Map()
771
- const boundAgents = new Map()
772
- const turnClassifications = new WeakMap()
773
- // Keep the latest incarnation joinable even after synchronous binding cleanup.
774
- // Exact-handle waiters retain their own record when a session activates again.
775
- const dispositions = new Map()
776
- const agentDispositions = new WeakMap()
777
- function dispositionFor(agent) {
778
- let record = agentDispositions.get(agent)
779
- if (!record) {
780
- record = Promise.withResolvers()
781
- record.promise.catch(() => {}) // emit initiators have no awaiting caller
782
- agentDispositions.set(agent, record)
783
- dispositions.set(agent.id, record)
784
- }
785
- return record
786
- }
787
- /** Per-agent Edit Lock setup, shared by the creation listener, the
788
- * preset-switch re-bind, the late-mount catch-up and the guard/acquire lazy
789
- * re-bind, so the paths can never diverge. Root selection and successful
790
- * startup are separate: explicit binding retry reuses the existing scope
791
- * and root, while ordinary entry points never repeat a failed startup.
792
- * @param {any} agent @param {boolean} [retry] @param {boolean} [transfer]
793
- * transfer (change edit-lock-binding-transfer) marks the driving-ingress
794
- * transfer admission: the domain's transferStart re-adjudicates holder
795
- * idleness at execution, and the post-bind registry-identity check is
796
- * skipped because the registry legitimately still names the idle holder. */
797
- async function setupAgentOnce(agent, retry = false, transfer = false) {
798
- if (closed || settled.has(agent)) return
799
- // No writer scope, root resolution, directory, reservation or session
800
- // authority may be installed when the mount preflight failed.
801
- if (platformFailure) return
802
- if (!registry.rootOf(agent)) try {
803
- // A previous generation's managed write survives in the agent's own
804
- // scope layer (a preset rebind never disposes own-layer registrations);
805
- // retire it first or the scope registry throws duplicate-registration
806
- // and the re-bind dies here (design D3).
807
- disposeWriteScope(writeScopes, agent)
808
- writeScopes.set(agent, { owner: service, dispose: installEditLockWriteScope(agent, ctx, service, sandboxPolicyRef) })
809
- registry.bind(agent, options.fixed?.root ?? agent?.session?.header?.cwd)
810
- } catch (error) {
811
- // The denial itself must never escape: restrict() throws on names the
812
- // agent's composition does not know, and an escape from this setup path
813
- // is fiber-fatal during a reload (2026-10-05 host crash). Deny only the
814
- // guarded tools the agent actually has.
815
- try {
816
- const known = GUARDED_TOOLS.filter(name => ctx.tools?.get?.(name, agent) !== undefined)
817
- if (known.length > 0) agent?.ctx?.tools?.restrict?.({ deny: [...known] })
818
- } catch { /* the denial is best-effort; the records below are the visible account */ }
819
- // Failure visibility (design D4): the same reason lands in the panel
820
- // state, the maintenance evidence, an in-session notice and the durable
821
- // audit; the warn stays as the host ring-buffer copy.
822
- const reason = String(/** @type {any} */ (error)?.message ?? error)
823
- startFailures.set(agent, reason)
824
- try { evidence?.recordSessionFailure?.(sessionOf(agent), reason) } catch { /* reporting only */ }
825
- audit(agent?.session ?? null, AUDIT_TYPES.editLockMaintenance, { kind: 'setup-failed', sessionId: sessionOf(agent) ?? null, reason })
826
- try { deliverLocal(agent, `Edit Lock is unavailable for this session (${reason}); managed file editing is disabled and the failed edit must not be retried. This failure is not repaired automatically: the user can restart DeepSeek Harness or start a new conversation.`) } catch { /* the audit record is the durable account */ }
827
- ctx.logger?.warn?.(`edit lock scope failed; edit tools denied: ${reason}`)
828
- return
829
- }
830
- try {
831
- const domain = await domainOf(agent)
832
- if (transfer) await domain.transferStart(agent)
833
- else if (retry) await domain.retryStart(agent)
834
- else await domain.start(agent)
835
- if (closed || disposedAgents.has(agent) || (retry && (!ownAgent(agent) || (!transfer && servingRuntime(agent.id) !== agent)))) {
836
- await domain.forget(agent)
837
- throw new Error('Edit Lock mount changed during binding')
838
- }
839
- startFailures.delete(agent)
840
- bindingFailures.delete(agent)
841
- try { evidence?.clearSessionFailure?.(sessionOf(agent)) } catch { /* reporting cannot undo a successful bind */ }
842
- // A genuine message from a turn that ran while binding was unavailable
843
- // is not a Continue receipt for a later injected wake after connection retry.
844
- if (retry) pendingAutoResume.delete(agent)
845
- settled.set(agent, domain)
846
- boundAgents.set(agent.id, agent)
847
- dispositionFor(agent)
848
- if (!retry) await delegateResumes.get(agent.id)?.resume(agent, domain)
849
- } catch (error) {
850
- // Kept so the panel can say WHY editing is unavailable instead of
851
- // showing "starting" forever; the notice aligns this catch with the
852
- // write-scope failure above.
853
- const reason = String(/** @type {any} */ (error)?.message ?? error)
854
- startFailures.set(agent, reason)
855
- const failure = /** @type {any} */ (error)?.bindingFailure
856
- if (failure?.code === 'binding-duplicate') bindingFailures.set(agent, failure)
857
- else bindingFailures.delete(agent)
858
- try { evidence?.recordSessionFailure?.(sessionOf(agent), reason) } catch { /* reporting only */ }
859
- try { deliverLocal(agent, bindingFailureNotice(reason, failure)) } catch { /* the panel record remains */ }
860
- ctx.logger?.warn?.(`edit lock registration failed for ${sessionOf(agent)}: ${reason}`)
861
- }
862
- }
863
- /** Concurrent entry points (agent/created, preset-switch, lazy re-bind)
864
- * share one in-flight setup per agent; a completed setup short-circuits on
865
- * the root binding. @param {any} agent @returns {Promise<void>} */
866
- function setupAgent(agent) {
867
- const pending = setupInFlight.get(agent)
868
- if (pending) return pending
869
- if (closed || settled.has(agent) || registry.rootOf(agent)) return Promise.resolve()
870
- const inner = setupAgentOnce(agent)
871
- const task = inner.finally(() => { if (setupInFlight.get(agent) === task) setupInFlight.delete(agent) })
872
- setupInFlight.set(agent, task)
873
- return task
874
- }
875
- /** Trusted driving ingresses only: the explicit /edit-lock retry (and panel
876
- * action), the message-armed pre-step retry, and the write-guard lazy
877
- * re-bind. Same Agent/root/mount; no writer reinstallation. Every attempt
878
- * past the eligibility gate is audited — a silently swallowed refusal is a
879
- * defect (change edit-lock-binding-transfer, design D3/D5).
880
- * @param {any} agent @param {'command' | 'message' | 'write-guard'} [via] */
881
- async function retryBinding(agent, via = 'command') {
882
- if (closed || disposedAgents.has(agent) || !ownAgent(agent)) throw new Error('Edit Lock retry requires the current live Agent of this mount')
883
- if (!registry.rootOf(agent) || bindingFailures.get(agent)?.code !== 'binding-duplicate') throw new Error('Edit Lock startup failure is not eligible for binding retry')
884
- const cause = bindingFailures.get(agent)
885
- const sessionId = sessionOf(agent) ?? null
886
- const root = registry.rootOf(agent) ?? undefined
887
- /** @param {string} result @param {Record<string, unknown>} [extra] */
888
- const record = (result, extra = {}) => audit(agent?.session ?? null, AUDIT_TYPES.editLockMaintenance, {
889
- kind: 'binding-transfer', sessionId, code: cause?.code ?? 'binding-duplicate', result, ...extra,
890
- }, { root })
891
- // Attestation trichotomy (design D5): the registry naming THIS claimant
892
- // keeps the existing superseded rebind; naming ANOTHER live local agent
893
- // routes to the transfer admission (holder idleness is re-adjudicated at
894
- // execution, publisher-side); an unavailable or empty registry stays
895
- // fail-closed — the holder cannot be attested.
896
- let serving
897
- try { serving = servingRuntime(agent.id) } catch { serving = undefined }
898
- if (serving === undefined) {
899
- const error = new Error('Edit Lock binding retry refused: the agent registry is unavailable or names no live agent for this session, so the holder cannot be attested. The recorded cause stays; a later retry remains possible.')
900
- record('refused', { reason: 'registry-unattestable', detail: error.message })
901
- throw error
902
- }
903
- const transfer = serving !== agent
904
- try {
905
- let task = setupInFlight.get(agent)
906
- if (!task) {
907
- const inner = setupAgentOnce(agent, true, transfer)
908
- task = inner.finally(() => { if (setupInFlight.get(agent) === task) setupInFlight.delete(agent) })
909
- setupInFlight.set(agent, task)
910
- }
911
- await task
912
- } catch (error) {
913
- record('refused', { reason: /** @type {any} */ (error)?.bindingFailure?.holderKind ?? 'retry-refused', detail: String(/** @type {any} */ (error)?.message ?? error) })
914
- throw error
915
- }
916
- const failure = startFailures.get(agent)
917
- if (failure || !settled.has(agent)) {
918
- // setupAgentOnce records instead of rethrowing: the refusal's holder
919
- // class comes from the freshly re-recorded structured failure.
920
- const error = new Error(failure ?? 'Edit Lock binding remains unavailable')
921
- record('refused', { reason: bindingFailures.get(agent)?.holderKind ?? 'binding-unavailable', detail: error.message })
922
- throw error
923
- }
924
- if (transfer) {
925
- record('transferred', { holder: cause?.holderKind ?? null })
926
- } else if (via === 'message') {
927
- // The message-armed superseded success keeps its established audit shape
928
- // (pinned by the editlock-binding-auto-retry integration scenario).
929
- audit(agent?.session ?? null, AUDIT_TYPES.editLockMaintenance, {
930
- kind: 'binding-auto-retry', sessionId, code: cause?.code ?? 'binding-duplicate', result: 'recovered',
931
- }, { root })
932
- }
933
- return 'Edit Lock connection recovered. Editing remains interrupted; use Continue editing when ready. No failed edit was replayed.'
934
- }
806
+ // The binding stores (boundAgents, dispositions, agentDispositions) and
807
+ // dispositionFor moved into binding-machine.js; delegateResumes and
808
+ // turnClassifications stay composition-owned and are declared above with it.
809
+ // setupAgentOnce / setupAgent / retryBinding moved into binding-machine.js
810
+ // (change edit-lock-deepening, design D3) — they arrive destructured above.
935
811
  ctx.on('agent/created', (/** @type {any} */ { agent }) => setupAgent(agent))
936
812
 
937
813
  // Late-mount catch-up (the same mount-order hazard the worktree-mode guard
@@ -1040,10 +916,8 @@ return (ctx, config = {}) => {
1040
916
  /** Turn identity seen last per agent, so a new turn is detected exactly once.
1041
917
  * @type {WeakMap<object, unknown>} */
1042
918
  const lastTurn = new WeakMap()
1043
- /** A genuine user message asked for the stopped session's automatic resume.
1044
- * Boolean, consumed once by the next turn's first pre-step (message-driven
1045
- * auto-resume D1/D6); WeakMap keys die with their agent. @type {WeakMap<object, boolean>} */
1046
- const pendingAutoResume = new WeakMap()
919
+ // pendingAutoResume moved next to the binding machine (its retry path clears
920
+ // an armed auto-resume); it is declared above with it.
1047
921
  /** A genuine user message also arms ONE bounded binding retry when this agent
1048
922
  * has a recorded duplicate-binding failure (message-driven self-heal, design
1049
923
  * D5): consumed at the next turn's first pre-step by awaiting the existing
@@ -1184,32 +1058,8 @@ return (ctx, config = {}) => {
1184
1058
 
1185
1059
  // A reply tool may only disappear at a turn boundary, never mid-turn.
1186
1060
  ctx.on('agent/turn-stopping', (/** @type {any} */ { agent }) => { retireReplyTool(agent) })
1187
- const disposalTasks = new WeakMap()
1188
- function disposeAgent(agent) {
1189
- if (disposedAgents.has(agent)) return disposalTasks.get(agent) ?? Promise.resolve()
1190
- if (boundAgents.get(agent.id) === agent) boundAgents.delete(agent.id)
1191
- // Undispatched sweep work armed by this agent cancels here (an in-flight
1192
- // authority transaction is never cancelled or reinterpreted).
1193
- disposedAgents.add(agent)
1194
- // The managed write lives on the agent's own scope layer, which dies with
1195
- // the agent: retire the host-lifetime registration here (design D3).
1196
- disposeWriteScope(writeScopes, agent)
1197
- replyTools.get(agent)?.dispose()
1198
- replyTools.delete(agent)
1199
- const timer = expiry.get(agent)?.timer
1200
- if (timer) clearTimeout(timer)
1201
- expiry.delete(agent)
1202
- settle.settled(agent)
1203
- const domain = settled.get(agent)
1204
- settled.delete(agent)
1205
- // The emit hook must cancel timers/scopes/sweeps synchronously. Only the
1206
- // final authority disposal waits for durable abnormal classification.
1207
- const record = dispositionFor(agent)
1208
- const task = Promise.resolve(turnClassifications.get(agent)).then(() => domain?.dispose(agent))
1209
- task.then(record.resolve, record.reject)
1210
- disposalTasks.set(agent, record.promise)
1211
- return record.promise
1212
- }
1061
+ // disposeAgent and its disposalTasks moved into binding-machine.js (change
1062
+ // edit-lock-deepening, design D3) — the destructured alias is shared below.
1213
1063
  ctx.on('agent/disposed', ({ agent }) => disposeAgent(agent).catch(() => {}))
1214
1064
 
1215
1065
  /** Cold-read fallback for the status view (design D1/D2): a session with no
@@ -1394,6 +1244,7 @@ return (ctx, config = {}) => {
1394
1244
  return async () => {
1395
1245
  if (closed) return
1396
1246
  closed = true
1247
+ liveServices.delete(service)
1397
1248
  offDelegateLifecycle()
1398
1249
  evidence?.disposing()
1399
1250
  try {
@@ -1405,10 +1256,9 @@ return (ctx, config = {}) => {
1405
1256
  offView()
1406
1257
  offCommand?.()
1407
1258
  for (const off of offTools) off?.()
1408
- // Retain uncertain evidence until every generation-owned domain is drained.
1409
- const results = await Promise.allSettled(registry.all().map(async domain => (await domain).close()))
1410
- const failure = results.find(result => result.status === 'rejected')
1411
- if (failure?.status === 'rejected') throw failure.reason
1259
+ // Retain uncertain evidence until every generation-owned domain is
1260
+ // drained; only the LAST sharing disposer closes them (design D1).
1261
+ await shared.release()
1412
1262
  evidence?.dispose()
1413
1263
  } catch (error) {
1414
1264
  evidence?.failed(error)
@@ -1417,6 +1267,10 @@ return (ctx, config = {}) => {
1417
1267
  }
1418
1268
  } catch (error) {
1419
1269
  evidence?.failed(error)
1270
+ // A mount that failed mid-apply must not stay counted in the shared
1271
+ // registry (design D1); domains are lazy, so this release closes nothing
1272
+ // unless a sibling mount already opened one — its count keeps it alive.
1273
+ if (shared) void shared.release().catch(() => {})
1420
1274
  throw error
1421
1275
  }
1422
1276
  }
@@ -4,6 +4,9 @@
4
4
  // supplies the parsed snapshot and the mount evidence. Nothing here opens a
5
5
  // runtime, grants authority, or changes what admission would decide; an
6
6
  // unresolved operation is reported, never settled.
7
+ import { dispositionFor } from './derived-facts.js'
8
+ import { recoveryConfirmation } from './admin-recovery.js'
9
+ import { LATE_WRITER_RISK } from './admin-ledger.js'
7
10
 
8
11
  /**
9
12
  * The switch state the maintenance panel renders. Detection is evidence-based:
@@ -52,7 +55,7 @@ export function summarizeAuthorityImage(snapshot) {
52
55
  const unresolved = operations
53
56
  .filter(op => op?.phase === 'publishing' || op?.phase === 'unknown')
54
57
  .map(op => {
55
- const disposition = state.adminRecoveries?.find(row => row.owner === op.sessionId && row.operations.some(item => item.operationId === op.operationId))
58
+ const disposition = dispositionFor(state, op)
56
59
  return { ...unresolvedRow(op), admissionBlocked: !disposition, administrativeRecoveryId: disposition?.recoveryId ?? null }
57
60
  })
58
61
  // Prepared operations never fence anything, but the online administrative
@@ -89,6 +92,115 @@ export function summarizeAuthorityImage(snapshot) {
89
92
  }
90
93
  }
91
94
 
95
+ /**
96
+ * Server-computed online-recovery proposals over the SAME parsed image the
97
+ * summary above projects (change edit-lock-recovery-proposals, D1/D2). The
98
+ * client renders these verbatim and never re-derives eligibility or the
99
+ * confirmation: eligibility uses the host's own disposition rule, and the
100
+ * confirmation is produced by the very function the manager re-verifies at
101
+ * commit time — the proposal is a read-time suggestion, never an
102
+ * authorization. `unmet` entries carry stable reason CODES in the manager's
103
+ * refusal order (disposition recorded → owner unknown → not interrupted →
104
+ * prepared outstanding → publishing outstanding → no unresolved operations);
105
+ * the prose is the client's localization job. Pure: reading changes nothing.
106
+ * @param {string} root @param {{ revision: number, state: any }} snapshot
107
+ */
108
+ export function recoveryProposals(root, snapshot) {
109
+ const state = snapshot.state
110
+ const revision = snapshot.revision
111
+ const operations = Array.isArray(state?.operations) ? state.operations : []
112
+ const sessionByOwner = new Map()
113
+ const interruptedByOwner = new Map()
114
+ for (const session of Array.isArray(state?.sessions) ? state.sessions : []) {
115
+ if (typeof session?.sessionId !== 'string') continue
116
+ sessionByOwner.set(session.sessionId, session)
117
+ interruptedByOwner.set(session.sessionId, session.interrupted === true)
118
+ }
119
+ /** Unresolved (publishing/unknown) operations by owner, in image order. */
120
+ const unresolvedByOwner = new Map()
121
+ const preparedOwners = new Set()
122
+ for (const op of operations) {
123
+ if (typeof op?.sessionId !== 'string') continue
124
+ if (op.phase === 'prepared') { preparedOwners.add(op.sessionId); continue }
125
+ if (op.phase !== 'publishing' && op.phase !== 'unknown') continue
126
+ const list = unresolvedByOwner.get(op.sessionId) ?? []
127
+ list.push(op)
128
+ unresolvedByOwner.set(op.sessionId, list)
129
+ }
130
+ // The panel's merged display rows: retained locks plus still-blocking file
131
+ // fences (one row per resource); wide fences and cross-owner file fences
132
+ // become named scope paths instead of rows.
133
+ const rowsByResource = new Map()
134
+ const scopePathsByOwner = new Map()
135
+ const blockedOwners = new Set()
136
+ /** @param {string} owner @param {unknown} path */
137
+ const addScopePath = (owner, path) => {
138
+ if (typeof path !== 'string') return
139
+ const paths = scopePathsByOwner.get(owner) ?? []
140
+ if (!paths.includes(path)) paths.push(path)
141
+ scopePathsByOwner.set(owner, paths)
142
+ }
143
+ for (const lock of Array.isArray(state?.locks) ? state.locks : []) {
144
+ if (lock?.status === 'active') continue
145
+ if (typeof lock?.resourceId !== 'string' || typeof lock?.owner !== 'string') continue
146
+ blockedOwners.add(lock.owner)
147
+ const row = rowsByResource.get(lock.resourceId) ?? { resourceId: lock.resourceId, owner: lock.owner, lockStatus: null, fence: false, interrupted: interruptedByOwner.get(lock.owner) ?? false }
148
+ row.lockStatus = row.lockStatus ?? (typeof lock.status === 'string' ? lock.status : null)
149
+ rowsByResource.set(lock.resourceId, row)
150
+ }
151
+ for (const [owner, ops] of unresolvedByOwner) {
152
+ for (const op of ops) {
153
+ if (dispositionFor(state, op)) continue // disposed: no longer blocks admission
154
+ blockedOwners.add(owner)
155
+ const scope = fenceScope(op?.fence)
156
+ if (scope.kind === 'file' && typeof scope.path === 'string') {
157
+ const existing = rowsByResource.get(scope.path)
158
+ if (existing) {
159
+ // Same owner locks AND fences the file (the incident shape): one row.
160
+ if (existing.owner === owner) existing.fence = true
161
+ else addScopePath(owner, scope.path)
162
+ } else {
163
+ rowsByResource.set(scope.path, { resourceId: scope.path, owner, lockStatus: null, fence: true, interrupted: interruptedByOwner.get(owner) ?? false })
164
+ }
165
+ } else {
166
+ addScopePath(owner, scope.path)
167
+ }
168
+ }
169
+ }
170
+ const rows = [...rowsByResource.values()]
171
+ // Eligibility: an interrupted owner whose every unresolved operation is a
172
+ // still-blocking unknown, with no prepared operation outstanding — exactly
173
+ // the preconditions the manager re-verifies at the FIFO execution point.
174
+ const eligible = []
175
+ for (const [owner, ops] of unresolvedByOwner) {
176
+ if (interruptedByOwner.get(owner) !== true) continue
177
+ if (preparedOwners.has(owner)) continue
178
+ if (!(ops.length > 0 && ops.every(op => op.phase === 'unknown' && !dispositionFor(state, op)))) continue
179
+ eligible.push({ owner, ops })
180
+ }
181
+ const candidateOwners = new Set(eligible.map(entry => entry.owner))
182
+ const blocked = rows.filter(row => candidateOwners.has(row.owner))
183
+ blocked.sort((a, b) => a.resourceId.localeCompare(b.resourceId))
184
+ const candidates = eligible.map(({ owner, ops }) => {
185
+ const scope = { root, owner, expectedRevision: revision, operationIds: ops.map(op => op.operationId).sort() }
186
+ return { owner, scope, confirmation: recoveryConfirmation(scope), risk: LATE_WRITER_RISK, rows: blocked.filter(row => row.owner === owner) }
187
+ })
188
+ const unmet = []
189
+ for (const owner of [...blockedOwners].sort()) {
190
+ if (candidateOwners.has(owner)) continue
191
+ const ops = unresolvedByOwner.get(owner) ?? []
192
+ const session = sessionByOwner.get(owner)
193
+ const reason = ops.some(op => dispositionFor(state, op)) ? 'disposition-recorded'
194
+ : !session ? 'owner-unknown'
195
+ : session.interrupted !== true ? 'not-interrupted'
196
+ : preparedOwners.has(owner) ? 'prepared-outstanding'
197
+ : ops.some(op => op.phase === 'publishing') ? 'publishing-outstanding'
198
+ : 'no-unresolved'
199
+ unmet.push({ owner, reason, rows: rows.filter(row => row.owner === owner), scopePaths: scopePathsByOwner.get(owner) ?? [] })
200
+ }
201
+ return { schemaVersion: 1, revision, candidates, blocked, unmet }
202
+ }
203
+
92
204
  /**
93
205
  * The profile-wide switch decision. `saved` is the value currently persisted
94
206
  * in settings; `enabledAtMount` is what the edit-lock row actually did when it