weir-harness 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/lib/client.capability-badge.js +2 -0
  2. package/lib/client.capability-manager.js +46 -179
  3. package/lib/client.capability-model.js +428 -3
  4. package/lib/client.file-lease-tool-model.js +189 -0
  5. package/lib/client.file-lease-tool-view.js +434 -0
  6. package/lib/client.js +105 -7
  7. package/lib/client.mcp-manager.js +563 -0
  8. package/package.json +1 -1
  9. package/skills/mcp-server-setup/SKILL.md +124 -0
  10. package/src/capabilities/apply-engine.js +18 -4
  11. package/src/capabilities/capability-remote.js +296 -45
  12. package/src/capabilities/initial-selection.js +3 -2
  13. package/src/capabilities/lifecycle-preload.js +69 -11
  14. package/src/capabilities/lifecycle-snapshot.js +127 -39
  15. package/src/capabilities/mcp-admission.js +2 -2
  16. package/src/capabilities/mcp-config.js +344 -0
  17. package/src/capabilities/mcp-credentials.js +180 -0
  18. package/src/capabilities/mcp-diagnostics.js +169 -0
  19. package/src/capabilities/mcp-drain.js +18 -4
  20. package/src/capabilities/mcp-facade.js +3 -3
  21. package/src/capabilities/mcp-gate.js +51 -52
  22. package/src/capabilities/mcp-install-tool.js +276 -0
  23. package/src/capabilities/mcp-manager.js +368 -142
  24. package/src/capabilities/mcp-mount.js +219 -79
  25. package/src/capabilities/mcp-operations.js +359 -0
  26. package/src/capabilities/mcp-process.js +43 -0
  27. package/src/capabilities/mcp-registry.js +74 -60
  28. package/src/capabilities/mcp-runtime.js +103 -0
  29. package/src/capabilities/read-payloads.js +8 -3
  30. package/src/capabilities/skill-applicability.js +185 -0
  31. package/src/capabilities/skill-builtin-migration.js +2 -1
  32. package/src/capabilities/skill-selection-plugin.js +161 -56
  33. package/src/file-lease/client.js +268 -35
  34. package/src/file-lease/index.js +4 -0
  35. package/src/file-lease/plugin.js +239 -15
  36. package/src/file-lease/tools.js +426 -38
  37. package/src/file-lease/watch.js +473 -0
  38. package/src/intent-gate/index.js +29 -12
  39. package/src/intent-gate/matcher.js +36 -2
  40. package/src/lease-broker/protocol.js +3 -2
  41. package/src/lease-broker/server.js +6 -3
  42. package/src/lease-broker/state.js +68 -11
  43. package/src/process/index.js +4 -2
  44. package/src/shared/audit.js +3 -0
  45. package/src/shared/child-scope.js +2 -0
  46. package/src/worktree/rules.js +1 -1
@@ -16,6 +16,11 @@
16
16
  // to a session that is mid-turn simply lands inside that turn and costs no
17
17
  // extra wake. A request for a session that is not here (another process, a
18
18
  // disposed agent) is silently dropped: the arbiter will expire it.
19
+ // 4. Release subscriptions (D2, D4, D5): ONE in-memory coordinator per lease
20
+ // client, shared by every mount of this bundle. A broadcast `released` fact
21
+ // is consumed once and delivered as a timer-deferred full UserMessage; a
22
+ // connection reset is a terminal state that tells each subscriber to
23
+ // re-check, never that the path is free.
19
24
  //
20
25
  // The transport is process-wide (D8/D11). Two preset mounts of this bundle
21
26
  // share ONE client per socket path, and unmounting must never close it — that
@@ -28,6 +33,7 @@
28
33
  import { createLineageResolver } from './lineage.js'
29
34
  import { userTextMessage } from '../shared/user-message.js'
30
35
  import { createLeaseTools, REPLY_TOOL, shortName } from './tools.js'
36
+ import { createWatchCoordinator } from './watch.js'
31
37
  import { AUDIT_TYPES, createAudit } from '../shared/audit.js'
32
38
  import { getSharedLeaseClient } from './singleton.js'
33
39
  import { buildLeaseView } from './view.js'
@@ -92,11 +98,87 @@ function noticedFor(agent) {
92
98
  return noticed
93
99
  }
94
100
 
101
+ /**
102
+ * The settled outcomes this process has already delivered, per requesting agent.
103
+ *
104
+ * One `settled` event is one process-wide fact, but every mount hears it and the
105
+ * requester must be told exactly once (D4). `issued` cannot dedupe it: a turn
106
+ * boundary clears that map while the outcome is allowed to arrive afterwards —
107
+ * which is exactly when this memo is the only thing that keeps a second mount
108
+ * from delivering the same outcome a second time. It is keyed by the live agent
109
+ * because that is this process's handle on the requesting session, and it is
110
+ * bounded because the arbiter settles a request exactly once: forgetting an old
111
+ * id can only lose a repeat that never comes.
112
+ * @type {WeakMap<object, Map<string, number>>}
113
+ */
114
+ const settledNoticesByAgent = new WeakMap()
115
+
116
+ /** How many delivered outcomes one agent's memo remembers (bounded, FIFO). */
117
+ const SETTLED_NOTICE_MEMO_LIMIT = 64
118
+
119
+ /**
120
+ * The broker incarnation every memo entry belongs to.
121
+ *
122
+ * Request ids come from the daemon's own counter, which starts over whenever a
123
+ * new daemon takes the socket, so a live session can be handed an id this
124
+ * process already delivered an outcome for. A transport reset is the boundary
125
+ * between two incarnations: nothing recorded before it says anything about an id
126
+ * issued after it.
127
+ */
128
+ let settledEpoch = 0
129
+
130
+ /**
131
+ * Claim the one model-facing delivery of a settled outcome (D4).
132
+ * @param {object} agent @param {string} requestId
133
+ * @returns {boolean} whether this caller is the one that may deliver it
134
+ */
135
+ function claimSettledNotice(agent, requestId) {
136
+ let notices = settledNoticesByAgent.get(agent)
137
+ if (notices === undefined) {
138
+ notices = new Map()
139
+ settledNoticesByAgent.set(agent, notices)
140
+ }
141
+ if (notices.get(requestId) === settledEpoch) return false
142
+ notices.set(requestId, settledEpoch)
143
+ if (notices.size > SETTLED_NOTICE_MEMO_LIMIT) {
144
+ // Oldest first: the ids evicted here belong to outcomes the arbiter already
145
+ // announced once and will never announce again.
146
+ const [oldest] = notices.keys()
147
+ if (oldest !== undefined) notices.delete(oldest)
148
+ }
149
+ return true
150
+ }
151
+
95
152
  /** @param {unknown} error @returns {string} */
96
153
  function messageOf(error) {
97
154
  return String(/** @type {any} */ (error)?.message ?? error)
98
155
  }
99
156
 
157
+ /**
158
+ * ONE release-subscription coordinator per lease client (design D2/D4).
159
+ *
160
+ * The client is process-wide, so its view of who waits for which path must be
161
+ * too: keying the coordinator by the client makes two preset mounts share one
162
+ * entry, one status flight and one notice, and lets either mount deliver a
163
+ * pending notice after the other one is gone. The map is weak because the
164
+ * coordinator — like the client — lives exactly as long as something holds it.
165
+ * @type {WeakMap<object, import('./watch.js').WatchCoordinator>}
166
+ */
167
+ const watchCoordinators = new WeakMap()
168
+
169
+ /**
170
+ * @param {object} client
171
+ * @returns {import('./watch.js').WatchCoordinator}
172
+ */
173
+ function coordinatorFor(client) {
174
+ let coordinator = watchCoordinators.get(client)
175
+ if (coordinator === undefined) {
176
+ coordinator = createWatchCoordinator()
177
+ watchCoordinators.set(client, coordinator)
178
+ }
179
+ return coordinator
180
+ }
181
+
100
182
  /**
101
183
  * The session this agent acts for: the durable header id when there is one,
102
184
  * else the live session id, else the agent id. Non-empty by construction, and
@@ -206,6 +288,39 @@ function settledText(state, requestId, short) {
206
288
  return `Request ${requestId}: no answer arrived for ${short}, so the request expired and ownership is unchanged. It becomes available when its validity ends; ask again later if you still need it.`
207
289
  }
208
290
 
291
+ /**
292
+ * The release notice addressed to a subscriber (D4).
293
+ *
294
+ * It says what happened, what to do next, and — explicitly — what it does NOT
295
+ * mean: the arbiter selected no winner and the path may already be gone again.
296
+ * The path is the model's short name; an absolute path never lands in a message.
297
+ * @param {string} short
298
+ * @returns {string}
299
+ */
300
+ function releasedNoticeText(short) {
301
+ return [
302
+ `${short} is no longer held by another session.`,
303
+ 'This is only an opportunity to call edit_lock_acquire for it — not a grant of ownership, and not a reservation: another session waiting for the same release may claim it first, and the ordinary holder conflict still applies.',
304
+ 'If your claim is refused, negotiate with edit_lock_try_steal or call edit_lock_watch again for the next release.',
305
+ ].join(' ')
306
+ }
307
+
308
+ /**
309
+ * The notice for a subscription a connection reset ended (D5).
310
+ *
311
+ * A reset says nothing about ownership, so this must not read like a release:
312
+ * the subscriber is told to re-check and to subscribe again if it is still
313
+ * refused, and nothing else.
314
+ * @param {string} short
315
+ * @returns {string}
316
+ */
317
+ function resetNoticeText(short) {
318
+ return [
319
+ `The ownership arbiter connection was reset, so the release subscription for ${short} ended without learning anything about ownership.`,
320
+ 'Nothing was released and nothing is free by this notice. Re-check with edit_lock_status, and call edit_lock_watch again if the path is still held by another session.',
321
+ ].join(' ')
322
+ }
323
+
209
324
  /**
210
325
  * Adapt one degradation record from the lease client to the shared audit
211
326
  * channel (design D7, `AUDIT_TYPES.fileLease`).
@@ -302,10 +417,11 @@ export function createFileLeasePlugin(options = {}) {
302
417
  /** @type {Set<any>} agents whose reply tool this mount registered */
303
418
  const replyAgents = new Set()
304
419
  /**
305
- * `requestId -> the session that asked for it`. A `settled` event carries no
306
- * session (it is pushed to the requesting connection, which every session of
307
- * this process shares), so this map is the only way to address the outcome.
308
- * It is routing state, never authority: the arbiter decides (D6).
420
+ * `requestId -> the session that asked for it`. A `settled` event names the
421
+ * requester's session, but a broker older than that field pushes the outcome
422
+ * only to the requesting connection (which every session of this process
423
+ * shares), so this map remains the fallback route. It is routing state,
424
+ * never authority: the arbiter decides (D6).
309
425
  * @type {Map<string, { session: string, path: string }>}
310
426
  */
311
427
  const issued = new Map()
@@ -336,6 +452,28 @@ export function createFileLeasePlugin(options = {}) {
336
452
  })
337
453
  const ownerOf = injectedOwnerOf ?? createOwnerResolver(agents)
338
454
  const deliver = injectedDeliver ?? defaultDeliver
455
+ /**
456
+ * This client's release subscriptions (D2/D4). ONE coordinator per client,
457
+ * not per mount: two mounts that subscribe the same session and path share
458
+ * one entry, one status flight and one notice, and one mount's teardown
459
+ * cannot cancel a wait another mount still owns.
460
+ */
461
+ const watches = coordinatorFor(client)
462
+ /**
463
+ * This mount's reference on the shared coordinator plus the notice channel it
464
+ * speaks through. The coordinator maintains `disposed`; `notify` only renders
465
+ * the outcome it has already re-checked (a release fact or a reset).
466
+ * @type {import('./watch.js').WatchMount}
467
+ */
468
+ const watchMount = {
469
+ disposed: false,
470
+ agentFor: (/** @type {string} */ session) => agentFor(session),
471
+ notify: (/** @type {import('./watch.js').WatchEntry} */ entry, /** @type {any} */ agent) => {
472
+ const short = shortName(entry.path, cwdOf(agent))
473
+ notify(agent, entry.state === 'reset' ? resetNoticeText(short) : releasedNoticeText(short), agent?.status === 'idle')
474
+ },
475
+ }
476
+ watches.join(watchMount)
339
477
 
340
478
  /**
341
479
  * Deliver a notice without letting delivery break the caller: both channels
@@ -503,6 +641,9 @@ export function createFileLeasePlugin(options = {}) {
503
641
  const session = sessionIdOf(agent)
504
642
  retireReplyTool(agent)
505
643
  forgetIssued(session)
644
+ // A disposed agent never comes back: its subscriptions end with it — the
645
+ // active waits AND the consumed notices still waiting for their timer.
646
+ if (session !== undefined) watches.cancelSession(session)
506
647
  if (session === undefined) return
507
648
  quiet(() => client.releaseAll(session))
508
649
  }
@@ -553,18 +694,71 @@ export function createFileLeasePlugin(options = {}) {
553
694
  notify(agent, requestText({ requestId, short: shortName(path, cwdOf(agent)), label, fromSession, seconds }), agent?.status === 'idle')
554
695
  }
555
696
 
556
- /** @param {any} event */
697
+ /**
698
+ * One negotiation outcome from the broker (D4).
699
+ *
700
+ * The event names the requester (`session`) and the path it asked about, and
701
+ * that pair is the routing address: a request outlives the turn that made it,
702
+ * while this mount's `issued` memo does not — a turn boundary clears it — so
703
+ * `issued` only routes an older broker's event, which names no session. An
704
+ * event that names neither is another process's outcome and is ignored here.
705
+ * @param {any} event
706
+ */
557
707
  function onSettled(event) {
558
708
  const requestId = text(event?.requestId)
559
709
  if (requestId === undefined) return
710
+ const state = text(event?.state) ?? 'expired'
560
711
  const entry = issued.get(requestId)
561
712
  issued.delete(requestId)
562
- // A request issued by another process settles in that process.
563
- if (entry === undefined) return
564
- const agent = agentFor(entry.session)
713
+ // The transfer made this session an owner by another route, so a later
714
+ // release notice for the same path would wake it for nothing: the wait is
715
+ // over because the goal was reached, not because a release happened. The
716
+ // wait ends BEFORE the outcome is routed, so the cancellation can never be
717
+ // lost to a cleared route.
718
+ const session = text(event?.session) ?? entry?.session
719
+ const path = text(event?.path) ?? entry?.path
720
+ if (state === 'transferred' && session !== undefined && path !== undefined) {
721
+ watches.settleSelf(session, path)
722
+ }
723
+ // No session and no route: the request was issued in another process.
724
+ if (session === undefined || path === undefined) return
725
+ const agent = agentFor(session)
565
726
  if (!agent) return
566
- const state = text(event?.state) ?? 'expired'
567
- notify(agent, settledText(state, requestId, shortName(entry.path, cwdOf(agent))), agent?.status === 'idle')
727
+ // ONE delivery per request in this process, however many mounts hear this
728
+ // event — including the mount whose `issued` entry routed it here.
729
+ if (!claimSettledNotice(agent, requestId)) return
730
+ notify(agent, settledText(state, requestId, shortName(path, cwdOf(agent))), agent?.status === 'idle')
731
+ }
732
+
733
+ /**
734
+ * One release fact from the broker (D1/D2).
735
+ *
736
+ * Consumption happens inside the coordinator, before anything is scheduled:
737
+ * the entry leaves the active map in `hit`, so a duplicate or repeated
738
+ * observation of the same release finds nothing to consume and delivers
739
+ * nothing. The fact carries no session, so every subscription of this client
740
+ * for that path is consumed — the arbiter never chooses a winner.
741
+ * @param {any} event
742
+ */
743
+ function onReleased(event) {
744
+ const path = text(event?.path)
745
+ if (path !== undefined) watches.consumePath(path)
746
+ }
747
+
748
+ /**
749
+ * The connection that could have delivered a release is gone (D5).
750
+ *
751
+ * Every active subscription ends in `reset` — a terminal state that claims
752
+ * nothing about ownership — and the coordinator's deferred notice tells each
753
+ * live subscriber to re-check and re-subscribe, never that the path became
754
+ * free.
755
+ */
756
+ function onConnectionReset() {
757
+ // A reset also ends the broker incarnation that allocated the request ids
758
+ // already answered in this process: the next one counts from its own zero.
759
+ settledEpoch += 1
760
+ if (disposed) return
761
+ watches.resetAll()
568
762
  }
569
763
 
570
764
  /** @param {any} event */
@@ -573,17 +767,18 @@ export function createFileLeasePlugin(options = {}) {
573
767
  try {
574
768
  if (event.event === 'request') await onRequest(event)
575
769
  else if (event.event === 'settled') onSettled(event)
770
+ else if (event.event === 'released') onReleased(event)
576
771
  } catch {
577
772
  // A listener is a consumer; it must never break the transport.
578
773
  }
579
774
  }
580
775
 
581
776
  /**
582
- * The tools see the client through this narrow facade for one reason: a
583
- * `settled` event carries no session, so the plugin has to remember which
584
- * session issued which requestId at the moment the request is made. The
585
- * facade adds that bookkeeping and nothing else — every call still ends in
586
- * the arbiter.
777
+ * The tools see the client through this narrow facade for one reason: the
778
+ * requester of a transfer has to be remembered from the moment the request
779
+ * is made, because a broker older than `settled.session` pushes the outcome
780
+ * without naming it. The facade adds that routing memo and nothing else —
781
+ * every call still ends in the arbiter.
587
782
  */
588
783
  const routedClient = Object.freeze({
589
784
  acquire: (/** @type {any} */ owner, /** @type {any} */ paths, /** @type {any} */ scope) => client.acquire(owner, paths, scope),
@@ -591,6 +786,12 @@ export function createFileLeasePlugin(options = {}) {
591
786
  status: (/** @type {any} */ filter) => client.status(filter),
592
787
  request: (/** @type {any} */ op, /** @type {any} */ payload) => client.request(op, payload),
593
788
  reply: (/** @type {any} */ input) => client.reply(input),
789
+ // Capability is a property of the CURRENT handshake. A client facade
790
+ // without the query (an embedder, an older injection) advertises nothing,
791
+ // so the watch tool fails explicitly instead of waiting for an event that
792
+ // cannot arrive.
793
+ supportsEvent: (/** @type {string} */ name) =>
794
+ typeof client.supportsEvent === 'function' ? client.supportsEvent(name) === true : false,
594
795
  requestTransfer: async (/** @type {any} */ path, /** @type {any} */ from) => {
595
796
  const reply = await client.requestTransfer(path, from)
596
797
  const requestId = text(reply?.requestId)
@@ -603,6 +804,8 @@ export function createFileLeasePlugin(options = {}) {
603
804
  client: routedClient,
604
805
  ownerOf,
605
806
  deliverNotice: (agent, text) => notify(agent, text, false),
807
+ watchCoordinator: watches,
808
+ mount: watchMount,
606
809
  })
607
810
  const replyDefinition = definitions.find((definition) => definition?.name === REPLY_TOOL)
608
811
  const staticDefinitions = definitions.filter((definition) => definition?.name !== REPLY_TOOL)
@@ -656,6 +859,18 @@ export function createFileLeasePlugin(options = {}) {
656
859
  ctx.logger?.warn?.(`file-lease: negotiation events are unavailable: ${messageOf(error)}`)
657
860
  }
658
861
 
862
+ // A vanished broker cannot announce anything, so the reset is a transport
863
+ // lifecycle signal rather than a wire event (D5). A client without it simply
864
+ // never reports resets — the other paths still work.
865
+ /** @type {() => void} */
866
+ let offReset = () => {}
867
+ try {
868
+ const off = typeof client.onReset === 'function' ? client.onReset(() => onConnectionReset()) : undefined
869
+ if (typeof off === 'function') offReset = off
870
+ } catch (error) {
871
+ ctx.logger?.warn?.(`file-lease: transport resets are unavailable: ${messageOf(error)}`)
872
+ }
873
+
659
874
  /**
660
875
  * The arbiter's health as the read-only surface reports it (D13): the client's
661
876
  * own connection state, never a fresh probe, so looking cannot change a
@@ -758,6 +973,15 @@ export function createFileLeasePlugin(options = {}) {
758
973
  } catch {
759
974
  // Already gone.
760
975
  }
976
+ try {
977
+ offReset()
978
+ } catch {
979
+ // Already gone.
980
+ }
981
+ // This mount's references leave the shared coordinator. A wait another
982
+ // mount still owns survives, and a pending notice transfers to a surviving
983
+ // reference; the last mount's teardown clears everything that is left.
984
+ watches.leave(watchMount)
761
985
  try {
762
986
  disposeView()
763
987
  } catch {