@corenel/harness 0.11.0 → 0.13.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 (77) hide show
  1. package/dist/core/fileChanges.d.ts +18 -0
  2. package/dist/core/fileChanges.d.ts.map +1 -0
  3. package/dist/core/fileChanges.js +85 -0
  4. package/dist/core/fileChanges.js.map +1 -0
  5. package/dist/core/loop.d.ts +10 -0
  6. package/dist/core/loop.d.ts.map +1 -1
  7. package/dist/core/loop.js +32 -2
  8. package/dist/core/loop.js.map +1 -1
  9. package/dist/core/spawn.d.ts +4 -0
  10. package/dist/core/spawn.d.ts.map +1 -1
  11. package/dist/core/spawn.js +11 -4
  12. package/dist/core/spawn.js.map +1 -1
  13. package/dist/dag/executeNode.d.ts +5 -0
  14. package/dist/dag/executeNode.d.ts.map +1 -1
  15. package/dist/dag/executeNode.js +5 -1
  16. package/dist/dag/executeNode.js.map +1 -1
  17. package/dist/guardrail/permission-service.d.ts +4 -0
  18. package/dist/guardrail/permission-service.d.ts.map +1 -1
  19. package/dist/guardrail/permission-service.js +10 -3
  20. package/dist/guardrail/permission-service.js.map +1 -1
  21. package/dist/guardrail/serialize.d.ts.map +1 -1
  22. package/dist/guardrail/serialize.js +4 -0
  23. package/dist/guardrail/serialize.js.map +1 -1
  24. package/dist/guardrail/validate.d.ts.map +1 -1
  25. package/dist/guardrail/validate.js +3 -1
  26. package/dist/guardrail/validate.js.map +1 -1
  27. package/dist/orchestrator/agentSearch.d.ts.map +1 -1
  28. package/dist/orchestrator/agentSearch.js +10 -0
  29. package/dist/orchestrator/agentSearch.js.map +1 -1
  30. package/dist/orchestrator/planDoc.d.ts +26 -0
  31. package/dist/orchestrator/planDoc.d.ts.map +1 -1
  32. package/dist/orchestrator/planDoc.js +132 -10
  33. package/dist/orchestrator/planDoc.js.map +1 -1
  34. package/dist/orchestrator/record.d.ts +100 -4
  35. package/dist/orchestrator/record.d.ts.map +1 -1
  36. package/dist/orchestrator/record.js +238 -4
  37. package/dist/orchestrator/record.js.map +1 -1
  38. package/dist/orchestrator/sidecarTools.d.ts +5 -0
  39. package/dist/orchestrator/sidecarTools.d.ts.map +1 -1
  40. package/dist/orchestrator/sidecarTools.js +53 -15
  41. package/dist/orchestrator/sidecarTools.js.map +1 -1
  42. package/dist/prompts/init.d.ts +12 -0
  43. package/dist/prompts/init.d.ts.map +1 -0
  44. package/dist/prompts/init.js +48 -0
  45. package/dist/prompts/init.js.map +1 -0
  46. package/dist/prompts/library/defaults.generated.js +2 -2
  47. package/dist/prompts/library/defaults.generated.js.map +1 -1
  48. package/dist/sidecar/deviceKeys.d.ts +75 -0
  49. package/dist/sidecar/deviceKeys.d.ts.map +1 -0
  50. package/dist/sidecar/deviceKeys.js +142 -0
  51. package/dist/sidecar/deviceKeys.js.map +1 -0
  52. package/dist/sidecar/host.d.ts +157 -23
  53. package/dist/sidecar/host.d.ts.map +1 -1
  54. package/dist/sidecar/host.js +340 -15
  55. package/dist/sidecar/host.js.map +1 -1
  56. package/dist/sidecar/identity.d.ts +41 -17
  57. package/dist/sidecar/identity.d.ts.map +1 -1
  58. package/dist/sidecar/identity.js +10 -21
  59. package/dist/sidecar/identity.js.map +1 -1
  60. package/dist/sidecar/server.d.ts +202 -30
  61. package/dist/sidecar/server.d.ts.map +1 -1
  62. package/dist/sidecar/server.js +722 -109
  63. package/dist/sidecar/server.js.map +1 -1
  64. package/dist/sidecar/testing.d.ts +97 -0
  65. package/dist/sidecar/testing.d.ts.map +1 -0
  66. package/dist/sidecar/testing.js +259 -0
  67. package/dist/sidecar/testing.js.map +1 -0
  68. package/dist/sidecar/wsChannel.d.ts +8 -1
  69. package/dist/sidecar/wsChannel.d.ts.map +1 -1
  70. package/dist/sidecar/wsChannel.js +17 -1
  71. package/dist/sidecar/wsChannel.js.map +1 -1
  72. package/dist/tools/callAgent.d.ts.map +1 -1
  73. package/dist/tools/callAgent.js +33 -13
  74. package/dist/tools/callAgent.js.map +1 -1
  75. package/dist/tools/orchestration.js +1 -1
  76. package/dist/tools/orchestration.js.map +1 -1
  77. package/package.json +2 -2
@@ -1,4 +1,5 @@
1
- import { SIDECAR_PROTOCOL, HOST_METHODS, errMessage, RUN_TASK_FEATURE, CREW_DEPLOY_FEATURE, } from '@corenel/protocol';
1
+ import { asEcPublicJwk, jwkThumbprint, verifySignature, randomToken, serverProofPayload, clientProofPayload, parseEnrollmentCode, bytesToB64url, codeProof, codeHash, } from './deviceKeys.js';
2
+ import { SIDECAR_PROTOCOL, HOST_METHODS, errMessage, RUN_TASK_FEATURE, RUN_PLAN_FEATURE, CREW_DEPLOY_FEATURE, DEVICES_FEATURE, isClerkOrgId, } from '@corenel/protocol';
2
3
  /** Decode a base64 chunk to bytes (atob is available in the browser and Node 18+). */
3
4
  function b64ToBytes(b64) {
4
5
  const bin = atob(b64);
@@ -7,6 +8,34 @@ function b64ToBytes(b64) {
7
8
  out[i] = bin.charCodeAt(i);
8
9
  return out;
9
10
  }
11
+ /** Prefix of the error a connection rejects with when the daemon's key is not
12
+ * the one this client pinned (or it cannot prove it holds it). A caller shows
13
+ * this state as its own, never as a generic connection failure, and never
14
+ * retries it. */
15
+ export const SIDECAR_KEY_MISMATCH = 'sidecar key mismatch';
16
+ /** The mismatch error, recognisable by `isSidecarKeyMismatch`. */
17
+ export function sidecarKeyMismatchError() {
18
+ return new Error(`${SIDECAR_KEY_MISMATCH}: this is not the sidecar you paired with`);
19
+ }
20
+ /** Prefix of the error `connect()` rejects with when this client has nothing
21
+ * to pair with (no device key, no code). */
22
+ export const SIDECAR_NOT_PAIRED = 'sidecar not paired';
23
+ export function isSidecarKeyMismatch(e) {
24
+ const msg = e instanceof Error ? e.message : String(e);
25
+ return msg.startsWith(SIDECAR_KEY_MISMATCH);
26
+ }
27
+ /* NOT ADMITTED, AS OPPOSED TO A DEAD DEVICE. A daemon sends identity-challenge
28
+ * only AFTER the device (or enrollment) proof passed, so a denial that follows
29
+ * a challenge in the same handshake is about the ACCOUNT (not verified, not
30
+ * the owner, not a member, another user's device), never the device. This
31
+ * needs no new signal from the daemon: the order of its own messages says it.
32
+ * Deliberately a different prefix from 'sidecar denied connection', so no
33
+ * caller that forgets a device on a denial can mistake one for the other. */
34
+ export const SIDECAR_NOT_ADMITTED = 'sidecar did not admit this account';
35
+ export function isSidecarAccountRefusal(e) {
36
+ const msg = e instanceof Error ? e.message : String(e);
37
+ return msg.startsWith(SIDECAR_NOT_ADMITTED);
38
+ }
10
39
  export class SidecarHost {
11
40
  constructor(opts) {
12
41
  Object.defineProperty(this, "ch", {
@@ -216,6 +245,18 @@ export class SidecarHost {
216
245
  writable: true,
217
246
  value: new Map()
218
247
  });
248
+ Object.defineProperty(this, "runPlanSeq", {
249
+ enumerable: true,
250
+ configurable: true,
251
+ writable: true,
252
+ value: 0
253
+ });
254
+ Object.defineProperty(this, "runPlanPending", {
255
+ enumerable: true,
256
+ configurable: true,
257
+ writable: true,
258
+ value: new Map()
259
+ });
219
260
  Object.defineProperty(this, "hostsSeq", {
220
261
  enumerable: true,
221
262
  configurable: true,
@@ -377,6 +418,98 @@ export class SidecarHost {
377
418
  writable: true,
378
419
  value: {}
379
420
  });
421
+ /** This client's device on this daemon: given, or just recorded. */
422
+ Object.defineProperty(this, "device", {
423
+ enumerable: true,
424
+ configurable: true,
425
+ writable: true,
426
+ value: void 0
427
+ });
428
+ /** How the CURRENT hello authenticates, and what its proofs are bound to. */
429
+ Object.defineProperty(this, "mode", {
430
+ enumerable: true,
431
+ configurable: true,
432
+ writable: true,
433
+ value: 'none'
434
+ });
435
+ Object.defineProperty(this, "clientNonce", {
436
+ enumerable: true,
437
+ configurable: true,
438
+ writable: true,
439
+ value: ''
440
+ });
441
+ /** The sidecar key this connection is pinned to. */
442
+ Object.defineProperty(this, "pinnedJkt", {
443
+ enumerable: true,
444
+ configurable: true,
445
+ writable: true,
446
+ value: null
447
+ });
448
+ /** The device id (device hello) or the offered key's jkt (enroll) that both
449
+ * proofs name. */
450
+ Object.defineProperty(this, "proofSubject", {
451
+ enumerable: true,
452
+ configurable: true,
453
+ writable: true,
454
+ value: ''
455
+ });
456
+ /** Whether a proof exchange is expected on this connection at all. */
457
+ Object.defineProperty(this, "expectProof", {
458
+ enumerable: true,
459
+ configurable: true,
460
+ writable: true,
461
+ value: false
462
+ });
463
+ /** The sidecar key this connection VERIFIED, once it has. */
464
+ Object.defineProperty(this, "verifiedSidecarJkt", {
465
+ enumerable: true,
466
+ configurable: true,
467
+ writable: true,
468
+ value: null
469
+ });
470
+ /** The sidecar id the daemon signed into its server-proof. An identity
471
+ * challenge is answered only for this daemon, and only once it proved the
472
+ * pinned key: an assertion is never fetched for a peer that has not. */
473
+ Object.defineProperty(this, "provenSidecarId", {
474
+ enumerable: true,
475
+ configurable: true,
476
+ writable: true,
477
+ value: null
478
+ });
479
+ /** An identity challenge arrived from the proven daemon during this
480
+ * handshake: the device credential was accepted (SIDECAR_NOT_ADMITTED). */
481
+ Object.defineProperty(this, "challengedInHandshake", {
482
+ enumerable: true,
483
+ configurable: true,
484
+ writable: true,
485
+ value: false
486
+ });
487
+ /** The credential failed before sending (a malformed code, no key). */
488
+ Object.defineProperty(this, "credentialError", {
489
+ enumerable: true,
490
+ configurable: true,
491
+ writable: true,
492
+ value: null
493
+ });
494
+ /** The pairing code's random part, for `codeProof` (enroll only). */
495
+ Object.defineProperty(this, "codeSecret", {
496
+ enumerable: true,
497
+ configurable: true,
498
+ writable: true,
499
+ value: null
500
+ });
501
+ Object.defineProperty(this, "devicesSeq", {
502
+ enumerable: true,
503
+ configurable: true,
504
+ writable: true,
505
+ value: 0
506
+ });
507
+ Object.defineProperty(this, "devicesPending", {
508
+ enumerable: true,
509
+ configurable: true,
510
+ writable: true,
511
+ value: new Map()
512
+ });
380
513
  Object.defineProperty(this, "imported", {
381
514
  enumerable: true,
382
515
  configurable: true,
@@ -391,10 +524,86 @@ export class SidecarHost {
391
524
  });
392
525
  this.opts = opts;
393
526
  this.ch = opts.channel;
527
+ this.device = opts.device;
394
528
  this.ch.onMessage((m) => this.onMessage(m));
395
529
  }
530
+ /** The hello's credential: the device if this client has one, else the
531
+ * enrollment code. Exactly one is sent, and no secret. Sets up what the
532
+ * proof exchange will be bound to. Without either (or without a key) there
533
+ * is nothing to pair with and `credentialError` is set. */
534
+ credentialFields() {
535
+ const key = this.opts.deviceKey;
536
+ this.clientNonce = randomToken();
537
+ if (this.device && key) {
538
+ this.mode = 'device';
539
+ this.pinnedJkt = this.device.sidecarJkt;
540
+ this.proofSubject = this.device.id;
541
+ this.expectProof = true;
542
+ return { clientNonce: this.clientNonce, device: { id: this.device.id } };
543
+ }
544
+ if (this.opts.enroll) {
545
+ const parsed = parseEnrollmentCode(this.opts.enroll);
546
+ if (!parsed || !key) {
547
+ this.credentialError = new Error('that is not a pairing code this app can use');
548
+ return {};
549
+ }
550
+ this.mode = 'enroll';
551
+ this.pinnedJkt = parsed.sidecarJkt;
552
+ this.codeSecret = parsed.secret;
553
+ this.proofSubject = key.jkt;
554
+ this.expectProof = true;
555
+ // The public key only: the code is proven later, never sent.
556
+ return { clientNonce: this.clientNonce, enroll: { publicKey: key.publicJwk } };
557
+ }
558
+ this.mode = 'none';
559
+ this.credentialError = new Error(`${SIDECAR_NOT_PAIRED}: pair it with a code from the sidecar`);
560
+ return {};
561
+ }
562
+ /** This client's device on this daemon (given, or just recorded), or
563
+ * undefined when it is still pairing by code. */
564
+ deviceCredential() { return this.device; }
565
+ /** The sidecar key this connection verified, or null before it has. */
566
+ sidecarKeyJkt() { return this.verifiedSidecarJkt; }
567
+ /** The daemon's `server-proof`: check it is the PINNED key and that it
568
+ * signed THIS exchange, then prove this client's key. On any
569
+ * failure nothing more is sent: the connection is dropped and `connect()`
570
+ * rejects with the key-mismatch error. */
571
+ async answerServerProof(m) {
572
+ const key = this.opts.deviceKey;
573
+ if (!this.expectProof || !key || this.verifiedSidecarJkt !== null)
574
+ return;
575
+ this.expectProof = false; // one exchange per connection
576
+ const sidecarKey = asEcPublicJwk(m.publicKey);
577
+ const sidecarJkt = sidecarKey ? await jwkThumbprint(sidecarKey) : null;
578
+ const ok = !!sidecarKey && !!sidecarJkt
579
+ && this.pinnedJkt !== null && sidecarJkt === this.pinnedJkt
580
+ && typeof m.serverNonce === 'string' && typeof m.sidecarId === 'string'
581
+ && await verifySignature(sidecarKey, serverProofPayload({
582
+ clientNonce: this.clientNonce, serverNonce: m.serverNonce, sidecarId: m.sidecarId, sidecarJkt, subject: this.proofSubject,
583
+ }), m.sig);
584
+ if (!ok || !sidecarJkt) {
585
+ this.failKeyMismatch();
586
+ return;
587
+ }
588
+ this.verifiedSidecarJkt = sidecarJkt;
589
+ this.provenSidecarId = m.sidecarId;
590
+ const payload = clientProofPayload({
591
+ serverNonce: m.serverNonce, clientNonce: this.clientNonce, sidecarId: m.sidecarId, sidecarJkt, subject: this.proofSubject,
592
+ });
593
+ const sig = bytesToB64url(await key.sign(payload));
594
+ // Knowledge of the code, bound to this exchange, only now that the daemon
595
+ // proved the key the code named.
596
+ const proofOfCode = this.mode === 'enroll' && this.codeSecret ? await codeProof(await codeHash(this.codeSecret), payload) : undefined;
597
+ this.ch.send(proofOfCode ? { t: 'client-proof', sig, codeProof: proofOfCode } : { t: 'client-proof', sig });
598
+ }
599
+ failKeyMismatch() {
600
+ const pending = this.welcome;
601
+ this.welcome = null;
602
+ pending?.reject(sidecarKeyMismatchError());
603
+ this.disconnect(SIDECAR_KEY_MISMATCH);
604
+ }
396
605
  /** Authenticate, then import the sidecar's tools. Resolves with their names,
397
- * or rejects if the sidecar denies the token / protocol.
606
+ * or rejects if the sidecar refuses the credential or protocol, or proves a key other than the pinned one.
398
607
  *
399
608
  * `timeoutMs`, when given, rejects the connection if no `welcome`/`denied`
400
609
  * ever arrives -- a handshake that silently never completes (a peer that
@@ -418,29 +627,42 @@ export class SidecarHost {
418
627
  pending?.reject(new Error(`sidecar handshake timed out after ${timeoutMs}ms waiting for welcome`));
419
628
  }, timeoutMs);
420
629
  }
630
+ const credential = this.credentialFields();
631
+ if (this.credentialError) {
632
+ const pending = this.welcome;
633
+ this.welcome = null;
634
+ pending?.reject(this.credentialError);
635
+ return;
636
+ }
421
637
  this.ch.send({
422
- t: 'hello', protocol: SIDECAR_PROTOCOL, token: this.opts.token,
638
+ t: 'hello', protocol: SIDECAR_PROTOCOL, ...credential,
423
639
  ...(this.opts.stateDir ? { stateDir: this.opts.stateDir } : {}),
424
640
  ...(this.opts.clientId ? { clientId: this.opts.clientId } : {}),
425
641
  ...(this.opts.clientName ? { clientName: this.opts.clientName } : {}),
426
642
  ...(this.opts.accounts?.length ? { accounts: this.opts.accounts } : {}),
427
- ...(this.opts.wantsIdentity ? { wantsIdentity: true } : {}),
428
643
  // What THIS client understands, so a daemon can tell an older app
429
644
  // (whose agents sync but never fire until deployed) from this one.
430
- capabilities: [CREW_DEPLOY_FEATURE],
645
+ // DEVICES_FEATURE only when the caller has somewhere to KEEP an issued
646
+ // device credential: a daemon issues one only to a client that says
647
+ // so, since one thrown away is a ghost device on the daemon for ever.
648
+ capabilities: [CREW_DEPLOY_FEATURE, ...(this.opts.onDeviceIssued && this.opts.deviceKey ? [DEVICES_FEATURE] : [])],
431
649
  });
432
650
  });
433
651
  }
434
652
  /** Answers the daemon's `identity-challenge`. `onIdentityChallenge` resolving
435
653
  * a string sends it as the assertion; resolving `null`, having no handler
436
654
  * at all, or THROWING all send `identity-assert` with no assertion -- every
437
- * one of those is a considered "no identity to offer", never a reason to
438
- * fail the handshake. The Clerk token itself never reaches this class: only
439
- * whatever assertion the callback hands back does. */
655
+ * one of those is a considered "no identity to offer", which the daemon
656
+ * refuses at once rather than waiting out its bound. The Clerk token itself
657
+ * never reaches this class: only whatever assertion the callback hands back
658
+ * does. A malformed `orgId` is not forwarded. */
440
659
  async answerIdentityChallenge(m) {
441
660
  let assertion = null;
442
661
  try {
443
- assertion = (await this.opts.onIdentityChallenge?.({ nonce: m.nonce, sidecarId: m.sidecarId })) ?? null;
662
+ const proven = this.verifiedSidecarJkt !== null && this.provenSidecarId !== null && m.sidecarId === this.provenSidecarId;
663
+ const jkt = proven ? this.opts.deviceKey?.jkt : undefined;
664
+ const orgId = isClerkOrgId(m.orgId) ? m.orgId : undefined;
665
+ assertion = jkt ? (await this.opts.onIdentityChallenge?.({ nonce: m.nonce, sidecarId: m.sidecarId, jkt, ...(orgId ? { orgId } : {}) })) ?? null : null;
444
666
  }
445
667
  catch {
446
668
  assertion = null;
@@ -517,6 +739,9 @@ export class SidecarHost {
517
739
  for (const p of this.releasePending.values())
518
740
  p.reject(new Error(reason));
519
741
  this.releasePending.clear();
742
+ for (const p of this.runPlanPending.values())
743
+ p.reject(new Error(reason));
744
+ this.runPlanPending.clear();
520
745
  for (const p of this.hostsPending.values())
521
746
  p.reject(new Error(reason));
522
747
  this.hostsPending.clear();
@@ -557,6 +782,9 @@ export class SidecarHost {
557
782
  for (const p of this.pushPending.values())
558
783
  p.reject(new Error(reason));
559
784
  this.pushPending.clear();
785
+ for (const p of this.devicesPending.values())
786
+ p.reject(new Error(reason));
787
+ this.devicesPending.clear();
560
788
  /* The rejections above already drop each login's waiter via the wrapper
561
789
  * mcpLogin installs. Cleared here too so that stays true if sendMcpAdmin
562
790
  * is ever the one carrying a login -- a stale waiter would fire a consent
@@ -568,7 +796,18 @@ export class SidecarHost {
568
796
  }
569
797
  onMessage(m) {
570
798
  switch (m.t) {
799
+ case 'server-proof': {
800
+ void this.answerServerProof(m);
801
+ return;
802
+ }
571
803
  case 'welcome': {
804
+ /* Welcomed ONLY after the daemon proved the pinned key. A welcome
805
+ * before that is an impostor (or a daemon too old for keys) skipping
806
+ * the proof: refuse it. */
807
+ if (this.verifiedSidecarJkt === null) {
808
+ this.failKeyMismatch();
809
+ return;
810
+ }
572
811
  this.features = m.features ?? [];
573
812
  // Runtime-validated: an untrusted/older daemon's `vapidPublicKey` is
574
813
  // just a JSON-parsed `string`, and a malformed one must never be
@@ -584,6 +823,21 @@ export class SidecarHost {
584
823
  this.identity = m.identity && typeof m.identity === 'object' && typeof m.identity.verified === 'boolean'
585
824
  ? m.identity : undefined;
586
825
  this.info = m.info ?? {};
826
+ /* A recorded device: accepted only as the answer to an enrollment or
827
+ * an upgrade (a device hello is never re-issued), only after this
828
+ * connection verified the daemon's key -- that key is what gets
829
+ * pinned -- and only in the shape the daemon mints. Anything else is
830
+ * ignored, so this client keeps pairing the way it just did. */
831
+ const issued = m.device;
832
+ const issuedId = typeof issued === 'object' && issued !== null ? issued.id : undefined;
833
+ if (this.mode !== 'device' && this.verifiedSidecarJkt !== null
834
+ && typeof issuedId === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(issuedId)) {
835
+ this.device = { id: issuedId, sidecarJkt: this.verifiedSidecarJkt };
836
+ try {
837
+ this.opts.onDeviceIssued?.({ ...this.device });
838
+ }
839
+ catch { /* the connection is up either way */ }
840
+ }
587
841
  for (const decl of m.tools) {
588
842
  const tool = { ...decl, run: (args) => this.invoke(decl.name, args) };
589
843
  this.opts.registerTool(tool);
@@ -594,11 +848,18 @@ export class SidecarHost {
594
848
  return;
595
849
  }
596
850
  case 'denied': {
597
- this.welcome?.reject(new Error(`sidecar denied connection: ${m.reason}`));
851
+ this.welcome?.reject(new Error(this.challengedInHandshake
852
+ ? `${SIDECAR_NOT_ADMITTED}: ${m.reason}`
853
+ : `sidecar denied connection: ${m.reason}`));
598
854
  this.welcome = null;
599
855
  return;
600
856
  }
601
857
  case 'identity-challenge': {
858
+ // Before the welcome: the device proof has passed (see
859
+ // SIDECAR_NOT_ADMITTED). Only a daemon that proved the pinned key
860
+ // counts; anything earlier is ignored here as it is below.
861
+ if (this.welcome && this.verifiedSidecarJkt !== null)
862
+ this.challengedInHandshake = true;
602
863
  void this.answerIdentityChallenge(m);
603
864
  return;
604
865
  }
@@ -1029,6 +1290,16 @@ export class SidecarHost {
1029
1290
  p.resolve(m.ok ? { ok: true, room: m.room } : { ok: false, problems: m.problems });
1030
1291
  return;
1031
1292
  }
1293
+ case 'devices-list-result':
1294
+ case 'devices-revoke-result': {
1295
+ // One map for both: each carries the COMPLETE list afterwards.
1296
+ const p = this.devicesPending.get(m.id);
1297
+ if (!p)
1298
+ return;
1299
+ this.devicesPending.delete(m.id);
1300
+ p.resolve(m.ok ? { ok: true, devices: m.devices } : { ok: false, error: m.error });
1301
+ return;
1302
+ }
1032
1303
  case 'hosts-list-result':
1033
1304
  case 'hosts-forget-result': {
1034
1305
  // One pending map for both, for the same reason as the pause results
@@ -1080,6 +1351,14 @@ export class SidecarHost {
1080
1351
  p.resolve(m.ok ? { ok: true, released: m.released } : { ok: false, error: m.error });
1081
1352
  return;
1082
1353
  }
1354
+ case 'run-plan-get-result': {
1355
+ const p = this.runPlanPending.get(m.id);
1356
+ if (!p)
1357
+ return;
1358
+ this.runPlanPending.delete(m.id);
1359
+ p.resolve(m.ok ? { ok: true, plan: m.plan } : { ok: false, error: m.error });
1360
+ return;
1361
+ }
1083
1362
  case 'room-list-result': {
1084
1363
  const p = this.roomListPending.get(m.id);
1085
1364
  if (!p)
@@ -1340,11 +1619,15 @@ export class SidecarHost {
1340
1619
  * An empty list means no record, which a caller renders rather than treats
1341
1620
  * as an error — a daemon that keeps none is a normal daemon.
1342
1621
  */
1343
- runHistory(agent, limit) {
1622
+ runHistory(agent, limit, crew) {
1344
1623
  return this.sessionCall((id) => ({
1345
- t: 'run-history', id, agent, ...(limit === undefined ? {} : { limit }),
1624
+ t: 'run-history', id, agent, ...(limit === undefined ? {} : { limit }), ...(crew ? { crew } : {}),
1346
1625
  }));
1347
1626
  }
1627
+ /** True when `runHistory` can be asked about one crew (global vs a
1628
+ * workspace). An older daemon answers every question from the global crew,
1629
+ * so a caller must know before it trusts a workspace answer. */
1630
+ supportsRunHistoryScope() { return this.features.includes('run-history-scope'); }
1348
1631
  /**
1349
1632
  * One run's kept events, as the daemon stored them.
1350
1633
  *
@@ -1561,9 +1844,10 @@ export class SidecarHost {
1561
1844
  }
1562
1845
  /** Answer an outstanding permission ask. Fire-and-forget over the wire — the
1563
1846
  * actual drop of the prompt is driven by the server's `permission-resolved`
1564
- * broadcast (`onPermissionResolved`), not by this call returning. */
1565
- answerPermission(id, decision) {
1566
- this.ch.send({ t: 'permission-answer', id, decision });
1847
+ * broadcast (`onPermissionResolved`), not by this call returning. `note` is
1848
+ * the person's reason for a deny; the daemon trims and caps it. */
1849
+ answerPermission(id, decision, note) {
1850
+ this.ch.send({ t: 'permission-answer', id, decision, ...(note ? { note } : {}) });
1567
1851
  }
1568
1852
  /** Push one definition file's content to the daemon, to be persisted under
1569
1853
  * this client's own `<stateDir>/hosts/<dirName>/` (Slice 2 host sync).
@@ -1630,6 +1914,13 @@ export class SidecarHost {
1630
1914
  configWrite(path, content) {
1631
1915
  return this.configCall({ t: 'config-write', id: ++this.configSeq, path, content }).then(() => { });
1632
1916
  }
1917
+ /** True when this daemon can append to a config file without a rewrite. */
1918
+ supportsConfigAppend() { return this.features.includes('config-append'); }
1919
+ /** Append to one config file (O_APPEND daemon-side). Only call when
1920
+ * supportsConfigAppend(); an older daemon would refuse the message. */
1921
+ configAppend(path, content) {
1922
+ return this.configCall({ t: 'config-append', id: ++this.configSeq, path, content }).then(() => { });
1923
+ }
1633
1924
  /** Delete one config file or subtree (recursive, idempotent). */
1634
1925
  configDelete(path) {
1635
1926
  return this.configCall({ t: 'config-delete', id: ++this.configSeq, path }).then(() => { });
@@ -1659,6 +1950,28 @@ export class SidecarHost {
1659
1950
  * directories. Feature-detected so a client offers a destructive control only
1660
1951
  * where the daemon would actually honour it. */
1661
1952
  supportsHostsAdmin() { return this.features.includes('hosts-admin'); }
1953
+ /** Whether the connected daemon keeps paired devices (enrollment codes,
1954
+ * device credentials, `devices-list`/`devices-revoke`). */
1955
+ supportsDevices() { return this.features.includes(DEVICES_FEATURE); }
1956
+ /** Every device paired with this daemon, with `self` marking this one. */
1957
+ listDevices() {
1958
+ const id = ++this.devicesSeq;
1959
+ return new Promise((resolve, reject) => {
1960
+ this.devicesPending.set(id, { resolve, reject });
1961
+ this.ch.send({ t: 'devices-list', id });
1962
+ });
1963
+ }
1964
+ /** Revoke one device: it can never connect again, and its live connections
1965
+ * close. `ok: false` is the daemon's considered refusal (this client's own
1966
+ * device, an unknown one), so it resolves; only a dropped connection
1967
+ * rejects. Resolves with the COMPLETE list afterwards. */
1968
+ revokeDevice(deviceId) {
1969
+ const id = ++this.devicesSeq;
1970
+ return new Promise((resolve, reject) => {
1971
+ this.devicesPending.set(id, { resolve, reject });
1972
+ this.ch.send({ t: 'devices-revoke', id, deviceId });
1973
+ });
1974
+ }
1662
1975
  /** Whether the connected daemon serves an MCP surface at all. `mcp-list`
1663
1976
  * answers an empty list either way (see sidecar.ts), so this is advisory --
1664
1977
  * a caller uses it to decide whether to bother asking, not as a gate. */
@@ -1864,6 +2177,18 @@ export class SidecarHost {
1864
2177
  this.ch.send({ t: 'workspace-release', id, root: ref.root, stateDir: ref.stateDir, runId: ref.runId });
1865
2178
  });
1866
2179
  }
2180
+ /** True when the daemon answers run-plan-get. */
2181
+ supportsRunPlan() { return this.features.includes(RUN_PLAN_FEATURE); }
2182
+ /** One run's plan from its record; `output: true` adds what the run
2183
+ * produced. `ok: false` is the daemon's answer; only a dropped connection
2184
+ * rejects. Ask only when supportsRunPlan() is true. */
2185
+ runPlanGet(runId, opts = {}) {
2186
+ const id = ++this.runPlanSeq;
2187
+ return new Promise((resolve, reject) => {
2188
+ this.runPlanPending.set(id, { resolve, reject });
2189
+ this.ch.send({ t: 'run-plan-get', id, runId, ...(opts.output ? { output: true } : {}) });
2190
+ });
2191
+ }
1867
2192
  /** Ask the daemon to open a room. `ok: false` here is the daemon's CONSIDERED
1868
2193
  * refusal (validation problems, or an unreachable participant) — a real answer,
1869
2194
  * not a transport failure, so it resolves rather than rejects. Only a dropped