@corenel/harness 0.2.0 → 0.3.2

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 (118) hide show
  1. package/dist/acp/agentMap.d.ts +34 -0
  2. package/dist/acp/agentMap.d.ts.map +1 -0
  3. package/dist/acp/agentMap.js +110 -0
  4. package/dist/acp/agentMap.js.map +1 -0
  5. package/dist/acp/index.d.ts +4 -0
  6. package/dist/acp/index.d.ts.map +1 -0
  7. package/dist/acp/index.js +6 -0
  8. package/dist/acp/index.js.map +1 -0
  9. package/dist/acp/policyMap.d.ts +55 -0
  10. package/dist/acp/policyMap.d.ts.map +1 -0
  11. package/dist/acp/policyMap.js +121 -0
  12. package/dist/acp/policyMap.js.map +1 -0
  13. package/dist/acp/types.d.ts +41 -0
  14. package/dist/acp/types.d.ts.map +1 -0
  15. package/dist/acp/types.js +14 -0
  16. package/dist/acp/types.js.map +1 -0
  17. package/dist/api/backend.d.ts +39 -0
  18. package/dist/api/backend.d.ts.map +1 -0
  19. package/dist/api/backend.js +126 -0
  20. package/dist/api/backend.js.map +1 -0
  21. package/dist/api/client.d.ts +95 -0
  22. package/dist/api/client.d.ts.map +1 -0
  23. package/dist/api/client.js +212 -0
  24. package/dist/api/client.js.map +1 -0
  25. package/dist/api/fileMirrorSource.d.ts +24 -0
  26. package/dist/api/fileMirrorSource.d.ts.map +1 -0
  27. package/dist/api/fileMirrorSource.js +59 -0
  28. package/dist/api/fileMirrorSource.js.map +1 -0
  29. package/dist/api/mirror.d.ts +102 -0
  30. package/dist/api/mirror.d.ts.map +1 -0
  31. package/dist/api/mirror.js +204 -0
  32. package/dist/api/mirror.js.map +1 -0
  33. package/dist/api/mirrorScheduler.d.ts +56 -0
  34. package/dist/api/mirrorScheduler.d.ts.map +1 -0
  35. package/dist/api/mirrorScheduler.js +167 -0
  36. package/dist/api/mirrorScheduler.js.map +1 -0
  37. package/dist/core/loop.d.ts +2 -0
  38. package/dist/core/loop.d.ts.map +1 -1
  39. package/dist/core/loop.js +1 -0
  40. package/dist/core/loop.js.map +1 -1
  41. package/dist/install/lockfile.d.ts +79 -0
  42. package/dist/install/lockfile.d.ts.map +1 -0
  43. package/dist/install/lockfile.js +155 -0
  44. package/dist/install/lockfile.js.map +1 -0
  45. package/dist/install/store.d.ts +13 -0
  46. package/dist/install/store.d.ts.map +1 -0
  47. package/dist/install/store.js +38 -0
  48. package/dist/install/store.js.map +1 -0
  49. package/dist/log.d.ts.map +1 -1
  50. package/dist/log.js +6 -1
  51. package/dist/log.js.map +1 -1
  52. package/dist/prompts/host.d.ts +4 -0
  53. package/dist/prompts/host.d.ts.map +1 -1
  54. package/dist/prompts/host.js.map +1 -1
  55. package/dist/prompts/index.d.ts.map +1 -1
  56. package/dist/prompts/index.js +4 -0
  57. package/dist/prompts/index.js.map +1 -1
  58. package/dist/prompts/library/defaults.generated.js +1 -1
  59. package/dist/prompts/library/defaults.generated.js.map +1 -1
  60. package/dist/providers/gateway.d.ts +3 -0
  61. package/dist/providers/gateway.d.ts.map +1 -1
  62. package/dist/providers/gateway.js +3 -0
  63. package/dist/providers/gateway.js.map +1 -1
  64. package/dist/sessions/apiLeaseStore.d.ts +10 -0
  65. package/dist/sessions/apiLeaseStore.d.ts.map +1 -0
  66. package/dist/sessions/apiLeaseStore.js +41 -0
  67. package/dist/sessions/apiLeaseStore.js.map +1 -0
  68. package/dist/sessions/head.d.ts +90 -0
  69. package/dist/sessions/head.d.ts.map +1 -0
  70. package/dist/sessions/head.js +82 -0
  71. package/dist/sessions/head.js.map +1 -0
  72. package/dist/sessions/inflight.d.ts +58 -0
  73. package/dist/sessions/inflight.d.ts.map +1 -0
  74. package/dist/sessions/inflight.js +0 -0
  75. package/dist/sessions/inflight.js.map +1 -0
  76. package/dist/sessions/lease.d.ts +59 -0
  77. package/dist/sessions/lease.d.ts.map +1 -0
  78. package/dist/sessions/lease.js +115 -0
  79. package/dist/sessions/lease.js.map +1 -0
  80. package/dist/sessions/leaseStore.d.ts +46 -0
  81. package/dist/sessions/leaseStore.d.ts.map +1 -0
  82. package/dist/sessions/leaseStore.js +58 -0
  83. package/dist/sessions/leaseStore.js.map +1 -0
  84. package/dist/sessions/timeline.d.ts +134 -0
  85. package/dist/sessions/timeline.d.ts.map +1 -0
  86. package/dist/sessions/timeline.js +127 -0
  87. package/dist/sessions/timeline.js.map +1 -0
  88. package/dist/sidecar/host.d.ts +223 -3
  89. package/dist/sidecar/host.d.ts.map +1 -1
  90. package/dist/sidecar/host.js +592 -2
  91. package/dist/sidecar/host.js.map +1 -1
  92. package/dist/sidecar/server.d.ts +124 -2
  93. package/dist/sidecar/server.d.ts.map +1 -1
  94. package/dist/sidecar/server.js +608 -9
  95. package/dist/sidecar/server.js.map +1 -1
  96. package/dist/snapshot/effects.d.ts +69 -0
  97. package/dist/snapshot/effects.d.ts.map +1 -0
  98. package/dist/snapshot/effects.js +67 -0
  99. package/dist/snapshot/effects.js.map +1 -0
  100. package/dist/snapshot/manifest.d.ts +73 -0
  101. package/dist/snapshot/manifest.d.ts.map +1 -0
  102. package/dist/snapshot/manifest.js +141 -0
  103. package/dist/snapshot/manifest.js.map +1 -0
  104. package/dist/snapshot/tree.d.ts +62 -0
  105. package/dist/snapshot/tree.d.ts.map +1 -0
  106. package/dist/snapshot/tree.js +147 -0
  107. package/dist/snapshot/tree.js.map +1 -0
  108. package/dist/tools/builtins.d.ts.map +1 -1
  109. package/dist/tools/builtins.js +61 -2
  110. package/dist/tools/builtins.js.map +1 -1
  111. package/dist/tools/namespaces.d.ts +1 -1
  112. package/dist/tools/namespaces.d.ts.map +1 -1
  113. package/dist/tools/namespaces.js +24 -0
  114. package/dist/tools/namespaces.js.map +1 -1
  115. package/dist/tools/todo.d.ts.map +1 -1
  116. package/dist/tools/todo.js +12 -0
  117. package/dist/tools/todo.js.map +1 -1
  118. package/package.json +6 -2
@@ -100,6 +100,43 @@ export class SidecarServer {
100
100
  writable: true,
101
101
  value: void 0
102
102
  });
103
+ /** Detach callbacks for runs this connection is watching, so a dropped socket
104
+ * stops the hub fanning out into a dead channel. */
105
+ Object.defineProperty(this, "runDetachers", {
106
+ enumerable: true,
107
+ configurable: true,
108
+ writable: true,
109
+ value: new Set()
110
+ });
111
+ /** Processes THIS connection is watching. A shared process table means two
112
+ * browsers can be paired to one daemon, and a client that never asked about a
113
+ * process should not be streamed its output - so output is fanned out per
114
+ * attachment, not to everyone. Spawning attaches you; `proc-attach` is how a
115
+ * second client (or the same one after a reload) opts in. */
116
+ Object.defineProperty(this, "attachedProcs", {
117
+ enumerable: true,
118
+ configurable: true,
119
+ writable: true,
120
+ value: new Set()
121
+ });
122
+ Object.defineProperty(this, "procUnsub", {
123
+ enumerable: true,
124
+ configurable: true,
125
+ writable: true,
126
+ value: void 0
127
+ });
128
+ /* ---- sessions ---- */
129
+ /** Per-session stream stoppers for THIS connection.
130
+ *
131
+ * Per connection, not global: two browsers may watch one session, and a
132
+ * dropped socket must stop only its own follows. Keyed by session because a
133
+ * session outlives the request that started watching it. */
134
+ Object.defineProperty(this, "sessionWatch", {
135
+ enumerable: true,
136
+ configurable: true,
137
+ writable: true,
138
+ value: new Map()
139
+ });
103
140
  Object.defineProperty(this, "memory", {
104
141
  enumerable: true,
105
142
  configurable: true,
@@ -182,9 +219,30 @@ export class SidecarServer {
182
219
  for (const t of opts.tools)
183
220
  this.tools.set(t.name, t);
184
221
  this.ch.onMessage((m) => this.onMessage(m));
222
+ // Every connected server subscribes, which is what makes a root added by one
223
+ // paired client reach ALL of them: two browsers on one daemon must never
224
+ // disagree about what is reachable. The payload is the COMPLETE set.
225
+ const offRoots = opts.roots?.subscribe((s) => this.ch.send({ t: 'roots', root: s.root, roots: s.roots }));
185
226
  this.ch.onClose?.(() => {
186
227
  this.attendedDetach?.();
187
228
  this.attendedDetach = undefined;
229
+ // A dropped connection must stop receiving run events, or the hub keeps
230
+ // fanning out into a dead channel for the rest of the run.
231
+ for (const detach of this.runDetachers)
232
+ detach();
233
+ this.runDetachers.clear();
234
+ offRoots?.();
235
+ // Stop streaming process output into a dead socket. The PROCESSES keep
236
+ // running - that is the point of the table living on the daemon - but this
237
+ // connection stops hearing about them until something re-attaches.
238
+ this.procUnsub?.();
239
+ this.procUnsub = undefined;
240
+ this.attachedProcs.clear();
241
+ // Same reasoning for sessions: the SESSIONS keep running — that is what
242
+ // makes resume cheap — but this connection stops being sent their
243
+ // metrics and logs.
244
+ for (const sid of [...this.sessionWatch.keys()])
245
+ this.stopWatching(sid);
188
246
  });
189
247
  }
190
248
  onMessage(m) {
@@ -232,10 +290,31 @@ export class SidecarServer {
232
290
  // pre-auth behavior (do not invent an ack variant).
233
291
  if (!this.authed)
234
292
  return;
235
- // P1 authed path: acknowledge-and-noop. The single commanding client
236
- // already gets events via its `run` id; multi-subscriber replay is P3.
237
- // Defined here so the message is forward-compatible and never mistaken
238
- // for unknown.
293
+ this.onSubscribe(m);
294
+ return;
295
+ case 'acp-start':
296
+ if (!this.authed) {
297
+ this.ch.send({ t: 'acp-error', id: m.id, error: 'not authenticated' });
298
+ return;
299
+ }
300
+ void this.onAcpStart(m);
301
+ return;
302
+ case 'acp-set-mode':
303
+ void this.onAcpSetMode(m);
304
+ return;
305
+ case 'acp-prompt':
306
+ if (!this.authed) {
307
+ this.ch.send({ t: 'acp-error', id: m.id, error: 'not authenticated' });
308
+ return;
309
+ }
310
+ void this.onAcpPrompt(m);
311
+ return;
312
+ case 'acp-cancel':
313
+ // A notification-shaped message like `subscribe`: no response channel,
314
+ // so a pre-auth cancel is dropped silently rather than answered.
315
+ if (!this.authed)
316
+ return;
317
+ this.opts.acp?.cancel();
239
318
  return;
240
319
  case 'permission-answer':
241
320
  // Guarded by auth like `run`/`subscribe` (auth boundary): a pre-auth
@@ -244,6 +323,99 @@ export class SidecarServer {
244
323
  return;
245
324
  this.opts.attendance?.answer(m.id, m.decision);
246
325
  return;
326
+ case 'roots-browse':
327
+ // Auth-gated like every other request: directory enumeration must never
328
+ // precede the pairing token. Failures answer ok:false rather than throwing
329
+ // across the channel.
330
+ if (!this.authed) {
331
+ this.ch.send({ t: 'roots-browse-result', id: m.id, ok: false, error: 'not authenticated' });
332
+ return;
333
+ }
334
+ void this.onRootsBrowse(m);
335
+ return;
336
+ case 'roots-add':
337
+ if (!this.authed) {
338
+ this.ch.send({ t: 'roots-add-result', id: m.id, ok: false, error: 'not authenticated' });
339
+ return;
340
+ }
341
+ void this.onRootsAdd(m);
342
+ return;
343
+ case 'proc-spawn':
344
+ if (!this.authed) {
345
+ this.ch.send({ t: 'proc-error', id: m.id, error: 'not authenticated' });
346
+ return;
347
+ }
348
+ void this.onProcSpawn(m);
349
+ return;
350
+ case 'proc-attach':
351
+ if (!this.authed) {
352
+ this.ch.send({ t: 'proc-attach-result', id: m.id, ok: false, error: 'not authenticated' });
353
+ return;
354
+ }
355
+ this.onProcAttach(m);
356
+ return;
357
+ case 'proc-input':
358
+ if (!this.authed) {
359
+ this.ch.send({ t: 'proc-input-result', id: m.id, ok: false, error: 'not authenticated' });
360
+ return;
361
+ }
362
+ this.onProcSimple(m.id, 'proc-input-result', () => this.requireProcs().write(m.procId, m.text));
363
+ return;
364
+ case 'proc-kill':
365
+ if (!this.authed) {
366
+ this.ch.send({ t: 'proc-kill-result', id: m.id, ok: false, error: 'not authenticated' });
367
+ return;
368
+ }
369
+ this.onProcSimple(m.id, 'proc-kill-result', () => this.requireProcs().kill(m.procId));
370
+ return;
371
+ case 'proc-list':
372
+ // An empty list is the honest answer for a daemon without the capability:
373
+ // it is running nothing, and an error here would make the common "what is
374
+ // running?" probe noisy on every sidecar that never allowed shell.
375
+ if (!this.authed) {
376
+ this.ch.send({ t: 'proc-list-result', id: m.id, procs: [] });
377
+ return;
378
+ }
379
+ this.ch.send({ t: 'proc-list-result', id: m.id, procs: this.opts.procs?.list() ?? [] });
380
+ return;
381
+ case 'session-list':
382
+ // An empty list is the honest answer from a daemon with no session
383
+ // layer: it is running none. An error would make the common "what is
384
+ // there?" probe noisy on every daemon that never enabled sessions.
385
+ if (!this.authed) {
386
+ this.ch.send({ t: 'session-list-result', id: m.id, sessions: [] });
387
+ return;
388
+ }
389
+ this.ch.send({ t: 'session-list-result', id: m.id, sessions: this.opts.sessions?.list() ?? [] });
390
+ return;
391
+ case 'session-create':
392
+ case 'session-pause':
393
+ case 'session-resume':
394
+ case 'session-destroy':
395
+ case 'session-commit':
396
+ case 'session-snapshots':
397
+ case 'session-watch':
398
+ case 'session-unwatch':
399
+ if (!this.authed) {
400
+ this.ch.send({ t: 'session-error', id: m.id, error: 'not authenticated' });
401
+ return;
402
+ }
403
+ void this.onSession(m);
404
+ return;
405
+ case 'run-history':
406
+ if (!this.authed) {
407
+ this.ch.send({ t: 'run-history-result', id: m.id, runs: [] });
408
+ return;
409
+ }
410
+ void this.onRunHistory(m);
411
+ return;
412
+ case 'run-transcript':
413
+ if (!this.authed) {
414
+ this.ch.send({ t: 'run-transcript-result', id: m.id, lines: [] });
415
+ return;
416
+ }
417
+ void this.onRunTranscript(m);
418
+ return;
247
419
  case 'host-sync-put':
248
420
  void this.onHostSyncPut(m);
249
421
  return;
@@ -305,6 +477,8 @@ export class SidecarServer {
305
477
  this.attendedDetach = this.opts.attendance.attach({
306
478
  send: (ask) => this.ch.send({ t: 'permission-ask', ...ask }),
307
479
  resolved: (id, decision, by) => this.ch.send({ t: 'permission-resolved', id, decision, by }),
480
+ runActivity: (activity) => this.ch.send({ t: 'run-activity', activity }),
481
+ registrations: (registrations) => this.ch.send({ t: 'registrations', registrations }),
308
482
  });
309
483
  }
310
484
  this.opts.log?.(`paired — ${this.tools.size} ${this.tools.size === 1 ? 'tool' : 'tools'} advertised (host dir: ${this.hostStateDir})`);
@@ -316,7 +490,24 @@ export class SidecarServer {
316
490
  // instead of adding one that would silently never fire. `info` (the confined
317
491
  // root + platform) lets the host show the working directory when browsing
318
492
  // this sidecar as a file source; omitted cleanly when not provided.
319
- const features = ['http-proxy', ...(this.opts.runner ? ['runner'] : [])];
493
+ const features = [
494
+ 'http-proxy',
495
+ ...(this.opts.runner ? ['runner'] : []),
496
+ ...(this.opts.acp ? ['acp'] : []),
497
+ ...(this.opts.roots?.areas.length ? ['add-roots'] : []),
498
+ ...(this.opts.procs ? ['procs'] : []),
499
+ ...(this.opts.sessions ? ['sessions'] : []),
500
+ // The operator's transcript CEILING, advertised so a client offers the
501
+ // per-run choice only where it could be honoured. Withheld means every
502
+ // `run` here keeps nothing however it asks — a client showing a "keep
503
+ // this" control against such a daemon would be promising storage that
504
+ // never happens.
505
+ ...(this.opts.sessions?.canKeepTranscripts ? ['keep-transcripts'] : []),
506
+ // Whether a session can be created FROM a repository. Off unless the
507
+ // operator declared somewhere a checkout may be written, so a client only
508
+ // offers "start from a repository" where it would actually work.
509
+ ...(this.opts.sessions?.canClone ? ['session-clone'] : []),
510
+ ];
320
511
  this.ch.send({ t: 'welcome', tools, features, ...(this.opts.info ? { info: this.opts.info } : {}) });
321
512
  }
322
513
  /** Proxy a host HTTP request server-side. Restricted to LOOPBACK targets — the
@@ -355,6 +546,213 @@ export class SidecarServer {
355
546
  this.ch.send({ t: 'http-err', id: m.id, error: errMessage(e) });
356
547
  }
357
548
  }
549
+ requireSessions() {
550
+ const s = this.opts.sessions;
551
+ if (!s)
552
+ throw new Error('this sidecar runs no sessions (start the daemon with --daemon)');
553
+ return s;
554
+ }
555
+ async onSession(m) {
556
+ const id = m.id;
557
+ try {
558
+ const sessions = this.requireSessions();
559
+ switch (m.t) {
560
+ case 'session-create':
561
+ this.ch.send({
562
+ t: 'session-created',
563
+ id,
564
+ session: await sessions.create({
565
+ ...(m.workdir ? { workdir: m.workdir } : {}),
566
+ /* `repo` asks the daemon to CLONE the source rather than mount a
567
+ * directory the client named. Forwarded, not judged: whether
568
+ * cloning is permitted at all, where a checkout may land, and
569
+ * which URLs are refused all live on the daemon side, where the
570
+ * operator's declarations are. */
571
+ ...(m.repo ? { repo: m.repo } : {}),
572
+ ...(m.owner ? { owner: m.owner } : {}),
573
+ ...(m.fromSnapshot ? { fromSnapshot: m.fromSnapshot } : {}),
574
+ // From the HANDSHAKE, never from this message: the dot-dir is a
575
+ // property of the client's workspace, and taking it from the
576
+ // request would let a caller point a session's state anywhere.
577
+ stateDir: this.hostStateDir,
578
+ }),
579
+ });
580
+ return;
581
+ case 'session-pause': {
582
+ // The drain result is carried through rather than reduced to ok/not:
583
+ // a caller about to capture has to know whether work was still
584
+ // running, because that is what makes a snapshot torn.
585
+ const r = await sessions.pause(m.sessionId, m.drainMs);
586
+ this.ch.send({ t: 'session-paused', id, drained: r.drained, remaining: r.remaining });
587
+ return;
588
+ }
589
+ case 'session-resume':
590
+ await sessions.resume(m.sessionId);
591
+ this.ch.send({ t: 'session-ok', id });
592
+ return;
593
+ case 'session-destroy':
594
+ this.stopWatching(m.sessionId);
595
+ await sessions.destroy(m.sessionId);
596
+ this.ch.send({ t: 'session-ok', id });
597
+ return;
598
+ case 'session-commit': {
599
+ if (!sessions.commit)
600
+ throw new Error('this session backend cannot capture snapshots');
601
+ const snapshot = await sessions.commit(m.sessionId, m.name);
602
+ this.ch.send({ t: 'session-committed', id, snapshot });
603
+ return;
604
+ }
605
+ case 'session-snapshots': {
606
+ // An empty list, not an error, when the backend cannot capture: the
607
+ // question "what could I restore from?" has an answer there, and it
608
+ // is "nothing". The client uses it to decide whether to offer the
609
+ // choice at all, and an error would make it look broken instead.
610
+ const snapshots = sessions.snapshots ? await sessions.snapshots() : [];
611
+ this.ch.send({ t: 'session-snapshots-result', id, snapshots });
612
+ return;
613
+ }
614
+ case 'session-watch': {
615
+ // Idempotent: watching twice from one connection would double every
616
+ // sample rather than doing nothing, which is the kind of bug a
617
+ // reconnecting UI creates by accident.
618
+ if (this.sessionWatch.has(m.sessionId)) {
619
+ this.ch.send({ t: 'session-ok', id });
620
+ return;
621
+ }
622
+ const stops = [];
623
+ const sid = m.sessionId;
624
+ if (sessions.watchMetrics) {
625
+ stops.push(sessions.watchMetrics(sid, (sample) => this.ch.send({ t: 'session-metrics', sessionId: sid, sample })));
626
+ }
627
+ if (sessions.watchLogs) {
628
+ stops.push(sessions.watchLogs(sid, (line) => this.ch.send({ t: 'session-log', sessionId: sid, line })));
629
+ }
630
+ this.sessionWatch.set(sid, stops);
631
+ this.ch.send({ t: 'session-ok', id });
632
+ return;
633
+ }
634
+ case 'session-unwatch':
635
+ this.stopWatching(m.sessionId);
636
+ this.ch.send({ t: 'session-ok', id });
637
+ return;
638
+ default:
639
+ this.ch.send({ t: 'session-error', id, error: `unhandled session message: ${m.t}` });
640
+ }
641
+ }
642
+ catch (e) {
643
+ this.ch.send({ t: 'session-error', id, error: errMessage(e) });
644
+ }
645
+ }
646
+ stopWatching(sessionId) {
647
+ for (const stop of this.sessionWatch.get(sessionId) ?? []) {
648
+ try {
649
+ stop();
650
+ }
651
+ catch { /* already stopped */ }
652
+ }
653
+ this.sessionWatch.delete(sessionId);
654
+ }
655
+ /* ---- long-running processes ---- */
656
+ /** The table, or a refusal that names WHY. "unknown message" would send an
657
+ * operator hunting for a bug when the real answer is that they did not pass
658
+ * --allow-shell. */
659
+ requireProcs() {
660
+ const procs = this.opts.procs;
661
+ if (!procs)
662
+ throw new Error('this sidecar was not started with --allow-shell, so it cannot run processes');
663
+ return procs;
664
+ }
665
+ /** Begin fanning this connection's attached processes back over the wire.
666
+ * Subscribed lazily: a client that never touches a process should not pay to
667
+ * be woken for someone else's build output. */
668
+ ensureProcStream() {
669
+ if (this.procUnsub)
670
+ return;
671
+ this.procUnsub = this.requireProcs().subscribe((ev) => {
672
+ if (!this.attachedProcs.has(ev.procId))
673
+ return;
674
+ if (ev.kind === 'out') {
675
+ this.ch.send({ t: 'proc-out', procId: ev.procId, seq: ev.chunk.seq, stream: ev.chunk.stream, text: ev.chunk.text });
676
+ return;
677
+ }
678
+ this.ch.send({
679
+ t: 'proc-exit',
680
+ procId: ev.procId,
681
+ code: ev.info.exitCode ?? null,
682
+ ...(ev.info.signal ? { signal: ev.info.signal } : {}),
683
+ });
684
+ // Attachment ends with the process. Keeping it would leak an entry per run
685
+ // on a connection that stays open for hours.
686
+ this.attachedProcs.delete(ev.procId);
687
+ });
688
+ }
689
+ async onProcSpawn(m) {
690
+ try {
691
+ const procs = this.requireProcs();
692
+ // Attach BEFORE spawning: a command that prints immediately would otherwise
693
+ // race the subscription and lose its first lines.
694
+ this.ensureProcStream();
695
+ const info = await procs.spawn({
696
+ command: m.command,
697
+ ...(m.cwd ? { cwd: m.cwd } : {}),
698
+ ...(m.root ? { root: m.root } : {}),
699
+ ...(m.label ? { label: m.label } : {}),
700
+ });
701
+ this.attachedProcs.add(info.procId);
702
+ this.ch.send({ t: 'proc-started', id: m.id, proc: info });
703
+ // A process can finish before the caller ever sees `proc-started` (`echo
704
+ // hi`). Replay whatever the table already holds so no output is lost to
705
+ // that race, and report the exit it already had.
706
+ const { chunks } = procs.backlog(info.procId);
707
+ for (const c of chunks)
708
+ this.ch.send({ t: 'proc-out', procId: info.procId, seq: c.seq, stream: c.stream, text: c.text });
709
+ const now = procs.get(info.procId);
710
+ if (now && !now.running) {
711
+ this.attachedProcs.delete(info.procId);
712
+ this.ch.send({ t: 'proc-exit', procId: info.procId, code: now.exitCode ?? null, ...(now.signal ? { signal: now.signal } : {}) });
713
+ }
714
+ }
715
+ catch (e) {
716
+ this.ch.send({ t: 'proc-error', id: m.id, error: errMessage(e) });
717
+ }
718
+ }
719
+ /** Re-attach to a process after a reload or from a second client. `sinceSeq`
720
+ * is what the client already has, so the replay is exactly the rest. */
721
+ onProcAttach(m) {
722
+ try {
723
+ const procs = this.requireProcs();
724
+ const info = procs.get(m.procId);
725
+ if (!info) {
726
+ this.ch.send({ t: 'proc-attach-result', id: m.id, ok: false, error: `no such process: ${m.procId}` });
727
+ return;
728
+ }
729
+ this.ensureProcStream();
730
+ if (info.running)
731
+ this.attachedProcs.add(m.procId);
732
+ const { chunks, truncated } = procs.backlog(m.procId, m.sinceSeq);
733
+ this.ch.send({ t: 'proc-attach-result', id: m.id, ok: true, proc: info, ...(truncated ? { truncated: true } : {}) });
734
+ for (const c of chunks)
735
+ this.ch.send({ t: 'proc-out', procId: m.procId, seq: c.seq, stream: c.stream, text: c.text });
736
+ // An already-exited process still owes the client its ending, or a
737
+ // reattaching UI shows a spinner for something finished an hour ago.
738
+ if (!info.running) {
739
+ this.ch.send({ t: 'proc-exit', procId: m.procId, code: info.exitCode ?? null, ...(info.signal ? { signal: info.signal } : {}) });
740
+ }
741
+ }
742
+ catch (e) {
743
+ this.ch.send({ t: 'proc-attach-result', id: m.id, ok: false, error: errMessage(e) });
744
+ }
745
+ }
746
+ /** The kill/input pair: act, then answer with ok or the reason. */
747
+ onProcSimple(id, reply, act) {
748
+ try {
749
+ act();
750
+ this.ch.send({ t: reply, id, ok: true });
751
+ }
752
+ catch (e) {
753
+ this.ch.send({ t: reply, id, ok: false, error: errMessage(e) });
754
+ }
755
+ }
358
756
  async onInvoke(m) {
359
757
  const tool = this.tools.get(m.name);
360
758
  if (!tool) {
@@ -382,14 +780,179 @@ export class SidecarServer {
382
780
  * turn). Events that arrive before we know the runId are buffered and flushed
383
781
  * right after `run-accepted`, so they still carry the correct runId and never
384
782
  * race ahead of it on the wire. */
783
+ async onAcpStart(m) {
784
+ const acp = this.opts.acp;
785
+ if (!acp) {
786
+ this.ch.send({ t: 'acp-error', id: m.id, error: 'this sidecar was not started with --acp' });
787
+ return;
788
+ }
789
+ try {
790
+ const started = await acp.start(m.target, (update) => {
791
+ this.ch.send({ t: 'acp-update', id: m.id, update });
792
+ }, {
793
+ ...(m.root ? { root: m.root } : {}),
794
+ ...(m.modeId ? { modeId: m.modeId } : {}),
795
+ ...(m.resume ? { resume: m.resume } : {}),
796
+ });
797
+ this.ch.send({ t: 'acp-started', id: m.id, ...started });
798
+ }
799
+ catch (err) {
800
+ this.ch.send({ t: 'acp-error', id: m.id, error: errMessage(err) });
801
+ }
802
+ }
803
+ async onAcpSetMode(m) {
804
+ const acp = this.opts.acp;
805
+ if (!acp) {
806
+ this.ch.send({ t: 'acp-error', id: m.id, error: 'this sidecar was not started with --acp' });
807
+ return;
808
+ }
809
+ try {
810
+ await acp.setMode(m.modeId);
811
+ this.ch.send({ t: 'acp-mode-set', id: m.id, modeId: m.modeId });
812
+ }
813
+ catch (err) {
814
+ this.ch.send({ t: 'acp-error', id: m.id, error: errMessage(err) });
815
+ }
816
+ }
817
+ async onAcpPrompt(m) {
818
+ const acp = this.opts.acp;
819
+ if (!acp) {
820
+ this.ch.send({ t: 'acp-error', id: m.id, error: 'this sidecar was not started with --acp' });
821
+ return;
822
+ }
823
+ try {
824
+ const { stopReason } = await acp.prompt(m.text, m.content);
825
+ this.ch.send({ t: 'acp-done', id: m.id, stopReason });
826
+ }
827
+ catch (err) {
828
+ this.ch.send({ t: 'acp-error', id: m.id, error: errMessage(err) });
829
+ }
830
+ }
831
+ /**
832
+ * Attach this connection as a VIEWER of a run it did not start.
833
+ *
834
+ * The replay comes first, so a client joining mid-run sees the whole run
835
+ * rather than the tail — a viewer that only sees what happens next cannot tell
836
+ * a stalled run from a quiet one. Events are tagged with the SUBSCRIBER's own
837
+ * message id, so a client correlating by id sees its own stream.
838
+ */
839
+ onSubscribe(m) {
840
+ const hub = this.opts.runs;
841
+ if (!hub) {
842
+ this.ch.send({ t: 'run-error', id: m.id, error: 'this sidecar does not support watching runs' });
843
+ return;
844
+ }
845
+ const attached = hub.subscribe(m.runId, {
846
+ event: (event) => this.ch.send({ t: 'event', id: m.id, runId: m.runId, event }),
847
+ done: (r) => {
848
+ this.ch.send({ t: 'run-done', id: m.id, runId: m.runId, text: r.text, stopReason: r.stopReason });
849
+ detachOnce();
850
+ },
851
+ error: (message) => {
852
+ this.ch.send({ t: 'run-error', id: m.id, error: message });
853
+ detachOnce();
854
+ },
855
+ });
856
+ if (!attached) {
857
+ this.ch.send({ t: 'run-error', id: m.id, error: `no such run: ${m.runId}` });
858
+ return;
859
+ }
860
+ // Held so a dropped connection detaches; also removed on done/error below.
861
+ const detachOnce = () => {
862
+ if (!this.runDetachers.has(attached.detach))
863
+ return;
864
+ this.runDetachers.delete(attached.detach);
865
+ attached.detach();
866
+ };
867
+ this.runDetachers.add(attached.detach);
868
+ this.ch.send({ t: 'run-accepted', id: m.id, runId: m.runId });
869
+ if (attached.replay.dropped > 0) {
870
+ // Say so rather than handing over a tail that looks complete.
871
+ this.opts.log?.(`subscribe ${m.runId}: replaying with ${attached.replay.dropped} earlier event(s) dropped`);
872
+ }
873
+ for (const event of attached.replay.events) {
874
+ this.ch.send({ t: 'event', id: m.id, runId: m.runId, event });
875
+ }
876
+ // A run that already finished still replays, then closes immediately.
877
+ if (attached.replay.done) {
878
+ this.ch.send({ t: 'run-done', id: m.id, runId: m.runId, ...attached.replay.done });
879
+ detachOnce();
880
+ }
881
+ else if (attached.replay.error) {
882
+ this.ch.send({ t: 'run-error', id: m.id, error: attached.replay.error });
883
+ detachOnce();
884
+ }
885
+ }
886
+ /**
887
+ * What this caller's agent has actually done.
888
+ *
889
+ * AN EMPTY LIST IS AN ANSWER, not a failure: a daemon that keeps no record
890
+ * has no history, and a client uses the rows to render rather than to detect
891
+ * a broken daemon. The identity handed down is the untrusted `{ clientId }`
892
+ * this connection authenticated with — the runner resolves it through the
893
+ * hosts manifest, which is what makes the result owner-scoped by
894
+ * construction rather than by a filter written here.
895
+ */
896
+ async onRunHistory(m) {
897
+ try {
898
+ const runs = this.opts.runner?.history
899
+ ? await this.opts.runner.history({ clientId: this.clientId }, m.agent, m.limit)
900
+ : [];
901
+ this.ch.send({ t: 'run-history-result', id: m.id, runs });
902
+ }
903
+ catch {
904
+ // A history that cannot be read is not a reason to break the caller's
905
+ // page; the rows it already has stay, and it sees none added.
906
+ this.ch.send({ t: 'run-history-result', id: m.id, runs: [] });
907
+ }
908
+ }
909
+ /** One run's kept events. Empty when they were never kept — the ordinary
910
+ * case, and a state the caller renders rather than treats as a failure. */
911
+ async onRunTranscript(m) {
912
+ try {
913
+ const lines = this.opts.runner?.transcript
914
+ ? await this.opts.runner.transcript({ clientId: this.clientId }, m.agent, m.runId)
915
+ : [];
916
+ this.ch.send({ t: 'run-transcript-result', id: m.id, lines });
917
+ }
918
+ catch {
919
+ this.ch.send({ t: 'run-transcript-result', id: m.id, lines: [] });
920
+ }
921
+ }
385
922
  async onRun(m) {
386
- if (!this.opts.runner) {
923
+ /* WHERE the run happens, decided once, up front.
924
+ *
925
+ * A `sessionId` is a confinement request. A daemon that cannot honour it
926
+ * says so rather than running the work in its own process anyway — silently
927
+ * downgrading is the failure IsolationLevel exists to prevent, and it would
928
+ * be invisible to the client that asked. */
929
+ if (m.sessionId !== undefined) {
930
+ const sessions = this.opts.sessions;
931
+ if (!sessions?.run) {
932
+ this.ch.send({ t: 'run-error', id: m.id, error: 'this sidecar cannot run agents inside a session' });
933
+ return;
934
+ }
935
+ }
936
+ else if (!this.opts.runner) {
387
937
  this.ch.send({ t: 'run-error', id: m.id, error: 'this sidecar cannot run agents' });
388
938
  return;
389
939
  }
940
+ const start = m.sessionId !== undefined
941
+ ? (agent, input, onEvent, identity) =>
942
+ /* The request is forwarded, never resolved here. Whether it is honoured
943
+ * is the session layer's call, because the operator's permission lives
944
+ * there; this class would have to duplicate that rule to decide, and a
945
+ * duplicated permission rule is one that eventually disagrees. */
946
+ this.opts.sessions.run(m.sessionId, agent, input, onEvent, identity, { keepTranscript: m.keepTranscript === true })
947
+ : (agent, input, onEvent, identity) => this.opts.runner.run(agent, input, onEvent, identity);
390
948
  let runId;
391
949
  const pending = [];
392
950
  const onEvent = (event) => {
951
+ // Publish to the hub as well as this connection, so another device can
952
+ // watch. Buffered until the runner reports its id, since the hub is keyed
953
+ // by it — the same reason `pending` exists for the commanding client.
954
+ if (runId !== undefined)
955
+ this.opts.runs?.publish(runId, event);
393
956
  if (runId === undefined) {
394
957
  pending.push(event);
395
958
  return;
@@ -403,16 +966,25 @@ export class SidecarServer {
403
966
  // (no manifest access here, by design; see the module doc), it just
404
967
  // hands the identity it already holds to the runner, exactly like
405
968
  // `onHostSyncPut`/`onHostSyncDelete` do for `hostSync.put`/`remove`.
406
- const started = await this.opts.runner.run(m.agent, m.input, onEvent, { clientId: this.clientId });
969
+ const started = await start(m.agent, m.input, onEvent, { clientId: this.clientId });
407
970
  runId = started.runId;
971
+ this.opts.runs?.open(runId);
408
972
  this.ch.send({ t: 'run-accepted', id: m.id, runId });
409
- for (const event of pending)
973
+ for (const event of pending) {
974
+ this.opts.runs?.publish(runId, event);
410
975
  this.ch.send({ t: 'event', id: m.id, runId, event });
976
+ }
411
977
  const fin = await started.done;
978
+ this.opts.runs?.finish(runId, { text: fin.text, stopReason: fin.stopReason });
412
979
  this.ch.send({ t: 'run-done', id: m.id, runId, text: fin.text, stopReason: fin.stopReason });
413
980
  }
414
981
  catch (e) {
415
- this.ch.send({ t: 'run-error', id: m.id, error: errMessage(e) });
982
+ const message = errMessage(e);
983
+ // Tell watchers too: a run that failed must not look merely silent to a
984
+ // second viewer.
985
+ if (runId !== undefined)
986
+ this.opts.runs?.fail(runId, message);
987
+ this.ch.send({ t: 'run-error', id: m.id, error: message });
416
988
  }
417
989
  }
418
990
  /** The identity to attribute a host-sync write to. Returns either the
@@ -429,6 +1001,33 @@ export class SidecarServer {
429
1001
  }
430
1002
  return { clientId: this.clientId };
431
1003
  }
1004
+ async onRootsBrowse(m) {
1005
+ if (!this.opts.roots) {
1006
+ this.ch.send({ t: 'roots-browse-result', id: m.id, ok: false, error: 'this sidecar cannot add working directories' });
1007
+ return;
1008
+ }
1009
+ try {
1010
+ this.ch.send({ t: 'roots-browse-result', id: m.id, ok: true, dirs: await this.opts.roots.browse(m.path) });
1011
+ }
1012
+ catch (e) {
1013
+ this.ch.send({ t: 'roots-browse-result', id: m.id, ok: false, error: errMessage(e) });
1014
+ }
1015
+ }
1016
+ async onRootsAdd(m) {
1017
+ if (!this.opts.roots) {
1018
+ this.ch.send({ t: 'roots-add-result', id: m.id, ok: false, error: 'this sidecar cannot add working directories' });
1019
+ return;
1020
+ }
1021
+ try {
1022
+ // The broadcast to every client is the controller's subscription, not this
1023
+ // reply -- so a refusal (below) reaches only the asker and changes nothing.
1024
+ await this.opts.roots.add(m.path);
1025
+ this.ch.send({ t: 'roots-add-result', id: m.id, ok: true });
1026
+ }
1027
+ catch (e) {
1028
+ this.ch.send({ t: 'roots-add-result', id: m.id, ok: false, error: errMessage(e) });
1029
+ }
1030
+ }
432
1031
  async onHostSyncPut(m) {
433
1032
  const identity = this.requireHostSyncIdentity();
434
1033
  if ('error' in identity) {