@corenel/harness 0.2.0 → 0.4.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 (163) 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/compression.d.ts +15 -1
  38. package/dist/compression.d.ts.map +1 -1
  39. package/dist/compression.js +32 -1
  40. package/dist/compression.js.map +1 -1
  41. package/dist/core/loop.d.ts +12 -0
  42. package/dist/core/loop.d.ts.map +1 -1
  43. package/dist/core/loop.js +138 -14
  44. package/dist/core/loop.js.map +1 -1
  45. package/dist/core/spawn.d.ts +54 -0
  46. package/dist/core/spawn.d.ts.map +1 -0
  47. package/dist/core/spawn.js +58 -0
  48. package/dist/core/spawn.js.map +1 -0
  49. package/dist/dag/executeNode.js +2 -2
  50. package/dist/fileSchema.js +1 -1
  51. package/dist/guardrail/guardrail.js +1 -1
  52. package/dist/guardrail/index.js +7 -7
  53. package/dist/guardrail/permission-service.js +1 -1
  54. package/dist/guardrail/serialize.js +1 -1
  55. package/dist/guardrail/store.js +2 -2
  56. package/dist/install/lockfile.d.ts +79 -0
  57. package/dist/install/lockfile.d.ts.map +1 -0
  58. package/dist/install/lockfile.js +155 -0
  59. package/dist/install/lockfile.js.map +1 -0
  60. package/dist/install/store.d.ts +13 -0
  61. package/dist/install/store.d.ts.map +1 -0
  62. package/dist/install/store.js +38 -0
  63. package/dist/install/store.js.map +1 -0
  64. package/dist/log.d.ts.map +1 -1
  65. package/dist/log.js +6 -1
  66. package/dist/log.js.map +1 -1
  67. package/dist/prompts/config.js +2 -2
  68. package/dist/prompts/host.d.ts +4 -0
  69. package/dist/prompts/host.d.ts.map +1 -1
  70. package/dist/prompts/host.js.map +1 -1
  71. package/dist/prompts/index.d.ts.map +1 -1
  72. package/dist/prompts/index.js +13 -6
  73. package/dist/prompts/index.js.map +1 -1
  74. package/dist/prompts/library/defaults.generated.js +2 -2
  75. package/dist/prompts/library/defaults.generated.js.map +1 -1
  76. package/dist/prompts/library/index.js +1 -1
  77. package/dist/providers/contextWindow.d.ts +19 -0
  78. package/dist/providers/contextWindow.d.ts.map +1 -0
  79. package/dist/providers/contextWindow.js +68 -0
  80. package/dist/providers/contextWindow.js.map +1 -0
  81. package/dist/providers/gateway.d.ts +3 -0
  82. package/dist/providers/gateway.d.ts.map +1 -1
  83. package/dist/providers/gateway.js +4 -1
  84. package/dist/providers/gateway.js.map +1 -1
  85. package/dist/sessions/apiLeaseStore.d.ts +10 -0
  86. package/dist/sessions/apiLeaseStore.d.ts.map +1 -0
  87. package/dist/sessions/apiLeaseStore.js +41 -0
  88. package/dist/sessions/apiLeaseStore.js.map +1 -0
  89. package/dist/sessions/blobGc.d.ts +62 -0
  90. package/dist/sessions/blobGc.d.ts.map +1 -0
  91. package/dist/sessions/blobGc.js +154 -0
  92. package/dist/sessions/blobGc.js.map +1 -0
  93. package/dist/sessions/fileRecallStore.js +1 -1
  94. package/dist/sessions/fileSessionStore.js +1 -1
  95. package/dist/sessions/head.d.ts +90 -0
  96. package/dist/sessions/head.d.ts.map +1 -0
  97. package/dist/sessions/head.js +82 -0
  98. package/dist/sessions/head.js.map +1 -0
  99. package/dist/sessions/inflight.d.ts +58 -0
  100. package/dist/sessions/inflight.d.ts.map +1 -0
  101. package/dist/sessions/inflight.js +0 -0
  102. package/dist/sessions/inflight.js.map +1 -0
  103. package/dist/sessions/lease.d.ts +59 -0
  104. package/dist/sessions/lease.d.ts.map +1 -0
  105. package/dist/sessions/lease.js +115 -0
  106. package/dist/sessions/lease.js.map +1 -0
  107. package/dist/sessions/leaseStore.d.ts +46 -0
  108. package/dist/sessions/leaseStore.d.ts.map +1 -0
  109. package/dist/sessions/leaseStore.js +58 -0
  110. package/dist/sessions/leaseStore.js.map +1 -0
  111. package/dist/sessions/timeline.d.ts +134 -0
  112. package/dist/sessions/timeline.d.ts.map +1 -0
  113. package/dist/sessions/timeline.js +127 -0
  114. package/dist/sessions/timeline.js.map +1 -0
  115. package/dist/sidecar/host.d.ts +241 -5
  116. package/dist/sidecar/host.d.ts.map +1 -1
  117. package/dist/sidecar/host.js +630 -5
  118. package/dist/sidecar/host.js.map +1 -1
  119. package/dist/sidecar/server.d.ts +124 -2
  120. package/dist/sidecar/server.d.ts.map +1 -1
  121. package/dist/sidecar/server.js +620 -9
  122. package/dist/sidecar/server.js.map +1 -1
  123. package/dist/sidecar/wsChannel.d.ts +4 -0
  124. package/dist/sidecar/wsChannel.d.ts.map +1 -0
  125. package/dist/sidecar/wsChannel.js +62 -0
  126. package/dist/sidecar/wsChannel.js.map +1 -0
  127. package/dist/skills.js +2 -2
  128. package/dist/snapshot/effects.d.ts +69 -0
  129. package/dist/snapshot/effects.d.ts.map +1 -0
  130. package/dist/snapshot/effects.js +67 -0
  131. package/dist/snapshot/effects.js.map +1 -0
  132. package/dist/snapshot/manifest.d.ts +73 -0
  133. package/dist/snapshot/manifest.d.ts.map +1 -0
  134. package/dist/snapshot/manifest.js +141 -0
  135. package/dist/snapshot/manifest.js.map +1 -0
  136. package/dist/snapshot/tree.d.ts +62 -0
  137. package/dist/snapshot/tree.d.ts.map +1 -0
  138. package/dist/snapshot/tree.js +147 -0
  139. package/dist/snapshot/tree.js.map +1 -0
  140. package/dist/tools/builtins.d.ts.map +1 -1
  141. package/dist/tools/builtins.js +67 -8
  142. package/dist/tools/builtins.js.map +1 -1
  143. package/dist/tools/callAgent.d.ts +16 -0
  144. package/dist/tools/callAgent.d.ts.map +1 -0
  145. package/dist/tools/callAgent.js +53 -0
  146. package/dist/tools/callAgent.js.map +1 -0
  147. package/dist/tools/files.d.ts +10 -0
  148. package/dist/tools/files.d.ts.map +1 -1
  149. package/dist/tools/files.js +159 -8
  150. package/dist/tools/files.js.map +1 -1
  151. package/dist/tools/http.js +1 -1
  152. package/dist/tools/namespaces.d.ts +1 -1
  153. package/dist/tools/namespaces.d.ts.map +1 -1
  154. package/dist/tools/namespaces.js +40 -2
  155. package/dist/tools/namespaces.js.map +1 -1
  156. package/dist/tools/procs.d.ts +18 -0
  157. package/dist/tools/procs.d.ts.map +1 -0
  158. package/dist/tools/procs.js +281 -0
  159. package/dist/tools/procs.js.map +1 -0
  160. package/dist/tools/todo.d.ts.map +1 -1
  161. package/dist/tools/todo.js +12 -0
  162. package/dist/tools/todo.js.map +1 -1
  163. package/package.json +18 -7
@@ -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,100 @@ 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-snapshot-usage':
398
+ case 'session-watch':
399
+ case 'session-unwatch':
400
+ if (!this.authed) {
401
+ this.ch.send({ t: 'session-error', id: m.id, error: 'not authenticated' });
402
+ return;
403
+ }
404
+ void this.onSession(m);
405
+ return;
406
+ case 'run-history':
407
+ if (!this.authed) {
408
+ this.ch.send({ t: 'run-history-result', id: m.id, runs: [] });
409
+ return;
410
+ }
411
+ void this.onRunHistory(m);
412
+ return;
413
+ case 'run-transcript':
414
+ if (!this.authed) {
415
+ this.ch.send({ t: 'run-transcript-result', id: m.id, lines: [] });
416
+ return;
417
+ }
418
+ void this.onRunTranscript(m);
419
+ return;
247
420
  case 'host-sync-put':
248
421
  void this.onHostSyncPut(m);
249
422
  return;
@@ -305,6 +478,8 @@ export class SidecarServer {
305
478
  this.attendedDetach = this.opts.attendance.attach({
306
479
  send: (ask) => this.ch.send({ t: 'permission-ask', ...ask }),
307
480
  resolved: (id, decision, by) => this.ch.send({ t: 'permission-resolved', id, decision, by }),
481
+ runActivity: (activity) => this.ch.send({ t: 'run-activity', activity }),
482
+ registrations: (registrations) => this.ch.send({ t: 'registrations', registrations }),
308
483
  });
309
484
  }
310
485
  this.opts.log?.(`paired — ${this.tools.size} ${this.tools.size === 1 ? 'tool' : 'tools'} advertised (host dir: ${this.hostStateDir})`);
@@ -316,7 +491,24 @@ export class SidecarServer {
316
491
  // instead of adding one that would silently never fire. `info` (the confined
317
492
  // root + platform) lets the host show the working directory when browsing
318
493
  // this sidecar as a file source; omitted cleanly when not provided.
319
- const features = ['http-proxy', ...(this.opts.runner ? ['runner'] : [])];
494
+ const features = [
495
+ 'http-proxy',
496
+ ...(this.opts.runner ? ['runner'] : []),
497
+ ...(this.opts.acp ? ['acp'] : []),
498
+ ...(this.opts.roots?.areas.length ? ['add-roots'] : []),
499
+ ...(this.opts.procs ? ['procs'] : []),
500
+ ...(this.opts.sessions ? ['sessions'] : []),
501
+ // The operator's transcript CEILING, advertised so a client offers the
502
+ // per-run choice only where it could be honoured. Withheld means every
503
+ // `run` here keeps nothing however it asks — a client showing a "keep
504
+ // this" control against such a daemon would be promising storage that
505
+ // never happens.
506
+ ...(this.opts.sessions?.canKeepTranscripts ? ['keep-transcripts'] : []),
507
+ // Whether a session can be created FROM a repository. Off unless the
508
+ // operator declared somewhere a checkout may be written, so a client only
509
+ // offers "start from a repository" where it would actually work.
510
+ ...(this.opts.sessions?.canClone ? ['session-clone'] : []),
511
+ ];
320
512
  this.ch.send({ t: 'welcome', tools, features, ...(this.opts.info ? { info: this.opts.info } : {}) });
321
513
  }
322
514
  /** Proxy a host HTTP request server-side. Restricted to LOOPBACK targets — the
@@ -355,6 +547,224 @@ export class SidecarServer {
355
547
  this.ch.send({ t: 'http-err', id: m.id, error: errMessage(e) });
356
548
  }
357
549
  }
550
+ requireSessions() {
551
+ const s = this.opts.sessions;
552
+ if (!s)
553
+ throw new Error('this sidecar runs no sessions (start the daemon with --daemon)');
554
+ return s;
555
+ }
556
+ async onSession(m) {
557
+ const id = m.id;
558
+ try {
559
+ const sessions = this.requireSessions();
560
+ switch (m.t) {
561
+ case 'session-create':
562
+ this.ch.send({
563
+ t: 'session-created',
564
+ id,
565
+ session: await sessions.create({
566
+ ...(m.workdir ? { workdir: m.workdir } : {}),
567
+ /* `repo` asks the daemon to CLONE the source rather than mount a
568
+ * directory the client named. Forwarded, not judged: whether
569
+ * cloning is permitted at all, where a checkout may land, and
570
+ * which URLs are refused all live on the daemon side, where the
571
+ * operator's declarations are. */
572
+ ...(m.repo ? { repo: m.repo } : {}),
573
+ ...(m.owner ? { owner: m.owner } : {}),
574
+ ...(m.fromSnapshot ? { fromSnapshot: m.fromSnapshot } : {}),
575
+ // From the HANDSHAKE, never from this message: the dot-dir is a
576
+ // property of the client's workspace, and taking it from the
577
+ // request would let a caller point a session's state anywhere.
578
+ stateDir: this.hostStateDir,
579
+ }),
580
+ });
581
+ return;
582
+ case 'session-pause': {
583
+ // The drain result is carried through rather than reduced to ok/not:
584
+ // a caller about to capture has to know whether work was still
585
+ // running, because that is what makes a snapshot torn.
586
+ const r = await sessions.pause(m.sessionId, m.drainMs);
587
+ this.ch.send({ t: 'session-paused', id, drained: r.drained, remaining: r.remaining });
588
+ return;
589
+ }
590
+ case 'session-resume':
591
+ await sessions.resume(m.sessionId);
592
+ this.ch.send({ t: 'session-ok', id });
593
+ return;
594
+ case 'session-destroy':
595
+ this.stopWatching(m.sessionId);
596
+ await sessions.destroy(m.sessionId);
597
+ this.ch.send({ t: 'session-ok', id });
598
+ return;
599
+ case 'session-commit': {
600
+ if (!sessions.commit)
601
+ throw new Error('this session backend cannot capture snapshots');
602
+ const snapshot = await sessions.commit(m.sessionId, m.name);
603
+ this.ch.send({ t: 'session-committed', id, snapshot });
604
+ return;
605
+ }
606
+ case 'session-snapshots': {
607
+ // An empty list, not an error, when the backend cannot capture: the
608
+ // question "what could I restore from?" has an answer there, and it
609
+ // is "nothing". The client uses it to decide whether to offer the
610
+ // choice at all, and an error would make it look broken instead.
611
+ const snapshots = sessions.snapshots ? await sessions.snapshots() : [];
612
+ this.ch.send({ t: 'session-snapshots-result', id, snapshots });
613
+ return;
614
+ }
615
+ case 'session-snapshot-usage': {
616
+ // Same shape as the empty-list answer above: a backend with no
617
+ // budget to report has zero usage against zero budget, not an
618
+ // error — a status surface should render that as "not applicable"
619
+ // rather than treat it as broken.
620
+ const usage = sessions.snapshotUsage
621
+ ? await sessions.snapshotUsage()
622
+ : { usedBytes: 0, budgetBytes: 0, count: 0 };
623
+ this.ch.send({ t: 'session-snapshot-usage-result', id, usage });
624
+ return;
625
+ }
626
+ case 'session-watch': {
627
+ // Idempotent: watching twice from one connection would double every
628
+ // sample rather than doing nothing, which is the kind of bug a
629
+ // reconnecting UI creates by accident.
630
+ if (this.sessionWatch.has(m.sessionId)) {
631
+ this.ch.send({ t: 'session-ok', id });
632
+ return;
633
+ }
634
+ const stops = [];
635
+ const sid = m.sessionId;
636
+ if (sessions.watchMetrics) {
637
+ stops.push(sessions.watchMetrics(sid, (sample) => this.ch.send({ t: 'session-metrics', sessionId: sid, sample })));
638
+ }
639
+ if (sessions.watchLogs) {
640
+ stops.push(sessions.watchLogs(sid, (line) => this.ch.send({ t: 'session-log', sessionId: sid, line })));
641
+ }
642
+ this.sessionWatch.set(sid, stops);
643
+ this.ch.send({ t: 'session-ok', id });
644
+ return;
645
+ }
646
+ case 'session-unwatch':
647
+ this.stopWatching(m.sessionId);
648
+ this.ch.send({ t: 'session-ok', id });
649
+ return;
650
+ default:
651
+ this.ch.send({ t: 'session-error', id, error: `unhandled session message: ${m.t}` });
652
+ }
653
+ }
654
+ catch (e) {
655
+ this.ch.send({ t: 'session-error', id, error: errMessage(e) });
656
+ }
657
+ }
658
+ stopWatching(sessionId) {
659
+ for (const stop of this.sessionWatch.get(sessionId) ?? []) {
660
+ try {
661
+ stop();
662
+ }
663
+ catch { /* already stopped */ }
664
+ }
665
+ this.sessionWatch.delete(sessionId);
666
+ }
667
+ /* ---- long-running processes ---- */
668
+ /** The table, or a refusal that names WHY. "unknown message" would send an
669
+ * operator hunting for a bug when the real answer is that they did not pass
670
+ * --allow-shell. */
671
+ requireProcs() {
672
+ const procs = this.opts.procs;
673
+ if (!procs)
674
+ throw new Error('this sidecar was not started with --allow-shell, so it cannot run processes');
675
+ return procs;
676
+ }
677
+ /** Begin fanning this connection's attached processes back over the wire.
678
+ * Subscribed lazily: a client that never touches a process should not pay to
679
+ * be woken for someone else's build output. */
680
+ ensureProcStream() {
681
+ if (this.procUnsub)
682
+ return;
683
+ this.procUnsub = this.requireProcs().subscribe((ev) => {
684
+ if (!this.attachedProcs.has(ev.procId))
685
+ return;
686
+ if (ev.kind === 'out') {
687
+ this.ch.send({ t: 'proc-out', procId: ev.procId, seq: ev.chunk.seq, stream: ev.chunk.stream, text: ev.chunk.text });
688
+ return;
689
+ }
690
+ this.ch.send({
691
+ t: 'proc-exit',
692
+ procId: ev.procId,
693
+ code: ev.info.exitCode ?? null,
694
+ ...(ev.info.signal ? { signal: ev.info.signal } : {}),
695
+ });
696
+ // Attachment ends with the process. Keeping it would leak an entry per run
697
+ // on a connection that stays open for hours.
698
+ this.attachedProcs.delete(ev.procId);
699
+ });
700
+ }
701
+ async onProcSpawn(m) {
702
+ try {
703
+ const procs = this.requireProcs();
704
+ // Attach BEFORE spawning: a command that prints immediately would otherwise
705
+ // race the subscription and lose its first lines.
706
+ this.ensureProcStream();
707
+ const info = await procs.spawn({
708
+ command: m.command,
709
+ ...(m.cwd ? { cwd: m.cwd } : {}),
710
+ ...(m.root ? { root: m.root } : {}),
711
+ ...(m.label ? { label: m.label } : {}),
712
+ });
713
+ this.attachedProcs.add(info.procId);
714
+ this.ch.send({ t: 'proc-started', id: m.id, proc: info });
715
+ // A process can finish before the caller ever sees `proc-started` (`echo
716
+ // hi`). Replay whatever the table already holds so no output is lost to
717
+ // that race, and report the exit it already had.
718
+ const { chunks } = procs.backlog(info.procId);
719
+ for (const c of chunks)
720
+ this.ch.send({ t: 'proc-out', procId: info.procId, seq: c.seq, stream: c.stream, text: c.text });
721
+ const now = procs.get(info.procId);
722
+ if (now && !now.running) {
723
+ this.attachedProcs.delete(info.procId);
724
+ this.ch.send({ t: 'proc-exit', procId: info.procId, code: now.exitCode ?? null, ...(now.signal ? { signal: now.signal } : {}) });
725
+ }
726
+ }
727
+ catch (e) {
728
+ this.ch.send({ t: 'proc-error', id: m.id, error: errMessage(e) });
729
+ }
730
+ }
731
+ /** Re-attach to a process after a reload or from a second client. `sinceSeq`
732
+ * is what the client already has, so the replay is exactly the rest. */
733
+ onProcAttach(m) {
734
+ try {
735
+ const procs = this.requireProcs();
736
+ const info = procs.get(m.procId);
737
+ if (!info) {
738
+ this.ch.send({ t: 'proc-attach-result', id: m.id, ok: false, error: `no such process: ${m.procId}` });
739
+ return;
740
+ }
741
+ this.ensureProcStream();
742
+ if (info.running)
743
+ this.attachedProcs.add(m.procId);
744
+ const { chunks, truncated } = procs.backlog(m.procId, m.sinceSeq);
745
+ this.ch.send({ t: 'proc-attach-result', id: m.id, ok: true, proc: info, ...(truncated ? { truncated: true } : {}) });
746
+ for (const c of chunks)
747
+ this.ch.send({ t: 'proc-out', procId: m.procId, seq: c.seq, stream: c.stream, text: c.text });
748
+ // An already-exited process still owes the client its ending, or a
749
+ // reattaching UI shows a spinner for something finished an hour ago.
750
+ if (!info.running) {
751
+ this.ch.send({ t: 'proc-exit', procId: m.procId, code: info.exitCode ?? null, ...(info.signal ? { signal: info.signal } : {}) });
752
+ }
753
+ }
754
+ catch (e) {
755
+ this.ch.send({ t: 'proc-attach-result', id: m.id, ok: false, error: errMessage(e) });
756
+ }
757
+ }
758
+ /** The kill/input pair: act, then answer with ok or the reason. */
759
+ onProcSimple(id, reply, act) {
760
+ try {
761
+ act();
762
+ this.ch.send({ t: reply, id, ok: true });
763
+ }
764
+ catch (e) {
765
+ this.ch.send({ t: reply, id, ok: false, error: errMessage(e) });
766
+ }
767
+ }
358
768
  async onInvoke(m) {
359
769
  const tool = this.tools.get(m.name);
360
770
  if (!tool) {
@@ -382,14 +792,179 @@ export class SidecarServer {
382
792
  * turn). Events that arrive before we know the runId are buffered and flushed
383
793
  * right after `run-accepted`, so they still carry the correct runId and never
384
794
  * race ahead of it on the wire. */
795
+ async onAcpStart(m) {
796
+ const acp = this.opts.acp;
797
+ if (!acp) {
798
+ this.ch.send({ t: 'acp-error', id: m.id, error: 'this sidecar was not started with --acp' });
799
+ return;
800
+ }
801
+ try {
802
+ const started = await acp.start(m.target, (update) => {
803
+ this.ch.send({ t: 'acp-update', id: m.id, update });
804
+ }, {
805
+ ...(m.root ? { root: m.root } : {}),
806
+ ...(m.modeId ? { modeId: m.modeId } : {}),
807
+ ...(m.resume ? { resume: m.resume } : {}),
808
+ });
809
+ this.ch.send({ t: 'acp-started', id: m.id, ...started });
810
+ }
811
+ catch (err) {
812
+ this.ch.send({ t: 'acp-error', id: m.id, error: errMessage(err) });
813
+ }
814
+ }
815
+ async onAcpSetMode(m) {
816
+ const acp = this.opts.acp;
817
+ if (!acp) {
818
+ this.ch.send({ t: 'acp-error', id: m.id, error: 'this sidecar was not started with --acp' });
819
+ return;
820
+ }
821
+ try {
822
+ await acp.setMode(m.modeId);
823
+ this.ch.send({ t: 'acp-mode-set', id: m.id, modeId: m.modeId });
824
+ }
825
+ catch (err) {
826
+ this.ch.send({ t: 'acp-error', id: m.id, error: errMessage(err) });
827
+ }
828
+ }
829
+ async onAcpPrompt(m) {
830
+ const acp = this.opts.acp;
831
+ if (!acp) {
832
+ this.ch.send({ t: 'acp-error', id: m.id, error: 'this sidecar was not started with --acp' });
833
+ return;
834
+ }
835
+ try {
836
+ const { stopReason } = await acp.prompt(m.text, m.content);
837
+ this.ch.send({ t: 'acp-done', id: m.id, stopReason });
838
+ }
839
+ catch (err) {
840
+ this.ch.send({ t: 'acp-error', id: m.id, error: errMessage(err) });
841
+ }
842
+ }
843
+ /**
844
+ * Attach this connection as a VIEWER of a run it did not start.
845
+ *
846
+ * The replay comes first, so a client joining mid-run sees the whole run
847
+ * rather than the tail — a viewer that only sees what happens next cannot tell
848
+ * a stalled run from a quiet one. Events are tagged with the SUBSCRIBER's own
849
+ * message id, so a client correlating by id sees its own stream.
850
+ */
851
+ onSubscribe(m) {
852
+ const hub = this.opts.runs;
853
+ if (!hub) {
854
+ this.ch.send({ t: 'run-error', id: m.id, error: 'this sidecar does not support watching runs' });
855
+ return;
856
+ }
857
+ const attached = hub.subscribe(m.runId, {
858
+ event: (event) => this.ch.send({ t: 'event', id: m.id, runId: m.runId, event }),
859
+ done: (r) => {
860
+ this.ch.send({ t: 'run-done', id: m.id, runId: m.runId, text: r.text, stopReason: r.stopReason });
861
+ detachOnce();
862
+ },
863
+ error: (message) => {
864
+ this.ch.send({ t: 'run-error', id: m.id, error: message });
865
+ detachOnce();
866
+ },
867
+ });
868
+ if (!attached) {
869
+ this.ch.send({ t: 'run-error', id: m.id, error: `no such run: ${m.runId}` });
870
+ return;
871
+ }
872
+ // Held so a dropped connection detaches; also removed on done/error below.
873
+ const detachOnce = () => {
874
+ if (!this.runDetachers.has(attached.detach))
875
+ return;
876
+ this.runDetachers.delete(attached.detach);
877
+ attached.detach();
878
+ };
879
+ this.runDetachers.add(attached.detach);
880
+ this.ch.send({ t: 'run-accepted', id: m.id, runId: m.runId });
881
+ if (attached.replay.dropped > 0) {
882
+ // Say so rather than handing over a tail that looks complete.
883
+ this.opts.log?.(`subscribe ${m.runId}: replaying with ${attached.replay.dropped} earlier event(s) dropped`);
884
+ }
885
+ for (const event of attached.replay.events) {
886
+ this.ch.send({ t: 'event', id: m.id, runId: m.runId, event });
887
+ }
888
+ // A run that already finished still replays, then closes immediately.
889
+ if (attached.replay.done) {
890
+ this.ch.send({ t: 'run-done', id: m.id, runId: m.runId, ...attached.replay.done });
891
+ detachOnce();
892
+ }
893
+ else if (attached.replay.error) {
894
+ this.ch.send({ t: 'run-error', id: m.id, error: attached.replay.error });
895
+ detachOnce();
896
+ }
897
+ }
898
+ /**
899
+ * What this caller's agent has actually done.
900
+ *
901
+ * AN EMPTY LIST IS AN ANSWER, not a failure: a daemon that keeps no record
902
+ * has no history, and a client uses the rows to render rather than to detect
903
+ * a broken daemon. The identity handed down is the untrusted `{ clientId }`
904
+ * this connection authenticated with — the runner resolves it through the
905
+ * hosts manifest, which is what makes the result owner-scoped by
906
+ * construction rather than by a filter written here.
907
+ */
908
+ async onRunHistory(m) {
909
+ try {
910
+ const runs = this.opts.runner?.history
911
+ ? await this.opts.runner.history({ clientId: this.clientId }, m.agent, m.limit)
912
+ : [];
913
+ this.ch.send({ t: 'run-history-result', id: m.id, runs });
914
+ }
915
+ catch {
916
+ // A history that cannot be read is not a reason to break the caller's
917
+ // page; the rows it already has stay, and it sees none added.
918
+ this.ch.send({ t: 'run-history-result', id: m.id, runs: [] });
919
+ }
920
+ }
921
+ /** One run's kept events. Empty when they were never kept — the ordinary
922
+ * case, and a state the caller renders rather than treats as a failure. */
923
+ async onRunTranscript(m) {
924
+ try {
925
+ const lines = this.opts.runner?.transcript
926
+ ? await this.opts.runner.transcript({ clientId: this.clientId }, m.agent, m.runId)
927
+ : [];
928
+ this.ch.send({ t: 'run-transcript-result', id: m.id, lines });
929
+ }
930
+ catch {
931
+ this.ch.send({ t: 'run-transcript-result', id: m.id, lines: [] });
932
+ }
933
+ }
385
934
  async onRun(m) {
386
- if (!this.opts.runner) {
935
+ /* WHERE the run happens, decided once, up front.
936
+ *
937
+ * A `sessionId` is a confinement request. A daemon that cannot honour it
938
+ * says so rather than running the work in its own process anyway — silently
939
+ * downgrading is the failure IsolationLevel exists to prevent, and it would
940
+ * be invisible to the client that asked. */
941
+ if (m.sessionId !== undefined) {
942
+ const sessions = this.opts.sessions;
943
+ if (!sessions?.run) {
944
+ this.ch.send({ t: 'run-error', id: m.id, error: 'this sidecar cannot run agents inside a session' });
945
+ return;
946
+ }
947
+ }
948
+ else if (!this.opts.runner) {
387
949
  this.ch.send({ t: 'run-error', id: m.id, error: 'this sidecar cannot run agents' });
388
950
  return;
389
951
  }
952
+ const start = m.sessionId !== undefined
953
+ ? (agent, input, onEvent, identity) =>
954
+ /* The request is forwarded, never resolved here. Whether it is honoured
955
+ * is the session layer's call, because the operator's permission lives
956
+ * there; this class would have to duplicate that rule to decide, and a
957
+ * duplicated permission rule is one that eventually disagrees. */
958
+ this.opts.sessions.run(m.sessionId, agent, input, onEvent, identity, { keepTranscript: m.keepTranscript === true })
959
+ : (agent, input, onEvent, identity) => this.opts.runner.run(agent, input, onEvent, identity);
390
960
  let runId;
391
961
  const pending = [];
392
962
  const onEvent = (event) => {
963
+ // Publish to the hub as well as this connection, so another device can
964
+ // watch. Buffered until the runner reports its id, since the hub is keyed
965
+ // by it — the same reason `pending` exists for the commanding client.
966
+ if (runId !== undefined)
967
+ this.opts.runs?.publish(runId, event);
393
968
  if (runId === undefined) {
394
969
  pending.push(event);
395
970
  return;
@@ -403,16 +978,25 @@ export class SidecarServer {
403
978
  // (no manifest access here, by design; see the module doc), it just
404
979
  // hands the identity it already holds to the runner, exactly like
405
980
  // `onHostSyncPut`/`onHostSyncDelete` do for `hostSync.put`/`remove`.
406
- const started = await this.opts.runner.run(m.agent, m.input, onEvent, { clientId: this.clientId });
981
+ const started = await start(m.agent, m.input, onEvent, { clientId: this.clientId });
407
982
  runId = started.runId;
983
+ this.opts.runs?.open(runId);
408
984
  this.ch.send({ t: 'run-accepted', id: m.id, runId });
409
- for (const event of pending)
985
+ for (const event of pending) {
986
+ this.opts.runs?.publish(runId, event);
410
987
  this.ch.send({ t: 'event', id: m.id, runId, event });
988
+ }
411
989
  const fin = await started.done;
990
+ this.opts.runs?.finish(runId, { text: fin.text, stopReason: fin.stopReason });
412
991
  this.ch.send({ t: 'run-done', id: m.id, runId, text: fin.text, stopReason: fin.stopReason });
413
992
  }
414
993
  catch (e) {
415
- this.ch.send({ t: 'run-error', id: m.id, error: errMessage(e) });
994
+ const message = errMessage(e);
995
+ // Tell watchers too: a run that failed must not look merely silent to a
996
+ // second viewer.
997
+ if (runId !== undefined)
998
+ this.opts.runs?.fail(runId, message);
999
+ this.ch.send({ t: 'run-error', id: m.id, error: message });
416
1000
  }
417
1001
  }
418
1002
  /** The identity to attribute a host-sync write to. Returns either the
@@ -429,6 +1013,33 @@ export class SidecarServer {
429
1013
  }
430
1014
  return { clientId: this.clientId };
431
1015
  }
1016
+ async onRootsBrowse(m) {
1017
+ if (!this.opts.roots) {
1018
+ this.ch.send({ t: 'roots-browse-result', id: m.id, ok: false, error: 'this sidecar cannot add working directories' });
1019
+ return;
1020
+ }
1021
+ try {
1022
+ this.ch.send({ t: 'roots-browse-result', id: m.id, ok: true, dirs: await this.opts.roots.browse(m.path) });
1023
+ }
1024
+ catch (e) {
1025
+ this.ch.send({ t: 'roots-browse-result', id: m.id, ok: false, error: errMessage(e) });
1026
+ }
1027
+ }
1028
+ async onRootsAdd(m) {
1029
+ if (!this.opts.roots) {
1030
+ this.ch.send({ t: 'roots-add-result', id: m.id, ok: false, error: 'this sidecar cannot add working directories' });
1031
+ return;
1032
+ }
1033
+ try {
1034
+ // The broadcast to every client is the controller's subscription, not this
1035
+ // reply -- so a refusal (below) reaches only the asker and changes nothing.
1036
+ await this.opts.roots.add(m.path);
1037
+ this.ch.send({ t: 'roots-add-result', id: m.id, ok: true });
1038
+ }
1039
+ catch (e) {
1040
+ this.ch.send({ t: 'roots-add-result', id: m.id, ok: false, error: errMessage(e) });
1041
+ }
1042
+ }
432
1043
  async onHostSyncPut(m) {
433
1044
  const identity = this.requireHostSyncIdentity();
434
1045
  if ('error' in identity) {