@north-light/crouter 0.3.217 → 0.3.219

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 (74) hide show
  1. package/dist/api/client.d.ts +5 -2
  2. package/dist/api/client.js +7 -1
  3. package/dist/api/dto/canvas.d.ts +9 -0
  4. package/dist/api/dto/nodes.d.ts +27 -1
  5. package/dist/builtin-memory/04-base-worker.md +7 -1
  6. package/dist/builtin-memory/internal/plugins.md +67 -0
  7. package/dist/builtin-memory/memory-read-orientation.md +13 -0
  8. package/dist/clients/attach/chrome/bash-jobs.js +1 -1
  9. package/dist/clients/attach/photon_rs_bg.wasm +0 -0
  10. package/dist/clients/attach/viewer.js +478 -478
  11. package/dist/commands/human/shared.js +1 -1
  12. package/dist/commands/memory/edit.js +6 -1
  13. package/dist/commands/memory/lint.d.ts +1 -6
  14. package/dist/commands/memory/lint.js +18 -126
  15. package/dist/commands/memory/list.d.ts +1 -0
  16. package/dist/commands/memory/list.js +17 -2
  17. package/dist/commands/memory/read.js +15 -6
  18. package/dist/commands/memory/shared.d.ts +32 -2
  19. package/dist/commands/memory/shared.js +179 -0
  20. package/dist/commands/memory/write.js +6 -1
  21. package/dist/commands/pkg/browse/catalog.js +2 -0
  22. package/dist/commands/pkg/browse/model.d.ts +4 -1
  23. package/dist/commands/pkg/plugin-manage.js +209 -148
  24. package/dist/core/bash-jobs.d.ts +2 -5
  25. package/dist/core/bash-jobs.js +4 -8
  26. package/dist/core/command-plugins/bundle.d.ts +4 -1
  27. package/dist/core/command-plugins/bundle.js +16 -2
  28. package/dist/core/human/component-docs.js +1 -0
  29. package/dist/core/human/scan.d.ts +5 -4
  30. package/dist/core/human/scan.js +7 -4
  31. package/dist/core/io.js +3 -2
  32. package/dist/core/manifest.d.ts +2 -0
  33. package/dist/core/manifest.js +5 -0
  34. package/dist/core/memory/extensions.d.ts +30 -0
  35. package/dist/core/memory/extensions.js +219 -0
  36. package/dist/core/preview-result-path.d.ts +4 -0
  37. package/dist/core/preview-result-path.js +25 -0
  38. package/dist/core/runtime/launch-config-worker.d.ts +1 -0
  39. package/dist/core/runtime/launch-config-worker.js +12 -0
  40. package/dist/core/runtime/launch.d.ts +16 -2
  41. package/dist/core/runtime/launch.js +55 -21
  42. package/dist/core/runtime/promote.d.ts +20 -2
  43. package/dist/core/runtime/promote.js +63 -59
  44. package/dist/core/runtime/recycle.js +2 -2
  45. package/dist/core/runtime/reset.js +2 -2
  46. package/dist/core/runtime/spawn.js +3 -3
  47. package/dist/core/runtime/warm-pool.js +9 -7
  48. package/dist/core/substrate/frontmatter-validation.d.ts +13 -0
  49. package/dist/core/substrate/frontmatter-validation.js +101 -0
  50. package/dist/core/substrate/index.d.ts +1 -0
  51. package/dist/core/substrate/index.js +1 -0
  52. package/dist/daemon/api/__tests__/bridge-heartbeat.test.d.ts +1 -0
  53. package/dist/daemon/api/__tests__/bridge-heartbeat.test.js +30 -0
  54. package/dist/daemon/api/__tests__/nodes-activity-query.test.d.ts +1 -0
  55. package/dist/daemon/api/__tests__/nodes-activity-query.test.js +101 -0
  56. package/dist/daemon/api/__tests__/seam/api-server.test.js +30 -0
  57. package/dist/daemon/api/bridge.d.ts +6 -0
  58. package/dist/daemon/api/bridge.js +39 -2
  59. package/dist/daemon/api/handlers/canvas.js +3 -0
  60. package/dist/daemon/api/handlers/inbox.js +4 -3
  61. package/dist/daemon/api/handlers/nodes.d.ts +1 -0
  62. package/dist/daemon/api/handlers/nodes.js +117 -36
  63. package/dist/daemon/api/handlers/reports.d.ts +4 -0
  64. package/dist/daemon/api/handlers/reports.js +14 -8
  65. package/dist/daemon/crtrd.js +7 -5
  66. package/dist/daemon/manage.d.ts +3 -0
  67. package/dist/daemon/manage.js +14 -0
  68. package/dist/pi-extensions/canvas-bash-valve.d.ts +4 -3
  69. package/dist/pi-extensions/canvas-bash-valve.js +19 -6
  70. package/dist/pi-extensions/canvas-preview-result.d.ts +0 -8
  71. package/dist/pi-extensions/canvas-preview-result.js +9 -23
  72. package/dist/types.d.ts +31 -0
  73. package/package.json +1 -1
  74. package/runtime.lock.json +2 -2
@@ -33,6 +33,40 @@ import { probeViewSocket, waitForBrokerViewSocket } from '../../core/runtime/vie
33
33
  * client cap) so a remote peer cannot buffer 256MiB before the broker's 24MiB
34
34
  * decoder rejects the message. */
35
35
  const wss = new WebSocketServer({ noServer: true, maxPayload: BROKER_READ_CAPS.maxLineBytes });
36
+ /** Keep an otherwise quiet observer connection alive through common 60s proxy
37
+ * idle limits. A viewer only observes a broker, so a missed heartbeat may close
38
+ * this socket but must never affect the broker or its in-flight work. */
39
+ export const REMOTE_ATTACH_HEARTBEAT_MS = 25_000;
40
+ export function startRemoteAttachHeartbeat(ws, intervalMs = REMOTE_ATTACH_HEARTBEAT_MS) {
41
+ let alive = true;
42
+ let stopped = false;
43
+ const acknowledge = () => { alive = true; };
44
+ const stop = () => {
45
+ if (stopped)
46
+ return;
47
+ stopped = true;
48
+ clearInterval(timer);
49
+ ws.off('pong', acknowledge);
50
+ };
51
+ ws.on('pong', acknowledge);
52
+ const timer = setInterval(() => {
53
+ if (!alive) {
54
+ stop();
55
+ ws.terminate();
56
+ return;
57
+ }
58
+ alive = false;
59
+ try {
60
+ ws.ping();
61
+ }
62
+ catch {
63
+ stop();
64
+ ws.terminate();
65
+ }
66
+ }, intervalMs);
67
+ timer.unref();
68
+ return stop;
69
+ }
36
70
  /** A WS close reason is capped at 123 UTF-8 bytes by the protocol. */
37
71
  function clampReason(reason) {
38
72
  const buf = Buffer.from(reason, 'utf8');
@@ -106,6 +140,7 @@ function bridgeConnection(ws, nodeId) {
106
140
  const decoder = new FrameDecoder(CLIENT_READ_CAPS);
107
141
  let closed = false;
108
142
  let viewerFaultRecorded = false;
143
+ let stopHeartbeat;
109
144
  socket.on('connect', () => {
110
145
  clearFault(nodeId, { link: 'relay↔broker' });
111
146
  });
@@ -113,6 +148,7 @@ function bridgeConnection(ws, nodeId) {
113
148
  if (closed)
114
149
  return;
115
150
  closed = true;
151
+ stopHeartbeat?.();
116
152
  if (!socket.destroyed) {
117
153
  try {
118
154
  socket.destroy();
@@ -128,6 +164,7 @@ function bridgeConnection(ws, nodeId) {
128
164
  /* ignore */
129
165
  }
130
166
  };
167
+ stopHeartbeat = startRemoteAttachHeartbeat(ws);
131
168
  // socket → WS: bounded decode, one WS text message per complete frame.
132
169
  socket.on('data', (chunk) => {
133
170
  let frames;
@@ -183,7 +220,7 @@ function bridgeConnection(ws, nodeId) {
183
220
  link: 'viewer↔crtrd',
184
221
  op: 'remote attach websocket',
185
222
  kind: classification.kind,
186
- retry: { disposition: 'manual' },
223
+ retry: { disposition: 'auto' },
187
224
  message: reasonText || `remote attach closed (${code})`,
188
225
  });
189
226
  }
@@ -197,7 +234,7 @@ function bridgeConnection(ws, nodeId) {
197
234
  link: 'viewer↔crtrd',
198
235
  op: 'remote attach websocket',
199
236
  kind: classification.kind,
200
- retry: { disposition: 'manual' },
237
+ retry: { disposition: 'auto' },
201
238
  message: 'remote attach websocket error',
202
239
  });
203
240
  }
@@ -278,6 +278,7 @@ function handleSnapshot() {
278
278
  lifecycle: row.lifecycle ?? meta?.lifecycle,
279
279
  status: row.status,
280
280
  cwd: row.cwd,
281
+ profile_id: meta?.profile_id ?? null,
281
282
  parent: meta?.parent ?? null,
282
283
  created: row.created,
283
284
  host_kind: hostKind,
@@ -289,6 +290,8 @@ function handleSnapshot() {
289
290
  streaming: row.streaming ?? false,
290
291
  hanging: (row.hanging ?? null),
291
292
  viewed: row.viewed ?? false,
293
+ final_report: meta?.final_report ?? null,
294
+ finalized_at: meta?.finalized_at ?? null,
292
295
  };
293
296
  });
294
297
  const subscriptions = Object.fromEntries(rows.map((row) => [row.node_id, subscriptionsOf(row.node_id).map((ref) => ref.node_id)]));
@@ -226,11 +226,12 @@ function handleList() {
226
226
  // GET /v1/human/inbox/history?node_id=<id> — page ticket history
227
227
  // ===========================================================================
228
228
  function handleHistory(ctx) {
229
+ // Home-wide when `node_id` is absent; an explicitly empty param is a caller bug.
229
230
  const nodeId = ctx.query.get('node_id');
230
- if (!nodeId || typeof nodeId !== 'string') {
231
- throw new ApiError(400, 'invalid_request', 'history requires a node_id query param');
231
+ if (nodeId === '') {
232
+ throw new ApiError(400, 'invalid_request', 'node_id must be nonempty when present');
232
233
  }
233
- const history = scanPageHistory(nodeId);
234
+ const history = scanPageHistory(nodeId ?? undefined);
234
235
  const canonicalRoot = realpathSync(ticketsRoot());
235
236
  const tickets = history.map((item) => pageSummaryToDTO(item, canonicalRoot));
236
237
  return { status: 200, body: { tickets } };
@@ -6,6 +6,7 @@ interface NodeHandlerDeps {
6
6
  }
7
7
  export declare function resolveCreateKind(kind: string | undefined, profile: ReturnType<typeof assertProfileActive>): string;
8
8
  export declare function handleCreate(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
9
+ export declare function handleList(ctx: RequestContext): HandlerResult;
9
10
  export declare function handlePromote(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
10
11
  export declare function handleYield(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
11
12
  export declare function handleConfig(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
@@ -7,26 +7,26 @@
7
7
  //
8
8
  // `{id}` is the target node.
9
9
  import { readdirSync } from 'node:fs';
10
- import { resolve } from 'node:path';
10
+ import { basename, resolve } from 'node:path';
11
11
  import { appendSituationalContext, closeNode, getNode, listNodes, nowIso, reviveNode, } from '../../../index.js';
12
12
  import { formatSituationalProse } from '../../../core/runtime/situational-context.js';
13
13
  import { assertLaunchModelRegistered, forkNode, resolveProfileId, spawnChild } from '../../../core/runtime/spawn.js';
14
14
  import { childrenOf, subscribersOf, subscriptionsOf, setMessageWait, updateNode } from '../../../core/canvas/canvas.js';
15
15
  import { subtreeIds } from '../../../core/canvas/nav-model.js';
16
+ import { ticketCountsForNodes } from '../../../core/canvas/attention.js';
16
17
  import { contextDir, reportsDir } from '../../../core/canvas/paths.js';
17
18
  import { nodeArtifacts } from '../../../core/canvas/history.js';
18
19
  import { readNodeMessagesPage, readNodeSession, readNodeSnapshot, transcriptMarkdown } from '../../../core/runtime/node-read.js';
19
20
  import { assembleNodeSubject } from '../../../core/substrate/subject.js';
20
21
  import { reviveAll } from '../../../core/runtime/revive-all.js';
21
- import { promote, requestYield, reshapeNode } from '../../../core/runtime/promote.js';
22
+ import { promoteAsync, requestYieldAsync, reshapeNodeAsync } from '../../../core/runtime/promote.js';
22
23
  import { recycleNode } from '../../../core/runtime/recycle.js';
23
24
  import { relaunchRoot } from '../../../core/runtime/reset.js';
24
25
  import { assertReopenGate } from '../../../core/runtime/reopen.js';
25
26
  import { writeYieldMessage, readGoal } from '../../../core/runtime/kickoff.js';
26
27
  import { hasRoadmap, seedRoadmap } from '../../../core/runtime/roadmap.js';
27
28
  import { renameWindow } from '../../../core/runtime/placement.js';
28
- import { readMergedLaunchConfig } from '../../../core/config.js';
29
- import { buildLaunchSpec, isProviderPinnedModelToken, normalizeModel } from '../../../core/runtime/launch.js';
29
+ import { buildLaunchSpecAsync, isProviderPinnedModelToken, normalizeModel, readMergedLaunchConfigAsync } from '../../../core/runtime/launch.js';
30
30
  import { modelRequestFromConfig } from '../../../core/model-routes.js';
31
31
  import { BrokerUnreachableError } from '../../../core/runtime/broker-request.js';
32
32
  import { isBrokerLive, persistDormantModel, setModelLive } from '../../../core/runtime/model-swap.js';
@@ -34,6 +34,7 @@ import { writeOutputSchema } from '../../../core/runtime/structured-output.js';
34
34
  import { claimWarmNode, refillWarmPool, warmPoolEnabled } from '../../../core/runtime/warm-pool.js';
35
35
  import { assertProfileActive, profileHome } from '../../../core/profiles/manifest.js';
36
36
  import { fullName } from '../../../core/canvas/labels.js';
37
+ import { reportsForNode } from './reports.js';
37
38
  import { getCron } from '../../../core/canvas/crons.js';
38
39
  import { cronWakeOrigin } from '../../../core/runtime/bearings.js';
39
40
  import { notFound, usage } from '../../../core/errors.js';
@@ -194,13 +195,13 @@ function requestFromLaunch(launch) {
194
195
  return modelRequestFromConfig(launch.model, intent, launch.modelExact === true);
195
196
  }
196
197
  async function assertNodeModelChangeRegistered(meta, target, registry) {
197
- const launch = buildLaunchSpec(target.kind, target.mode, {
198
+ const launch = (await buildLaunchSpecAsync(target.kind, target.mode, {
198
199
  lifecycle: target.lifecycle,
199
200
  hasManager: meta.parent !== null,
200
201
  model: target.model,
201
202
  cwd: meta.cwd,
202
203
  profileId: meta.profile_id ?? null,
203
- }).launch;
204
+ })).launch;
204
205
  await assertLaunchModelRegistered(requestFromLaunch(launch), { cwd: meta.cwd, profileId: meta.profile_id ?? null }, registry);
205
206
  }
206
207
  export async function handleCreate(ctx, deps = {}) {
@@ -232,16 +233,16 @@ export async function handleCreate(ctx, deps = {}) {
232
233
  const profileId = await resolveProfileId(req.profile, parent ?? null, contextCwd);
233
234
  const profile = assertProfileActive(profileId);
234
235
  const kind = resolveCreateKind(req.kind, profile);
235
- assertKindServer(kind);
236
236
  const resolvedReq = { ...req, kind };
237
237
  const cwd = resolveNodeCwd(resolvedReq, parent, profileId, contextCwd);
238
- const launch = buildLaunchSpec(kind, (resolvedReq.mode ?? 'base'), {
238
+ await assertKindServer(kind, cwd, profileId);
239
+ const launch = (await buildLaunchSpecAsync(kind, (resolvedReq.mode ?? 'base'), {
239
240
  lifecycle: req.root === true ? (req.root_lifecycle ?? 'resident') : 'terminal',
240
241
  hasManager: req.root !== true,
241
242
  ...(req.model !== undefined ? { model: req.model } : {}),
242
243
  cwd,
243
244
  profileId,
244
- }).launch;
245
+ })).launch;
245
246
  await assertLaunchModelRegistered(requestFromLaunch(launch), { cwd, profileId }, deps.modelRegistry);
246
247
  // --- Warm path: hand out an already-booted spare instead of paying a full
247
248
  // engine boot. A miss is not a failure — it falls through to the cold
@@ -266,26 +267,106 @@ export async function handleCreate(ctx, deps = {}) {
266
267
  // ---------------------------------------------------------------------------
267
268
  // GET /v1/nodes — list (filters) ; GET /v1/nodes/{id} — detail
268
269
  // ---------------------------------------------------------------------------
269
- function handleList(ctx) {
270
- const status = ctx.query.get('status');
271
- const kind = ctx.query.get('kind');
272
- const mode = ctx.query.get('mode');
273
- const under = ctx.query.get('under');
274
- const hanging = ctx.query.get('hanging');
275
- let rows = status !== null ? listNodes({ status }) : listNodes();
276
- if (kind !== null)
277
- rows = rows.filter((r) => r.kind === kind);
278
- if (mode !== null)
279
- rows = rows.filter((r) => r.mode === mode);
280
- if (under !== null) {
281
- const scope = new Set(subtreeIds(under));
282
- rows = rows.filter((r) => scope.has(r.node_id));
270
+ function singleQueryValue(ctx, name) {
271
+ const values = ctx.query.getAll(name);
272
+ if (values.length > 1)
273
+ throw usage(`${name} must appear at most once`);
274
+ return values[0];
275
+ }
276
+ function optionalBoolean(value, name) {
277
+ if (value === undefined)
278
+ return undefined;
279
+ if (value === '1' || value === 'true')
280
+ return true;
281
+ if (value === '0' || value === 'false')
282
+ return false;
283
+ throw usage(`${name} must be true or false`);
284
+ }
285
+ function oneOf(value, name, allowed) {
286
+ if (value === undefined)
287
+ return undefined;
288
+ if (allowed.includes(value))
289
+ return value;
290
+ throw usage(`${name} must be one of: ${allowed.join(', ')}`);
291
+ }
292
+ function parseListQuery(ctx) {
293
+ const allowed = new Set([
294
+ 'status', 'lifecycle', 'kind', 'mode', 'profile_id', 'profile_prefix',
295
+ 'parent', 'top_level', 'under', 'hanging', 'include',
296
+ ]);
297
+ for (const [name] of ctx.query) {
298
+ if (!allowed.has(name))
299
+ throw usage(`unknown nodes query parameter: ${name}`);
283
300
  }
284
- if (hanging === '1' || hanging === 'true') {
285
- // A dangling manager edge: a non-root whose recorded parent is gone.
286
- rows = rows.filter((r) => r.parent !== null && getNode(r.parent) === null);
301
+ const status = oneOf(singleQueryValue(ctx, 'status'), 'status', ['active', 'idle', 'done', 'dead', 'canceled']);
302
+ const lifecycle = oneOf(singleQueryValue(ctx, 'lifecycle'), 'lifecycle', ['terminal', 'resident']);
303
+ const kind = singleQueryValue(ctx, 'kind');
304
+ const mode = oneOf(singleQueryValue(ctx, 'mode'), 'mode', ['base', 'orchestrator']);
305
+ const profileId = singleQueryValue(ctx, 'profile_id');
306
+ const profilePrefix = singleQueryValue(ctx, 'profile_prefix');
307
+ const parent = singleQueryValue(ctx, 'parent');
308
+ const topLevel = optionalBoolean(singleQueryValue(ctx, 'top_level'), 'top_level');
309
+ const under = singleQueryValue(ctx, 'under');
310
+ const hanging = optionalBoolean(singleQueryValue(ctx, 'hanging'), 'hanging');
311
+ const include = oneOf(singleQueryValue(ctx, 'include'), 'include', ['activity']);
312
+ if (parent !== undefined && topLevel === true) {
313
+ throw usage('parent and top_level=true cannot be combined');
287
314
  }
288
- const body = rows.map(toNodeSummaryDTO);
315
+ return {
316
+ ...(status !== undefined ? { status } : {}),
317
+ ...(lifecycle !== undefined ? { lifecycle } : {}),
318
+ ...(kind !== undefined ? { kind } : {}),
319
+ ...(mode !== undefined ? { mode } : {}),
320
+ ...(profileId !== undefined ? { profile_id: profileId } : {}),
321
+ ...(profilePrefix !== undefined ? { profile_prefix: profilePrefix } : {}),
322
+ ...(parent !== undefined ? { parent } : {}),
323
+ ...(topLevel !== undefined ? { top_level: topLevel } : {}),
324
+ ...(under !== undefined ? { under } : {}),
325
+ ...(hanging !== undefined ? { hanging } : {}),
326
+ ...(include !== undefined ? { include: include } : {}),
327
+ };
328
+ }
329
+ export function handleList(ctx) {
330
+ const query = parseListQuery(ctx);
331
+ let rows = query.status !== undefined ? listNodes({ status: query.status }) : listNodes();
332
+ if (query.lifecycle !== undefined)
333
+ rows = rows.filter((row) => row.lifecycle === query.lifecycle);
334
+ if (query.kind !== undefined)
335
+ rows = rows.filter((row) => row.kind === query.kind);
336
+ if (query.mode !== undefined)
337
+ rows = rows.filter((row) => row.mode === query.mode);
338
+ if (query.profile_id !== undefined)
339
+ rows = rows.filter((row) => row.profile_id === query.profile_id);
340
+ if (query.profile_prefix !== undefined)
341
+ rows = rows.filter((row) => row.profile_id?.startsWith(query.profile_prefix));
342
+ if (query.parent !== undefined)
343
+ rows = rows.filter((row) => row.parent === query.parent);
344
+ if (query.top_level === true)
345
+ rows = rows.filter((row) => row.parent === null);
346
+ if (query.under !== undefined) {
347
+ const scope = new Set(subtreeIds(query.under));
348
+ rows = rows.filter((row) => scope.has(row.node_id));
349
+ }
350
+ if (query.hanging === true) {
351
+ rows = rows.filter((row) => row.parent !== null && getNode(row.parent) === null);
352
+ }
353
+ const counts = query.include === 'activity'
354
+ ? ticketCountsForNodes(rows.map((row) => row.node_id))
355
+ : null;
356
+ const body = rows.map((row) => {
357
+ const node = toNodeSummaryDTO(row);
358
+ if (counts !== null) {
359
+ const reports = reportsForNode(row.node_id);
360
+ node.activity = {
361
+ latest_report: reports[0] ?? null,
362
+ final_report: row.final_report === null
363
+ ? null
364
+ : reports.find((report) => basename(report.path) === row.final_report) ?? null,
365
+ pending_human_count: counts[row.node_id] ?? 0,
366
+ };
367
+ }
368
+ return node;
369
+ });
289
370
  return { status: 200, body };
290
371
  }
291
372
  function handleDetail(ctx) {
@@ -473,12 +554,12 @@ async function handleRecycle(ctx) {
473
554
  },
474
555
  };
475
556
  }
476
- function handleDemote(ctx) {
557
+ async function handleDemote(ctx) {
477
558
  const id = ctx.params['id'];
478
559
  requireMeta(id);
479
560
  // Use the canonical reshape primitive to ensure modelExact, provider
480
561
  // pin/intent, and default-name migration are all preserved.
481
- reshapeNode(id, { lifecycle: 'terminal' });
562
+ await reshapeNodeAsync(id, { lifecycle: 'terminal' });
482
563
  return { status: 200, body: detailOf(id) };
483
564
  }
484
565
  export async function handlePromote(ctx, deps = {}) {
@@ -493,7 +574,7 @@ export async function handlePromote(ctx, deps = {}) {
493
574
  model: req.model,
494
575
  }, deps.modelRegistry);
495
576
  }
496
- const result = promote(id, {
577
+ const result = await promoteAsync(id, {
497
578
  ...(req.kind !== undefined ? { kind: req.kind } : {}),
498
579
  ...(req.resident === true ? { resident: true } : {}),
499
580
  ...(req.model !== undefined ? { model: req.model } : {}),
@@ -520,7 +601,7 @@ export async function handleYield(ctx, deps = {}) {
520
601
  }
521
602
  const note = req.note ?? 'Refresh: continue against your roadmap or goal.';
522
603
  writeYieldMessage(id, note);
523
- const result = requestYield(id, {
604
+ const result = await requestYieldAsync(id, {
524
605
  ...(req.promote === true ? { promote: true } : {}),
525
606
  ...(req.kind !== undefined ? { kind: req.kind } : {}),
526
607
  ...(req.model !== undefined ? { model: req.model } : {}),
@@ -563,7 +644,7 @@ export async function handleConfig(ctx, deps = {}) {
563
644
  }
564
645
  if (patch.model !== undefined) {
565
646
  const pinned = isProviderPinnedModelToken(patch.model);
566
- const ladders = readMergedLaunchConfig(meta.cwd, meta.profile_id ?? null).modelLadders;
647
+ const ladders = (await readMergedLaunchConfigAsync(meta.cwd, meta.profile_id ?? null)).modelLadders;
567
648
  const spec = normalizeModel(patch.model, ladders);
568
649
  await assertLaunchModelRegistered({ kind: 'concrete', spec }, { cwd: meta.cwd, profileId: meta.profile_id ?? null }, deps.modelRegistry);
569
650
  const persistDormant = () => {
@@ -599,7 +680,7 @@ export async function handleConfig(ctx, deps = {}) {
599
680
  // --- kind / mode / lifecycle: reshape via canonical primitive (CLI parity). ---
600
681
  if (patch.kind !== undefined || patch.mode !== undefined || patch.lifecycle !== undefined) {
601
682
  if (patch.kind !== undefined)
602
- assertKindServer(patch.kind);
683
+ await assertKindServer(patch.kind, meta.cwd, meta.profile_id ?? null);
603
684
  const newKind = patch.kind ?? meta.kind;
604
685
  const newMode = (patch.mode ?? meta.mode);
605
686
  const newLifecycle = patch.lifecycle ?? meta.lifecycle;
@@ -615,7 +696,7 @@ export async function handleConfig(ctx, deps = {}) {
615
696
  }
616
697
  // Use canonical reshape primitive to preserve modelExact, provider pin/intent,
617
698
  // and default-name migration.
618
- meta = reshapeNode(id, {
699
+ meta = await reshapeNodeAsync(id, {
619
700
  ...(patch.kind !== undefined ? { kind: newKind } : {}),
620
701
  ...(patch.mode !== undefined ? { mode: newMode } : {}),
621
702
  ...(patch.lifecycle !== undefined ? { lifecycle: newLifecycle } : {}),
@@ -633,8 +714,8 @@ export async function handleConfig(ctx, deps = {}) {
633
714
  }
634
715
  /** Validate a `--kind` against the installed personas server-side (crtrd reads
635
716
  * the merged launch config). Mirrors the CLI's assertKind. */
636
- function assertKindServer(kind) {
637
- const kinds = Object.keys(readMergedLaunchConfig().kinds);
717
+ async function assertKindServer(kind, cwd, profileId) {
718
+ const kinds = Object.keys((await readMergedLaunchConfigAsync(cwd, profileId)).kinds);
638
719
  if (!kinds.includes(kind)) {
639
720
  throw usage(`unknown kind: ${kind}`, { received: kind, next: `Valid kinds: ${kinds.sort().join(', ')}.` });
640
721
  }
@@ -1,2 +1,6 @@
1
+ import type { ReportDTO } from '../../../api/index.js';
1
2
  import type { RouteTable } from '../router.js';
3
+ /** Read one node's pushed reports, newest first. This stays in the daemon so
4
+ * collection projections can include report activity without client-side N+1s. */
5
+ export declare function reportsForNode(id: string): ReportDTO[];
2
6
  export declare const reportRoutes: RouteTable;
@@ -110,12 +110,9 @@ function compactToIso(stamp) {
110
110
  const m = stamp.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$/);
111
111
  return m === null ? stamp : `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}`;
112
112
  }
113
- function handleList(ctx) {
114
- const id = ctx.params['id'];
115
- requireNode(id);
116
- const tierFilter = ctx.query.get('tier');
117
- const limitRaw = ctx.query.get('limit');
118
- const limit = limitRaw !== null ? Number(limitRaw) : undefined;
113
+ /** Read one node's pushed reports, newest first. This stays in the daemon so
114
+ * collection projections can include report activity without client-side N+1s. */
115
+ export function reportsForNode(id) {
119
116
  const dir = reportsDir(id);
120
117
  let files;
121
118
  try {
@@ -136,9 +133,18 @@ function handleList(ctx) {
136
133
  (m ? compactToIso(m[1]) : new Date(statSync(abs).mtimeMs).toISOString());
137
134
  reports.push({ path: abs, tier, body: body.trim(), created });
138
135
  }
139
- // Newest first, then apply the optional tier + limit filters.
140
136
  reports.sort((a, b) => (a.created < b.created ? 1 : a.created > b.created ? -1 : 0));
141
- let out = tierFilter !== null ? reports.filter((r) => r.tier === tierFilter) : reports;
137
+ return reports;
138
+ }
139
+ function handleList(ctx) {
140
+ const id = ctx.params['id'];
141
+ requireNode(id);
142
+ const tierFilter = ctx.query.get('tier');
143
+ const limitRaw = ctx.query.get('limit');
144
+ const limit = limitRaw !== null ? Number(limitRaw) : undefined;
145
+ let out = reportsForNode(id);
146
+ if (tierFilter !== null)
147
+ out = out.filter((report) => report.tier === tierFilter);
142
148
  if (limit !== undefined && Number.isFinite(limit) && limit >= 0)
143
149
  out = out.slice(0, limit);
144
150
  return { status: 200, body: out };
@@ -62,7 +62,7 @@ import { StorageMaintenanceReconciler } from './reconcilers/storage-maintenance.
62
62
  import { captureLivenessSnapshot, capturePidCommand, captureTeardownSnapshot, isPidAlive, killProcessTreePids, } from '../core/canvas/pid.js';
63
63
  import { bindFleet, boundFleet } from '../core/runtime/fleet.js';
64
64
  import { bindDaemonControl, clearDaemonControl } from './control.js';
65
- import { spawnDaemon } from './manage.js';
65
+ import { execDaemon, spawnDaemon } from './manage.js';
66
66
  import { reconcileAndMigrateBoot } from '../core/canvas/boot.js';
67
67
  import { resolveInstallId } from '../core/canvas/install-id.js';
68
68
  import { setInstallId } from '../core/runtime/nodes.js';
@@ -480,11 +480,10 @@ export async function runDaemon(opts = {}) {
480
480
  }, HANDOVER_ACK_GRACE_MS);
481
481
  return { graceMs: HANDOVER_ACK_GRACE_MS };
482
482
  }
483
- /** Quiesce exactly as a SIGTERM would, then spawn the successor and exit.
483
+ /** Quiesce exactly as a SIGTERM would, then start the successor.
484
484
  * Ordering is forced: the successor's claim can only be won after this
485
- * daemon has released its own, so teardown strictly precedes the spawn. The
486
- * successor resolves its entry through `runtime/selected`, which is how it
487
- * picks up a generation this process never loaded. */
485
+ * daemon has released its own. PID 1 is replaced in place so a container
486
+ * does not stop between daemon generations; ordinary hosts spawn and exit. */
488
487
  async function performHandover() {
489
488
  try {
490
489
  await requestCleanup();
@@ -495,6 +494,9 @@ export async function runDaemon(opts = {}) {
495
494
  process.exit(1);
496
495
  }
497
496
  try {
497
+ if (process.pid === 1) {
498
+ await execDaemon();
499
+ }
498
500
  const spawned = await spawnDaemon();
499
501
  operationIdContext.fresh(() => {
500
502
  emitEvent({
@@ -36,6 +36,9 @@ export declare function findDaemonPids(): number[];
36
36
  * reaped. Best-effort throughout: a pid that dies between enumeration and signal
37
37
  * just no-ops. */
38
38
  export declare function sweepStrayDaemons(keepPid: number | null): number[];
39
+ /** Replace PID 1 with the selected daemon generation. Docker keeps a container
40
+ * alive only while PID 1 lives, so a handover there must preserve its pid. */
41
+ export declare function execDaemon(): Promise<never>;
39
42
  export interface SpawnDaemonResult {
40
43
  /** True when a new daemon process was spawned. */
41
44
  started: boolean;
@@ -190,6 +190,20 @@ export function sweepStrayDaemons(keepPid) {
190
190
  }
191
191
  return strays;
192
192
  }
193
+ // ---------------------------------------------------------------------------
194
+ // daemon launch — low-level spawn/exec
195
+ // ---------------------------------------------------------------------------
196
+ /** Replace PID 1 with the selected daemon generation. Docker keeps a container
197
+ * alive only while PID 1 lives, so a handover there must preserve its pid. */
198
+ export async function execDaemon() {
199
+ const execve = process.execve;
200
+ if (execve === undefined) {
201
+ throw new Error(`this Node.js ${process.version} runtime cannot replace PID 1 with the successor daemon`);
202
+ }
203
+ const executable = hostExecPath();
204
+ const launchArgs = await resolveDaemonLaunch();
205
+ return execve(executable, [executable, ...launchArgs, '--canvas-home', crtrHome()], sanitizedDaemonEnv());
206
+ }
193
207
  // Daemon lifecycle transitions are bounded so callers never claim a state
194
208
  // change before the pidfile and process liveness agree.
195
209
  //
@@ -4,9 +4,10 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
4
4
  * not open with a sleep (or its duration isn't statically knowable). */
5
5
  export declare function leadingSleepSeconds(command: string): number | null;
6
6
  /** The valve's BashOperations backend. Each tool execution closes over its own
7
- * `takePurpose`, so concurrent calls cannot exchange labels. It is consumed even
8
- * when the command is invalid or refused, leaving no state to leak later. */
9
- export declare function createValveOperations(nodeId: string, contextDir: string, takePurpose?: () => string | null): BashOperations;
7
+ * `takePurpose` and its own preview-result path, so concurrent calls cannot
8
+ * exchange labels or result mirrors. The purpose is consumed even when the
9
+ * command is invalid or refused, leaving no state to leak later. */
10
+ export declare function createValveOperations(nodeId: string, contextDir: string, takePurpose?: () => string | null, previewPath?: string): BashOperations;
10
11
  type BashToolDefinition = ReturnType<typeof createBashToolDefinition>;
11
12
  /** Build the valve-backed bash tool and, when configured, add `purpose` to its
12
13
  * schema. Bind each purpose directly to its own execution: Pi may preflight a
@@ -27,8 +27,10 @@
27
27
  import { spawn } from 'node:child_process';
28
28
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync, } from 'node:fs';
29
29
  import { homedir } from 'node:os';
30
+ import { dirname } from 'node:path';
30
31
  import { backgroundBashJob, bashJobPaths, formatBashElapsed, newBashJobId, normalizeBashJobPurpose } from '../core/bash-jobs.js';
31
32
  import { readConfig } from '../core/config.js';
33
+ import { PREVIEW_RESULT_PATH_ENV, previewResultPath } from '../core/preview-result-path.js';
32
34
  import { resolveAdmittedHostCommand } from '../core/runtime/spawn-env.js';
33
35
  import { Type } from 'typebox';
34
36
  import { createBashToolDefinition } from '@earendil-works/pi-coding-agent';
@@ -218,9 +220,10 @@ function resolveExecutionCwd(cwd) {
218
220
  };
219
221
  }
220
222
  /** The valve's BashOperations backend. Each tool execution closes over its own
221
- * `takePurpose`, so concurrent calls cannot exchange labels. It is consumed even
222
- * when the command is invalid or refused, leaving no state to leak later. */
223
- export function createValveOperations(nodeId, contextDir, takePurpose = () => null) {
223
+ * `takePurpose` and its own preview-result path, so concurrent calls cannot
224
+ * exchange labels or result mirrors. The purpose is consumed even when the
225
+ * command is invalid or refused, leaving no state to leak later. */
226
+ export function createValveOperations(nodeId, contextDir, takePurpose = () => null, previewPath) {
224
227
  return {
225
228
  exec: (command, cwd, { onData, signal, timeout, env }) => {
226
229
  return new Promise((resolve, reject) => {
@@ -309,12 +312,20 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
309
312
  const execution = resolveExecutionCwd(cwd);
310
313
  if (execution.warning)
311
314
  onData(Buffer.from(execution.warning));
312
- const shellPath = resolveAdmittedHostCommand('bash', env ?? process.env, execution.cwd);
315
+ // The preview path reaches crtr through the environment, never through
316
+ // the command text: cmd.sh, job inspection, and the doc substrate's
317
+ // `command` surfaces all read that text as the agent's own words.
318
+ let childEnv = env ?? process.env;
319
+ if (previewPath !== undefined) {
320
+ mkdirSync(dirname(previewPath), { recursive: true });
321
+ childEnv = { ...childEnv, [PREVIEW_RESULT_PATH_ENV]: previewPath };
322
+ }
323
+ const shellPath = resolveAdmittedHostCommand('bash', childEnv, execution.cwd);
313
324
  const child = spawn(shellPath, ['-c', SUPERVISOR_SCRIPT, '--', paths.cmdSh, paths.jobLog, paths.jobExit, paths.jobRun, paths.jobDone, paths.jobBg, paths.jobId, nodeId, shellPath], {
314
325
  cwd: execution.cwd,
315
326
  detached: true,
316
327
  stdio: 'ignore',
317
- env: env ?? process.env,
328
+ env: childEnv,
318
329
  });
319
330
  child.on('error', (err) => {
320
331
  finish(() => reject(err));
@@ -418,7 +429,9 @@ export function createValveToolDefinition(nodeId, contextDir, cwd, exposePurpose
418
429
  const schemaDefinition = exposePurpose ? withPurpose(baseDefinition) : baseDefinition;
419
430
  const execute = (toolCallId, params, signal, onUpdate, ctx) => {
420
431
  const purpose = exposePurpose ? normalizeBashJobPurpose(params['purpose']) : null;
421
- const definition = createBashToolDefinition(cwd, { operations: createValveOperations(nodeId, contextDir, () => purpose) });
432
+ const definition = createBashToolDefinition(cwd, {
433
+ operations: createValveOperations(nodeId, contextDir, () => purpose, previewResultPath(toolCallId)),
434
+ });
422
435
  return definition.execute(toolCallId, params, signal, onUpdate, ctx);
423
436
  };
424
437
  return { ...schemaDefinition, execute };
@@ -1,17 +1,9 @@
1
- interface ToolCallEvent {
2
- toolName: string;
3
- toolCallId: string;
4
- input: {
5
- command?: string;
6
- };
7
- }
8
1
  interface ToolResultEvent {
9
2
  toolName: string;
10
3
  toolCallId: string;
11
4
  details?: Record<string, unknown>;
12
5
  }
13
6
  interface PiLike {
14
- on(event: 'tool_call', handler: (event: ToolCallEvent) => void): void;
15
7
  on(event: 'tool_result', handler: (event: ToolResultEvent) => {
16
8
  details: Record<string, unknown>;
17
9
  } | void): void;