weir-harness 1.1.6 → 1.2.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.
@@ -26,7 +26,7 @@ import { createStaleSweepScheduler } from './stale-sweep.js'
26
26
  import { createRemoteEditLockDomain, endpointFor, serveEditLockEndpoint } from './remote.js'
27
27
  import { registerBindingRuntime, createBindingAttester } from './binding-attestation.js'
28
28
  import { localDomain, remoteDomain } from './domain.js'
29
- import { AUTHORITY_DIR, createDomainRegistry, excludeFromGit, managementRootFor } from './domains.js'
29
+ import { AUTHORITY_DIR, createDomainRegistry, excludeFromGit, managementRootFor, sharedDomainRegistryFor } from './domains.js'
30
30
  import { createRecoveryDriver } from './recovery.js'
31
31
  import { createSettlementDriver } from './settle.js'
32
32
  import { editLockLimits } from '../settings/sections.js'
@@ -41,6 +41,29 @@ const inject = ['tools', 'fs', 'agents']
41
41
  /** Tool names whose definitions must be Edit Lock routed when enabled. */
42
42
  export const GUARDED_TOOLS = Object.freeze(['write', 'edit', 'hash_edit', 'lsp_rename', 'str_replace_editor'])
43
43
 
44
+ /** Claimed managed-editor executions, shared by every mount of this bundle in
45
+ * the process (change edit-lock-shared-domain-mounts, design D3): membership
46
+ * is execute-object identity, so cross-host admission is impossible and an
47
+ * execution claimed by a disposed mount is harmless — no live guard reads it.
48
+ * Whichever mount's guard stage hears an execution, the verdict is the same.
49
+ * @type {WeakSet<Function>} */
50
+ const managed = new WeakSet()
51
+
52
+ /** Host-lifetime live weirEditLock services (design D3): every mount registers
53
+ * its service at apply and unregisters at dispose, so the endpoint currency
54
+ * proof can accept any live sibling mount's service identity.
55
+ * @type {WeakMap<object, Set<object>>} */
56
+ const liveServicesByHost = new WeakMap()
57
+ /** @param {object} host @returns {Set<object>} */
58
+ function liveServicesFor(host) {
59
+ let services = liveServicesByHost.get(host)
60
+ if (!services) {
61
+ services = new Set()
62
+ liveServicesByHost.set(host, services)
63
+ }
64
+ return services
65
+ }
66
+
44
67
  /** Row config wins over the settings section; `root` + `authorityDirectory`
45
68
  * pin one fixed domain (development compositions and probes).
46
69
  * @param {unknown} config @param {unknown} [settings] */
@@ -258,7 +281,7 @@ function registerLockTools(ctx, service) {
258
281
  output: text('message'),
259
282
  async execute(/** @type {any} */ _args, /** @type {any} */ exec) {
260
283
  const unavailable = service.startupFailure(exec.agent)
261
- if (unavailable) return { ...unavailable, message: `Edit Lock unavailable: ${unavailable.reason}. ${unavailable.bindingFailure?.retryable ? 'Do not retry failed edits: the binding is retried automatically when the user next sends a message, and /edit-lock retry also retries the connection; neither resumes editing by itself.' : 'Inspect Edit Lock maintenance before continuing.'}` }
284
+ if (unavailable) return { ...unavailable, message: `Edit Lock unavailable: ${unavailable.reason}. ${statusRetryHint(unavailable.bindingFailure)}` }
262
285
  const locks = await service.locks(exec)
263
286
  const lines = locks.map((/** @type {any} */ lock) => `- ${lock.resourceId} owner=${lock.mine ? 'this session' : lock.owner} status=${lock.status} generation=${lock.generation}${lock.reason ? ` reason=${lock.reason}` : ''}`)
264
287
  // The reservation is part of what the holder needs to know: without it the
@@ -310,6 +333,41 @@ function describe(status) {
310
333
  return lines.join('\n')
311
334
  }
312
335
 
336
+ /** In-session binding-failure notice copy (change edit-lock-binding-transfer,
337
+ * design D4): the transferable class (same-process idle holder) is a calm
338
+ * self-heal announcement rather than an editing-disabled alarm and never
339
+ * carries the raw holder classification or the reservation warning; a mid-turn
340
+ * holder names the real state and the retry moment. Template layer stays
341
+ * English (AGENTS §3.7). @param {string} reason @param {any} failure */
342
+ function bindingFailureNotice(reason, failure) {
343
+ if (failure?.code === 'binding-duplicate' && failure.holderKind === 'transferable') {
344
+ 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."
345
+ }
346
+ if (failure?.code === 'binding-duplicate' && failure.holderKind === 'serving') {
347
+ 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."
348
+ }
349
+ 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.'}`
350
+ }
351
+
352
+ /** Read-only binding-unavailable guidance tail for the tool/service surfaces
353
+ * (domainFor and edit_lock_status), split by the recorded holder class.
354
+ * @param {string} failure @param {any} bindingFailure */
355
+ function bindingUnavailableMessage(failure, bindingFailure) {
356
+ const kind = bindingFailure?.holderKind
357
+ if (kind === 'transferable') return `Edit Lock binding is unavailable (${failure}). The holder is an inactive attachment of this same session: the binding transfers automatically when the user next sends a message, and /edit-lock retry also takes it over; neither resumes editing by itself. Do not retry the failed edit.`
358
+ if (kind === 'serving') return `Edit Lock binding is unavailable (${failure}). This session is being edited in another attachment right now; retry the connection once that turn finishes. Do not retry the failed edit.`
359
+ return `Edit Lock binding is unavailable (${failure}). 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.`
360
+ }
361
+
362
+ /** edit_lock_status read-only guidance tail, split by the recorded holder
363
+ * class (change edit-lock-binding-transfer). @param {any} bindingFailure */
364
+ function statusRetryHint(bindingFailure) {
365
+ if (bindingFailure?.retryable !== true) return 'Inspect Edit Lock maintenance before continuing.'
366
+ if (bindingFailure.holderKind === 'transferable') return 'The holder is an inactive attachment of this same session: editing attaches automatically when you next send a message, and /edit-lock retry also takes it over; neither resumes editing by itself.'
367
+ if (bindingFailure.holderKind === 'serving') return 'This session is being edited in another attachment right now; /edit-lock retry retries the connection once that turn finishes — it does not resume editing by itself.'
368
+ return 'Do not retry failed edits: the binding is retried automatically when the user next sends a message, and /edit-lock retry also retries the connection; neither resumes editing by itself.'
369
+ }
370
+
313
371
  /** Composition dependencies, not persisted settings. Defaults are the host paths.
314
372
  * @param {{ platform?: string, resolveRoot?: typeof managementRootFor, endpoint?: typeof endpointFor, exclude?: typeof excludeFromGit }} [dependencies] */
315
373
  export function createEditLockPlugin({ platform = process.platform, resolveRoot = managementRootFor, endpoint = endpointFor, exclude = excludeFromGit } = {}) {
@@ -335,6 +393,10 @@ return (ctx, config = {}) => {
335
393
  evidence?.failed(platformFailure)
336
394
  ctx.logger?.warn?.(platformFailure.message)
337
395
  }
396
+ /** The shared-registry handle, assigned inside the mount try so a failed
397
+ * apply can release its count (design D1: a failed mount enters no count).
398
+ * @type {any} */
399
+ let shared = null
338
400
  try {
339
401
  /** @type {any} */
340
402
  let sandboxPolicyRef = null
@@ -342,6 +404,11 @@ return (ctx, config = {}) => {
342
404
  // Capture the original host filesystem once; the manager is its only writer.
343
405
  const fs = ctx.fs
344
406
  const audit = createAudit(ctx)
407
+ // The host-lifetime key shared by the write-scope store, the domain registry
408
+ // container and the live-service set (design D1/D3) — identical to the key
409
+ // expression the settings evidence store uses.
410
+ const host = ctx.root ?? ctx
411
+ const liveServices = liveServicesFor(host)
345
412
  /** Successful undefined means no live runtime; missing service means unknown.
346
413
  * @param {string} sessionId */
347
414
  const servingRuntime = sessionId => {
@@ -361,12 +428,12 @@ return (ctx, config = {}) => {
361
428
  const disposedAgents = new WeakSet()
362
429
  /** One queued automatic-recovery summary notice per management root (design
363
430
  * D3): delivered to the first agent that finishes domain start, then gone.
364
- * @type {Map<string, string>} */
365
- const recoveryNotices = new Map()
366
- // The tool registry may hand back a normalized copy of a definition; the
367
- // claimed execute function is what actually runs, so it is the identity.
368
- /** @type {WeakSet<Function>} */
369
- const managed = new WeakSet()
431
+ * Shared across sibling mounts with the domain registry (change
432
+ * edit-lock-shared-domain-mounts): the domain opens through the first
433
+ * mount's closure, so the queue must live in the shared entry for the
434
+ * mount that hears the first post-recovery turn to drain it. Assigned
435
+ * with `shared` below. @type {Map<string, string>} */
436
+ let recoveryNotices
370
437
  /** Managed write registrations outlive this mount: the tool is installed
371
438
  * into the agent's OWN scope layer, which a preset rebind never disposes.
372
439
  * Their disposers live in a host-lifetime store so any later generation can
@@ -375,7 +442,7 @@ return (ctx, config = {}) => {
375
442
  * tearing the managed write down mid-gap would expose the stock writers
376
443
  * until the next mount's per-agent re-install.
377
444
  * @type {WeakMap<object, import('./write-scopes.js').WriteScopeEntry>} */
378
- const writeScopes = writeScopesFor(ctx.root ?? ctx)
445
+ const writeScopes = writeScopesFor(host)
379
446
  let closed = false
380
447
  /** Remote channel agents on the publisher side → their notice sink.
381
448
  * @type {WeakMap<object, (event: unknown) => void>} */
@@ -384,8 +451,11 @@ return (ctx, config = {}) => {
384
451
  const startFailures = new WeakMap()
385
452
  /** Publisher-classified failures, never inferred from text. @type {WeakMap<object, any>} */
386
453
  const bindingFailures = new WeakMap()
387
- /** Settled domain per bound agent, for synchronous gates. @type {WeakMap<object, any>} */
388
- const settled = new WeakMap()
454
+ /** Settled domain per bound agent, for synchronous gates. Shared across
455
+ * sibling mounts with the domain registry (design D3): whichever mount
456
+ * hears an event reads the same binding. Assigned with `shared` below.
457
+ * @type {WeakMap<object, any>} */
458
+ let settled
389
459
  /** In-flight setup per agent, shared by every entry point (creation,
390
460
  * preset-switch, catch-up, lazy re-bind) so concurrent callers can never
391
461
  * double-register. @type {WeakMap<object, Promise<void>>} */
@@ -474,16 +544,26 @@ return (ctx, config = {}) => {
474
544
  })
475
545
  let server
476
546
  try { server = await serveEditLockEndpoint(lifecycle, endpoint(directory), sinks, attestBinding,
477
- (agent, requireProof) => {
547
+ (agent, requireProof, transfer = false) => {
478
548
  if (closed || disposedAgents.has(agent)) return false
479
549
  const agents = ctx.get?.('agents')
480
550
  if (typeof agents?.get === 'function') {
481
- if (agents.get(agent.id) !== agent) return false
551
+ if (transfer === true) {
552
+ // A transfer claimant is NEVER the registry's serving agent —
553
+ // that mismatch is the defect being repaired (change
554
+ // edit-lock-binding-transfer). Its liveness proof is listing
555
+ // among the live root agents; holder-side admission is the
556
+ // lifecycle's transferStart.
557
+ if (typeof agents.roots !== 'function' || !agents.roots().includes(agent)) return false
558
+ } else if (agents.get(agent.id) !== agent) return false
482
559
  } else if (requireProof) return false
483
560
  // Fresh creation keeps the established missing/throwing-registry
484
561
  // fallback. A reclaim or retry never uses that fallback as proof.
562
+ // Currency proof (design D3): the agent's resolved service must be a
563
+ // LIVE service of this process — after a preset switch that is the
564
+ // sibling mount's service, never this mount's own necessarily.
485
565
  if (typeof presets?.serviceFor === 'function') {
486
- try { return presets.serviceFor(agent, 'weirEditLock') === service }
566
+ try { return liveServices.has(presets.serviceFor(agent, 'weirEditLock')) }
487
567
  catch { return !requireProof }
488
568
  }
489
569
  return true
@@ -499,12 +579,21 @@ return (ctx, config = {}) => {
499
579
  throw error
500
580
  }
501
581
  }
502
- const registry = createDomainRegistry(root => {
582
+ // One domain registry per host + effective domain configuration, shared by
583
+ // every same-process preset mount of this bundle (design D1): a sibling
584
+ // mount never degrades to a same-process remote client, and the LAST
585
+ // disposer closes all domains through the established drain discipline. The
586
+ // first mount's `open` closure serves every sharing mount — mounts are
587
+ // process-persistent, so the capture is safe (design D1 trade-off).
588
+ shared = sharedDomainRegistryFor(host, options.fixed ? `${options.fixed.root}\n${options.fixed.directory}` : '', () => createDomainRegistry(root => {
503
589
  const fixed = options.fixed
504
590
  if (fixed) return openDomain(fixed.root, fixed.directory, root)
505
591
  try { exclude(root) } catch (error) { ctx.logger?.warn?.(`edit lock: could not update .git/info/exclude: ${/** @type {any} */ (error)?.message ?? error}`) }
506
592
  return openDomain(root, join(root, AUTHORITY_DIR), root)
507
- }, resolveRoot)
593
+ }, resolveRoot))
594
+ const registry = shared.registry
595
+ settled = shared.settled
596
+ recoveryNotices = shared.recoveryNotices
508
597
  /** @param {any} agent */
509
598
  const domainOf = agent => registry.forAgent(agent)
510
599
  /** Lazy re-bind (design D2): an own-preset main agent with no binding —
@@ -520,7 +609,7 @@ return (ctx, config = {}) => {
520
609
  await setupInFlight.get(agent)
521
610
  if (settled.has(agent)) return settled.get(agent)
522
611
  const failure = startFailures.get(agent)
523
- if (failure && bindingFailures.has(agent)) throw new Error(`Edit Lock binding is unavailable (${failure}). 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.`)
612
+ if (failure && bindingFailures.has(agent)) throw new Error(bindingUnavailableMessage(failure, bindingFailures.get(agent)))
524
613
  if (failure) throw new Error(`this session has no Edit Lock domain: setup failed under the current preset generation (${failure}); the automatic re-bind was already attempted. Recovery: restart DeepSeek Harness or start a new conversation.`)
525
614
  // No fence match (foreign preset) or unmount: keep the stock refusal.
526
615
  return registry.forAgent(agent)
@@ -623,7 +712,17 @@ return (ctx, config = {}) => {
623
712
  if (platformFailure) return { state: 'unavailable', reason: platformFailure.message, bindingFailure: null }
624
713
  if (!agent || settled.has(agent) || !startFailures.has(agent)) return null
625
714
  let eligible = false
626
- try { eligible = !closed && !disposedAgents.has(agent) && ownAgent(agent) && servingRuntime(agent.id) === agent } catch { /* no trusted live identity */ }
715
+ try {
716
+ const serving = servingRuntime(agent.id)
717
+ eligible = !closed && !disposedAgents.has(agent) && ownAgent(agent)
718
+ && (serving === agent
719
+ // The registry names ANOTHER live local agent — the dual-agent defect
720
+ // this change heals (edit-lock-binding-transfer). The recorded
721
+ // duplicate-binding failure is still THIS agent's honest panel state:
722
+ // show it, with its Retry action, instead of hiding behind the ghost's
723
+ // healthy binding. An empty registry stays hidden (unattestable).
724
+ || (serving !== undefined && bindingFailures.get(agent)?.code === 'binding-duplicate'))
725
+ } catch { /* no trusted live identity */ }
627
726
  if (!eligible) return null
628
727
  const failure = bindingFailures.get(agent)
629
728
  return { state: 'unavailable', reason: startFailures.get(agent),
@@ -682,6 +781,9 @@ return (ctx, config = {}) => {
682
781
  },
683
782
  })
684
783
  ctx.reflect.provide('weirEditLock', service)
784
+ // Any live sibling mount's service identity attests endpoint currency
785
+ // (design D3); unregister at dispose.
786
+ liveServices.add(service)
685
787
 
686
788
  const offTools = registerLockTools(ctx, service)
687
789
 
@@ -700,6 +802,15 @@ return (ctx, config = {}) => {
700
802
  const agent = exec?.agent
701
803
  if (!closed && agent && !registry.rootOf(agent) && !startFailures.has(agent) && (agent?.session?.header?.delegationDepth ?? 0) === 0 && ownAgent(agent)) {
702
804
  await setupAgent(agent)
805
+ // The lazy re-bind is a driving ingress (design D1, change
806
+ // edit-lock-binding-transfer): when the re-setup just recorded a
807
+ // duplicate-binding failure, ONE transfer-capable retry runs here — an
808
+ // idle same-process holder hands the binding over mid-turn. The recorded
809
+ // failure then suppresses further attempts for the rest of this mount
810
+ // generation, so arbitrary write calls never loop retries.
811
+ if (bindingFailures.get(agent)?.code === 'binding-duplicate') {
812
+ try { await retryBinding(agent, 'write-guard') } catch { /* audited inside retryBinding; the denial below names the recorded cause */ }
813
+ }
703
814
  if (admitted()) return next()
704
815
  }
705
816
  const failure = /** @type {any} */ (agent) ? startFailures.get(agent) : undefined
@@ -728,8 +839,12 @@ return (ctx, config = {}) => {
728
839
  * re-bind, so the paths can never diverge. Root selection and successful
729
840
  * startup are separate: explicit binding retry reuses the existing scope
730
841
  * and root, while ordinary entry points never repeat a failed startup.
731
- * @param {any} agent */
732
- async function setupAgentOnce(agent, retry = false) {
842
+ * @param {any} agent @param {boolean} [retry] @param {boolean} [transfer]
843
+ * transfer (change edit-lock-binding-transfer) marks the driving-ingress
844
+ * transfer admission: the domain's transferStart re-adjudicates holder
845
+ * idleness at execution, and the post-bind registry-identity check is
846
+ * skipped because the registry legitimately still names the idle holder. */
847
+ async function setupAgentOnce(agent, retry = false, transfer = false) {
733
848
  if (closed || settled.has(agent)) return
734
849
  // No writer scope, root resolution, directory, reservation or session
735
850
  // authority may be installed when the mount preflight failed.
@@ -764,9 +879,10 @@ return (ctx, config = {}) => {
764
879
  }
765
880
  try {
766
881
  const domain = await domainOf(agent)
767
- if (retry) await domain.retryStart(agent)
882
+ if (transfer) await domain.transferStart(agent)
883
+ else if (retry) await domain.retryStart(agent)
768
884
  else await domain.start(agent)
769
- if (closed || disposedAgents.has(agent) || (retry && (!ownAgent(agent) || servingRuntime(agent.id) !== agent))) {
885
+ if (closed || disposedAgents.has(agent) || (retry && (!ownAgent(agent) || (!transfer && servingRuntime(agent.id) !== agent)))) {
770
886
  await domain.forget(agent)
771
887
  throw new Error('Edit Lock mount changed during binding')
772
888
  }
@@ -790,7 +906,7 @@ return (ctx, config = {}) => {
790
906
  if (failure?.code === 'binding-duplicate') bindingFailures.set(agent, failure)
791
907
  else bindingFailures.delete(agent)
792
908
  try { evidence?.recordSessionFailure?.(sessionOf(agent), reason) } catch { /* reporting only */ }
793
- try { deliverLocal(agent, `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.'}`) } catch { /* the panel record remains */ }
909
+ try { deliverLocal(agent, bindingFailureNotice(reason, failure)) } catch { /* the panel record remains */ }
794
910
  ctx.logger?.warn?.(`edit lock registration failed for ${sessionOf(agent)}: ${reason}`)
795
911
  }
796
912
  }
@@ -806,20 +922,64 @@ return (ctx, config = {}) => {
806
922
  setupInFlight.set(agent, task)
807
923
  return task
808
924
  }
809
- /** Trusted command ingress only. Same Agent/root/mount; no writer reinstallation.
810
- * @param {any} agent */
811
- async function retryBinding(agent) {
812
- if (closed || disposedAgents.has(agent) || !ownAgent(agent) || servingRuntime(agent.id) !== agent) throw new Error('Edit Lock retry requires the current live Agent of this mount')
925
+ /** Trusted driving ingresses only: the explicit /edit-lock retry (and panel
926
+ * action), the message-armed pre-step retry, and the write-guard lazy
927
+ * re-bind. Same Agent/root/mount; no writer reinstallation. Every attempt
928
+ * past the eligibility gate is audited — a silently swallowed refusal is a
929
+ * defect (change edit-lock-binding-transfer, design D3/D5).
930
+ * @param {any} agent @param {'command' | 'message' | 'write-guard'} [via] */
931
+ async function retryBinding(agent, via = 'command') {
932
+ if (closed || disposedAgents.has(agent) || !ownAgent(agent)) throw new Error('Edit Lock retry requires the current live Agent of this mount')
813
933
  if (!registry.rootOf(agent) || bindingFailures.get(agent)?.code !== 'binding-duplicate') throw new Error('Edit Lock startup failure is not eligible for binding retry')
814
- let task = setupInFlight.get(agent)
815
- if (!task) {
816
- const inner = setupAgentOnce(agent, true)
817
- task = inner.finally(() => { if (setupInFlight.get(agent) === task) setupInFlight.delete(agent) })
818
- setupInFlight.set(agent, task)
934
+ const cause = bindingFailures.get(agent)
935
+ const sessionId = sessionOf(agent) ?? null
936
+ const root = registry.rootOf(agent) ?? undefined
937
+ /** @param {string} result @param {Record<string, unknown>} [extra] */
938
+ const record = (result, extra = {}) => audit(agent?.session ?? null, AUDIT_TYPES.editLockMaintenance, {
939
+ kind: 'binding-transfer', sessionId, code: cause?.code ?? 'binding-duplicate', result, ...extra,
940
+ }, { root })
941
+ // Attestation trichotomy (design D5): the registry naming THIS claimant
942
+ // keeps the existing superseded rebind; naming ANOTHER live local agent
943
+ // routes to the transfer admission (holder idleness is re-adjudicated at
944
+ // execution, publisher-side); an unavailable or empty registry stays
945
+ // fail-closed — the holder cannot be attested.
946
+ let serving
947
+ try { serving = servingRuntime(agent.id) } catch { serving = undefined }
948
+ if (serving === undefined) {
949
+ 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.')
950
+ record('refused', { reason: 'registry-unattestable', detail: error.message })
951
+ throw error
952
+ }
953
+ const transfer = serving !== agent
954
+ try {
955
+ let task = setupInFlight.get(agent)
956
+ if (!task) {
957
+ const inner = setupAgentOnce(agent, true, transfer)
958
+ task = inner.finally(() => { if (setupInFlight.get(agent) === task) setupInFlight.delete(agent) })
959
+ setupInFlight.set(agent, task)
960
+ }
961
+ await task
962
+ } catch (error) {
963
+ record('refused', { reason: /** @type {any} */ (error)?.bindingFailure?.holderKind ?? 'retry-refused', detail: String(/** @type {any} */ (error)?.message ?? error) })
964
+ throw error
819
965
  }
820
- await task
821
966
  const failure = startFailures.get(agent)
822
- if (failure || !settled.has(agent)) throw new Error(failure ?? 'Edit Lock binding remains unavailable')
967
+ if (failure || !settled.has(agent)) {
968
+ // setupAgentOnce records instead of rethrowing: the refusal's holder
969
+ // class comes from the freshly re-recorded structured failure.
970
+ const error = new Error(failure ?? 'Edit Lock binding remains unavailable')
971
+ record('refused', { reason: bindingFailures.get(agent)?.holderKind ?? 'binding-unavailable', detail: error.message })
972
+ throw error
973
+ }
974
+ if (transfer) {
975
+ record('transferred', { holder: cause?.holderKind ?? null })
976
+ } else if (via === 'message') {
977
+ // The message-armed superseded success keeps its established audit shape
978
+ // (pinned by the editlock-binding-auto-retry integration scenario).
979
+ audit(agent?.session ?? null, AUDIT_TYPES.editLockMaintenance, {
980
+ kind: 'binding-auto-retry', sessionId, code: cause?.code ?? 'binding-duplicate', result: 'recovered',
981
+ }, { root })
982
+ }
823
983
  return 'Edit Lock connection recovered. Editing remains interrupted; use Continue editing when ready. No failed edit was replayed.'
824
984
  }
825
985
  ctx.on('agent/created', (/** @type {any} */ { agent }) => setupAgent(agent))
@@ -953,17 +1113,17 @@ return (ctx, config = {}) => {
953
1113
  lastTurn.set(agent, turn)
954
1114
  if (pendingBindingRetry.get(agent) === true) {
955
1115
  pendingBindingRetry.delete(agent)
956
- const cause = bindingFailures.get(agent)
957
1116
  try {
958
- await retryBinding(agent)
1117
+ await retryBinding(agent, 'message')
959
1118
  // The genuine message that armed this retry is also this turn's
960
1119
  // Continue receipt: re-arm what setupAgentOnce(retry) cleared, then
961
- // fall through to the ordinary auto-resume consumption below.
1120
+ // fall through to the ordinary auto-resume consumption below. A
1121
+ // transfer success (registry named an idle same-process holder)
1122
+ // lands here identically: bound interrupted, then resumed in-turn.
962
1123
  pendingAutoResume.set(agent, true)
963
- audit(agent?.session ?? null, AUDIT_TYPES.editLockMaintenance, {
964
- kind: 'binding-auto-retry', sessionId: sessionOf(agent) ?? null,
965
- code: cause?.code ?? 'binding-duplicate', result: 'recovered',
966
- }, { root: registry.rootOf(agent) ?? undefined })
1124
+ // The outcome audit (transferred / refused) was written inside
1125
+ // retryBinding — success AND refusal alike; the catch below is not
1126
+ // a silent swallow any more (design D3).
967
1127
  } catch { /* a safe refusal keeps the recorded cause; the turn proceeds with edits denied */ }
968
1128
  }
969
1129
  const domain = settled.get(agent)
@@ -1284,6 +1444,7 @@ return (ctx, config = {}) => {
1284
1444
  return async () => {
1285
1445
  if (closed) return
1286
1446
  closed = true
1447
+ liveServices.delete(service)
1287
1448
  offDelegateLifecycle()
1288
1449
  evidence?.disposing()
1289
1450
  try {
@@ -1295,10 +1456,9 @@ return (ctx, config = {}) => {
1295
1456
  offView()
1296
1457
  offCommand?.()
1297
1458
  for (const off of offTools) off?.()
1298
- // Retain uncertain evidence until every generation-owned domain is drained.
1299
- const results = await Promise.allSettled(registry.all().map(async domain => (await domain).close()))
1300
- const failure = results.find(result => result.status === 'rejected')
1301
- if (failure?.status === 'rejected') throw failure.reason
1459
+ // Retain uncertain evidence until every generation-owned domain is
1460
+ // drained; only the LAST sharing disposer closes them (design D1).
1461
+ await shared.release()
1302
1462
  evidence?.dispose()
1303
1463
  } catch (error) {
1304
1464
  evidence?.failed(error)
@@ -1307,6 +1467,10 @@ return (ctx, config = {}) => {
1307
1467
  }
1308
1468
  } catch (error) {
1309
1469
  evidence?.failed(error)
1470
+ // A mount that failed mid-apply must not stay counted in the shared
1471
+ // registry (design D1); domains are lazy, so this release closes nothing
1472
+ // unless a sibling mount already opened one — its count keeps it alive.
1473
+ if (shared) void shared.release().catch(() => {})
1310
1474
  throw error
1311
1475
  }
1312
1476
  }
@@ -169,6 +169,17 @@ export function createEditLockLifecycle(runtime, sessionForAgent, options = {})
169
169
  }
170
170
  return result
171
171
  }
172
+ /** Holder liveness signal (change edit-lock-binding-transfer, task 1.1): the
173
+ * DSH agent `status` vocabulary is exactly 'idle' | 'running' (idle covers the
174
+ * idle and maintenance phases). Only 'idle' proves the holder is not mid-turn;
175
+ * a missing, non-string or throwing read is undeterminable and never admits.
176
+ * @param {object} holder @returns {string | undefined} */
177
+ function holderStatus(holder) {
178
+ try {
179
+ const status = /** @type {any} */ (holder)?.status
180
+ return typeof status === 'string' ? status : undefined
181
+ } catch { return undefined }
182
+ }
172
183
  /** Read-only startup admission. A revoked session is startable: it binds as
173
184
  * interrupted and its revocation ends only through the trusted resume path
174
185
  * (the epoch advance is the repair; binding alone grants no authority).
@@ -184,22 +195,42 @@ export function createEditLockLifecycle(runtime, sessionForAgent, options = {})
184
195
  }
185
196
  /** Register once before publishing an agent's editing tools. A session the
186
197
  * manager already knows (restart, re-created agent) is never re-armed: it
187
- * 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
188
201
  * @returns {Promise<'active'|'interrupted'>} */
189
202
  function start(agent) {
190
203
  if (closed) return Promise.reject(new Error('edit lifecycle closed'))
191
- 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
+ }
192
217
  const sessionId = sessionForAgent(agent)
193
218
  if (!sessionId) return Promise.reject(new Error('unregistered lifecycle agent'))
194
219
  const prior = [...entries].find(([, entry]) => entry.sessionId === sessionId)
195
220
  if (prior) {
196
221
  let serving
197
222
  try { serving = options.servingRuntime?.(sessionId) } catch { /* unknown: refuse */ }
223
+ // A registry-named local holder that is provably not mid-turn is the
224
+ // TRANSFERABLE class (serving-idle): passive paths still refuse, but the
225
+ // refusal announces the automatic takeover instead of a window hunt.
226
+ const attestation = serving === prior[0]
227
+ ? (/** @type {any} */ (prior[0]).remote !== true && holderStatus(prior[0]) === 'idle' ? 'serving-idle' : 'serving')
228
+ : 'unknown'
198
229
  const refusal = () => {
199
- const reason = bindingRefusalReason(/** @type {any} */ (prior[0]).remote === true ? undefined : process.pid, serving === prior[0] ? 'serving' : 'unknown')
230
+ const reason = bindingRefusalReason(/** @type {any} */ (prior[0]).remote === true ? undefined : process.pid, attestation)
200
231
  // Remote callers render after their retry budget; local callers have no wire.
201
232
  return Object.assign(new Error(/** @type {any} */ (agent).remote === true ? reason : `${reason}: ${bindingGuidance(sessionId, reason)}`),
202
- { bindingFailure: bindingFailure(/** @type {any} */ (prior[0]).remote === true ? undefined : process.pid, serving === prior[0] ? 'serving' : 'unknown') })
233
+ { bindingFailure: bindingFailure(/** @type {any} */ (prior[0]).remote === true ? undefined : process.pid, attestation) })
203
234
  }
204
235
  // Remote entries are channel proxies, NOT runtime identities. Only the
205
236
  // endpoint's nonce attestation may reclaim them (including same-process).
@@ -236,6 +267,39 @@ export function createEditLockLifecycle(runtime, sessionForAgent, options = {})
236
267
  })()
237
268
  return entry.ready
238
269
  }
270
+ /** Trusted driving-ingress transfer admission (change
271
+ * edit-lock-binding-transfer, design D1/D2): take the binding from a
272
+ * same-process, local (non-remote-proxy) holder that is registry-named and
273
+ * provably not mid-turn, reusing the exact superseded-reclaim semantics —
274
+ * durable revocation and forget, no lock is released, the claimant binds as
275
+ * interrupted. Every other shape (remote proxy, holder not registry-named,
276
+ * mid-turn, undeterminable liveness, unavailable registry) delegates to the
277
+ * ordinary start(), keeping its fail-closed refusal and its classification.
278
+ * Passive entry points (creation setup, catch-up, preset switch) never call
279
+ * this. @param {object} agent @returns {Promise<'active'|'interrupted'>} */
280
+ function transferStart(agent) {
281
+ if (closed) return Promise.reject(new Error('edit lifecycle closed'))
282
+ if (entries.has(agent)) return Promise.reject(new Error('agent already registered'))
283
+ const sessionId = sessionForAgent(agent)
284
+ if (!sessionId) return Promise.reject(new Error('unregistered lifecycle agent'))
285
+ const prior = [...entries].find(([, entry]) => entry.sessionId === sessionId)
286
+ if (!prior) return start(agent)
287
+ let serving
288
+ try { serving = options.servingRuntime?.(sessionId) } catch { /* unattestable: fall through to the ordinary refusal */ }
289
+ if (/** @type {any} */ (prior[0]).remote !== true && serving === prior[0] && holderStatus(prior[0]) === 'idle') {
290
+ // Concurrent claimants re-check after the shared revocation settles;
291
+ // no file lock is released and the claimant starts interrupted. A failed
292
+ // revocation keeps the transferable refusal: the holder entry is retained
293
+ // (start()'s reclaim-failure discipline) and the next driving ingress
294
+ // re-attempts the transfer.
295
+ return forget(prior[0]).then(() => start(agent), () => {
296
+ const reason = bindingRefusalReason(process.pid, 'serving-idle')
297
+ throw Object.assign(new Error(/** @type {any} */ (agent).remote === true ? reason : `${reason}: ${bindingGuidance(sessionId, reason)}`),
298
+ { bindingFailure: bindingFailure(process.pid, 'serving-idle') })
299
+ })
300
+ }
301
+ return start(agent)
302
+ }
239
303
  return Object.freeze({
240
304
  assertStartable,
241
305
  /** Local bindings only; endpoint arbitration reports channel proxies. */
@@ -283,6 +347,8 @@ export function createEditLockLifecycle(runtime, sessionForAgent, options = {})
283
347
  pendingRequests(exec) { return negotiation.pending(host.executionFor(exec.agent).sessionId) },
284
348
  }),
285
349
  start,
350
+ /** Explicit driving-ingress transfer entry; ordinary start() never transfers. */
351
+ transferStart,
286
352
  stop,
287
353
  /** Normal end of an agent: an agent that is still active (never stopped,
288
354
  * not recovering) can never edit again, so its active locks are released
@@ -172,6 +172,22 @@ export async function serveEditLockEndpoint(lifecycle, endpoint, sinks, attest,
172
172
  const agent = Object.freeze({ id: sessionId, remote: true })
173
173
  sinks.set(agent, event => transport.notify(event))
174
174
  inner = createEditLockPeer(lifecycle, agent)
175
+ // A retry open is a trusted driving ingress (change
176
+ // edit-lock-binding-transfer): when the ordinary start is refused by
177
+ // a duplicate LOCAL holder, the transfer admission re-adjudicates the
178
+ // holder's idleness publisher-side. start() runs first and refuses
179
+ // without side effects, so a non-transferable refusal keeps its
180
+ // original classification and is evaluated exactly once per caller.
181
+ if (message.request.retry === true && typeof lifecycle.transferStart === 'function') {
182
+ try {
183
+ const state = await lifecycle.start(agent)
184
+ return { peer: inner, state }
185
+ } catch (error) {
186
+ if (/** @type {any} */ (error)?.bindingFailure?.code !== 'binding-duplicate') throw error
187
+ const state = await lifecycle.transferStart(agent)
188
+ return { peer: inner, state }
189
+ }
190
+ }
175
191
  const state = await lifecycle.start(agent)
176
192
  return { peer: inner, state }
177
193
  }, { nonce: typeof message.request.nonce === 'string' && message.request.nonce.length <= 256 ? message.request.nonce : undefined,
@@ -199,14 +215,17 @@ export async function serveEditLockEndpoint(lifecycle, endpoint, sinks, attest,
199
215
  })
200
216
  return Object.freeze({
201
217
  bindings: arbitration.bindings,
202
- /** @param {any} agent @param {boolean} [retry] */
203
- startLocal(agent, retry = false) {
218
+ /** @param {any} agent @param {boolean} [retry] @param {boolean} [transfer]
219
+ * transfer: the claimant arrives through a driving ingress whose registry
220
+ * identity is the defect being repaired — the lifecycle transfer admission
221
+ * decides, and the currency proof relaxes accordingly (isCurrentLocal). */
222
+ startLocal(agent, retry = false, transfer = false) {
204
223
  return arbitration.openLocal(agent.id, async reclaimed => {
205
224
  const current = () => {
206
- try { return !closed && (isCurrentLocal ? isCurrentLocal(agent, reclaimed || retry) : !(reclaimed || retry)) } catch { return false }
225
+ try { return !closed && (isCurrentLocal ? isCurrentLocal(agent, reclaimed || retry, transfer) : !(reclaimed || retry)) } catch { return false }
207
226
  }
208
227
  if (!current()) throw new Error('local edit binding claimant is no longer current')
209
- const state = await lifecycle.start(agent)
228
+ const state = transfer && typeof lifecycle.transferStart === 'function' ? await lifecycle.transferStart(agent) : await lifecycle.start(agent)
210
229
  if (!current()) {
211
230
  // A stopped entry still has subtractive cleanup authority. Forget it
212
231
  // only after durable revocation; never dispose (which releases locks).