@north-light/crouter 0.3.181 → 0.3.183

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 (105) hide show
  1. package/dist/build-root.js +10 -3
  2. package/dist/builtin-memory/internal/memory-loading.md +3 -1
  3. package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
  4. package/dist/clients/attach/__tests__/titled-editor-paste.test.d.ts +1 -0
  5. package/dist/clients/attach/__tests__/titled-editor-paste.test.js +114 -0
  6. package/dist/clients/attach/__tests__/titled-editor-preview.test.js +4 -1
  7. package/dist/clients/attach/input/titled-editor.d.ts +18 -0
  8. package/dist/clients/attach/input/titled-editor.js +62 -0
  9. package/dist/clients/attach/render/chat-view.d.ts +3 -0
  10. package/dist/clients/attach/render/chat-view.js +15 -6
  11. package/dist/clients/attach/render/context-message.js +2 -2
  12. package/dist/clients/attach/render/group-activity.d.ts +18 -1
  13. package/dist/clients/attach/render/group-activity.js +54 -6
  14. package/dist/clients/attach/render/group-recap.js +13 -2
  15. package/dist/clients/attach/render/{markdown-headings.d.ts → markdown-source.d.ts} +4 -4
  16. package/dist/clients/attach/render/markdown-source.js +165 -0
  17. package/dist/clients/attach/session/bindings.js +5 -2
  18. package/dist/clients/attach/viewer.js +396 -394
  19. package/dist/clients/inbox/review/keys.js +2 -9
  20. package/dist/clients/inbox/tui/keys.js +2 -9
  21. package/dist/commands/cron.js +1 -1
  22. package/dist/commands/memory/delete.js +1 -1
  23. package/dist/commands/memory/find.js +1 -1
  24. package/dist/commands/memory/lint.js +28 -3
  25. package/dist/commands/memory/list.js +1 -1
  26. package/dist/commands/memory/origin.js +1 -1
  27. package/dist/commands/memory/read.js +4 -4
  28. package/dist/commands/memory/write.js +1 -1
  29. package/dist/commands/sys/settings-shell.d.ts +0 -3
  30. package/dist/commands/sys/settings-shell.js +1 -38
  31. package/dist/commands/sys/settings.js +2 -3
  32. package/dist/commands/sys/setup-core.d.ts +2 -17
  33. package/dist/commands/sys/setup-core.js +9 -33
  34. package/dist/commands/sys/setup-wizard.d.ts +1 -3
  35. package/dist/commands/sys/setup-wizard.js +9 -39
  36. package/dist/commands/sys/setup.js +6 -6
  37. package/dist/commands/sys/sync-project-guidance.js +62 -12
  38. package/dist/core/__tests__/fault-classifier.test.js +20 -0
  39. package/dist/core/__tests__/nested-store-discovery.test.d.ts +1 -0
  40. package/dist/core/__tests__/nested-store-discovery.test.js +89 -0
  41. package/dist/core/__tests__/on-read-nested-store.test.d.ts +1 -0
  42. package/dist/core/__tests__/on-read-nested-store.test.js +117 -0
  43. package/dist/core/__tests__/plugin-link-symlinked-scope-root.test.d.ts +1 -0
  44. package/dist/core/__tests__/plugin-link-symlinked-scope-root.test.js +38 -0
  45. package/dist/core/__tests__/serial/tmux-surface.test.js +8 -5
  46. package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +34 -1
  47. package/dist/core/command.js +5 -3
  48. package/dist/core/fault-classifier.d.ts +1 -1
  49. package/dist/core/fault-classifier.js +7 -0
  50. package/dist/core/fs-utils.js +6 -1
  51. package/dist/core/keybindings/index.d.ts +1 -1
  52. package/dist/core/keybindings/index.js +1 -1
  53. package/dist/core/keybindings/match.d.ts +11 -0
  54. package/dist/core/keybindings/match.js +22 -0
  55. package/dist/core/memory/doc-link-grammar.js +3 -7
  56. package/dist/core/memory/inline-ref-guidance.js +1 -1
  57. package/dist/core/memory-resolver.d.ts +7 -1
  58. package/dist/core/memory-resolver.js +20 -7
  59. package/dist/core/nested-stores.d.ts +12 -0
  60. package/dist/core/nested-stores.js +146 -0
  61. package/dist/core/preview-registry.js +4 -2
  62. package/dist/core/runtime/bearings.d.ts +1 -1
  63. package/dist/core/runtime/bearings.js +2 -2
  64. package/dist/core/runtime/broker/fault-retry.js +23 -4
  65. package/dist/core/runtime/broker/inbox.d.ts +4 -0
  66. package/dist/core/runtime/broker/inbox.js +17 -8
  67. package/dist/core/runtime/broker-extension-render.d.ts +1 -1
  68. package/dist/core/runtime/broker-extension-render.js +2 -2
  69. package/dist/core/runtime/broker-protocol.d.ts +1 -1
  70. package/dist/core/runtime/deliver-live.js +11 -1
  71. package/dist/core/runtime/fault.js +1 -1
  72. package/dist/core/runtime/tmux-bindings.js +42 -20
  73. package/dist/core/runtime/tool-group-summary.js +2 -6
  74. package/dist/core/substrate/injected-store.d.ts +6 -0
  75. package/dist/core/substrate/injected-store.js +19 -0
  76. package/dist/core/substrate/on-read-node.d.ts +4 -2
  77. package/dist/core/substrate/on-read-node.js +5 -3
  78. package/dist/core/substrate/on-read.d.ts +4 -2
  79. package/dist/core/substrate/on-read.js +27 -5
  80. package/dist/pi-extensions/canvas-context-intro.d.ts +1 -1
  81. package/dist/pi-extensions/canvas-context-intro.js +14 -3
  82. package/dist/pi-extensions/canvas-doc-substrate.js +13 -2
  83. package/dist/pi-extensions/canvas-inbox-watcher.js +34 -1
  84. package/dist/shared/generated-context.d.ts +4 -0
  85. package/dist/shared/generated-context.js +20 -1
  86. package/dist/shared/tool-groups.d.ts +3 -3
  87. package/dist/shared/tool-groups.js +1 -4
  88. package/package.json +1 -1
  89. package/runtime.lock.json +2 -2
  90. package/scripts/postinstall.mjs +1 -1
  91. package/dist/clients/attach/render/markdown-headings.js +0 -93
  92. package/dist/commands/search/answer.d.ts +0 -1
  93. package/dist/commands/search/answer.js +0 -50
  94. package/dist/commands/search/contents.d.ts +0 -1
  95. package/dist/commands/search/contents.js +0 -96
  96. package/dist/commands/search/exa.d.ts +0 -53
  97. package/dist/commands/search/exa.js +0 -157
  98. package/dist/commands/search/puremd.d.ts +0 -11
  99. package/dist/commands/search/puremd.js +0 -53
  100. package/dist/commands/search/web.d.ts +0 -1
  101. package/dist/commands/search/web.js +0 -65
  102. package/dist/commands/search.d.ts +0 -2
  103. package/dist/commands/search.js +0 -24
  104. package/dist/commands/sys/panels/keys-panel.d.ts +0 -16
  105. package/dist/commands/sys/panels/keys-panel.js +0 -60
@@ -1,5 +1,13 @@
1
1
  import { defineRoot } from './core/command.js';
2
2
  const TAGLINE = 'crtr: agentic runtime.';
3
+ /** Flags handled before dispatch, so they belong to no leaf schema and are
4
+ * stated once at root (help.ts renders them as the Globals footer). */
5
+ const GLOBALS = [
6
+ {
7
+ name: '--json',
8
+ desc: "stdout as the leaf's declared outputs in raw JSON instead of prose — for scripts (a cron's bash, external tooling) that must branch on canvas state; prose stays the contract for what you read yourself.",
9
+ },
10
+ ];
3
11
  /** Lazy registry: subtree name → dynamic importer that builds its BranchDef.
4
12
  * The whole point is that each `import()` only compiles that one subtree's
5
13
  * module graph. The hot path (`crtr surface node focus …`, `crtr push …`, …) dispatches
@@ -10,7 +18,6 @@ const TAGLINE = 'crtr: agentic runtime.';
10
18
  const SUBTREE_LOADERS = {
11
19
  memory: async () => (await import('./commands/memory.js')).registerMemory(),
12
20
  profile: async () => (await import('./commands/profile.js')).registerProfile(),
13
- search: async () => (await import('./commands/search.js')).registerSearch(),
14
21
  pkg: async () => (await import('./commands/pkg.js')).registerPkg(),
15
22
  human: async () => (await import('./commands/human.js')).registerHuman(),
16
23
  sys: async () => (await import('./commands/sys.js')).registerSys(),
@@ -53,7 +60,7 @@ export async function coreCommandPaths() {
53
60
  export async function resolveRoot(first) {
54
61
  const loader = first !== undefined ? SUBTREE_LOADERS[first] : undefined;
55
62
  if (loader !== undefined) {
56
- return defineRoot({ tagline: TAGLINE, globals: [], subtrees: [await loader()] });
63
+ return defineRoot({ tagline: TAGLINE, globals: GLOBALS, subtrees: [await loader()] });
57
64
  }
58
65
  return buildRoot();
59
66
  }
@@ -88,5 +95,5 @@ export async function buildRoot() {
88
95
  // sys doctor). Reads only stored manifests — zero network.
89
96
  const snapshot = buildExternalCommandSnapshot(new Set(SUBTREE_NAMES));
90
97
  const external = composeExternalSubtrees(snapshot);
91
- return defineRoot({ tagline: TAGLINE, globals: [], subtrees: [...core, ...external] });
98
+ return defineRoot({ tagline: TAGLINE, globals: GLOBALS, subtrees: [...core, ...external] });
92
99
  }
@@ -13,7 +13,7 @@ Every memory doc declares its own loading policy in frontmatter; the runtime nev
13
13
  ## The two hooks
14
14
 
15
15
  - **System prompt** (`system-prompt-visibility`) — the boot catalog assembled into every node's system prompt at revive.
16
- - **File read** (`file-read-visibility`) — context attached to the workspace or to files. Two events: **workspace mount** during first-message assembly (docs routed `applies-to: "."`), and a later **matching file read** (docs routed by glob). Nothing positional fires from where the doc happens to sit on disk — only from its declared route.
16
+ - **File read** (`file-read-visibility`) — context attached to the workspace or to files. Two events: **workspace mount** during first-message assembly (docs routed `applies-to: "."`), and a later **matching file read** — docs routed by glob, and `.`-routed docs whose store's owning dir contains the read file. Nothing positional fires from where the doc happens to sit on disk — only from its declared route.
17
17
 
18
18
  Each doc sets both rungs explicitly; there is no kind-based default. Usually one axis carries a real rung and the other is `none`.
19
19
 
@@ -38,6 +38,8 @@ At boot/first-message assembly the runtime mounts: builtin docs, the user store
38
38
 
39
39
  A root `INDEX.md` is a workspace's front door: `system-prompt-visibility: none`, `file-read-visibility: content`, `applies-to: "."` — the operating guide loads when that workspace mounts, not in every boot catalog. Multiple mounted roots render broad-to-specific, each under its own envelope name.
40
40
 
41
+ A `.crouter/memory/` store nested BELOW a mounted root (a package or subsystem dir) is delivered by the read path, not by boot: reading any file beneath its owning dir surfaces its `.`-routed docs, deduplicated against everything already in the transcript. Addressability is separate — the `crtr memory` leaves (list/read/find/lint/delete/origin) discover nested stores through a bounded walk (git-aware, depth- and time-capped), while the boot catalog stays ancestor+profile only, which is why a nested doc's `system-prompt-visibility` rung is inert (lint warns; set `none`).
42
+
41
43
  ## Ordering
42
44
 
43
45
  The boot render is structural, never a per-doc knob: docs group by rung (content bodies as prose, then previews, then names), and within a group order general-to-specific — scope first (builtin → user → profile → outermost project root → nearest), then tree position (higher directories before deeper), then filename. A numeric `NN-` filename prefix (stripped from the doc's name) is the sparing escape hatch when an exact sequence must be pinned.
@@ -30,7 +30,7 @@ async function summarizedGroup(messages, key) {
30
30
  stats: {},
31
31
  state: { isStreaming: false },
32
32
  toolGroupSummaries: {
33
- [key]: { bullets: ['broker recap'], nodesSpawned: 0, filesDeleted: 0 },
33
+ [key]: { bullets: ['broker recap'], filesDeleted: 0 },
34
34
  },
35
35
  });
36
36
  const group = internals.groups.find((candidate) => candidate.key === key);
@@ -70,7 +70,7 @@ test('a folded recap carries paths and counts, never diff or write payload bodie
70
70
  bashMode: tone('bashMode'), bashModeAlt: tone('bashModeAlt'),
71
71
  };
72
72
  const recap = new GroupRecapComponent();
73
- recap.setRecap({ bullets: ['finished'], nodesSpawned: 0, filesDeleted: 2 }, 2, { memories: [{ action: 'read', name: 'alpha', path: '/tmp/memories/alpha.md' }], files: recorder.activity().files }, true, palette);
73
+ recap.setRecap({ bullets: ['finished'], filesDeleted: 2 }, 2, { memories: [{ action: 'read', name: 'alpha', path: '/tmp/memories/alpha.md' }], files: recorder.activity().files, nodes: [] }, true, palette);
74
74
  const text = recap.render(120).join('\n');
75
75
  assert.match(text, /overwritten\.ts/, 'the recap still names what changed');
76
76
  assert.doesNotMatch(text, /sensitive-old-diff|sensitive-new-diff|sensitive-write-payload/, 'payload bodies never leak into a recap');
@@ -0,0 +1,114 @@
1
+ // titled-editor-paste.test.ts — regression for the unbracketed multi-line paste
2
+ // bug: a multi-line paste into the attach viewer submitted its FIRST line as a
3
+ // prompt, then turned every following line into its own turn, a queued steering
4
+ // message, or a "Agent is already processing" rejection.
5
+ //
6
+ // Root cause: when a terminal delivers a paste WITHOUT bracketed-paste markers,
7
+ // pi-tui's StdinBuffer never enters paste mode and splits the paste into single
8
+ // sequences, so each embedded line ending reaches the editor as a bare Enter and
9
+ // the stock editor submits on it. TitledEditor.handleInput holds a line ending
10
+ // for PASTE_BURST_MS and settles it on what FOLLOWS: more input means a paste's
11
+ // own line break (insert a newline), silence means a human pressed Enter
12
+ // (submit).
13
+ //
14
+ // Deciding on what follows — not on the gap before — is what keeps
15
+ // `tmux send-keys <text> Enter` (one write, so its Enter arrives microseconds
16
+ // after the text) submitting, which the earlier gap-based rescue broke. Both
17
+ // properties are pinned below.
18
+ //
19
+ // The tests drive handleInput directly, bypassing the heavy TUI/stdin
20
+ // constructor via Object.create and stubbing only the members the branch
21
+ // touches; `super.handleInput` is intercepted by replacing
22
+ // CustomEditor.prototype.handleInput (what the ES `super` call resolves to).
23
+ import { test } from 'node:test';
24
+ import assert from 'node:assert/strict';
25
+ import { setTimeout as delay } from 'node:timers/promises';
26
+ import { CustomEditor } from '@earendil-works/pi-coding-agent';
27
+ import { TitledEditor } from '../input/titled-editor.js';
28
+ /** Longer than PASTE_BURST_MS (10ms) — the hold window must have expired. */
29
+ const AFTER_HOLD_MS = 40;
30
+ /** A TitledEditor with its constructor bypassed and its collaborators stubbed:
31
+ * - `km.matches` recognizes only the real Enter (`\r`) as `tui.input.submit`
32
+ * and never the newLine chord (so that branch stays out of the way);
33
+ * - `isShowingAutocomplete` is always false;
34
+ * - `insertTextAtCursor` records inserts;
35
+ * - the inherited (super) handleInput records what would have been submitted. */
36
+ function makeHarness() {
37
+ const inserted = [];
38
+ const submitted = [];
39
+ const editor = Object.create(TitledEditor.prototype);
40
+ Object.assign(editor, {
41
+ km: {
42
+ matches: (data, binding) => binding === 'tui.input.submit' ? data === '\r' : false,
43
+ },
44
+ tui: { requestRender: () => { } },
45
+ isShowingAutocomplete: () => false,
46
+ insertTextAtCursor: (s) => inserted.push(s),
47
+ getCursor: () => ({ line: 0, col: 0 }),
48
+ getLines: () => [''],
49
+ maybeTriggerNonLeadingRefAutocomplete: () => { },
50
+ });
51
+ const originalSuper = CustomEditor.prototype.handleInput;
52
+ // `super.handleInput(data)` inside TitledEditor resolves to this prototype
53
+ // method; the stock editor SUBMITS a bare `\r`, so record that as a submit.
54
+ CustomEditor.prototype.handleInput = function (data) {
55
+ if (data === '\r' || data === '\n')
56
+ submitted.push(data);
57
+ };
58
+ return { editor, inserted, submitted, restore: () => { CustomEditor.prototype.handleInput = originalSuper; } };
59
+ }
60
+ test('unbracketed multi-line paste becomes newlines, never a mid-paste submit', async () => {
61
+ const { editor, inserted, submitted, restore } = makeHarness();
62
+ try {
63
+ // A paste floods handleInput synchronously: "line1\rline2\rline3".
64
+ for (const ch of ['l', 'i', 'n', 'e', '1', '\r', 'l', 'i', 'n', 'e', '2', '\r', 'l', 'i', 'n', 'e', '3']) {
65
+ editor.handleInput(ch);
66
+ }
67
+ await delay(AFTER_HOLD_MS);
68
+ assert.equal(inserted.filter((s) => s === '\n').length, 2, 'both pasted returns became newlines');
69
+ assert.equal(submitted.length, 0, 'no submit fired mid-paste — the full paste survives');
70
+ }
71
+ finally {
72
+ restore();
73
+ }
74
+ });
75
+ test('an unbracketed paste ending in a newline submits ONCE, carrying the whole block', async () => {
76
+ const { editor, inserted, submitted, restore } = makeHarness();
77
+ try {
78
+ for (const ch of ['a', '\n', 'b', '\n'])
79
+ editor.handleInput(ch);
80
+ await delay(AFTER_HOLD_MS);
81
+ assert.equal(inserted.filter((s) => s === '\n').length, 1, 'the interior line feed became a newline');
82
+ assert.equal(submitted.length, 1, 'only the trailing line feed submitted, with both lines in the editor');
83
+ }
84
+ finally {
85
+ restore();
86
+ }
87
+ });
88
+ test('a human Enter submits', async () => {
89
+ const { editor, inserted, submitted, restore } = makeHarness();
90
+ try {
91
+ editor.handleInput('\r');
92
+ await delay(AFTER_HOLD_MS);
93
+ assert.equal(submitted.length, 1, 'a pressed Enter submits');
94
+ assert.equal(inserted.length, 0, 'a pressed Enter does not insert a newline');
95
+ }
96
+ finally {
97
+ restore();
98
+ }
99
+ });
100
+ test('text and Enter delivered in one write (tmux send-keys) still submits', async () => {
101
+ const { editor, inserted, submitted, restore } = makeHarness();
102
+ try {
103
+ // `tmux send-keys -t <pane> '/graph' Enter` flushes both in a single write,
104
+ // so the Enter arrives microseconds after the text — with nothing after it.
105
+ for (const ch of ['/', 'g', 'r', 'a', 'p', 'h', '\r'])
106
+ editor.handleInput(ch);
107
+ await delay(AFTER_HOLD_MS);
108
+ assert.equal(submitted.length, 1, 'the trailing Enter of a send-keys burst submits');
109
+ assert.equal(inserted.length, 0, 'nothing was turned into a newline');
110
+ }
111
+ finally {
112
+ restore();
113
+ }
114
+ });
@@ -66,7 +66,7 @@ test('Enter-confirming a non-leading ref completion accepts the token and return
66
66
  assert.equal(editor.getText(), 'hey check /dev', 'the token was replaced in place, not submitted as typed');
67
67
  assert.equal(submitted, undefined, 'Enter-confirming a ref completion must never submit the message');
68
68
  });
69
- test('a real Enter submission preserves raw leading/trailing whitespace end-to-end', () => {
69
+ test('a real Enter submission preserves raw leading/trailing whitespace end-to-end', async () => {
70
70
  const editor = makeEditor();
71
71
  let submitted;
72
72
  editor.onSubmit = (text) => {
@@ -74,6 +74,9 @@ test('a real Enter submission preserves raw leading/trailing whitespace end-to-e
74
74
  };
75
75
  editor.setText(' hello world ');
76
76
  editor.handleInput('\r'); // real Enter keystroke — tui.input.submit
77
+ // The unbracketed-paste rescue holds a line ending for PASTE_BURST_MS to see
78
+ // whether more input follows it (a paste's own line break) before submitting.
79
+ await new Promise((resolve) => setTimeout(resolve, 40));
77
80
  assert.equal(submitted, ' hello world ', 'onSubmit receives the exact untrimmed text, not the base editor\'s trimmed copy');
78
81
  assert.equal(editor.getText(), '', 'submitValue still resets editor state as usual');
79
82
  });
@@ -84,6 +84,17 @@ export declare class TitledEditor extends CustomEditor {
84
84
  * converted to an accessor below whose getter always returns our substitution
85
85
  * wrapper, not this value directly. */
86
86
  private realOnSubmit;
87
+ /** A line ending held back to learn whether more input follows it within
88
+ * `PASTE_BURST_MS` — see `handleInput`'s unbracketed-paste rescue. More input
89
+ * means it was a line break inside a paste (insert a newline); silence means
90
+ * a human pressed Enter (submit). */
91
+ private pendingReturn;
92
+ /** How long a line ending is held before it counts as a real Enter. An
93
+ * unbracketed paste floods `handleInput` within one event-loop tick, so its
94
+ * next character always lands far inside this window; a human's next
95
+ * keystroke after Enter is ~30ms+ away at the very fastest. Short enough that
96
+ * the added submit latency is imperceptible. */
97
+ private static readonly PASTE_BURST_MS;
87
98
  constructor(tui: TUI, theme: EditorTheme, keybindings: ConstructorParameters<typeof CustomEditor>[2], options?: EditorOptions);
88
99
  /** Insert a newline on the newLine chord (Alt+Enter / Shift+Enter) using crtr's
89
100
  * OWN keybindings manager, then defer everything else to the stock editor.
@@ -101,6 +112,13 @@ export declare class TitledEditor extends CustomEditor {
101
112
  * the terminal delivers as bracketed-paste TEXT, not an image event). Returns
102
113
  * true when it consumed the drop, so the raw path is never inserted as text. */
103
114
  onPasteImagePath?: (path: string) => boolean;
115
+ /** Settle a line ending held by the unbracketed-paste rescue. `'newline'` (more
116
+ * input arrived, so it was a paste's own line break) inserts it in place;
117
+ * `'submit'` (the hold window expired in silence, so a human pressed Enter)
118
+ * replays it through the stock editor and repaints, because this runs outside
119
+ * the TUI's own post-`handleInput` render request. No-op when nothing is
120
+ * held. */
121
+ private settleHeldReturn;
104
122
  /** Width of an `[Image #N]` token sitting immediately before the cursor on the
105
123
  * current line, or 0 when the cursor isn't at the end of such a token — used to
106
124
  * delete/skip the token atomically. The token's separating space is a normal,
@@ -441,6 +441,17 @@ export class TitledEditor extends CustomEditor {
441
441
  * converted to an accessor below whose getter always returns our substitution
442
442
  * wrapper, not this value directly. */
443
443
  realOnSubmit;
444
+ /** A line ending held back to learn whether more input follows it within
445
+ * `PASTE_BURST_MS` — see `handleInput`'s unbracketed-paste rescue. More input
446
+ * means it was a line break inside a paste (insert a newline); silence means
447
+ * a human pressed Enter (submit). */
448
+ pendingReturn;
449
+ /** How long a line ending is held before it counts as a real Enter. An
450
+ * unbracketed paste floods `handleInput` within one event-loop tick, so its
451
+ * next character always lands far inside this window; a human's next
452
+ * keystroke after Enter is ~30ms+ away at the very fastest. Short enough that
453
+ * the added submit latency is imperceptible. */
454
+ static PASTE_BURST_MS = 10;
444
455
  // CustomEditor's ctor signature; captured so we own the newline chord (see
445
456
  // handleInput). The keybindings param is typed as pi's app-subclass manager,
446
457
  // but the attach viewer feeds it pi-tui's base manager — hence the cast.
@@ -507,6 +518,25 @@ export class TitledEditor extends CustomEditor {
507
518
  * the terminal delivers as bracketed-paste TEXT, not an image event). Returns
508
519
  * true when it consumed the drop, so the raw path is never inserted as text. */
509
520
  onPasteImagePath;
521
+ /** Settle a line ending held by the unbracketed-paste rescue. `'newline'` (more
522
+ * input arrived, so it was a paste's own line break) inserts it in place;
523
+ * `'submit'` (the hold window expired in silence, so a human pressed Enter)
524
+ * replays it through the stock editor and repaints, because this runs outside
525
+ * the TUI's own post-`handleInput` render request. No-op when nothing is
526
+ * held. */
527
+ settleHeldReturn(as) {
528
+ const held = this.pendingReturn;
529
+ if (held === undefined)
530
+ return;
531
+ clearTimeout(held.timer);
532
+ this.pendingReturn = undefined;
533
+ if (as === 'newline') {
534
+ this.insertTextAtCursor('\n');
535
+ return;
536
+ }
537
+ super.handleInput(held.data);
538
+ this.tui.requestRender();
539
+ }
510
540
  /** Width of an `[Image #N]` token sitting immediately before the cursor on the
511
541
  * current line, or 0 when the cursor isn't at the end of such a token — used to
512
542
  * delete/skip the token atomically. The token's separating space is a normal,
@@ -531,6 +561,9 @@ export class TitledEditor extends CustomEditor {
531
561
  return match ? match[0].length : 0;
532
562
  }
533
563
  handleInput(data) {
564
+ // Any input arriving while a line ending is held settles it as a paste's own
565
+ // line break: insert it FIRST so this key lands after it, in typed order.
566
+ this.settleHeldReturn('newline');
534
567
  // Keep ↓ on the editor's native focused-input path. Only when its own
535
568
  // wrap-aware cursor and prompt-history navigation make no change does the
536
569
  // adjacent roster get a chance to take over.
@@ -590,6 +623,35 @@ export class TitledEditor extends CustomEditor {
590
623
  const droppedPath = detectDroppedImagePath(data);
591
624
  if (droppedPath && this.onPasteImagePath?.(droppedPath))
592
625
  return;
626
+ // Unbracketed multi-line paste rescue. When the terminal delivers a paste
627
+ // WITHOUT bracketed-paste markers (\x1b[200~ … \x1b[201~) — the outer
628
+ // terminal may not support them, or tmux attached its client with a terminfo
629
+ // entry lacking the Enbp/Dsbp caps — pi-tui's StdinBuffer never enters paste
630
+ // mode and instead splits the paste into single sequences. Each embedded
631
+ // line ending then reaches us as a bare Enter, and the stock editor SUBMITS
632
+ // on it: line one becomes a prompt and every following line becomes its own
633
+ // turn, is queued as steering, or is lost to pi's "Agent is already
634
+ // processing" rejection when it lands inside prompt()'s async preflight. The
635
+ // bracketed path (when markers DO arrive) is a single `\x1b[200~…` sequence
636
+ // that never trips this branch, so it stays fully intact.
637
+ //
638
+ // A pasted line break is told from a pressed Enter by what FOLLOWS it, not
639
+ // by what precedes it: a paste floods handleInput with its next character
640
+ // microseconds later, while an Enter is followed by human-scale silence.
641
+ // Deciding on the following input (rather than the preceding gap) is what
642
+ // keeps `tmux send-keys <text> Enter` — one write, so its Enter arrives
643
+ // microseconds after the text — submitting as intended, since nothing
644
+ // follows it. A paste that ends in a trailing newline likewise submits, by
645
+ // then carrying its WHOLE text, so no line is ever split off or lost.
646
+ // Skipped while the autocomplete popup is open, where Enter confirms a
647
+ // completion and must stay immediate.
648
+ if ((data === '\r' || data === '\n') && !this.isShowingAutocomplete()) {
649
+ this.pendingReturn = {
650
+ data,
651
+ timer: setTimeout(() => this.settleHeldReturn('submit'), TitledEditor.PASTE_BURST_MS),
652
+ };
653
+ return;
654
+ }
593
655
  const { line: beforeLine, col: beforeCol } = this.getCursor();
594
656
  const beforeText = this.getLines()[beforeLine] ?? '';
595
657
  super.handleInput(data);
@@ -96,6 +96,9 @@ export declare class ChatView {
96
96
  private readonly groups;
97
97
  private openGroup;
98
98
  private readonly groupSummaries;
99
+ /** Node id → display name for every node spawned in this transcript, so a
100
+ * later group's finish notice can name the child rather than its id. */
101
+ private readonly nodeRoster;
99
102
  private streamingWasGroupMember;
100
103
  private readonly onFooterEvent;
101
104
  /** Viewer hook that places activity in its fixed slot, when available. */
@@ -30,7 +30,7 @@ import { generatedContextExpandedBody, generatedContextPresentation, isRuntimeRe
30
30
  import { assistantVisibleText, isTrueUserMessage, } from '../../../shared/tool-groups.js';
31
31
  import { transformMessageDiagrams } from './diagram.js';
32
32
  import { transformMessageHtml } from './html-markdown.js';
33
- import { styleAttachMarkdownHeadings, styleAttachMessageMarkdown, styleAttachSummaryMarkdown, } from './markdown-headings.js';
33
+ import { styleAttachMarkdownSource, styleAttachMessageMarkdown, styleAttachSummaryMarkdown, } from './markdown-source.js';
34
34
  import { createCrtrEditToolDefinition } from './edit-diff.js';
35
35
  import { GroupActivityRecorder } from './group-activity.js';
36
36
  import { GroupRecapComponent } from './group-recap.js';
@@ -290,6 +290,9 @@ export class ChatView {
290
290
  groups = [];
291
291
  openGroup;
292
292
  groupSummaries = new Map();
293
+ /** Node id → display name for every node spawned in this transcript, so a
294
+ * later group's finish notice can name the child rather than its id. */
295
+ nodeRoster = new Map();
293
296
  streamingWasGroupMember = false;
294
297
  onFooterEvent;
295
298
  /** Viewer hook that places activity in its fixed slot, when available. */
@@ -1032,7 +1035,11 @@ export class ChatView {
1032
1035
  this.appendExistingGroupMember(component);
1033
1036
  }
1034
1037
  else {
1035
- this.appendGroupMember(component);
1038
+ const group = this.appendGroupMember(component);
1039
+ // A child's final arrives as an inbox digest, not a tool call: without
1040
+ // this the folded recap would hide the one row saying it finished.
1041
+ if (generated.kind === 'inbox')
1042
+ group?.activity.noteInboxDigest(generated.body);
1036
1043
  }
1037
1044
  return;
1038
1045
  }
@@ -1104,18 +1111,18 @@ export class ChatView {
1104
1111
  this.appendUserPart(new Spacer(1), !isTrueUserMessage(message));
1105
1112
  const skillBlock = parseSkillBlock(textContent);
1106
1113
  if (skillBlock) {
1107
- const component = new SkillInvocationMessageComponent({ ...skillBlock, content: styleAttachMarkdownHeadings(skillBlock.content) }, attachMarkdownTheme());
1114
+ const component = new SkillInvocationMessageComponent({ ...skillBlock, content: styleAttachMarkdownSource(skillBlock.content) }, attachMarkdownTheme());
1108
1115
  component.setExpanded(this.toolOutputExpanded);
1109
1116
  this.appendUserPart(component, !isTrueUserMessage(message));
1110
1117
  this.registerCopyBlock(component, 'user', textContent);
1111
1118
  if (skillBlock.userMessage) {
1112
- const userMessage = new UserMessageComponent(styleAttachMarkdownHeadings(skillBlock.userMessage), attachMarkdownTheme());
1119
+ const userMessage = new UserMessageComponent(styleAttachMarkdownSource(skillBlock.userMessage), attachMarkdownTheme());
1113
1120
  this.appendUserPart(userMessage, !isTrueUserMessage(message));
1114
1121
  this.registerCopyBlock(userMessage, 'user', skillBlock.userMessage);
1115
1122
  }
1116
1123
  }
1117
1124
  else {
1118
- const component = new UserMessageComponent(styleAttachMarkdownHeadings(textContent), attachMarkdownTheme());
1125
+ const component = new UserMessageComponent(styleAttachMarkdownSource(textContent), attachMarkdownTheme());
1119
1126
  this.appendUserPart(component, !isTrueUserMessage(message));
1120
1127
  this.registerCopyBlock(component, 'user', textContent);
1121
1128
  }
@@ -1183,7 +1190,7 @@ export class ChatView {
1183
1190
  const group = {
1184
1191
  members: [],
1185
1192
  recap,
1186
- activity: new GroupActivityRecorder(this.cwd),
1193
+ activity: new GroupActivityRecorder(this.cwd, this.nodeRoster),
1187
1194
  toolCount: 0,
1188
1195
  inFlight: new Set(),
1189
1196
  };
@@ -1203,6 +1210,7 @@ export class ChatView {
1203
1210
  this.append(component);
1204
1211
  if (group)
1205
1212
  group.members.push(component);
1213
+ return group;
1206
1214
  }
1207
1215
  /** Used for context that may join an existing run but must not create one. */
1208
1216
  appendExistingGroupMember(component) {
@@ -1422,6 +1430,7 @@ export class ChatView {
1422
1430
  this.groups.length = 0;
1423
1431
  this.openGroup = undefined;
1424
1432
  this.groupSummaries.clear();
1433
+ this.nodeRoster.clear();
1425
1434
  this.pendingTools.clear();
1426
1435
  this.lastAssistantText = undefined;
1427
1436
  this.copyBlockRecords.length = 0;
@@ -11,7 +11,7 @@
11
11
  import { UserMessageComponent } from '@earendil-works/pi-coding-agent';
12
12
  import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
13
13
  import { attachMarkdownTheme } from '../config.js';
14
- import { styleAttachMarkdownHeadings } from './markdown-headings.js';
14
+ import { styleAttachMarkdownSource } from './markdown-source.js';
15
15
  /** Default label/hint for the `crtr-context` bearings block. */
16
16
  const LABEL = 'crtr context';
17
17
  const COLLAPSED_HINT = ' orienting bearings — ctrl+o to expand';
@@ -30,7 +30,7 @@ export class ContextMessageComponent {
30
30
  this.collapsedHint = collapsedHint;
31
31
  this.expandsWithTools = expandsWithTools;
32
32
  this.expanded = expandsWithTools && expanded;
33
- this.expandedBody = new UserMessageComponent(styleAttachMarkdownHeadings(body), attachMarkdownTheme());
33
+ this.expandedBody = new UserMessageComponent(styleAttachMarkdownSource(body), attachMarkdownTheme());
34
34
  }
35
35
  /** Ctrl+O global tool-output toggle (ChatView.isExpandable duck-type). */
36
36
  setExpanded(expanded) {
@@ -12,22 +12,39 @@ export type MemoryActivity = {
12
12
  name: string;
13
13
  path?: string;
14
14
  };
15
+ /** One node this group started or saw finish. `name` is the spawn's display
16
+ * name, or the node id when the finish has no matching spawn in this session. */
17
+ export type NodeActivity = {
18
+ action: 'spawned' | 'finished';
19
+ name: string;
20
+ id: string;
21
+ };
15
22
  export type GroupActivity = {
16
23
  files: readonly FileActivity[];
17
24
  memories: readonly MemoryActivity[];
25
+ nodes: readonly NodeActivity[];
18
26
  };
19
27
  /** Per-group recorder. Calls retain only their current transcript references
20
28
  * until they settle; the settled activity is plain immutable evidence. */
21
29
  export declare class GroupActivityRecorder {
22
30
  private readonly cwd;
31
+ private readonly roster;
23
32
  private readonly calls;
24
33
  private readonly files;
25
34
  private readonly memories;
26
35
  private readonly memorySet;
27
- constructor(cwd: string);
36
+ private readonly nodes;
37
+ private readonly nodeSet;
38
+ /** `roster` is the ChatView-wide node id → display name record: spawns fill
39
+ * it, and a later group's finish reads the name a sibling group recorded. */
40
+ constructor(cwd: string, roster?: Map<string, string>);
41
+ /** Record the nodes an inbox digest reports finished. The digest names senders
42
+ * by node id, so the spawn roster supplies the display name when it has one. */
43
+ noteInboxDigest(body: string): void;
28
44
  noteCall(id: string, name: string, args: unknown): void;
29
45
  settle(id: string, result: unknown, isError: boolean): void;
30
46
  resolveWithoutResult(id: string): void;
31
47
  activity(): GroupActivity;
48
+ private addNode;
32
49
  private extract;
33
50
  }
@@ -3,10 +3,12 @@
3
3
  // calls into the exact evidence already present in the session transcript.
4
4
  import { homedir } from 'node:os';
5
5
  import { join, resolve } from 'node:path';
6
+ import { inboxFinishedSenders } from '../../../shared/generated-context.js';
6
7
  import { countDisplayDiffLines } from './edit-diff.js';
7
8
  const EMPTY_ACTIVITY = Object.freeze({
8
9
  files: Object.freeze([]),
9
10
  memories: Object.freeze([]),
11
+ nodes: Object.freeze([]),
10
12
  });
11
13
  function object(value) {
12
14
  return typeof value === 'object' && value !== null ? value : undefined;
@@ -77,7 +79,7 @@ function shellTokens(segment) {
77
79
  }
78
80
  /** Deliberately match an executable token, not a result-shaped string or a
79
81
  * quoted command fragment. */
80
- function invokesMemoryCommand(args, verb) {
82
+ function invokesCrtrCommand(args, path) {
81
83
  const command = object(args)?.command;
82
84
  if (typeof command !== 'string')
83
85
  return false;
@@ -86,7 +88,9 @@ function invokesMemoryCommand(args, verb) {
86
88
  while (/^[A-Za-z_][A-Za-z0-9_]*=\S+$/.test(tokens[0] ?? ''))
87
89
  tokens.shift();
88
90
  const executable = tokens[0]?.split('/').at(-1);
89
- return (executable === 'crtr' || executable === 'crouter') && tokens[1] === 'memory' && tokens[2] === verb;
91
+ if (executable !== 'crtr' && executable !== 'crouter')
92
+ return false;
93
+ return path.every((word, index) => tokens[index + 1] === word);
90
94
  });
91
95
  }
92
96
  /** A resolved read/write begins with the identity fields emitted by its leaf.
@@ -105,6 +109,19 @@ function resolvedMemories(result, action) {
105
109
  }
106
110
  return memories;
107
111
  }
112
+ /** The `node new` receipt line (commands/node/create.ts `render`), which names
113
+ * the node only once its broker is actually running. */
114
+ const SPAWNED_NODE_RE = /^Spawned "([^"\n]+)" \(([^)\s]+)\)/gm;
115
+ function spawnedNodes(result) {
116
+ const nodes = [];
117
+ for (const match of resultText(result).matchAll(SPAWNED_NODE_RE)) {
118
+ const name = match[1]?.trim();
119
+ const id = match[2]?.trim();
120
+ if (name && id)
121
+ nodes.push({ action: 'spawned', name, id });
122
+ }
123
+ return nodes;
124
+ }
108
125
  /** Local mirror of pi's `resolveToCwd`: file tools expand `~` before resolving
109
126
  * against the node cwd, and the recap must key the exact same file identically. */
110
127
  function canonicalPath(rawPath, cwd) {
@@ -132,12 +149,25 @@ function fileChange(name, args, result) {
132
149
  * until they settle; the settled activity is plain immutable evidence. */
133
150
  export class GroupActivityRecorder {
134
151
  cwd;
152
+ roster;
135
153
  calls = new Map();
136
154
  files = new Map();
137
155
  memories = [];
138
156
  memorySet = new Set();
139
- constructor(cwd) {
157
+ nodes = [];
158
+ nodeSet = new Set();
159
+ /** `roster` is the ChatView-wide node id → display name record: spawns fill
160
+ * it, and a later group's finish reads the name a sibling group recorded. */
161
+ constructor(cwd, roster = new Map()) {
140
162
  this.cwd = cwd;
163
+ this.roster = roster;
164
+ }
165
+ /** Record the nodes an inbox digest reports finished. The digest names senders
166
+ * by node id, so the spawn roster supplies the display name when it has one. */
167
+ noteInboxDigest(body) {
168
+ for (const id of inboxFinishedSenders(body)) {
169
+ this.addNode({ action: 'finished', name: this.roster.get(id) ?? id, id });
170
+ }
141
171
  }
142
172
  noteCall(id, name, args) {
143
173
  const existing = this.calls.get(id);
@@ -163,16 +193,34 @@ export class GroupActivityRecorder {
163
193
  entry.args = undefined;
164
194
  }
165
195
  activity() {
166
- if (this.files.size === 0 && this.memories.length === 0)
196
+ if (this.files.size === 0 && this.memories.length === 0 && this.nodes.length === 0)
167
197
  return EMPTY_ACTIVITY;
168
198
  const files = [...this.files.values()].map((file) => Object.freeze({ ...file }));
169
199
  const memories = this.memories.map((memory) => Object.freeze({ ...memory }));
170
- return Object.freeze({ files: Object.freeze(files), memories: Object.freeze(memories) });
200
+ const nodes = this.nodes.map((node) => Object.freeze({ ...node }));
201
+ return Object.freeze({
202
+ files: Object.freeze(files),
203
+ memories: Object.freeze(memories),
204
+ nodes: Object.freeze(nodes),
205
+ });
206
+ }
207
+ addNode(node) {
208
+ const key = `${node.action}:${node.id}`;
209
+ if (this.nodeSet.has(key))
210
+ return;
211
+ this.nodeSet.add(key);
212
+ this.nodes.push(node);
213
+ if (node.action === 'spawned')
214
+ this.roster.set(node.id, node.name);
171
215
  }
172
216
  extract(entry, result) {
173
217
  if (entry.name === 'bash') {
218
+ if (invokesCrtrCommand(entry.args, ['node', 'new'])) {
219
+ for (const node of spawnedNodes(result))
220
+ this.addNode(node);
221
+ }
174
222
  for (const action of ['read', 'write']) {
175
- if (!invokesMemoryCommand(entry.args, action))
223
+ if (!invokesCrtrCommand(entry.args, ['memory', action]))
176
224
  continue;
177
225
  for (const memory of resolvedMemories(result, action)) {
178
226
  const key = `${action}:${memory.path ?? memory.name}`;
@@ -31,6 +31,9 @@ function countMemories(memories, action) {
31
31
  function distinctFileCount(files) {
32
32
  return new Set(files.map((file) => file.key)).size;
33
33
  }
34
+ function countNodes(nodes, action) {
35
+ return nodes.filter((node) => node.action === action).length;
36
+ }
34
37
  class RecapDetailLine {
35
38
  prefix;
36
39
  path;
@@ -56,6 +59,9 @@ export class GroupRecapComponent extends Container {
56
59
  return;
57
60
  this.addChild(new Spacer(1));
58
61
  if (detailed) {
62
+ for (const node of activity.nodes) {
63
+ this.addChild(new RecapDetailLine(`${palette.accent(ICON.graph)} `, palette.muted(node.name), palette.muted(node.action === 'spawned' ? ' spawned' : ' finished')));
64
+ }
59
65
  for (const memory of activity.memories) {
60
66
  const icon = memory.action === 'write' ? TOOL_ICON.write : MEMORY_ICON;
61
67
  this.addChild(new RecapDetailLine(`${palette.info(icon)} `, memoryName(memory, palette), ''));
@@ -69,8 +75,13 @@ export class GroupRecapComponent extends Container {
69
75
  }
70
76
  else {
71
77
  const stats = [toolCallCount];
72
- if (summary.nodesSpawned >= 1) {
73
- stats.push(`${palette.accent(ICON.graph)} ${palette.muted(count(summary.nodesSpawned, 'node spawned', 'nodes spawned'))}`);
78
+ const nodesSpawned = countNodes(activity.nodes, 'spawned');
79
+ if (nodesSpawned >= 1) {
80
+ stats.push(`${palette.accent(ICON.graph)} ${palette.muted(count(nodesSpawned, 'node spawned', 'nodes spawned'))}`);
81
+ }
82
+ const nodesFinished = countNodes(activity.nodes, 'finished');
83
+ if (nodesFinished >= 1) {
84
+ stats.push(`${palette.accent(ICON.graph)} ${palette.muted(count(nodesFinished, 'node finished', 'nodes finished'))}`);
74
85
  }
75
86
  const changedFiles = distinctFileCount(activity.files);
76
87
  if (changedFiles >= 1) {