weir-harness 2.0.0 → 2.1.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 (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 +40 -9
  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 +575 -146
  24. package/src/capabilities/mcp-mount.js +220 -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 +170 -57
  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
@@ -294,6 +294,13 @@ export function createLeaseClient({
294
294
  /** @type {Promise<LeaseConnection> | null} */
295
295
  let connecting = null
296
296
  let closed = false
297
+ /**
298
+ * Waits that a close answers instead of the dial. `connect` has no abort
299
+ * contract — an injected one may never settle at all — so a close must
300
+ * release these itself; nothing here waits for a socket (D5).
301
+ * @type {Set<() => void>}
302
+ */
303
+ const closeWaiters = new Set()
297
304
  /** @type {number | undefined} */
298
305
  let startedAt
299
306
  /** A connection reset means the daemon may have restarted with an empty table (D6). */
@@ -306,6 +313,14 @@ export function createLeaseClient({
306
313
  let lastDegradedAt = null
307
314
  /** @type {string | undefined} */
308
315
  let lastScope
316
+ /**
317
+ * The push events the CURRENT handshake advertised. Empty until a handshake
318
+ * answers and after the connection is gone, so "not advertised" and "not
319
+ * connected" both read as unsupported: a caller must never wait for an event
320
+ * this incarnation cannot send.
321
+ * @type {Set<string>}
322
+ */
323
+ let eventCapabilities = new Set()
309
324
 
310
325
  /**
311
326
  * The replay ledger. `path -> {session, lineage, holdUntil}` — nothing more.
@@ -317,6 +332,10 @@ export function createLeaseClient({
317
332
  const labels = new Map()
318
333
  /** @type {Set<(event: any) => void>} */
319
334
  const eventHandlers = new Set()
335
+ /** Connection-reset lifecycle listeners (D5). Separate from the wire events
336
+ * above on purpose: a reset is a property of THIS client's transport, not a
337
+ * fact the broker can send. @type {Set<() => void>} */
338
+ const resetHandlers = new Set()
320
339
  /**
321
340
  * @type {Map<string, {
322
341
  * resolve: (reply: any) => void,
@@ -386,6 +405,24 @@ export function createLeaseClient({
386
405
  }
387
406
  }
388
407
 
408
+ /**
409
+ * Tell every listener that the current connection is gone.
410
+ *
411
+ * Deliberately separate from wire events: a broker that is SIGKILLed cannot
412
+ * announce that it died, so a client that only listened to `EVENTS` would
413
+ * wait forever. Called exactly once per connection (the socket wrapper makes
414
+ * its close notification once-only) and never for an intentional `close()`.
415
+ */
416
+ function notifyReset() {
417
+ for (const handler of [...resetHandlers]) {
418
+ try {
419
+ handler()
420
+ } catch {
421
+ // A listener is a consumer; it must never break the transport.
422
+ }
423
+ }
424
+ }
425
+
389
426
  /** @param {string} line */
390
427
  function handleFrame(line) {
391
428
  /** @type {any} */
@@ -425,25 +462,61 @@ export function createLeaseClient({
425
462
  }
426
463
  }
427
464
 
428
- /** @param {LeaseConnection} target */
429
- function handleDisconnect(target) {
430
- if (connection !== target) return
465
+ /**
466
+ * Answer every wait the close latch owns. A close is terminal for the whole
467
+ * transport lifetime, so a caller parked on a dial is told now rather than
468
+ * inheriting a dial that may never return.
469
+ */
470
+ function notifyCloseWaiters() {
471
+ for (const waiter of [...closeWaiters]) waiter()
472
+ }
473
+
474
+ /**
475
+ * The ONE connection-invalidation outlet (D5).
476
+ *
477
+ * A connection ends in three ways — the socket closed under us, we dropped it
478
+ * to retry, or the client is closing — and all three must leave the same state
479
+ * behind: no current connection, no handshake, no advertised capabilities, no
480
+ * waiter still believing an answer is coming, and exactly one reset
481
+ * notification for the connection that just went away.
482
+ *
483
+ * Only the CURRENT target may do that. A stale target — a handshake that lost
484
+ * a race, or a socket already replaced by a retry — is a no-op: its late close
485
+ * must not clear the live connection's state or notify a second time.
486
+ *
487
+ * @param {LeaseConnection | null} target
488
+ * @param {boolean} [closing] true for an intentional client close: the ledger is
489
+ * already gone, so nothing is queued for replay
490
+ * @returns {boolean} whether this target was the current connection
491
+ */
492
+ function invalidateConnection(target, closing = false) {
493
+ if (target === null || connection !== target) return false
431
494
  connection = null
432
495
  greeted = false
496
+ // The advertised capabilities belonged to that connection.
497
+ eventCapabilities = new Set()
433
498
  // D6: a reset connection means the daemon may have died and come back with
434
499
  // an empty table, so retained leases have to be replayed on the next dial.
435
- if (ledger.size > 0) replayPending = true
436
- failWaiters(transportError('lease broker connection closed'))
500
+ if (!closing && ledger.size > 0) replayPending = true
501
+ failWaiters(transportError(closing ? 'lease client closed' : 'lease broker connection closed'))
502
+ // D5: a reset is a terminal state for anything waiting on this connection,
503
+ // and the only moment a vanished broker can be reported at all.
504
+ notifyReset()
505
+ return true
506
+ }
507
+
508
+ /** @param {LeaseConnection} target */
509
+ function handleDisconnect(target) {
510
+ invalidateConnection(target)
437
511
  }
438
512
 
439
513
  /**
440
- * @param {LeaseConnection} target
514
+ * Close a socket without touching client state. Used where the connection is
515
+ * already invalidated (or was never adopted) and only the transport is left.
516
+ * @param {LeaseConnection | null} target
441
517
  */
442
- function dropConnection(target) {
443
- if (connection === target) {
444
- connection = null
445
- greeted = false
446
- }
518
+ function closeSocket(target) {
519
+ if (target === null) return
447
520
  try {
448
521
  target.close()
449
522
  } catch {
@@ -451,10 +524,80 @@ export function createLeaseClient({
451
524
  }
452
525
  }
453
526
 
454
- /** @returns {Promise<{ connection: LeaseConnection | null, error: any }>} */
527
+ /**
528
+ * @param {LeaseConnection} target
529
+ * @returns {boolean} whether this target was the current connection
530
+ */
531
+ function dropConnection(target) {
532
+ // Invalidate first: closing the socket reports its close synchronously, and
533
+ // that report must find nothing left to invalidate (exactly-once reset).
534
+ const invalidated = invalidateConnection(target)
535
+ closeSocket(target)
536
+ return invalidated
537
+ }
538
+
539
+ /**
540
+ * Wait for `work`, but never past `close()`.
541
+ *
542
+ * The transport has no abort contract, so a close answers the wait itself:
543
+ * the dial is left to whatever it does, and nobody inherits a promise that
544
+ * may never settle. The waiter is removed as soon as either side answers, and
545
+ * `work` is always consumed — an abandoned dial that fails later can never
546
+ * surface as an unhandled rejection.
547
+ *
548
+ * @template T
549
+ * @param {Promise<T>} work
550
+ * @returns {Promise<T>}
551
+ */
552
+ function untilClosed(work) {
553
+ return new Promise((resolve, reject) => {
554
+ let waiting = true
555
+ /** @param {() => void} settle */
556
+ const finish = (settle) => {
557
+ if (!waiting) return
558
+ waiting = false
559
+ closeWaiters.delete(onClose)
560
+ settle()
561
+ }
562
+ const onClose = () => finish(() => reject(transportError('lease client is closed')))
563
+ closeWaiters.add(onClose)
564
+ // Attach first: a close may already have fired, and `work`'s outcome must
565
+ // still be handled when this wait is answered by the latch instead.
566
+ Promise.resolve(work).then(
567
+ (value) => finish(() => resolve(value)),
568
+ (error) => finish(() => reject(error)),
569
+ )
570
+ if (closed) onClose()
571
+ })
572
+ }
573
+
574
+ /**
575
+ * Dial once, but not past a close.
576
+ *
577
+ * A socket that only arrives after the close is closed on arrival: it can
578
+ * never be adopted, greeted, replayed or used to serve a request, and the
579
+ * caller was already answered by the latch.
580
+ * @returns {Promise<{ connection: LeaseConnection | null, error: any }>}
581
+ */
455
582
  async function attemptConnect() {
583
+ /** @type {LeaseConnection | Promise<LeaseConnection>} */
584
+ let started
585
+ try {
586
+ // Started synchronously: the dial is the caller's, not a later task's.
587
+ started = connect(endpoint)
588
+ } catch (error) {
589
+ return { connection: null, error }
590
+ }
591
+ /** @type {Promise<LeaseConnection>} */
592
+ const dial = Promise.resolve(started).then((connection) => {
593
+ if (closed) {
594
+ closeSocket(connection)
595
+ throw transportError('lease client is closed')
596
+ }
597
+ return connection
598
+ })
456
599
  try {
457
- return { connection: await connect(endpoint), error: null }
600
+ return { connection: await untilClosed(dial), error: null }
458
601
  } catch (error) {
459
602
  return { connection: null, error }
460
603
  }
@@ -482,10 +625,21 @@ export function createLeaseClient({
482
625
  * be able to start the daemon again on the next call, because "the daemon is
483
626
  * gone" is a transient condition, not a decision to stop arbitrating.
484
627
  *
628
+ * The wait is bounded by this client's lifetime, never by a dial timeout: a
629
+ * close answers it through the latch, and `connect` is left to settle on its
630
+ * own. A dial that never returns therefore cannot park a caller.
631
+ *
485
632
  * @returns {Promise<LeaseConnection>}
486
633
  */
487
634
  async function openConnection() {
635
+ // A close requested before or during the dial is terminal: never spawn,
636
+ // adopt or keep waiting for an arbiter on behalf of a client that is gone.
637
+ if (closed) throw transportError('lease client closed')
488
638
  const first = await attemptConnect()
639
+ if (closed) {
640
+ closeSocket(first.connection)
641
+ throw transportError('lease client closed')
642
+ }
489
643
  if (first.connection !== null) return first.connection
490
644
  if (!isAbsentEndpoint(first.error)) {
491
645
  throw transportError(`lease broker is unreachable at ${endpoint}`, first.error)
@@ -496,9 +650,15 @@ export function createLeaseClient({
496
650
  // attempt count is what actually bounds the wait.
497
651
  const maxAttempts = Math.max(1, Math.ceil(connectRetryBudgetMs / Math.max(1, connectRetryMs))) + 2
498
652
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
653
+ if (closed) throw transportError('lease client closed')
499
654
  if (now() >= deadline) break
500
- await delay(connectRetryMs)
655
+ await untilClosed(delay(connectRetryMs))
656
+ if (closed) throw transportError('lease client closed')
501
657
  const retry = await attemptConnect()
658
+ if (closed) {
659
+ closeSocket(retry.connection)
660
+ throw transportError('lease client closed')
661
+ }
502
662
  if (retry.connection !== null) return retry.connection
503
663
  if (!isAbsentEndpoint(retry.error)) {
504
664
  throw transportError(`lease broker is unreachable at ${endpoint}`, retry.error)
@@ -514,6 +674,12 @@ export function createLeaseClient({
514
674
  */
515
675
  async function establish() {
516
676
  const fresh = await openConnection()
677
+ // A close during the dial owns the socket now: it is closed instead of
678
+ // installed, so no handshake, replay or lease can happen after close.
679
+ if (closed) {
680
+ closeSocket(fresh)
681
+ throw transportError('lease client closed')
682
+ }
517
683
  const previousStartedAt = startedAt
518
684
  connection = fresh
519
685
  greeted = false
@@ -537,6 +703,12 @@ export function createLeaseClient({
537
703
  dropConnection(fresh)
538
704
  throw transportError(`lease broker handshake failed at ${endpoint}`, error)
539
705
  }
706
+ // A close may have invalidated this connection while the hello was in
707
+ // flight; even an already-delivered reply must not resurrect it.
708
+ if (closed) {
709
+ dropConnection(fresh)
710
+ throw transportError('lease client closed')
711
+ }
540
712
  if (hello.ok !== true) {
541
713
  // A protocol mismatch is infrastructure, not a conflict: the write goes
542
714
  // through and the audit records why (D7). Nobody is asked to fix anything.
@@ -545,6 +717,11 @@ export function createLeaseClient({
545
717
  throw transportError(`lease broker refused the handshake (${code})`)
546
718
  }
547
719
  greeted = true
720
+ // The handshake is the ONLY source of the push-event list, and an older
721
+ // broker simply does not carry the field: empty means "not advertised".
722
+ eventCapabilities = new Set(
723
+ Array.isArray(hello.events) ? hello.events.filter((entry) => typeof entry === 'string' && entry.length > 0) : [],
724
+ )
548
725
  const reported = typeof hello.startedAt === 'number' ? hello.startedAt : previousStartedAt
549
726
  startedAt = reported
550
727
  const restarted = replayPending || (previousStartedAt !== undefined && previousStartedAt !== reported)
@@ -557,16 +734,26 @@ export function createLeaseClient({
557
734
  }
558
735
 
559
736
  /** @returns {Promise<LeaseConnection>} */
560
- function ensureConnection() {
561
- if (closed) return Promise.reject(transportError('lease client is closed'))
562
- if (connection !== null && greeted) return Promise.resolve(connection)
563
- if (connecting !== null) return connecting
564
- /** @type {Promise<LeaseConnection>} */
565
- const attempt = establish().finally(() => {
566
- if (connecting === attempt) connecting = null
567
- })
568
- connecting = attempt
569
- return attempt
737
+ async function ensureConnection() {
738
+ if (closed) throw transportError('lease client is closed')
739
+ /** @type {LeaseConnection} */
740
+ let target
741
+ if (connection !== null && greeted) {
742
+ target = connection
743
+ } else if (connecting !== null) {
744
+ target = await connecting
745
+ } else {
746
+ /** @type {Promise<LeaseConnection>} */
747
+ const attempt = establish().finally(() => {
748
+ if (connecting === attempt) connecting = null
749
+ })
750
+ connecting = attempt
751
+ target = await attempt
752
+ }
753
+ // A close that landed while this dial was in flight is terminal: a caller
754
+ // resuming afterwards must never speak on the abandoned connection.
755
+ if (closed) throw transportError('lease client is closed')
756
+ return target
570
757
  }
571
758
 
572
759
  /**
@@ -578,11 +765,14 @@ export function createLeaseClient({
578
765
  async function request(op, payload = {}) {
579
766
  if (typeof op !== 'string' || op.length === 0) throw new TypeError('lease request requires an operation name')
580
767
  const target = await ensureConnection()
768
+ if (closed) throw transportError('lease client is closed')
581
769
  if (replayQueued) {
582
770
  // Flush before the caller's own frame so an observe-after-reconnect call
583
771
  // reports the world the replay has already restored.
584
772
  replayQueued = false
585
773
  await runReplay()
774
+ // The replay is wire I/O too: a close during it stops this request as well.
775
+ if (closed) throw transportError('lease client is closed')
586
776
  }
587
777
  return await exchange(target, op, payload)
588
778
  }
@@ -932,6 +1122,7 @@ export function createLeaseClient({
932
1122
  */
933
1123
  async function replayHolds() {
934
1124
  await ensureConnection()
1125
+ if (closed) throw transportError('lease client is closed')
935
1126
  // An explicit replay satisfies a queued one: never send the same retention
936
1127
  // twice just because a reconnect happened to arm it.
937
1128
  replayQueued = false
@@ -1014,7 +1205,7 @@ export function createLeaseClient({
1014
1205
  }
1015
1206
 
1016
1207
  /**
1017
- * Subscribe to broker-pushed `request` / `settled` events.
1208
+ * Subscribe to broker-pushed `request` / `settled` / `released` events.
1018
1209
  * @param {(event: any) => void} handler
1019
1210
  * @returns {() => void} unsubscribe
1020
1211
  */
@@ -1027,24 +1218,64 @@ export function createLeaseClient({
1027
1218
  }
1028
1219
 
1029
1220
  /**
1030
- * Close the connection. The daemon keeps running: it is shared with every other
1221
+ * Subscribe to connection resets (D5). A reset means the current connection
1222
+ * is gone, so anything that was waiting on a push from it is over — there is
1223
+ * nothing to replay and no fact to infer about ownership.
1224
+ * @param {() => void} handler
1225
+ * @returns {() => void} unsubscribe
1226
+ */
1227
+ function onReset(handler) {
1228
+ if (typeof handler !== 'function') throw new TypeError('onReset requires a handler')
1229
+ resetHandlers.add(handler)
1230
+ return () => {
1231
+ resetHandlers.delete(handler)
1232
+ }
1233
+ }
1234
+
1235
+ /**
1236
+ * Read-only capability query: did the CURRENT handshake advertise this push
1237
+ * event? An older broker, a closed client and a dropped connection all answer
1238
+ * `false` — the caller must report the capability as unavailable instead of
1239
+ * waiting for an event that cannot arrive.
1240
+ * @param {string} name
1241
+ * @returns {boolean}
1242
+ */
1243
+ function supportsEvent(name) {
1244
+ return typeof name === 'string' && eventCapabilities.has(name)
1245
+ }
1246
+
1247
+ /**
1248
+ * Close the client. The daemon keeps running: it is shared with every other
1031
1249
  * client on this machine, and its own idle window is what ends it.
1250
+ *
1251
+ * A close is terminal for the whole transport lifetime, including a dial or
1252
+ * handshake that is still in flight: the callers waiting on that attempt are
1253
+ * settled with a transport failure the moment the close lands (even when the
1254
+ * underlying dial never returns), the late socket is closed instead of
1255
+ * adopted, and any subscription waiting on it is told with exactly one reset
1256
+ * notification.
1032
1257
  */
1033
1258
  function close() {
1034
1259
  if (closed) return
1035
1260
  closed = true
1036
- const target = connection
1037
- connection = null
1038
- greeted = false
1039
1261
  ledger.clear()
1040
- failWaiters(transportError('lease client closed'))
1262
+ const target = connection
1041
1263
  if (target !== null) {
1042
- try {
1043
- target.close()
1044
- } catch {
1045
- // Already gone.
1046
- }
1264
+ // An intentional close ends the current connection like any other loss, so
1265
+ // a subscription waiting on it is told instead of waiting forever.
1266
+ invalidateConnection(target, true)
1267
+ closeSocket(target)
1268
+ notifyCloseWaiters()
1269
+ return
1047
1270
  }
1271
+ // No connection is installed yet, but a dial or handshake may still be in
1272
+ // flight. `establish` closes its late socket rather than adopting it and the
1273
+ // latch answers the callers now; the reset is reported here because no
1274
+ // connection exists to be invalidated later.
1275
+ const dialing = connecting !== null
1276
+ failWaiters(transportError('lease client closed'))
1277
+ if (dialing) notifyReset()
1278
+ notifyCloseWaiters()
1048
1279
  }
1049
1280
 
1050
1281
  return Object.freeze({
@@ -1061,6 +1292,8 @@ export function createLeaseClient({
1061
1292
  releaseAll,
1062
1293
  replayHolds,
1063
1294
  onEvent,
1295
+ onReset,
1296
+ supportsEvent,
1064
1297
  close,
1065
1298
  })
1066
1299
  }
@@ -18,9 +18,13 @@ export { spawnDaemon } from './spawn.js'
18
18
  export {
19
19
  createLeaseTools,
20
20
  REPLY_TOOL,
21
+ WATCH_TOOL,
21
22
  MAX_HOLD_MINUTES,
22
23
  DEFAULT_HOLD_MINUTES,
23
24
  } from './tools.js'
25
+ // The in-memory release-subscription coordinator behind `edit_lock_watch` (D2):
26
+ // one per lease client, never persisted.
27
+ export { createWatchCoordinator, WATCH_STATES } from './watch.js'
24
28
  export { createFileLeasePlugin, name, inject, apply } from './plugin.js'
25
29
  // The read-only ownership projection behind the status surface (D13). Pure, so a
26
30
  // caller can shape a snapshot without a client, a clock or a socket.