@north-light/crouter 0.3.263 → 0.3.269

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 (116) hide show
  1. package/dist/api/client.d.ts +5 -1
  2. package/dist/api/client.js +6 -0
  3. package/dist/api/dto/canvas.d.ts +53 -2
  4. package/dist/api/dto/nodes.d.ts +6 -2
  5. package/dist/api/routes.d.ts +1 -0
  6. package/dist/api/routes.js +1 -0
  7. package/dist/builtin-memory/05-kinds/advisor/advice-contract.md +2 -2
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +50 -0
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +46 -6
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +6 -2
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +7 -2
  12. package/dist/clients/attach/session/whip-achievements.d.ts +4 -0
  13. package/dist/clients/attach/session/whip-achievements.js +145 -0
  14. package/dist/clients/attach/slash/dispatch.js +5 -3
  15. package/dist/clients/attach/viewer.js +607 -606
  16. package/dist/commands/canvas-history/grep.js +3 -22
  17. package/dist/commands/canvas-history/read.js +5 -3
  18. package/dist/commands/canvas-history/search.js +3 -22
  19. package/dist/commands/canvas-history/shared.d.ts +8 -0
  20. package/dist/commands/canvas-history/shared.js +64 -1
  21. package/dist/commands/canvas-history/stats.d.ts +1 -0
  22. package/dist/commands/canvas-history/stats.js +68 -0
  23. package/dist/commands/canvas-history.js +4 -3
  24. package/dist/commands/node/create.js +2 -2
  25. package/dist/commands/sys/config.js +19 -2
  26. package/dist/commands/sys/panels/models-panel.js +3 -1
  27. package/dist/core/__tests__/integration/revive.test.js +4 -2
  28. package/dist/core/__tests__/model-pin-durability.test.js +25 -1
  29. package/dist/core/__tests__/relaunch-root.test.js +17 -0
  30. package/dist/core/__tests__/seam/broker-provider-retry.test.js +19 -4
  31. package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.js +19 -2
  32. package/dist/core/broker-client/__tests__/transport-relay.test.js +3 -1
  33. package/dist/core/broker-client/transport-relay.js +5 -1
  34. package/dist/core/canvas/__tests__/history-transcript.test.d.ts +1 -0
  35. package/dist/core/canvas/__tests__/history-transcript.test.js +102 -0
  36. package/dist/core/canvas/browse/app.d.ts +1 -1
  37. package/dist/core/canvas/browse/app.js +13 -10
  38. package/dist/core/canvas/browse/model.js +16 -1
  39. package/dist/core/canvas/browse/render.d.ts +23 -3
  40. package/dist/core/canvas/browse/render.js +429 -64
  41. package/dist/core/canvas/canvas.js +12 -8
  42. package/dist/core/canvas/extensions.d.ts +1 -1
  43. package/dist/core/canvas/extensions.js +7 -1
  44. package/dist/core/canvas/history.d.ts +36 -1
  45. package/dist/core/canvas/history.js +328 -7
  46. package/dist/core/canvas/labels.d.ts +2 -2
  47. package/dist/core/canvas/migrations.js +32 -0
  48. package/dist/core/canvas/node-recap.d.ts +23 -0
  49. package/dist/core/canvas/node-recap.js +67 -0
  50. package/dist/core/canvas/render-source.d.ts +33 -1
  51. package/dist/core/canvas/render-source.js +80 -22
  52. package/dist/core/canvas/types.d.ts +9 -0
  53. package/dist/core/config.js +13 -5
  54. package/dist/core/keybindings/catalog.d.ts +2 -2
  55. package/dist/core/keybindings/catalog.js +2 -1
  56. package/dist/core/model-routes.js +1 -1
  57. package/dist/core/runtime/broker/auth-reload.js +4 -1
  58. package/dist/core/runtime/broker/fault-retry.js +7 -2
  59. package/dist/core/runtime/broker/frame-client.js +4 -0
  60. package/dist/core/runtime/broker/frame-dispatch.js +7 -0
  61. package/dist/core/runtime/broker/inbox.d.ts +12 -2
  62. package/dist/core/runtime/broker/inbox.js +68 -38
  63. package/dist/core/runtime/broker-protocol.d.ts +6 -0
  64. package/dist/core/runtime/broker.js +14 -0
  65. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  66. package/dist/core/runtime/canvas-extensions.js +2 -0
  67. package/dist/core/runtime/interactive-deliver.d.ts +8 -2
  68. package/dist/core/runtime/interactive-deliver.js +12 -3
  69. package/dist/core/runtime/launch-target.js +1 -1
  70. package/dist/core/runtime/launch.d.ts +12 -6
  71. package/dist/core/runtime/launch.js +19 -17
  72. package/dist/core/runtime/model-swap.d.ts +5 -4
  73. package/dist/core/runtime/model-swap.js +7 -5
  74. package/dist/core/runtime/promote.js +2 -1
  75. package/dist/core/runtime/reset.js +4 -2
  76. package/dist/core/runtime/revive.js +22 -0
  77. package/dist/core/runtime/spawn.d.ts +3 -3
  78. package/dist/core/runtime/spawn.js +20 -3
  79. package/dist/core/runtime/stamp/channel.d.ts +18 -0
  80. package/dist/core/runtime/stamp/channel.js +74 -0
  81. package/dist/core/runtime/stamp/protocol.d.ts +86 -0
  82. package/dist/core/runtime/stamp/protocol.js +113 -0
  83. package/dist/core/runtime/tmux-driver.d.ts +11 -1
  84. package/dist/core/runtime/tmux-driver.js +27 -11
  85. package/dist/core/self-update.js +2 -5
  86. package/dist/core/tui/draw.d.ts +1 -0
  87. package/dist/core/tui/draw.js +5 -1
  88. package/dist/core/tui/markdown.d.ts +17 -0
  89. package/dist/core/tui/markdown.js +407 -0
  90. package/dist/core/user-settings.d.ts +4 -0
  91. package/dist/core/user-settings.js +1 -0
  92. package/dist/daemon/api/__tests__/node-model-validation.test.js +21 -0
  93. package/dist/daemon/api/bridge.d.ts +3 -2
  94. package/dist/daemon/api/bridge.js +34 -9
  95. package/dist/daemon/api/handlers/canvas.js +62 -16
  96. package/dist/daemon/api/handlers/messages.js +12 -1
  97. package/dist/daemon/api/handlers/nodes.js +10 -3
  98. package/dist/daemon/api/map.js +2 -0
  99. package/dist/daemon/messaging/node-message.js +15 -1
  100. package/dist/daemon/reconcilers/broker-supervision.js +26 -9
  101. package/dist/daemon/reconcilers/live-obligation.d.ts +8 -1
  102. package/dist/daemon/reconcilers/live-obligation.js +17 -0
  103. package/dist/pi-extensions/canvas-inbox-watcher.js +27 -2
  104. package/dist/pi-extensions/canvas-recap.js +4 -0
  105. package/dist/pi-extensions/canvas-stamp.d.ts +14 -0
  106. package/dist/pi-extensions/canvas-stamp.js +77 -0
  107. package/dist/pi-extensions/canvas-stophook.js +3 -0
  108. package/dist/shared/__tests__/env-boundary.test.js +4 -2
  109. package/dist/shared/crtr-version.d.ts +2 -0
  110. package/dist/shared/crtr-version.js +18 -0
  111. package/dist/shared/env.d.ts +10 -0
  112. package/dist/shared/env.js +19 -4
  113. package/dist/types.d.ts +10 -0
  114. package/dist/types.js +4 -0
  115. package/package.json +1 -1
  116. package/runtime.lock.json +2 -2
@@ -11,7 +11,7 @@
11
11
  // the sanctioned place for it.
12
12
  import { existsSync, readdirSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
- import { getNode, listNodes, pruneNodes, pruneToLimit, rebuildIndex, rosterEdges, rosterNodes, subscriptionsOf } from '../../../core/canvas/canvas.js';
14
+ import { listNodes, pruneNodes, pruneToLimit, rebuildIndex, rosterEdges, rosterNodes } from '../../../core/canvas/canvas.js';
15
15
  import { activeBackgroundBashJobs } from '../../../core/bash-jobs.js';
16
16
  import { contextDir } from '../../../core/canvas/paths.js';
17
17
  import { nowIso } from '../../../core/fs-utils.js';
@@ -20,7 +20,7 @@ import { dashboardRowsAllFromSource, enrichRowsFromSource } from '../../../core/
20
20
  import { localCanvasSource } from '../../../core/canvas/source.js';
21
21
  import { listFocuses } from '../../../core/canvas/focuses.js';
22
22
  import { ticketCountsForNodes, ticketsAcrossCanvas } from '../../../core/canvas/attention.js';
23
- import { buildCorpus, resolveRef, } from '../../../core/canvas/history.js';
23
+ import { buildCorpus, groupCorpus, resolveRef, } from '../../../core/canvas/history.js';
24
24
  import { nodeDir, nodesRoot } from '../../../core/canvas/paths.js';
25
25
  import { reapEmptyNodes } from '../../../core/runtime/placement.js';
26
26
  import { general, notFound, usage } from '../../../core/errors.js';
@@ -130,9 +130,36 @@ function buildCorpusFromQuery(q) {
130
130
  statuses: q.statuses,
131
131
  sinceMs: q.since_ms,
132
132
  untilMs: q.until_ms,
133
+ origins: q.origins,
134
+ roles: q.roles,
135
+ from: q.from,
133
136
  };
134
137
  return buildCorpus(scope, corpusFilter);
135
138
  }
139
+ function parseStatsBody(body) {
140
+ if (body === undefined || body === null)
141
+ return {};
142
+ if (typeof body !== 'object')
143
+ throw usage('history stats body must be a JSON object');
144
+ return body;
145
+ }
146
+ const GROUP_KEYS = ['origin', 'node', 'kind', 'day', 'type', 'role'];
147
+ function handleHistoryStats(ctx) {
148
+ const q = parseStatsBody(ctx.body);
149
+ const by = (q.group_by ?? 'type');
150
+ if (!GROUP_KEYS.includes(by)) {
151
+ throw usage(`--group-by must be one of: ${GROUP_KEYS.join(', ')}; received: ${String(q.group_by)}`);
152
+ }
153
+ const arts = buildCorpusFromQuery(q);
154
+ const groups = groupCorpus(arts, by);
155
+ const body = {
156
+ group_by: by,
157
+ groups,
158
+ total: groups.reduce((sum, g) => sum + g.count, 0),
159
+ scanned: arts.length,
160
+ };
161
+ return { status: 200, body };
162
+ }
136
163
  function handleHistoryGrep(ctx) {
137
164
  const q = parseGrepBody(ctx.body);
138
165
  const limit = Math.min(100, Math.max(1, q.limit ?? 20));
@@ -254,7 +281,11 @@ function handleHistoryRead(ctx) {
254
281
  });
255
282
  }
256
283
  const source = resolved.source === 'report' && resolved.reportKind ? `report:${resolved.reportKind}` : resolved.source;
257
- const content = includeFrontmatter && resolved.raw !== null ? resolved.raw : resolved.body;
284
+ // `raw` is the verbatim record; `--frontmatter` is the narrower legacy ask
285
+ // for the same bytes on a markdown artifact. Either one yields the raw form
286
+ // when the artifact has one.
287
+ const wantRaw = ctx.query.get('format') === 'raw' || includeFrontmatter;
288
+ const content = wantRaw && resolved.raw !== null ? resolved.raw : resolved.body;
258
289
  const body = {
259
290
  ref,
260
291
  node: `${resolved.nodeName} (${resolved.nodeId})`,
@@ -288,39 +319,53 @@ async function handleSnapshot(ctx) {
288
319
  const asks = ticketCountsForNodes(rows.map((r) => r.node_id));
289
320
  await enrichRowsFromSource(localCanvasSource, rows, asks);
290
321
  const nodes = rows.map((row) => {
291
- const meta = getNode(row.node_id);
292
- const hostKind = meta?.host_kind ?? 'tmux';
322
+ // Every field here comes from the NodeRow listNodes already fetched (stashed
323
+ // on the row as srcRow) — this handler opens no meta.json at all.
324
+ const src = row.srcRow;
325
+ const hostKind = src?.host_kind ?? 'tmux';
293
326
  return {
294
327
  node_id: row.node_id,
295
328
  name: row.name,
296
- title: meta?.name ?? row.name,
297
- description: meta?.description ?? null,
329
+ title: row.handle ?? row.name,
330
+ description: row.description ?? null,
298
331
  kind: row.kind,
299
332
  mode: row.mode,
300
- lifecycle: row.lifecycle ?? meta?.lifecycle,
333
+ lifecycle: row.lifecycle ?? src?.lifecycle,
301
334
  status: row.status,
302
335
  cwd: row.cwd,
303
- profile_id: meta?.profile_id ?? null,
304
- parent: meta?.parent ?? null,
336
+ profile_id: row.profile_id ?? null,
337
+ parent: src?.parent ?? null,
305
338
  created: row.created,
306
339
  host_kind: hostKind,
307
340
  enterable: hostKind === 'broker',
308
341
  attention_count: row.asks,
309
- ...(meta?.cycles !== undefined ? { cycles: meta.cycles } : {}),
342
+ ...(src?.cycles != null ? { cycles: src.cycles } : {}),
310
343
  ...(row.mtimeMs !== undefined ? { last_activity: new Date(row.mtimeMs).toISOString() } : {}),
311
344
  ctx_tokens: row.ctx_tokens,
312
345
  streaming: row.streaming ?? false,
313
- jobs_active: meta !== null && isBrokerLive(meta)
346
+ jobs_active: src !== undefined && isBrokerLive(src)
314
347
  ? activeBackgroundBashJobs(contextDir(row.node_id)).length
315
348
  : 0,
316
- waiting_for: meta?.waiting_for ?? null,
349
+ waiting_for: src?.waiting_for ?? null,
317
350
  hanging: (row.hanging ?? null),
318
351
  viewed: row.viewed ?? false,
319
- final_report: meta?.final_report ?? null,
320
- finalized_at: meta?.finalized_at ?? null,
352
+ final_report: src?.final_report ?? null,
353
+ finalized_at: src?.finalized_at ?? null,
321
354
  };
322
355
  });
323
- const subscriptions = Object.fromEntries(rows.map((row) => [row.node_id, subscriptionsOf(row.node_id).map((ref) => ref.node_id)]));
356
+ // Opt 3: one rosterEdges() pass grouped by subscriber, replacing the per-node
357
+ // subscriptionsOf query. Both are `subscribes_to` edges (active + inactive)
358
+ // ORDER BY created, so grouping preserves the exact per-node ordering. Rows
359
+ // with no outgoing edge still get an empty array (iterate rows, not the map).
360
+ const edgesByFrom = new Map();
361
+ for (const e of rosterEdges()) {
362
+ const list = edgesByFrom.get(e.from_id);
363
+ if (list === undefined)
364
+ edgesByFrom.set(e.from_id, [e.to_id]);
365
+ else
366
+ list.push(e.to_id);
367
+ }
368
+ const subscriptions = Object.fromEntries(rows.map((row) => [row.node_id, edgesByFrom.get(row.node_id) ?? []]));
324
369
  const body = { generated_at: nowIso(), nodes, subscriptions };
325
370
  return { status: 200, body };
326
371
  }
@@ -479,6 +524,7 @@ export const canvasRoutes = [
479
524
  { method: 'POST', pattern: '/v1/canvas/history/search', handler: handleHistorySearch },
480
525
  { method: 'POST', pattern: '/v1/canvas/history/grep', handler: handleHistoryGrep },
481
526
  { method: 'GET', pattern: '/v1/canvas/history/read', handler: handleHistoryRead },
527
+ { method: 'POST', pattern: '/v1/canvas/history/stats', handler: handleHistoryStats },
482
528
  { method: 'GET', pattern: '/v1/canvas/snapshot', handler: handleSnapshot },
483
529
  { method: 'GET', pattern: '/v1/canvas/roster', handler: handleRoster },
484
530
  { method: 'POST', pattern: '/v1/canvas/prune', handler: handlePrune },
@@ -249,7 +249,18 @@ async function handleMessage(ctx) {
249
249
  try {
250
250
  if (hasSidecar)
251
251
  appendSituationalContext(id, sidecar);
252
- await deliverLive(id, req.body, [...(hasSidecar ? [sidecar] : []), ...oneShotCards]);
252
+ await deliverLive(id, req.body, {
253
+ cards: [...(hasSidecar ? [sidecar] : []), ...oneShotCards],
254
+ // Same authorship the durable route would record for this send, so a
255
+ // reader cannot tell live from inbox delivery by the stamp.
256
+ origin: from === 'human'
257
+ ? { type: 'human' }
258
+ : {
259
+ type: 'runtime',
260
+ kind: 'inbox',
261
+ entries: [{ ...(from === null ? {} : { from }), kind: 'message', tier }],
262
+ },
263
+ });
253
264
  cancelCronsOnWake(id);
254
265
  const body = {
255
266
  node_id: id,
@@ -156,9 +156,10 @@ function buildRecipe(req, parent, cwd) {
156
156
  /** The warm-pool request this create is asking for, or null when it carries
157
157
  * something a pre-booted spare cannot honor. A spare is a bare root with no
158
158
  * kickoff, so anything that shapes the node AT BIRTH — a prompt to run, a
159
- * forked conversation, a managed worktree, a caller-pinned node id, a spine
160
- * parent — disqualifies it. `kind`, `mode`, `model`, `name`, `situational_context`
161
- * and `output_schema` are all applied at claim time and so do not. A terminal
159
+ * forked conversation, a managed worktree, a model override, a caller-pinned
160
+ * node id, a spine parent — disqualifies it. `kind`, `mode`, `name`,
161
+ * `situational_context` and `output_schema` are all applied at claim time and
162
+ * so do not. A terminal
162
163
  * root is not warm-compatible: every spare is resident. */
163
164
  function warmRequestFor(req, cwd, profileId) {
164
165
  if (!warmPoolEnabled())
@@ -171,6 +172,11 @@ function warmRequestFor(req, cwd, profileId) {
171
172
  return null;
172
173
  if ((req.prompt ?? '') !== '')
173
174
  return null;
175
+ // An explicit override freezes the requested model before the first turn;
176
+ // applying it to a live spare would switch models after its broker already
177
+ // launched its default route.
178
+ if (req.model !== undefined)
179
+ return null;
174
180
  if (req.fork_from !== undefined || req.worktree !== undefined || req.node_id !== undefined)
175
181
  return null;
176
182
  return {
@@ -196,6 +202,7 @@ async function assertNodeModelChangeRegistered(meta, target, registry) {
196
202
  lifecycle: target.lifecycle,
197
203
  hasManager: meta.parent !== null,
198
204
  model: target.model,
205
+ modelOverride: true,
199
206
  cwd: meta.cwd,
200
207
  profileId: meta.profile_id ?? null,
201
208
  })).launch;
@@ -28,6 +28,8 @@ export function toNodeSummaryDTO(row) {
28
28
  return {
29
29
  node_id: row.node_id,
30
30
  name: row.name,
31
+ description: row.description,
32
+ cycles: row.cycles,
31
33
  kind: row.kind,
32
34
  mode: row.mode,
33
35
  lifecycle: row.lifecycle,
@@ -39,7 +39,21 @@ export async function deliverNodeMessage(args) {
39
39
  // card; only live delivery needs this message's own envelope.
40
40
  await deliverLive(args.node_id, args.runtime === undefined
41
41
  ? args.body
42
- : formatCard(args.runtime.kind, args.runtime.facts, args.body));
42
+ : formatCard(args.runtime.kind, args.runtime.facts, args.body), {
43
+ // The authorship the durable fallback below would record for this
44
+ // same send, so the two routes stamp identically.
45
+ origin: args.from === 'human'
46
+ ? { type: 'human' }
47
+ : {
48
+ type: 'runtime',
49
+ kind: 'inbox',
50
+ entries: [{
51
+ ...(args.from == null ? {} : { from: args.from }),
52
+ kind: 'message',
53
+ tier: 'normal',
54
+ }],
55
+ },
56
+ });
43
57
  // The broker acknowledgement is asynchronous to the SQLite guard. Recheck
44
58
  // before reporting an engine receipt so a final that committed during the
45
59
  // request is not represented as a routable delivery.
@@ -24,23 +24,30 @@ import { deliverLive } from '../../core/runtime/interactive-deliver.js';
24
24
  import { BrokerUnreachableError } from '../../core/runtime/broker-request.js';
25
25
  import { formatCard, PARK_SUMMARY_PROMPT } from '../../shared/generated-context.js';
26
26
  import { brokerThresholdsForDaemon } from '../../core/runtime/fleet.js';
27
+ import { readConfig } from '../../core/config.js';
27
28
  import { beginPendingPark, consumePendingPark, pendingParkIds, pendingParkStartedAt } from '../park-pending.js';
28
29
  import { applyParkEvent, parkEvent } from './live-obligation.js';
29
- import { envNoDaemonAutostart, envTestParkSummaryGraceMs, envTestUnattendedParkMs, } from '../../shared/env.js';
30
+ import { envNoDaemonAutostart, envTestParkSummaryGraceMs, envTestUnattendedParkMs, envUnattendedParkMs, } from '../../shared/env.js';
30
31
  const execFileAsync = promisify(execFile);
31
32
  export const YIELD_STALL_GRACE_MS = 3 * 60_000;
32
33
  const KILL_ESCALATE_MS = 20_000;
33
34
  export const WEDGE_QUIET_MS = 20 * 60_000;
34
35
  export const WEDGE_CPU_MAX_PERCENT = 2;
35
36
  const OVER_CAP_WARN_THROTTLE_MS = 5 * 60_000;
36
- const DEFAULT_UNATTENDED_PARK_MS = 15 * 60_000;
37
37
  // Longer than a summary turn and shorter than the unattended clock itself, so a
38
38
  // stuck parking turn cannot hold a broker for a whole extra clock period.
39
39
  const DEFAULT_PARK_SUMMARY_GRACE_MS = 5 * 60_000;
40
40
  export function unattendedParkMsForDaemon() {
41
+ // The `CRTR_UNATTENDED_PARK_MS` override wins whenever set, autostart or not.
42
+ const envOverride = envUnattendedParkMs();
43
+ if (envOverride !== undefined)
44
+ return envOverride;
45
+ // Otherwise the user-scope config value is the base (default 15 min), with the
46
+ // test-only override winning only when autostart is disabled.
47
+ const configured = readConfig('user').lifecycle.unattendedParkMs;
41
48
  if (!envNoDaemonAutostart())
42
- return DEFAULT_UNATTENDED_PARK_MS;
43
- return envTestUnattendedParkMs() ?? DEFAULT_UNATTENDED_PARK_MS;
49
+ return configured;
50
+ return envTestUnattendedParkMs() ?? configured;
44
51
  }
45
52
  export function parkSummaryGraceMsForDaemon() {
46
53
  return envTestParkSummaryGraceMs() ?? DEFAULT_PARK_SUMMARY_GRACE_MS;
@@ -149,6 +156,11 @@ export class BrokerSupervisionReconciler {
149
156
  ctx.lifecycle.registerDetached(warning);
150
157
  }
151
158
  async run(now, ctx) {
159
+ // Resolve the unattended-park interval once per tick — it reads user
160
+ // config.json, so a per-broker read would add one file read per eligible
161
+ // broker and could apply different values within one tick after a config
162
+ // write (mirrors how `runFleetPreamble` snapshots the broker thresholds).
163
+ const unattendedParkMs = unattendedParkMsForDaemon();
152
164
  for (const row of ctx.rows) {
153
165
  const id = row.node_id;
154
166
  const entry = ctx.fleet.get(id);
@@ -161,7 +173,7 @@ export class BrokerSupervisionReconciler {
161
173
  this.handleYieldStall(row, entry.pid, now);
162
174
  await this.handleWedgeDetection(id, entry.pid, row.pi_pid_identity, now);
163
175
  this.handleFatalFault(id);
164
- this.handleUnattendedParking(id, getNode(id), entry.pid, now, ctx);
176
+ this.handleUnattendedParking(id, getNode(id), entry.pid, now, ctx, unattendedParkMs);
165
177
  }
166
178
  catch (err) {
167
179
  this.unattendedSince.delete(id);
@@ -211,7 +223,7 @@ export class BrokerSupervisionReconciler {
211
223
  return true;
212
224
  return hasUnseenWakeMail(id);
213
225
  }
214
- handleUnattendedParking(id, row, livePid, now, ctx) {
226
+ handleUnattendedParking(id, row, livePid, now, ctx, unattendedParkMs) {
215
227
  // An unclaimed warm-pool spare is not a conversation: the booted broker it
216
228
  // holds IS its whole value, and nobody is meant to be attending it. Its
217
229
  // lifetime belongs to `reapStaleSpares` (warm-pool.ts) and its own TTL, so
@@ -269,7 +281,7 @@ export class BrokerSupervisionReconciler {
269
281
  this.unattendedSince.set(id, now);
270
282
  return;
271
283
  }
272
- if (now - since < unattendedParkMsForDaemon())
284
+ if (now - since < unattendedParkMs)
273
285
  return;
274
286
  this.unattendedSince.delete(id);
275
287
  const event = parkEvent(id, row.lifecycle);
@@ -302,7 +314,9 @@ export class BrokerSupervisionReconciler {
302
314
  * the node is working again and its clock simply restarts. */
303
315
  async requestParkSummary(id) {
304
316
  try {
305
- const route = await deliverLive(id, formatCard('park', {}, PARK_SUMMARY_PROMPT));
317
+ const route = await deliverLive(id, formatCard('park', {}, PARK_SUMMARY_PROMPT), {
318
+ origin: { type: 'runtime', kind: 'park' },
319
+ });
306
320
  if (route === 'steer' && consumePendingPark(id))
307
321
  return;
308
322
  emitEvent({
@@ -361,7 +375,10 @@ export class BrokerSupervisionReconciler {
361
375
  if (client === '')
362
376
  return;
363
377
  try {
364
- await execFileAsync('tmux', ['display-message', '-d', '10000', '-c', client, msg], {
378
+ // `-C` keeps the client's panes painting while the warning sits in
379
+ // its status line; without it every attached viewer freezes for the
380
+ // full 10s. The plain retry below covers a tmux without the flag.
381
+ await execFileAsync('tmux', ['display-message', '-C', '-d', '10000', '-c', client, msg], {
365
382
  encoding: 'utf8',
366
383
  timeout: 2000,
367
384
  });
@@ -11,10 +11,17 @@ export declare function hasLiveObligation(nodeId: string): boolean;
11
11
  * Deferred mail deliberately does not count: it rides the node's next natural
12
12
  * cycle rather than causing one. */
13
13
  export declare function hasPendingWake(nodeId: string): boolean;
14
+ /** Whether a person is looking at this node right now: a focused viewer pane,
15
+ * or a broker client that declared itself attending. A client that hellos with
16
+ * `attends: false` is a system observer and deliberately does not count, which
17
+ * is how Northlight Core identifies itself — so an open product page is
18
+ * invisible here, while crouter's own local and remote viewers are not. */
19
+ export declare function hasHumanViewer(nodeId: string): boolean;
14
20
  /** What parking an unattended node actually amounts to — the one rule both the
15
21
  * live-broker park clock and the lifecycle tick apply.
16
22
  *
17
- * `release` — something can still wake it, so parking is a pause.
23
+ * `release` — something can still wake it, or a person is watching it, so
24
+ * parking is a pause.
18
25
  * `park` — a resident with nothing left to wake it has completed its
19
26
  * conversation, and the row becomes prunable history. The live
20
27
  * clock asks it for a summary turn first; this verdict is what
@@ -28,9 +28,26 @@ export function hasPendingWake(nodeId) {
28
28
  return true;
29
29
  if (hasUnseenWakeMail(nodeId))
30
30
  return true;
31
+ return hasHumanViewer(nodeId);
32
+ }
33
+ /** Whether a person is looking at this node right now: a focused viewer pane,
34
+ * or a broker client that declared itself attending. A client that hellos with
35
+ * `attends: false` is a system observer and deliberately does not count, which
36
+ * is how Northlight Core identifies itself — so an open product page is
37
+ * invisible here, while crouter's own local and remote viewers are not. */
38
+ export function hasHumanViewer(nodeId) {
31
39
  return focusOf(nodeId) !== null || hasAttachedBrokerViewers(nodeId);
32
40
  }
33
41
  export function parkEvent(nodeId, lifecycle) {
42
+ // Attendance defeats unattended parking outright: the grace exists to settle
43
+ // a conversation nobody is in, and terminalizing a node somebody is watching
44
+ // is the one outcome the grace must never reach. The live clock resets its
45
+ // own timer on the same test, so this covers the paths that reach a verdict
46
+ // without it — the settlement re-check after a parking turn, and the tick's
47
+ // sweep over already-idle rows. The live clock discards its timer on that
48
+ // same reset, so parking resumes a full grace after the last viewer detaches.
49
+ if (hasHumanViewer(nodeId))
50
+ return 'release';
34
51
  if (hasLiveObligation(nodeId))
35
52
  return 'release';
36
53
  if (lifecycle === 'resident')
@@ -17,6 +17,7 @@
17
17
  import { envNodeId, envProfileId, envWatcherDebounceMs, envWatcherTickMs } from '../shared/env.js';
18
18
  import { coalesceBrokerInbox, entryCards, readBrokerCanceledEntryIds, readBrokerCursor, readBrokerInboxSince, reportRefNodeId, } from '../core/runtime/broker/inbox.js';
19
19
  import { advanceBrokerInboxCursor, brokerExtensionState } from '../core/runtime/broker/daemon-ops.js';
20
+ import { announceMessageOrigin } from '../core/runtime/stamp/channel.js';
20
21
  import { captureGoalIfAbsent, generateAndCommitInitialName } from './broker-local.js';
21
22
  import { emitEvent } from '../core/events/emit.js';
22
23
  import { errorClassFromError } from '../core/events/errors.js';
@@ -97,6 +98,26 @@ function emitStoredBestEffort(operationId, input) {
97
98
  // Delivery state remains authoritative when its observational record cannot append.
98
99
  }
99
100
  }
101
+ /** The authorship stamped onto a delivered message. A person's words arrive
102
+ * verbatim and are stamped as theirs — no channel, because the durable inbox
103
+ * is not a transport a reader can attribute. Every other delivery is the
104
+ * runtime's inbox card, carrying each entry's attribution as the coalescer
105
+ * received it. */
106
+ function deliveryOrigin(delivery) {
107
+ if (delivery.kind === 'human')
108
+ return { type: 'human' };
109
+ return {
110
+ type: 'runtime',
111
+ kind: 'inbox',
112
+ entries: delivery.entries.map((entry) => ({
113
+ ...(entry.from === null ? {} : { from: entry.from }),
114
+ ...(entry.from_name === undefined ? {} : { fromName: entry.from_name }),
115
+ kind: entry.kind,
116
+ tier: entry.tier,
117
+ ...(entry.ref === undefined ? {} : { ref: entry.ref }),
118
+ })),
119
+ };
120
+ }
100
121
  function deliveryFacts(entries, channel) {
101
122
  if (entries.length === 0)
102
123
  throw new Error(`cannot create an empty ${channel} delivery`);
@@ -729,7 +750,7 @@ class CanvasInboxWatcher {
729
750
  * unit: a throw anywhere requeues the whole entry, so a retry may repeat a
730
751
  * card already handed off (context is restatable; the human's body is not,
731
752
  * and it is sent last). */
732
- sendVisible(entries, cards, content, route, stage) {
753
+ sendVisible(entries, cards, content, origin, route, stage) {
733
754
  return this.sendDelivery('visible', entries, () => {
734
755
  if (route !== 'idle' && route !== 'steer' && route !== 'followUp') {
735
756
  throw new Error(`visible send requested for non-send route ${route}`);
@@ -737,6 +758,10 @@ class CanvasInboxWatcher {
737
758
  for (const card of cards) {
738
759
  this.pi.sendMessage({ customType: SITUATIONAL_CONTEXT_CUSTOM_TYPE, content: card, display: true }, route === 'idle' ? {} : { deliverAs: route });
739
760
  }
761
+ // Claimed by the stamp writer against the message pi persists, so a send
762
+ // that throws here leaves an announcement nobody claims rather than one
763
+ // that mislabels a later message.
764
+ announceMessageOrigin(content, origin);
740
765
  if (route === 'idle') {
741
766
  const start = brokerIdleTurnStart();
742
767
  if (start === undefined)
@@ -803,7 +828,7 @@ class CanvasInboxWatcher {
803
828
  if (!this.canMutate())
804
829
  return { inactive: true, requeueEntryIds };
805
830
  if (delivery !== undefined) {
806
- const result = this.sendVisible(delivery.entries, delivery.cards, delivery.text, route, stage);
831
+ const result = this.sendVisible(delivery.entries, delivery.cards, delivery.text, deliveryOrigin(delivery), route, stage);
807
832
  if (result === 'inactive')
808
833
  return { inactive: true, requeueEntryIds };
809
834
  if (result === 'failed') {
@@ -41,6 +41,7 @@
41
41
  import { envNodeId, envRecapIdleMs } from '../shared/env.js';
42
42
  import { brokerExtensionState } from '../core/runtime/broker/daemon-ops.js';
43
43
  import { generateRecap } from '../core/runtime/recap.js';
44
+ import { writeNodeRecap } from '../core/canvas/node-recap.js';
44
45
  import { isReviewBoundary } from '../core/runtime/session-visibility.js';
45
46
  import { editorLabelForBrokerNode } from '../core/runtime/broker-extension-render.js';
46
47
  import { generateAndCommitRecapName, readRoadmap } from './broker-local.js';
@@ -315,6 +316,9 @@ export function registerCanvasRecap(pi, recapGenerator = generateRecap, nameRege
315
316
  // Automatic work paints only while its idle snapshot remains current.
316
317
  if ((!manual && activityVersion !== snapshotVersion) || !canPaint())
317
318
  return;
319
+ // Persist what we are about to paint. The widget dies with the broker; the
320
+ // file is what `crtr canvas browse` shows for a dormant node.
321
+ writeNodeRecap(nodeId, briefing);
318
322
  recapShown = true;
319
323
  recapStale = activityVersion !== snapshotVersion;
320
324
  showRecap(briefing);
@@ -0,0 +1,14 @@
1
+ interface StampCtxLike {
2
+ sessionManager: {
3
+ /** Whole file, not the active branch: a session stamp on an abandoned
4
+ * branch still describes this file's owner. */
5
+ getEntries: () => unknown[];
6
+ };
7
+ }
8
+ interface PiLike {
9
+ on: (event: 'session_start' | 'message_end', handler: (event: unknown, ctx: StampCtxLike) => void | Promise<void>) => void;
10
+ appendEntry: (customType: string, data?: unknown) => void;
11
+ }
12
+ /** Register the stamp writer. */
13
+ export declare function registerCanvasStamp(pi: PiLike): void;
14
+ export default registerCanvasStamp;
@@ -0,0 +1,77 @@
1
+ // canvas-stamp.ts — the sole writer of `crtr-stamp` entries.
2
+ //
3
+ // Two records land in the session jsonl. A SESSION stamp names the node and the
4
+ // crtr release that own the file, appended at session start only when the
5
+ // latest one on disk disagrees — which covers a fresh session, a fork (whose
6
+ // copied history still carries the source node's stamp), a new cycle, and a
7
+ // runtime upgrade mid-node, without a rule per case. A MESSAGE stamp records
8
+ // where one user message came from, joined to it by the message's own
9
+ // timestamp.
10
+ //
11
+ // Every user message is stamped; an origin appears only when a delivery seam
12
+ // announced one (absence means unknown, never a guess). An assistant message is
13
+ // stamped only when an `ext` contribution is pending, so an ordinary turn costs
14
+ // no line.
15
+ //
16
+ // Ordering note: pi runs message_end handlers BEFORE it persists the finalized
17
+ // message, so the stamp is written as that message's structural parent. The
18
+ // join is by `msgTs`, so tree position carries no meaning here.
19
+ import { envNodeId } from '../shared/env.js';
20
+ import { crtrVersion } from '../shared/crtr-version.js';
21
+ import { claimMessageOrigin, drainStampExt, hasPendingStampExt, } from '../core/runtime/stamp/channel.js';
22
+ import { CRTR_STAMP_CUSTOM_TYPE, latestSessionStamp, STAMP_SCHEMA_VERSION, } from '../core/runtime/stamp/protocol.js';
23
+ /** Plain text of a message's content (string or content blocks). */
24
+ function messageText(content) {
25
+ if (typeof content === 'string')
26
+ return content;
27
+ if (Array.isArray(content)) {
28
+ return content
29
+ .filter((block) => typeof block === 'object' && block !== null && typeof block.text === 'string')
30
+ .map((block) => block.text)
31
+ .join('\n');
32
+ }
33
+ return '';
34
+ }
35
+ function envelope(kind, payload) {
36
+ const ext = drainStampExt();
37
+ return {
38
+ v: STAMP_SCHEMA_VERSION,
39
+ kind,
40
+ payload,
41
+ ...(ext === undefined ? {} : { ext }),
42
+ };
43
+ }
44
+ /** Register the stamp writer. */
45
+ export function registerCanvasStamp(pi) {
46
+ pi.on('session_start', (_event, ctx) => {
47
+ const node = envNodeId();
48
+ if (node === undefined || node.trim() === '')
49
+ return;
50
+ const crtr = crtrVersion();
51
+ const latest = latestSessionStamp(ctx.sessionManager.getEntries());
52
+ if (latest !== null && latest.payload.node === node && latest.payload.crtr === crtr)
53
+ return;
54
+ pi.appendEntry(CRTR_STAMP_CUSTOM_TYPE, envelope('session', { node, crtr }));
55
+ });
56
+ pi.on('message_end', (event) => {
57
+ const message = event?.message;
58
+ if (message === undefined || message === null)
59
+ return;
60
+ if (typeof message.timestamp !== 'number')
61
+ return;
62
+ const msgTs = message.timestamp;
63
+ if (message.role === 'user') {
64
+ const origin = claimMessageOrigin(messageText(message.content));
65
+ pi.appendEntry(CRTR_STAMP_CUSTOM_TYPE, envelope('message', {
66
+ msgTs,
67
+ ...(origin === null ? {} : { origin }),
68
+ }));
69
+ return;
70
+ }
71
+ // Any other message earns a line only for a pending contribution.
72
+ if (!hasPendingStampExt())
73
+ return;
74
+ pi.appendEntry(CRTR_STAMP_CUSTOM_TYPE, envelope('message', { msgTs }));
75
+ });
76
+ }
77
+ export default registerCanvasStamp;
@@ -33,6 +33,7 @@ import { activeBackgroundBashJobs } from '../core/bash-jobs.js';
33
33
  import { CONTEXT_NUDGE_CUSTOM_TYPE, formatCard } from '../shared/generated-context.js';
34
34
  import { isReviewBoundary } from '../core/runtime/session-visibility.js';
35
35
  import { runRefreshAbort } from '../core/runtime/broker/extension-abort.js';
36
+ import { announceMessageOrigin } from '../core/runtime/stamp/channel.js';
36
37
  /** Condense a tool call into one short line for the activity cue. Picks the most
37
38
  * identifying arg (command / path / pattern / description), collapses whitespace,
38
39
  * and hard-caps the length so a giant bash command can't bloat telemetry.json. */
@@ -271,6 +272,7 @@ export function registerCanvasStophook(pi) {
271
272
  };
272
273
  const enactSettleDirective = (directive, ctx) => {
273
274
  if (directive.action === 'reprompt') {
275
+ announceMessageOrigin(directive.prompt, { type: 'runtime', kind: 'stop-guard' });
274
276
  pi.sendUserMessage(directive.prompt, { deliverAs: 'followUp' });
275
277
  return;
276
278
  }
@@ -566,6 +568,7 @@ export function registerCanvasStophook(pi) {
566
568
  firedBands.add(at);
567
569
  const note = formatCard('context-nudge', {}, steerNote(at, node.lifecycle, node.mode));
568
570
  if (runContinues) {
571
+ announceMessageOrigin(note, { type: 'runtime', kind: 'context-nudge' });
569
572
  pi.sendUserMessage(note, { deliverAs: 'steer' });
570
573
  }
571
574
  else {
@@ -17,6 +17,7 @@ const MIGRATED_VARS = [
17
17
  'CRTR_PROFILE_ID',
18
18
  'CRTR_NODE_CWD',
19
19
  'CRTR_MODEL_INTENT',
20
+ 'CRTR_MODEL_EXACT',
20
21
  'CRTR_HOME',
21
22
  'CRTR_STREAM_WATCHDOG_MS',
22
23
  'CRTR_WATCHER_TICK_MS',
@@ -35,6 +36,7 @@ const MIGRATED_VARS = [
35
36
  'CRTR_WARN_LIVE_BROKERS',
36
37
  'CRTR_TEST_UNATTENDED_PARK_MS',
37
38
  'CRTR_TEST_PARK_SUMMARY_GRACE_MS',
39
+ 'CRTR_UNATTENDED_PARK_MS',
38
40
  ];
39
41
  // Matches `process.env['VAR']`, `process.env.VAR`, or `process.env[\`VAR\`]`
40
42
  // for exactly one of MIGRATED_VARS — not a longer name that merely starts
@@ -53,8 +55,8 @@ const EXEMPTIONS = new Map([
53
55
  ],
54
56
  [
55
57
  'core/runtime/broker.ts',
56
- "clears CRTR_MODEL_INTENT via `delete process.env[...]` — a mutation, " +
57
- 'not a read, so it stays here per shared/env.ts\'s header comment.',
58
+ "clears CRTR_MODEL_INTENT and sets CRTR_MODEL_EXACT via process.env — mutations, " +
59
+ 'not reads, so they stay here per shared/env.ts\'s header comment.',
58
60
  ],
59
61
  ]);
60
62
  function tsFilesUnder(dir) {
@@ -0,0 +1,2 @@
1
+ /** The running crouter package version (`package.json` `version`). */
2
+ export declare function crtrVersion(): string;
@@ -0,0 +1,18 @@
1
+ // The installed crtr release string, read once per process from the package
2
+ // manifest. Callers that record a version into durable state (the session
3
+ // stamp) and callers that compare against npm (self-update) read this one
4
+ // implementation.
5
+ import { readFileSync } from 'node:fs';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ let cached = null;
9
+ /** The running crouter package version (`package.json` `version`). */
10
+ export function crtrVersion() {
11
+ if (cached !== null)
12
+ return cached;
13
+ // src|dist/shared/crtr-version.ts → up to src|dist/ → up to pkg root
14
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
15
+ const parsed = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
16
+ cached = parsed.version;
17
+ return cached;
18
+ }