@corenel/harness 0.1.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 (180) 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/conversation.d.ts +0 -11
  38. package/dist/conversation.d.ts.map +1 -1
  39. package/dist/conversation.js +1 -24
  40. package/dist/conversation.js.map +1 -1
  41. package/dist/core/budget.d.ts +40 -0
  42. package/dist/core/budget.d.ts.map +1 -0
  43. package/dist/core/budget.js +62 -0
  44. package/dist/core/budget.js.map +1 -0
  45. package/dist/core/loop.d.ts +11 -0
  46. package/dist/core/loop.d.ts.map +1 -1
  47. package/dist/core/loop.js +89 -20
  48. package/dist/core/loop.js.map +1 -1
  49. package/dist/core/pricing.d.ts +8 -0
  50. package/dist/core/pricing.d.ts.map +1 -0
  51. package/dist/core/pricing.js +12 -0
  52. package/dist/core/pricing.js.map +1 -0
  53. package/dist/core/recovery.d.ts +60 -0
  54. package/dist/core/recovery.d.ts.map +1 -0
  55. package/dist/core/recovery.js +128 -0
  56. package/dist/core/recovery.js.map +1 -0
  57. package/dist/guardrail/builtins.js +6 -6
  58. package/dist/guardrail/builtins.js.map +1 -1
  59. package/dist/guardrail/permission-service.js +1 -1
  60. package/dist/guardrail/permission-service.js.map +1 -1
  61. package/dist/guardrail/serialize.d.ts.map +1 -1
  62. package/dist/guardrail/serialize.js +6 -0
  63. package/dist/guardrail/serialize.js.map +1 -1
  64. package/dist/guardrail/validate.d.ts.map +1 -1
  65. package/dist/guardrail/validate.js +13 -0
  66. package/dist/guardrail/validate.js.map +1 -1
  67. package/dist/install/lockfile.d.ts +79 -0
  68. package/dist/install/lockfile.d.ts.map +1 -0
  69. package/dist/install/lockfile.js +155 -0
  70. package/dist/install/lockfile.js.map +1 -0
  71. package/dist/install/store.d.ts +13 -0
  72. package/dist/install/store.d.ts.map +1 -0
  73. package/dist/install/store.js +38 -0
  74. package/dist/install/store.js.map +1 -0
  75. package/dist/log.d.ts.map +1 -1
  76. package/dist/log.js +6 -1
  77. package/dist/log.js.map +1 -1
  78. package/dist/prompts/host.d.ts +4 -0
  79. package/dist/prompts/host.d.ts.map +1 -1
  80. package/dist/prompts/host.js.map +1 -1
  81. package/dist/prompts/index.d.ts +6 -0
  82. package/dist/prompts/index.d.ts.map +1 -1
  83. package/dist/prompts/index.js +16 -6
  84. package/dist/prompts/index.js.map +1 -1
  85. package/dist/prompts/library/defaults.generated.d.ts.map +1 -1
  86. package/dist/prompts/library/defaults.generated.js +22 -4
  87. package/dist/prompts/library/defaults.generated.js.map +1 -1
  88. package/dist/prompts/toolsMarker.d.ts +13 -0
  89. package/dist/prompts/toolsMarker.d.ts.map +1 -0
  90. package/dist/prompts/toolsMarker.js +21 -0
  91. package/dist/prompts/toolsMarker.js.map +1 -0
  92. package/dist/providers/gateway.d.ts +19 -1
  93. package/dist/providers/gateway.d.ts.map +1 -1
  94. package/dist/providers/gateway.js +33 -4
  95. package/dist/providers/gateway.js.map +1 -1
  96. package/dist/services.d.ts +13 -0
  97. package/dist/services.d.ts.map +1 -1
  98. package/dist/services.js +2 -0
  99. package/dist/services.js.map +1 -1
  100. package/dist/sessions/apiLeaseStore.d.ts +10 -0
  101. package/dist/sessions/apiLeaseStore.d.ts.map +1 -0
  102. package/dist/sessions/apiLeaseStore.js +41 -0
  103. package/dist/sessions/apiLeaseStore.js.map +1 -0
  104. package/dist/sessions/fileRecallStore.d.ts +33 -0
  105. package/dist/sessions/fileRecallStore.d.ts.map +1 -0
  106. package/dist/sessions/fileRecallStore.js +112 -0
  107. package/dist/sessions/fileRecallStore.js.map +1 -0
  108. package/dist/sessions/fileSessionStore.d.ts +20 -0
  109. package/dist/sessions/fileSessionStore.d.ts.map +1 -0
  110. package/dist/sessions/fileSessionStore.js +151 -0
  111. package/dist/sessions/fileSessionStore.js.map +1 -0
  112. package/dist/sessions/head.d.ts +90 -0
  113. package/dist/sessions/head.d.ts.map +1 -0
  114. package/dist/sessions/head.js +82 -0
  115. package/dist/sessions/head.js.map +1 -0
  116. package/dist/sessions/inflight.d.ts +58 -0
  117. package/dist/sessions/inflight.d.ts.map +1 -0
  118. package/dist/sessions/inflight.js +0 -0
  119. package/dist/sessions/inflight.js.map +1 -0
  120. package/dist/sessions/jsonl.d.ts +23 -0
  121. package/dist/sessions/jsonl.d.ts.map +1 -0
  122. package/dist/sessions/jsonl.js +52 -0
  123. package/dist/sessions/jsonl.js.map +1 -0
  124. package/dist/sessions/lease.d.ts +59 -0
  125. package/dist/sessions/lease.d.ts.map +1 -0
  126. package/dist/sessions/lease.js +115 -0
  127. package/dist/sessions/lease.js.map +1 -0
  128. package/dist/sessions/leaseStore.d.ts +46 -0
  129. package/dist/sessions/leaseStore.d.ts.map +1 -0
  130. package/dist/sessions/leaseStore.js +58 -0
  131. package/dist/sessions/leaseStore.js.map +1 -0
  132. package/dist/sessions/testFs.d.ts +5 -0
  133. package/dist/sessions/testFs.d.ts.map +1 -0
  134. package/dist/sessions/testFs.js +85 -0
  135. package/dist/sessions/testFs.js.map +1 -0
  136. package/dist/sessions/timeline.d.ts +134 -0
  137. package/dist/sessions/timeline.d.ts.map +1 -0
  138. package/dist/sessions/timeline.js +127 -0
  139. package/dist/sessions/timeline.js.map +1 -0
  140. package/dist/sidecar/host.d.ts +284 -2
  141. package/dist/sidecar/host.d.ts.map +1 -1
  142. package/dist/sidecar/host.js +725 -1
  143. package/dist/sidecar/host.js.map +1 -1
  144. package/dist/sidecar/server.d.ts +217 -2
  145. package/dist/sidecar/server.d.ts.map +1 -1
  146. package/dist/sidecar/server.js +828 -3
  147. package/dist/sidecar/server.js.map +1 -1
  148. package/dist/skills.d.ts +8 -3
  149. package/dist/skills.d.ts.map +1 -1
  150. package/dist/skills.js +71 -35
  151. package/dist/skills.js.map +1 -1
  152. package/dist/snapshot/effects.d.ts +69 -0
  153. package/dist/snapshot/effects.d.ts.map +1 -0
  154. package/dist/snapshot/effects.js +67 -0
  155. package/dist/snapshot/effects.js.map +1 -0
  156. package/dist/snapshot/manifest.d.ts +73 -0
  157. package/dist/snapshot/manifest.d.ts.map +1 -0
  158. package/dist/snapshot/manifest.js +141 -0
  159. package/dist/snapshot/manifest.js.map +1 -0
  160. package/dist/snapshot/tree.d.ts +62 -0
  161. package/dist/snapshot/tree.d.ts.map +1 -0
  162. package/dist/snapshot/tree.js +147 -0
  163. package/dist/snapshot/tree.js.map +1 -0
  164. package/dist/tools/builtins.d.ts +1 -0
  165. package/dist/tools/builtins.d.ts.map +1 -1
  166. package/dist/tools/builtins.js +150 -5
  167. package/dist/tools/builtins.js.map +1 -1
  168. package/dist/tools/files.d.ts.map +1 -1
  169. package/dist/tools/files.js +13 -0
  170. package/dist/tools/files.js.map +1 -1
  171. package/dist/tools/http.d.ts.map +1 -1
  172. package/dist/tools/http.js.map +1 -1
  173. package/dist/tools/namespaces.d.ts +1 -1
  174. package/dist/tools/namespaces.d.ts.map +1 -1
  175. package/dist/tools/namespaces.js +24 -0
  176. package/dist/tools/namespaces.js.map +1 -1
  177. package/dist/tools/todo.d.ts.map +1 -1
  178. package/dist/tools/todo.js +12 -0
  179. package/dist/tools/todo.js.map +1 -1
  180. package/package.json +26 -34
@@ -1,4 +1,16 @@
1
1
  import { SIDECAR_PROTOCOL, errMessage, isLoopbackHost, } from '@corenel/protocol';
2
+ /** True for anything with a callable `.then` -- covers native Promises and any
3
+ * other thenable a caller's `onClient` might return, without assuming it's a
4
+ * real Promise instance (the declared type is `() => void`; an async function
5
+ * is assignable to that per TS, so the actual runtime return must be checked). */
6
+ function isThenable(v) {
7
+ return typeof v === 'object' && v !== null && typeof v.then === 'function';
8
+ }
9
+ /** Cap on `clientId`/`clientName` before they are forwarded to `onClient` (and,
10
+ * downstream, used as a manifest key / dirName seed) — the wire message is
11
+ * declared `string` but arrives via `JSON.parse(...) as SidecarMsg` with no
12
+ * runtime check, so an oversized field must never reach persistence unbounded. */
13
+ const MAX_IDENTITY_FIELD_LEN = 200;
2
14
  /** Base64-encode bytes without a Node Buffer (btoa is in the browser + Node 18+),
3
15
  * so this module stays free of node:* types for the shared harness build. */
4
16
  function bytesToB64(bytes) {
@@ -57,6 +69,74 @@ export class SidecarServer {
57
69
  writable: true,
58
70
  value: new Map()
59
71
  });
72
+ /** The connected host's dot-dir name, from `hello.stateDir` (informational —
73
+ * doesn't change any sidecar behavior yet; reserved for future workspace-state
74
+ * features that need to know the host's dir name). */
75
+ Object.defineProperty(this, "hostStateDir", {
76
+ enumerable: true,
77
+ configurable: true,
78
+ writable: true,
79
+ value: '.corenel'
80
+ });
81
+ /** The authenticated connection's `clientId`, retained here (from `hello`) so
82
+ * later `host-sync-*` messages can be attributed to their owner without
83
+ * trusting anything the client sends per-call. Set only when the `hello`
84
+ * passed BOTH the token check AND the identity `typeof` gate below --
85
+ * `undefined` means either "not authenticated yet" or "authenticated, but this
86
+ * connection carries no identity to attribute files to" (an older client).
87
+ * Either way, host-sync must be rejected; see `requireHostSyncIdentity`. */
88
+ Object.defineProperty(this, "clientId", {
89
+ enumerable: true,
90
+ configurable: true,
91
+ writable: true,
92
+ value: void 0
93
+ });
94
+ /** Detach function for this connection's attendance attachment, set on a
95
+ * successful `hello` when `opts.attendance` is wired. Called once, on channel
96
+ * close, so a dead connection stops receiving/counting toward asks. */
97
+ Object.defineProperty(this, "attendedDetach", {
98
+ enumerable: true,
99
+ configurable: true,
100
+ writable: true,
101
+ value: void 0
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
+ });
60
140
  Object.defineProperty(this, "memory", {
61
141
  enumerable: true,
62
142
  configurable: true,
@@ -139,10 +219,37 @@ export class SidecarServer {
139
219
  for (const t of opts.tools)
140
220
  this.tools.set(t.name, t);
141
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 }));
226
+ this.ch.onClose?.(() => {
227
+ this.attendedDetach?.();
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);
246
+ });
142
247
  }
143
248
  onMessage(m) {
144
249
  switch (m.t) {
145
250
  case 'hello':
251
+ if (this.authed)
252
+ return; // identity is fixed at first hello; a second must not re-own the connection
146
253
  this.onHello(m);
147
254
  return;
148
255
  case 'invoke':
@@ -170,24 +277,238 @@ export class SidecarServer {
170
277
  }
171
278
  void this.onHttpReq(m);
172
279
  return;
280
+ case 'run':
281
+ if (!this.authed) {
282
+ this.ch.send({ t: 'run-error', id: m.id, error: 'not authenticated' });
283
+ return;
284
+ }
285
+ void this.onRun(m);
286
+ return;
287
+ case 'subscribe':
288
+ // Guarded by auth like `run` (auth boundary). Pre-auth: drop silently —
289
+ // subscribe has no response channel, so no message is the correct
290
+ // pre-auth behavior (do not invent an ack variant).
291
+ if (!this.authed)
292
+ return;
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();
318
+ return;
319
+ case 'permission-answer':
320
+ // Guarded by auth like `run`/`subscribe` (auth boundary): a pre-auth
321
+ // answer is dropped silently rather than forwarded into the registry.
322
+ if (!this.authed)
323
+ return;
324
+ this.opts.attendance?.answer(m.id, m.decision);
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;
419
+ case 'host-sync-put':
420
+ void this.onHostSyncPut(m);
421
+ return;
422
+ case 'host-sync-delete':
423
+ void this.onHostSyncDelete(m);
424
+ return;
173
425
  default:
174
- return; // welcome / denied / invoke-result / host-call are host-side
426
+ // welcome / denied / invoke-result / host-call / event / run-accepted /
427
+ // run-done / permission-ask / permission-resolved are host-side.
428
+ return;
175
429
  }
176
430
  }
177
431
  onHello(m) {
178
432
  if (m.protocol !== SIDECAR_PROTOCOL) {
433
+ this.opts.log?.(`denied: unsupported protocol ${m.protocol}`);
179
434
  this.ch.send({ t: 'denied', reason: `protocol ${m.protocol} unsupported` });
180
435
  return;
181
436
  }
182
437
  if (!safeEqual(m.token ?? '', this.opts.token)) {
438
+ this.opts.log?.('denied: invalid token');
183
439
  this.ch.send({ t: 'denied', reason: 'invalid token' });
184
440
  return;
185
441
  }
186
442
  this.authed = true;
443
+ this.hostStateDir = m.stateDir ?? '.corenel';
444
+ // Runtime-validate identity, even though the declared type is `string` --
445
+ // the message crossed the wire via `JSON.parse(...) as SidecarMsg`, so
446
+ // nothing actually guaranteed that. A non-string `clientId` (e.g. a number
447
+ // or object) must never reach `onClient`: downstream consumers use it as an
448
+ // object key (a daemon's hosts manifest) or feed it to a directory-name
449
+ // sanitizer that assumes a string.
450
+ if (typeof m.clientId === 'string' && m.clientId) {
451
+ const clientId = m.clientId.slice(0, MAX_IDENTITY_FIELD_LEN);
452
+ this.clientId = clientId;
453
+ const name = typeof m.clientName === 'string' && m.clientName
454
+ ? m.clientName.slice(0, MAX_IDENTITY_FIELD_LEN)
455
+ : clientId;
456
+ // Best-effort, fire-and-forget: a throwing/rejecting `onClient` must never
457
+ // deny/break an otherwise valid, authenticated handshake -- identity
458
+ // recording is informational, not part of the auth decision. Covers BOTH
459
+ // failure modes: a synchronous throw (try/catch) and a rejected promise
460
+ // from an async callback (the `.then(undefined, ...)` below), since the
461
+ // latter would otherwise surface as an unhandled rejection in the host
462
+ // process -- not caught by try/catch, which only sees the (resolved)
463
+ // Promise object the call returns, not its eventual rejection.
464
+ try {
465
+ const result = this.opts.onClient?.({ clientId, name });
466
+ if (isThenable(result)) {
467
+ result.then(undefined, (err) => {
468
+ this.opts.log?.(`onClient hook rejected: ${errMessage(err)}`);
469
+ });
470
+ }
471
+ }
472
+ catch (err) {
473
+ this.opts.log?.(`onClient hook threw: ${errMessage(err)}`);
474
+ }
475
+ }
476
+ if (this.opts.attendance) {
477
+ this.attendedDetach = this.opts.attendance.attach({
478
+ send: (ask) => this.ch.send({ t: 'permission-ask', ...ask }),
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 }),
482
+ });
483
+ }
484
+ this.opts.log?.(`paired — ${this.tools.size} ${this.tools.size === 1 ? 'tool' : 'tools'} advertised (host dir: ${this.hostStateDir})`);
187
485
  const tools = [...this.tools.values()].map(({ run: _run, ...decl }) => decl);
188
486
  // Advertise the loopback HTTP proxy so the host can route local-LLM calls
189
- // through us (server-side fetch, no browser CORS) instead of failing.
190
- this.ch.send({ t: 'welcome', tools, features: ['http-proxy'] });
487
+ // through us (server-side fetch, no browser CORS) instead of failing, and
488
+ // 'runner' only when a crew runner is actually wired — that's what lets the
489
+ // host safely enable daemon-tier triggers (schedule/continuous/self-directed)
490
+ // instead of adding one that would silently never fire. `info` (the confined
491
+ // root + platform) lets the host show the working directory when browsing
492
+ // this sidecar as a file source; omitted cleanly when not provided.
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
+ ];
511
+ this.ch.send({ t: 'welcome', tools, features, ...(this.opts.info ? { info: this.opts.info } : {}) });
191
512
  }
192
513
  /** Proxy a host HTTP request server-side. Restricted to LOOPBACK targets — the
193
514
  * sidecar must never be usable as an SSRF pivot to internal/metadata hosts. The
@@ -225,20 +546,524 @@ export class SidecarServer {
225
546
  this.ch.send({ t: 'http-err', id: m.id, error: errMessage(e) });
226
547
  }
227
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
+ }
228
756
  async onInvoke(m) {
229
757
  const tool = this.tools.get(m.name);
230
758
  if (!tool) {
759
+ this.opts.log?.(`invoke ${m.name}: unknown tool`);
231
760
  this.ch.send({ t: 'invoke-result', id: m.id, ok: false, error: `unknown tool ${m.name}` });
232
761
  return;
233
762
  }
763
+ this.opts.log?.(`invoke ${m.name}`);
234
764
  try {
235
765
  const result = await tool.run(m.args, this.makeContext());
236
766
  this.ch.send({ t: 'invoke-result', id: m.id, ok: true, result });
237
767
  }
238
768
  catch (e) {
769
+ this.opts.log?.(`invoke ${m.name} failed: ${errMessage(e)}`);
239
770
  this.ch.send({ t: 'invoke-result', id: m.id, ok: false, error: errMessage(e) });
240
771
  }
241
772
  }
773
+ /** Runs a crew agent via the configured `runner` and streams its lifecycle back
774
+ * to the host: `run-accepted` (with the daemon-assigned runId) once the runner
775
+ * has started, one `event` per `onEvent` callback, then exactly one of
776
+ * `run-done` (final text + stop reason) or `run-error`.
777
+ *
778
+ * `runner.run` returns the runId asynchronously, but a runner is free to call
779
+ * `onEvent` synchronously before that promise settles (e.g. an immediate first
780
+ * turn). Events that arrive before we know the runId are buffered and flushed
781
+ * right after `run-accepted`, so they still carry the correct runId and never
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
+ }
922
+ async onRun(m) {
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) {
937
+ this.ch.send({ t: 'run-error', id: m.id, error: 'this sidecar cannot run agents' });
938
+ return;
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);
948
+ let runId;
949
+ const pending = [];
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);
956
+ if (runId === undefined) {
957
+ pending.push(event);
958
+ return;
959
+ }
960
+ this.ch.send({ t: 'event', id: m.id, runId, event });
961
+ };
962
+ try {
963
+ // Task 12 fix-wave: forward this connection's authenticated `clientId`
964
+ // (or `undefined` for an unidentified/older peer) as an UNTRUSTED
965
+ // `{ clientId }` identity -- this class must never resolve it itself
966
+ // (no manifest access here, by design; see the module doc), it just
967
+ // hands the identity it already holds to the runner, exactly like
968
+ // `onHostSyncPut`/`onHostSyncDelete` do for `hostSync.put`/`remove`.
969
+ const started = await start(m.agent, m.input, onEvent, { clientId: this.clientId });
970
+ runId = started.runId;
971
+ this.opts.runs?.open(runId);
972
+ this.ch.send({ t: 'run-accepted', id: m.id, runId });
973
+ for (const event of pending) {
974
+ this.opts.runs?.publish(runId, event);
975
+ this.ch.send({ t: 'event', id: m.id, runId, event });
976
+ }
977
+ const fin = await started.done;
978
+ this.opts.runs?.finish(runId, { text: fin.text, stopReason: fin.stopReason });
979
+ this.ch.send({ t: 'run-done', id: m.id, runId, text: fin.text, stopReason: fin.stopReason });
980
+ }
981
+ catch (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 });
988
+ }
989
+ }
990
+ /** The identity to attribute a host-sync write to. Returns either the
991
+ * authenticated `clientId`, or an error string explaining why there is none --
992
+ * either this connection is not authenticated yet, or it authenticated with no
993
+ * `clientId` (an older client). The latter is NOT an auth failure (the pairing
994
+ * token already authenticated the connection); it is a "no owner to attribute
995
+ * files to" rule -- a write with no owner must never reach the port. */
996
+ requireHostSyncIdentity() {
997
+ if (!this.authed)
998
+ return { error: 'not authenticated' };
999
+ if (this.clientId === undefined) {
1000
+ return { error: 'this connection carries no client identity to attribute host-sync writes to' };
1001
+ }
1002
+ return { clientId: this.clientId };
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
+ }
1031
+ async onHostSyncPut(m) {
1032
+ const identity = this.requireHostSyncIdentity();
1033
+ if ('error' in identity) {
1034
+ this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: identity.error });
1035
+ return;
1036
+ }
1037
+ if (!this.opts.hostSync) {
1038
+ this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: 'this sidecar cannot sync host config' });
1039
+ return;
1040
+ }
1041
+ try {
1042
+ await this.opts.hostSync.put(identity.clientId, m.path, m.content);
1043
+ this.ch.send({ t: 'host-sync-result', id: m.id, ok: true });
1044
+ }
1045
+ catch (e) {
1046
+ this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: errMessage(e) });
1047
+ }
1048
+ }
1049
+ async onHostSyncDelete(m) {
1050
+ const identity = this.requireHostSyncIdentity();
1051
+ if ('error' in identity) {
1052
+ this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: identity.error });
1053
+ return;
1054
+ }
1055
+ if (!this.opts.hostSync) {
1056
+ this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: 'this sidecar cannot sync host config' });
1057
+ return;
1058
+ }
1059
+ try {
1060
+ await this.opts.hostSync.remove(identity.clientId, m.path);
1061
+ this.ch.send({ t: 'host-sync-result', id: m.id, ok: true });
1062
+ }
1063
+ catch (e) {
1064
+ this.ch.send({ t: 'host-sync-result', id: m.id, ok: false, error: errMessage(e) });
1065
+ }
1066
+ }
242
1067
  /** A ToolContext whose capabilities proxy back to the host (persist-nothing).
243
1068
  * The stores, the host's file workspace, and the user prompts (confirm/ask) all
244
1069
  * round-trip over the channel. confirm/ask default to allow/dismiss when the