@crouton-kit/crouter 0.3.19 → 0.3.21

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 (90) hide show
  1. package/dist/builtin-memory/crouter-development/marketplaces.md +2 -4
  2. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +3 -5
  3. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +3 -6
  4. package/dist/builtin-memory/crouter-development/personas.md +2 -6
  5. package/dist/builtin-memory/crouter-development/plugins.md +2 -4
  6. package/dist/builtin-memory/design.md +2 -4
  7. package/dist/builtin-memory/development.md +3 -3
  8. package/dist/builtin-memory/planning.md +3 -4
  9. package/dist/builtin-memory/spec.md +3 -10
  10. package/dist/builtin-personas/design/PERSONA.md +1 -0
  11. package/dist/builtin-personas/design/orchestrator.md +1 -0
  12. package/dist/builtin-personas/developer/PERSONA.md +1 -0
  13. package/dist/builtin-personas/developer/orchestrator.md +1 -0
  14. package/dist/builtin-personas/explore/PERSONA.md +1 -0
  15. package/dist/builtin-personas/explore/orchestrator.md +4 -0
  16. package/dist/builtin-personas/general/PERSONA.md +1 -0
  17. package/dist/builtin-personas/general/orchestrator.md +4 -0
  18. package/dist/builtin-personas/orchestration-kernel.md +1 -1
  19. package/dist/builtin-personas/plan/PERSONA.md +1 -0
  20. package/dist/builtin-personas/plan/orchestrator.md +1 -0
  21. package/dist/builtin-personas/plan/reviewers/architecture-fit/PERSONA.md +1 -0
  22. package/dist/builtin-personas/plan/reviewers/code-smells/PERSONA.md +1 -0
  23. package/dist/builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md +1 -0
  24. package/dist/builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md +1 -0
  25. package/dist/builtin-personas/plan/reviewers/security/PERSONA.md +1 -0
  26. package/dist/builtin-personas/review/PERSONA.md +1 -0
  27. package/dist/builtin-personas/review/orchestrator.md +4 -0
  28. package/dist/builtin-personas/spec/PERSONA.md +1 -0
  29. package/dist/builtin-personas/spec/orchestrator.md +1 -0
  30. package/dist/commands/human/queue.js +13 -2
  31. package/dist/commands/memory/__tests__/lint-schema.test.d.ts +1 -0
  32. package/dist/commands/memory/__tests__/lint-schema.test.js +29 -0
  33. package/dist/commands/memory/find.js +10 -15
  34. package/dist/commands/memory/lint.d.ts +7 -0
  35. package/dist/commands/memory/lint.js +10 -1
  36. package/dist/commands/memory/shared.js +1 -2
  37. package/dist/commands/memory/write.js +6 -9
  38. package/dist/commands/node.js +51 -26
  39. package/dist/commands/push.js +10 -15
  40. package/dist/commands/tmux-spread.js +16 -1
  41. package/dist/commands/view-cycle.js +9 -5
  42. package/dist/commands/view-run.js +3 -2
  43. package/dist/core/__tests__/broker-attach-limits.test.d.ts +1 -0
  44. package/dist/core/__tests__/broker-attach-limits.test.js +157 -0
  45. package/dist/core/__tests__/broker-attach-stream.test.d.ts +1 -0
  46. package/dist/core/__tests__/broker-attach-stream.test.js +125 -0
  47. package/dist/core/__tests__/broker-crash-teardown.test.d.ts +1 -0
  48. package/dist/core/__tests__/broker-crash-teardown.test.js +116 -0
  49. package/dist/core/__tests__/broker-dialogs.test.d.ts +1 -0
  50. package/dist/core/__tests__/broker-dialogs.test.js +126 -0
  51. package/dist/core/__tests__/broker-dormant-wake.test.d.ts +1 -0
  52. package/dist/core/__tests__/broker-dormant-wake.test.js +51 -0
  53. package/dist/core/__tests__/broker-lifecycle.test.js +14 -604
  54. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.d.ts +1 -0
  55. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +102 -0
  56. package/dist/core/__tests__/canvas-inbox-watcher.test.js +2 -36
  57. package/dist/core/__tests__/cascade-close.test.js +3 -2
  58. package/dist/core/__tests__/context-intro.test.js +23 -27
  59. package/dist/core/__tests__/detach-focus.test.js +2 -2
  60. package/dist/core/__tests__/helpers/broker-clients.d.ts +43 -0
  61. package/dist/core/__tests__/helpers/broker-clients.js +178 -0
  62. package/dist/core/__tests__/live-mutation-verbs.test.d.ts +1 -0
  63. package/dist/core/__tests__/live-mutation-verbs.test.js +175 -0
  64. package/dist/core/__tests__/live-mutation.test.js +4 -147
  65. package/dist/core/__tests__/memory.test.js +4 -4
  66. package/dist/core/__tests__/subscription-delivery.test.js +3 -3
  67. package/dist/core/bootstrap.js +18 -14
  68. package/dist/core/canvas/canvas.js +1 -1
  69. package/dist/core/canvas/types.d.ts +6 -0
  70. package/dist/core/help.d.ts +2 -2
  71. package/dist/core/help.js +1 -1
  72. package/dist/core/render.d.ts +4 -3
  73. package/dist/core/render.js +38 -41
  74. package/dist/core/resolver.js +1 -1
  75. package/dist/core/runtime/bearings.d.ts +2 -2
  76. package/dist/core/runtime/bearings.js +2 -2
  77. package/dist/core/runtime/launch.d.ts +9 -2
  78. package/dist/core/runtime/launch.js +20 -3
  79. package/dist/core/runtime/nodes.d.ts +4 -0
  80. package/dist/core/runtime/nodes.js +1 -0
  81. package/dist/core/runtime/promote.js +3 -0
  82. package/dist/core/runtime/reset.js +3 -2
  83. package/dist/core/runtime/spawn.d.ts +4 -0
  84. package/dist/core/runtime/spawn.js +2 -1
  85. package/dist/core/substrate/render.d.ts +1 -2
  86. package/dist/core/substrate/render.js +9 -12
  87. package/dist/core/substrate/schema.d.ts +18 -13
  88. package/dist/core/substrate/schema.js +12 -11
  89. package/dist/pi-extensions/canvas-context-intro.js +5 -3
  90. package/package.json +1 -1
@@ -119,6 +119,7 @@ const nodeNew = defineLeaf({
119
119
  { kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an INDEPENDENT root instead of a managed child: no parent (top-level on the canvas), NO subscription back to you (you are NOT woken by it), resident lifecycle. It records spawned_by=you for provenance and is brought forefront so it can be driven directly. Use for a node you hand off and do not manage (e.g. a sub-orchestrator a human will discuss with).' },
120
120
  { kind: 'flag', name: 'fork-from', type: 'string', required: false, constraint: 'FORK the new node from an existing pi conversation instead of starting it fresh: pass a node id (forks from that node\'s session), an absolute session `.jsonl` path, or a partial pi session uuid. pi copies that whole history into a NEW session for the child (the source is untouched), then the prompt is delivered as the next message — i.e. the child wakes up as a continuation of that conversation. Use to branch exploratory work off a node that already built up the context you need, instead of re-deriving it. One-shot at birth: the fork resumes its own session thereafter.' },
121
121
  { kind: 'flag', name: 'headless', type: 'bool', required: false, constraint: 'Spawn the node on the HEADLESS broker host (no tmux pane) instead of a tmux window. Overrides the `headless` config default for this spawn; omit to use that default (which itself defaults to a tmux pane).' },
122
+ { kind: 'flag', name: 'model', type: 'enum', choices: ['ultra', 'strong', 'medium', 'light'], required: false, constraint: 'Override the model the node runs on, by capability TIER: ultra (frontier), strong (opus), medium (sonnet), light (haiku). Omit to use the kind\'s persona default (explore=light, every other kind=strong). The override is durable — it survives revives and polymorphs (promote/demote).' },
122
123
  ],
123
124
  output: [
124
125
  { name: 'node_id', type: 'string', required: true, constraint: 'The new node id.' },
@@ -148,9 +149,10 @@ const nodeNew = defineLeaf({
148
149
  const parent = input['parent'];
149
150
  const root = input['root'] === true;
150
151
  const forkFrom = input['forkFrom'];
152
+ const model = input['model'];
151
153
  // Host precedence: explicit --headless flag > config `headless` default > tmux.
152
154
  const hostKind = input['headless'] === true ? 'broker' : readConfig('user').headless === true ? 'broker' : 'tmux';
153
- const res = spawnChild({ kind, mode, cwd, name, prompt, parent, root, forkFrom, hostKind });
155
+ const res = spawnChild({ kind, mode, cwd, name, prompt, parent, root, forkFrom, hostKind, model });
154
156
  return {
155
157
  node_id: res.node.node_id,
156
158
  name: res.node.name,
@@ -162,7 +164,14 @@ const nodeNew = defineLeaf({
162
164
  : childFollowUp(parent ?? process.env['CRTR_NODE_ID']),
163
165
  };
164
166
  },
165
- render: (r) => `<spawned name="${r['name']}" id="${r['node_id']}" status="${r['status']}">\n${r['follow_up']}\n</spawned>`,
167
+ render: (r) => {
168
+ const win = r['window'] ? ` in window ${r['window']}` : '';
169
+ const lines = [`Spawned "${r['name']}" (${r['node_id']}) — ${r['status']}${win}.`];
170
+ if (r['session'])
171
+ lines.push(`- session: ${r['session']}`);
172
+ lines.push('', String(r['follow_up']));
173
+ return lines.join('\n');
174
+ },
166
175
  });
167
176
  // ---------------------------------------------------------------------------
168
177
  // node list — the active canvas (or a status slice)
@@ -350,9 +359,16 @@ const nodeRecycle = defineLeaf({
350
359
  const res = await recycleNode(id, pane);
351
360
  return { recycled: res.recycled, node_id: id, finalized: res.finalized, delivered: res.delivered.length, new_root: res.newRoot ?? undefined };
352
361
  },
353
- render: (r) => r['recycled'] === true
354
- ? `<recycled id="${r['node_id']}" finalized="${r['finalized']}" delivered="${r['delivered']}" new_root="${r['new_root'] ?? ''}"/>`
355
- : `<recycle-failed id="${r['node_id'] ?? ''}">not in tmux, or no agent in this pane</recycle-failed>`,
362
+ render: (r) => {
363
+ if (r['recycled'] !== true)
364
+ return `Recycle failed for ${r['node_id'] ?? '?'}not in tmux, or no agent in this pane.`;
365
+ const lines = [`Recycled the pane — finished ${r['node_id']}.`, `- finalized: ${r['finalized']}`];
366
+ if (r['finalized'] === true)
367
+ lines.push(`- delivered: ${r['delivered']}`);
368
+ if (r['new_root'])
369
+ lines.push(`- new root: ${r['new_root']}`);
370
+ return lines.join('\n');
371
+ },
356
372
  });
357
373
  // ---------------------------------------------------------------------------
358
374
  // node close — close a node + cascade-cancel its exclusive subtree (Alt+C → x)
@@ -396,9 +412,12 @@ const nodeClose = defineLeaf({
396
412
  const res = closeNode(id);
397
413
  return { closed: true, node_id: res.root, count: res.closed.length, closed_ids: res.closed, spared: res.spared };
398
414
  },
399
- render: (r) => r['closed'] === true
400
- ? `<closed id="${r['node_id']}" count="${r['count']}" spared="${r['spared']?.length ?? 0}"/>`
401
- : `<close-failed/>`,
415
+ render: (r) => {
416
+ if (r['closed'] !== true)
417
+ return 'Close failed — no node found in this pane to close.';
418
+ const spared = r['spared']?.length ?? 0;
419
+ return `Closed ${r['node_id']} and its exclusive subtree — ${r['count']} node(s) closed${spared > 0 ? `, ${spared} spared (still managed from outside the subtree)` : ''}.`;
420
+ },
402
421
  });
403
422
  // ---------------------------------------------------------------------------
404
423
  // node cycle — DFS-walk the canvas one window at a time (Alt+] / Alt+[)
@@ -484,8 +503,8 @@ const nodeCycle = defineLeaf({
484
503
  return { focused: res.focused, node_id: targetId, name: target.name, from: fromId };
485
504
  },
486
505
  render: (r) => r['focused'] === true
487
- ? `<cycled to="${r['node_id']}" name="${r['name'] ?? ''}" from="${r['from'] ?? ''}"/>`
488
- : `<cycle-noop>no other live node to focus</cycle-noop>`,
506
+ ? `Cycled to "${r['name'] ?? ''}" (${r['node_id']}) from ${r['from'] ?? '?'}.`
507
+ : 'No other live node to focus.',
489
508
  });
490
509
  // ---------------------------------------------------------------------------
491
510
  // node msg — direct-address any node at a wake tier (wakes a dormant target)
@@ -580,7 +599,7 @@ const nodeSubscribe = defineLeaf({
580
599
  subscribe(subscriber, publisher, !passive);
581
600
  return { subscribed: true, subscriber, publisher, mode: passive ? 'passive' : 'active' };
582
601
  },
583
- render: (r) => `<subscribed subscriber="${r['subscriber']}" publisher="${r['publisher']}" mode="${r['mode']}"/>`,
602
+ render: (r) => `Subscribed ${r['subscriber']} ${r['publisher']} (${r['mode']}).`,
584
603
  });
585
604
  const nodeUnsubscribe = defineLeaf({
586
605
  name: 'unsubscribe',
@@ -607,7 +626,7 @@ const nodeUnsubscribe = defineLeaf({
607
626
  unsubscribe(subscriber, publisher);
608
627
  return { unsubscribed: true, subscriber, publisher };
609
628
  },
610
- render: (r) => `<unsubscribed subscriber="${r['subscriber']}" publisher="${r['publisher']}"/>`,
629
+ render: (r) => `Unsubscribed ${r['subscriber']} from ${r['publisher']}.`,
611
630
  });
612
631
  // ---------------------------------------------------------------------------
613
632
  // node promote — become an orchestrator (the worker→orchestrator polymorph)
@@ -682,6 +701,7 @@ function setLifecycle(id, value, opts) {
682
701
  const { launch } = buildLaunchSpec(target.kind, target.mode, {
683
702
  lifecycle: value,
684
703
  hasManager: target.parent !== null,
704
+ model: target.model_override ?? undefined,
685
705
  });
686
706
  const meta = updateNode(id, { lifecycle: value, launch });
687
707
  // --detach: shove the still-running agent into the background crtr session,
@@ -725,7 +745,7 @@ const nodeDemote = defineLeaf({
725
745
  const res = setLifecycle(id, 'terminal', { pane, detach: input['detach'] === true });
726
746
  return { node_id: res.node_id, lifecycle: res.lifecycle, detached: res.detached };
727
747
  },
728
- render: (r) => `<demoted node="${r['node_id']}" lifecycle="${r['lifecycle']}"${r['detached'] === true ? ' detached="true"' : ''}/>`,
748
+ render: (r) => `Demoted ${r['node_id']} lifecycle now ${r['lifecycle']} (in place)${r['detached'] === true ? ', relocated to the background crtr session' : ''}.`,
729
749
  });
730
750
  // ---------------------------------------------------------------------------
731
751
  // node lifecycle — flip the lifecycle axis (terminal ↔ resident), independent
@@ -762,7 +782,7 @@ const nodeLifecycle = defineLeaf({
762
782
  const res = setLifecycle(id, value, { pane, detach: input['detach'] === true });
763
783
  return { node_id: res.node_id, lifecycle: res.lifecycle, detached: res.detached };
764
784
  },
765
- render: (r) => `<lifecycle node="${r['node_id']}" set="${r['lifecycle']}"${r['detached'] === true ? ' detached="true"' : ''}/>`,
785
+ render: (r) => `Set ${r['node_id']} lifecycle → ${r['lifecycle']}${r['detached'] === true ? ', relocated to the background crtr session' : ''}.`,
766
786
  });
767
787
  // ---------------------------------------------------------------------------
768
788
  // node yield — refresh: discard context, revive fresh against the roadmap
@@ -905,10 +925,6 @@ function etaHint(fireAt, now) {
905
925
  return ` (~${hrs}h)`;
906
926
  return ` (~${Math.round(ms / 86_400_000)}d)`;
907
927
  }
908
- /** Escape a value for a rendered XML attribute (list rows carry free-text notes). */
909
- function xmlAttr(s) {
910
- return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
911
- }
912
928
  // node wake at ---------------------------------------------------------------
913
929
  const nodeWakeAt = defineLeaf({
914
930
  name: 'at',
@@ -1008,7 +1024,10 @@ const nodeWakeAt = defineLeaf({
1008
1024
  : `Bare self-alarm armed. You wake in a fresh window at ${fireAt}${eta}. End your turn now to go dormant; do not push final. The wake re-reads your roadmap.`;
1009
1025
  return { id, kind, fires_at: fireAt, recur: cadenceDisplay(recur), target, guidance };
1010
1026
  },
1011
- render: (r) => `<wake-armed id="${r['id']}" kind="${r['kind']}" fires-at="${r['fires_at']}" recur="${r['recur']}" target="${r['target']}">\n${r['guidance']}\n</wake-armed>`,
1027
+ render: (r) => {
1028
+ const recur = r['recur'] !== 'none' ? `, recurs ${r['recur']}` : '';
1029
+ return `Armed ${r['kind']} wake ${r['id']} — fires ${r['fires_at']}${recur}, target ${r['target']}.\n\n${r['guidance']}`;
1030
+ },
1012
1031
  });
1013
1032
  // node wake until ------------------------------------------------------------
1014
1033
  const nodeWakeUntil = defineLeaf({
@@ -1060,7 +1079,7 @@ const nodeWakeUntil = defineLeaf({
1060
1079
  const guidance = `Deadline armed. You wake on the first inbox message, or at ${w.fireAt}${eta} if none arrives. Now delegate / end your turn to go dormant — whichever fires first cancels the other.`;
1061
1080
  return { id, fires_at: w.fireAt, target: 'self', guidance };
1062
1081
  },
1063
- render: (r) => `<deadline-armed id="${r['id']}" fires-at="${r['fires_at']}" target="${r['target']}">\n${r['guidance']}\n</deadline-armed>`,
1082
+ render: (r) => `Armed deadline ${r['id']} fires ${r['fires_at']}, target ${r['target']}.\n\n${r['guidance']}`,
1064
1083
  });
1065
1084
  // node wake spawn -----------------------------------------------------------
1066
1085
  const nodeWakeSpawn = defineLeaf({
@@ -1161,7 +1180,10 @@ const nodeWakeSpawn = defineLeaf({
1161
1180
  : `Deferred spawn armed: a fresh ${kind} node is born at ${fireAt}${eta}. No node exists yet — inspect/cancel via \`crtr node wake list\` / \`crtr node wake cancel ${id}\`. Pick up other work or end your turn.`;
1162
1181
  return { id, kind, fires_at: fireAt, recur: cadenceDisplay(recur), guidance };
1163
1182
  },
1164
- render: (r) => `<spawn-deferred id="${r['id']}" kind="${r['kind']}" fires-at="${r['fires_at']}" recur="${r['recur']}">\n${r['guidance']}\n</spawn-deferred>`,
1183
+ render: (r) => {
1184
+ const recur = r['recur'] !== 'none' ? `, recurs ${r['recur']}` : '';
1185
+ return `Armed deferred spawn ${r['id']} — a ${r['kind']} node, fires ${r['fires_at']}${recur}.\n\n${r['guidance']}`;
1186
+ },
1165
1187
  });
1166
1188
  // node wake list -------------------------------------------------------------
1167
1189
  const nodeWakeList = defineLeaf({
@@ -1243,10 +1265,13 @@ const nodeWakeList = defineLeaf({
1243
1265
  },
1244
1266
  render: (r) => {
1245
1267
  const wakes = r['wakes'];
1246
- const rows = wakes.map((w) => ` <wake id="${w['id']}" kind="${w['kind']}" next="${w['next']}" recur="${xmlAttr(String(w['recur']))}" target="${xmlAttr(String(w['target']))}" owner="${xmlAttr(String(w['owner']))}" note="${xmlAttr(String(w['note']))}"/>`);
1247
- const body = rows.length > 0 ? '\n' + rows.join('\n') + '\n' : '\n';
1248
- return (`<wakes scope="${xmlAttr(String(r['scope']))}" count="${wakes.length}">${body}</wakes>\n` +
1249
- '<follow_up>Cancel one with `crtr node wake cancel <id>`. Fired one-shots are gone; a recurrence shows its NEXT fire, not past slots.</follow_up>');
1268
+ const follow = 'Cancel one with `crtr node wake cancel <id>`. Fired one-shots are gone; a recurrence shows its NEXT fire, not past slots.';
1269
+ if (wakes.length === 0)
1270
+ return `No pending wakes in scope ${r['scope']}.\n\n${follow}`;
1271
+ const cell = (v) => String(v).replace(/\|/g, '\\|').replace(/\n/g, ' ');
1272
+ const head = '| id | kind | next | recur | target | owner | note |\n| --- | --- | --- | --- | --- | --- | --- |';
1273
+ const rows = wakes.map((w) => `| ${cell(w['id'])} | ${cell(w['kind'])} | ${cell(w['next'])} | ${cell(w['recur'])} | ${cell(w['target'])} | ${cell(w['owner'])} | ${cell(w['note'])} |`);
1274
+ return `${wakes.length} pending wake(s) in scope ${r['scope']}.\n\n${head}\n${rows.join('\n')}\n\n${follow}`;
1250
1275
  },
1251
1276
  });
1252
1277
  // node wake cancel -----------------------------------------------------------
@@ -1273,7 +1298,7 @@ const nodeWakeCancel = defineLeaf({
1273
1298
  cancelWake(id);
1274
1299
  return { id, was_pending: wasPending };
1275
1300
  },
1276
- render: (r) => `<wake-canceled id="${r['id']}" was-pending="${r['was_pending']}"/>`,
1301
+ render: (r) => `Canceled wake ${r['id']}${r['was_pending'] === true ? '' : ' (already gone — no-op)'}.`,
1277
1302
  });
1278
1303
  // node wake (branch) ---------------------------------------------------------
1279
1304
  const nodeWake = defineBranch({
@@ -112,7 +112,7 @@ function makeTierLeaf(tier) {
112
112
  : tier === 'urgent'
113
113
  ? `Urgent report fanned to ${n} subscriber(s) — they are force-woken.`
114
114
  : `Progress report fanned to ${n} subscriber(s).`;
115
- return `<pushed tier="${tier}" status="${r['status']}" delivered="${n}">\n${line}\nreport: ${r['report_path']}\n</pushed>`;
115
+ return `${line}\n\n- report: ${r['report_path']}`;
116
116
  },
117
117
  });
118
118
  }
@@ -161,16 +161,16 @@ const feedReadLeaf = defineLeaf({
161
161
  const n = Array.isArray(r['entries']) ? r['entries'].length : 0;
162
162
  const rawDigest = typeof r['digest'] === 'string' ? r['digest'] : '';
163
163
  if (n > 0 && rawDigest.trim() !== '') {
164
- return `<feed node="${r['node_id']}" unread="${n}">\n${rawDigest}\n</feed>`;
164
+ return `${n} unread pointer${n === 1 ? '' : 's'} drained from ${r['node_id']}'s inbox.\n\n${rawDigest.trim()}`;
165
165
  }
166
166
  // Empty drain has two distinct causes — say which, honestly. An inbox that
167
167
  // already holds entries was drained when the watcher woke you (the wake
168
168
  // message WAS that digest); a never-used inbox simply has nothing yet.
169
169
  const total = typeof r['inbox_total'] === 'number' ? r['inbox_total'] : 0;
170
- const digest = total > 0
171
- ? 'Nothing unread — but your inbox is not empty. The watcher drains your inbox to wake you, so the entries you already saw in your wake message are the same ones you would read here; that is why this is empty. Re-read the whole history (full message bodies included) with `crtr feed read --all`, or dereference the refs from the wake digest you already have.'
172
- : 'Inbox empty — nothing has arrived from your subscriptions yet. Expected while workers run: a worker that has not pushed yet leaves no pointer, and it will wake you the moment it does. The wake is automatic — just continue your own work or end your turn. (Reach for `crtr feed peek` only if you suspect a worker died, not to confirm a live one.)';
173
- return `<feed node="${r['node_id']}" unread="${n}">\n${digest}\n</feed>`;
170
+ const explanation = total > 0
171
+ ? 'Your inbox is not empty, though. The watcher drains your inbox to wake you, so the entries you already saw in your wake message are the same ones you would read here; that is why this is empty. Re-read the whole history (full message bodies included) with `crtr feed read --all`, or dereference the refs from the wake digest you already have.'
172
+ : 'Your inbox is empty — nothing has arrived from your subscriptions yet. Expected while workers run: a worker that has not pushed yet leaves no pointer, and it will wake you the moment it does. The wake is automatic — just continue your own work or end your turn. (Reach for `crtr feed peek` only if you suspect a worker died, not to confirm a live one.)';
173
+ return `No unread pointers for ${r['node_id']} (unread=0).\n\n${explanation}`;
174
174
  },
175
175
  });
176
176
  // ---------------------------------------------------------------------------
@@ -249,28 +249,23 @@ const feedPeekLeaf = defineLeaf({
249
249
  const tail = unread > 0
250
250
  ? `${unread} unread report${unread === 1 ? '' : 's'} sit in your inbox — run \`crtr feed read\` to absorb ${unread === 1 ? 'it' : 'them'}.`
251
251
  : 'If you spawned workers, they have finished and detached, or you never subscribed. Nothing will wake you.';
252
- return `<peek node="${id}" subscriptions="0" unread="${unread}" verdict="empty">\nNo nodes below you. ${tail}\n</peek>`;
252
+ return `No nodes below you (0 subscriptions, ${unread} unread). ${tail}`;
253
253
  }
254
254
  const working = kids.filter((k) => k.status === 'active' || k.status === 'idle');
255
255
  const liveWaking = working.filter((k) => k.active); // active sub to a live node = it will wake me
256
256
  const done = kids.filter((k) => k.status === 'done' || k.status === 'canceled');
257
257
  const dead = kids.filter((k) => k.status === 'dead');
258
- let verdict;
259
258
  let line;
260
259
  if (dead.length > 0) {
261
- verdict = 'attention';
262
260
  line = `\u26a0 ${dead.length} below you ${dead.length === 1 ? 'is' : 'are'} dead and will NOT wake you. Inspect with \`crtr node inspect show <id>\`, then re-delegate or proceed without ${dead.length === 1 ? 'it' : 'them'}.`;
263
261
  }
264
262
  else if (liveWaking.length > 0) {
265
- verdict = 'working';
266
263
  line = `Safe to yield \u2014 ${liveWaking.length} worker${liveWaking.length === 1 ? '' : 's'} running async will wake you on the next push. Nothing to do now; end your turn and chill.`;
267
264
  }
268
265
  else if (unread > 0) {
269
- verdict = 'ready';
270
266
  line = `Nothing still running, but ${unread} unread report${unread === 1 ? '' : 's'} \u2014 run \`crtr feed read\` to absorb ${unread === 1 ? 'it' : 'them'}, then continue or finish.`;
271
267
  }
272
268
  else {
273
- verdict = 'idle';
274
269
  line = 'Everything below you has finished and been drained \u2014 nothing is running. Continue your own work, or `crtr push final` to finish.';
275
270
  }
276
271
  const rows = kids.map((k) => {
@@ -279,10 +274,10 @@ const feedPeekLeaf = defineLeaf({
279
274
  ? `pushed ${fmtAge(k.last_push.ts)} [${k.last_push.kind}]${k.last_push.ref !== null ? ` ref:${k.last_push.ref}` : ''}`
280
275
  : 'no push yet';
281
276
  const glyph = STATUS_GLYPH[k.status] ?? '?';
282
- return ` ${glyph} ${k.node_id} ${k.kind} ${k.name}${sub} \u00b7 ${k.status} \u00b7 spawned ${fmtAge(k.spawned)} \u00b7 cyc ${k.cycles} \u00b7 ${push}`;
277
+ return `- ${glyph} ${k.name}${sub} (${k.node_id}) \u00b7 ${k.kind} \u00b7 ${k.status} \u00b7 spawned ${fmtAge(k.spawned)} \u00b7 cyc ${k.cycles} \u00b7 ${push}`;
283
278
  }).join('\n');
284
- const attrs = `node="${id}" subscriptions="${kids.length}" working="${working.length}" done="${done.length}" dead="${dead.length}" unread="${unread}" verdict="${verdict}"`;
285
- return `<peek ${attrs}>\n${line}\n\n${rows}\n</peek>`;
279
+ const summary = `${kids.length} node${kids.length === 1 ? '' : 's'} below you — ${working.length} working, ${done.length} done, ${dead.length} dead, ${unread} unread:`;
280
+ return `${line}\n\n${summary}\n${rows}`;
286
281
  },
287
282
  });
288
283
  // ---------------------------------------------------------------------------
@@ -125,5 +125,20 @@ export const tmuxSpreadLeaf = defineLeaf({
125
125
  focused: spread.focused,
126
126
  };
127
127
  },
128
- render: (r) => `<spread node window="${r['window'] ?? ''}" joined="${r['children_joined'].length}" overflow="${r['overflow'].length}" focused="${r['focused']}"/>`,
128
+ render: (r) => {
129
+ const joined = r['children_joined'] ?? [];
130
+ const overflow = r['overflow'] ?? [];
131
+ const where = r['window'] !== undefined && r['window'] !== ''
132
+ ? ` into window ${r['window']}${r['session'] !== undefined ? ` (session ${r['session']})` : ''}`
133
+ : '';
134
+ const lead = `Spread node${where} — ${joined.length} child pane(s) joined, ${overflow.length} overflow, ${r['focused'] === true ? 'window focused' : 'window not focused'}.`;
135
+ const blocks = [lead];
136
+ if (joined.length > 0) {
137
+ blocks.push(['Children joined:', ...joined.map((c) => `- ${c}`)].join('\n'));
138
+ }
139
+ if (overflow.length > 0) {
140
+ blocks.push(['Overflow (max_panes_per_window reached):', ...overflow.map((c) => `- ${c}`)].join('\n'));
141
+ }
142
+ return blocks.join('\n\n');
143
+ },
129
144
  });
@@ -117,9 +117,13 @@ export const viewCycleLeaf = defineLeaf({
117
117
  catch { /* best-effort */ }
118
118
  return { cycled: true, view: targetId, from: current };
119
119
  },
120
- render: (r) => r['cycled'] === true
121
- ? `<view-cycled to="${r['view']}" from="${r['from'] ?? ''}"/>`
122
- : r['from'] !== undefined
123
- ? `<view-cycle-noop>no other view to switch to</view-cycle-noop>`
124
- : `<view-cycle-noop>this pane is not hosting a view</view-cycle-noop>`,
120
+ render: (r) => {
121
+ if (r['cycled'] === true) {
122
+ return `Cycled the monitor to view "${r['view']}" (from "${r['from']}").`;
123
+ }
124
+ if (r['from'] !== undefined) {
125
+ return `No other view to switch to — the pane is still on "${r['from']}".`;
126
+ }
127
+ return 'Nothing to cycle — this pane is not hosting a view.';
128
+ },
125
129
  });
@@ -181,11 +181,12 @@ export const viewRunLeaf = defineLeaf({
181
181
  render: (result) => {
182
182
  const hosted = result['hosted'];
183
183
  if (hosted === 'window') {
184
- return `<view-opened mode="window" view="${result['view']}" window="${result['window']}" pane="${result['pane']}"/>`;
184
+ return `Opened view "${result['view']}" as a window monitor — window ${result['window']}, pane ${result['pane']}.`;
185
185
  }
186
186
  if (hosted === 'split') {
187
- return `<view-opened mode="split" view="${result['view']}" pane="${result['pane']}"/>`;
187
+ return `Opened view "${result['view']}" as a split monitor — pane ${result['pane']}.`;
188
188
  }
189
+ // In-pane host: the terminal was held until quit; nothing to report.
189
190
  return '';
190
191
  },
191
192
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,157 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/broker-attach-limits.test.ts
2
+ //
3
+ // T8 — the `crtr attach` acceptance gate, gates G4/G7/G8/G9 (controller
4
+ // arbitration, decoder overflow caps, backpressure shedding, and the load-bearing
5
+ // ONE-WRITER proof). Split out of broker-lifecycle.test.ts (see its header for
6
+ // the full file map); the tests are the original acceptance gate, unchanged, on
7
+ // their own isolated harness. Each test drives the REAL detached broker process +
8
+ // REAL view.sock with the enriched fake engine, using the PRODUCTION
9
+ // ViewSocketClient (helpers/broker-clients.ts) as the controller/observer — raw
10
+ // node:net only where the client lifecycle is awkward (G7 oversized line, G8
11
+ // stalled viewer). The engine is hosted IN-PROCESS by the broker, so engine pid
12
+ // == broker pid == node.pi_pid == boot.pid.
13
+ import { test, before, after, afterEach } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import { join } from 'node:path';
16
+ import { createHarness, hasTmux } from './helpers/harness.js';
17
+ import { isPidAlive } from '../canvas/pid.js';
18
+ import { createAttachKit, delay, tok, frameHas, brokerLogText, lsofHolders, } from './helpers/broker-clients.js';
19
+ let h;
20
+ let root;
21
+ const kit = createAttachKit(() => h);
22
+ const { attach, attachUntil, connectRaw } = kit;
23
+ before(async () => {
24
+ if (!hasTmux())
25
+ return;
26
+ h = await createHarness({ sessionPrefix: 'crtr-brklim' });
27
+ root = h.spawnRoot('broker-attach-limits suite root');
28
+ });
29
+ after(async () => {
30
+ if (h !== undefined)
31
+ await h.dispose();
32
+ });
33
+ afterEach(() => {
34
+ kit.closeAll();
35
+ });
36
+ const brokerPid = (id) => h.node(id).pi_pid;
37
+ // ---------------------------------------------------------------------------
38
+ // G4 — arbitration + observer read. Guards: 2nd client is admitted observer, an
39
+ // observer prompt is rejected not_controller, BOTH clients receive the relay.
40
+ // Failure mode: two controllers, or an observer driving the engine, or fan-out
41
+ // that misses a viewer.
42
+ // ---------------------------------------------------------------------------
43
+ test('G4 — second client is observer; observer prompt → error{not_controller}; both receive the stream', { skip: !hasTmux() }, async () => {
44
+ const id = await h.spawnHeadlessChild(root, 'headless worker — G4');
45
+ const c1 = await attach(id, 'controller', 'g4-ctrl');
46
+ assert.equal(c1.welcome.role, 'controller', 'first client holds control');
47
+ const c2 = await attach(id, 'controller', 'g4-second'); // requests control; held → observer
48
+ assert.equal(c2.welcome.role, 'observer', 'second client is admitted read-only observer (first-attach-wins)');
49
+ c2.send({ type: 'prompt', text: 'observer must not drive' });
50
+ const err = await c2.waitFrame((f) => f.type === 'error', 'G4 observer prompt rejected');
51
+ assert.equal(err.code, 'not_controller', 'G4: observer prompt → error{not_controller}');
52
+ const token = tok('G4-BROADCAST');
53
+ c1.send({ type: 'prompt', text: token });
54
+ await c1.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G4 controller received the stream');
55
+ await c2.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G4 observer ALSO received the stream');
56
+ });
57
+ // ---------------------------------------------------------------------------
58
+ // G7 — decoder overflow (guards C5 OOM). A client line over BROKER_READ_CAPS is
59
+ // cap-and-dropped; the broker survives and other clients are unaffected. Failure
60
+ // mode: an unbounded decoder buffer growing the broker to OOM.
61
+ // ---------------------------------------------------------------------------
62
+ test('G7 — an oversized client line is dropped (frame_overflow), the broker survives, other clients unaffected', { skip: !hasTmux() }, async () => {
63
+ const id = await h.spawnHeadlessChild(root, 'headless worker — G7');
64
+ const pid = brokerPid(id);
65
+ const boots = h.bootCount(id);
66
+ const survivor = await attach(id, 'observer', 'g7-survivor');
67
+ // 26 MiB with NO newline > BROKER_READ_CAPS.maxLineBytes (24 MiB) → the bounded
68
+ // FrameDecoder throws FrameOverflowError; the broker drops the peer.
69
+ const bad = await connectRaw(id, { read: true });
70
+ bad.writeRaw(Buffer.alloc(26 * 1024 * 1024, 0x78));
71
+ await bad.waitClosed('G7 oversized peer dropped by the broker');
72
+ await h.waitFor(() => /frame overflow/.test(brokerLogText(h, id)) || null, { label: 'G7 broker logged the frame-overflow drop' });
73
+ // The broker survives and the other client is unaffected: a fresh turn relays.
74
+ const token = tok('G7-AFTER');
75
+ h.fakeCmd(id, { cmd: 'stream', text: token });
76
+ await survivor.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G7 survivor still receives live frames');
77
+ assert.equal(isPidAlive(pid), true, 'G7: the broker survived the overflow');
78
+ assert.equal(brokerPid(id), pid, 'G7: broker pid unchanged');
79
+ assert.equal(h.bootCount(id), boots, 'G7: no reboot');
80
+ });
81
+ // ---------------------------------------------------------------------------
82
+ // G8 — backpressure leak (guards M1). A stalled (non-reading) viewer is shed at
83
+ // the HWM (32 MiB byte cap) while the broker + other viewers are unaffected.
84
+ // Failure mode: an indefinitely-growing per-viewer queue (broker OOM) or a slow
85
+ // viewer back-pressuring the shared engine.
86
+ // ---------------------------------------------------------------------------
87
+ test('G8 — a stalled viewer is dropped at the backpressure HWM; the broker + fast viewers are unaffected', { skip: !hasTmux() }, async () => {
88
+ const id = await h.spawnHeadlessChild(root, 'headless worker — G8');
89
+ const pid = brokerPid(id);
90
+ const fast = await attach(id, 'observer', 'g8-fast'); // reads normally
91
+ // A helloed viewer that NEVER reads (paused socket). It must be in the broadcast
92
+ // set before the flood; hello, then a short beat for the broker to process it.
93
+ const stalled = await connectRaw(id, { read: false });
94
+ stalled.send({ type: 'hello', role: 'observer', client_id: 'g8-stalled' });
95
+ await delay(400);
96
+ // A fast event stream: ~60 MiB across 240 message_update frames. The stalled
97
+ // viewer's per-viewer backlog crosses the 32 MiB byte cap → dropped; the fast
98
+ // viewer drains between frames (per-update yield) and survives.
99
+ //
100
+ // The DROP SIGNAL is the broker's own log line, NOT the stalled socket's 'close':
101
+ // a PAUSED node socket does not surface the peer FIN/close until it is resumed,
102
+ // so the broker-side `backpressure high-water mark exceeded` line is the
103
+ // deterministic, race-free proof that the slow viewer was shed at the HWM.
104
+ const token = tok('G8-FLOOD');
105
+ h.fakeCmd(id, { cmd: 'stream', text: token, updates: 240, padBytes: 256 * 1024, tool: false });
106
+ await h.waitFor(() => /backpressure high-water mark exceeded/.test(brokerLogText(h, id)) || null, { label: 'G8 broker shed the stalled viewer at the HWM', timeoutMs: 30_000 });
107
+ // The fast viewer drained the WHOLE stream (agent_end is the terminal frame) —
108
+ // proving only the stalled viewer was shed, while the fast one was unaffected.
109
+ await fast.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G8 fast viewer received the whole stream', 30_000);
110
+ assert.equal(isPidAlive(pid), true, 'G8: the broker survived the slow-viewer flood');
111
+ assert.equal(brokerPid(id), pid, 'G8: broker pid unchanged (the engine was not back-pressured into a restart)');
112
+ });
113
+ // ---------------------------------------------------------------------------
114
+ // G9 — the load-bearing ONE-WRITER assertion. Across attach→detach→reattach the
115
+ // broker pid AND engine pid (the same, in-process) are UNCHANGED and no second
116
+ // engine is ever spawned; the viewer holds ONLY the socket (never the .jsonl).
117
+ // Failure mode: a viewer that spawns/forks a second engine or opens the session
118
+ // .jsonl — the corruption the headless design exists to prevent.
119
+ // ---------------------------------------------------------------------------
120
+ test('G9 — one-writer: broker/engine pid stable across attach→detach→reattach; no second engine; viewer holds only the socket', { skip: !hasTmux() }, async () => {
121
+ const id = await h.spawnHeadlessChild(root, 'headless worker — G9');
122
+ const pid0 = brokerPid(id);
123
+ const boots0 = h.bootCount(id);
124
+ const c1 = await attach(id, 'controller', 'g9-a');
125
+ const a = tok('G9-A');
126
+ c1.send({ type: 'prompt', text: a });
127
+ await c1.waitFrame((f) => f.type === 'agent_end' && frameHas(f, a), 'G9 first turn relayed');
128
+ c1.send({ type: 'bye' });
129
+ c1.close();
130
+ await delay(300);
131
+ assert.equal(brokerPid(id), pid0, 'G9: broker/engine pid unchanged after detach');
132
+ assert.equal(isPidAlive(pid0), true, 'G9: the one engine is still alive after detach');
133
+ assert.equal(h.bootCount(id), boots0, 'G9: no second engine spawned across detach');
134
+ // attachUntil (not a fixed sleep) synchronizes on the controller handoff: the
135
+ // detach's controllerId=null lands a beat after close, so retry until the
136
+ // reattach is admitted controller before driving the second prompt.
137
+ const c2 = await attachUntil(id, 'controller', 'g9-b', (x) => x.welcome.role === 'controller', 'G9 reattach re-takes control');
138
+ assert.equal(c2.welcome.role, 'controller', 'G9: the reattached client drives the SAME engine as controller');
139
+ const b = tok('G9-B');
140
+ c2.send({ type: 'prompt', text: b });
141
+ await c2.waitFrame((f) => f.type === 'agent_end' && frameHas(f, b), 'G9 reattached controller drives the SAME engine');
142
+ assert.equal(brokerPid(id), pid0, 'G9: STILL the same broker/engine pid after reattach (one writer, never two)');
143
+ assert.equal(isPidAlive(pid0), true, 'G9: the single engine is alive across the full cycle');
144
+ assert.equal(h.bootCount(id), boots0, 'G9: exactly one engine boot across attach→detach→reattach');
145
+ // fd-check (best-effort): the viewer host (this test process) must NOT hold the
146
+ // session .jsonl — only the broker may. With the fake engine the .jsonl is not
147
+ // held open continuously, so the meaningful assertion is that the VIEWER never
148
+ // appears among its holders (and any holder that exists is the broker).
149
+ const jsonl = join(h.home, 'nodes', id, 'fake-session.jsonl');
150
+ const holders = lsofHolders(jsonl);
151
+ if (holders !== null) {
152
+ assert.ok(!holders.includes(process.pid), 'G9: the attach client (this process) does NOT hold the session .jsonl');
153
+ for (const holder of holders) {
154
+ assert.equal(holder, pid0, 'G9: the ONLY holder of the session .jsonl is the broker/engine');
155
+ }
156
+ }
157
+ });
@@ -0,0 +1 @@
1
+ export {};