@north-light/crouter 0.3.182 → 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 (102) 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-extension-render.d.ts +1 -1
  66. package/dist/core/runtime/broker-extension-render.js +2 -2
  67. package/dist/core/runtime/broker-protocol.d.ts +1 -1
  68. package/dist/core/runtime/deliver-live.js +11 -1
  69. package/dist/core/runtime/fault.js +1 -1
  70. package/dist/core/runtime/tmux-bindings.js +42 -20
  71. package/dist/core/runtime/tool-group-summary.js +2 -6
  72. package/dist/core/substrate/injected-store.d.ts +6 -0
  73. package/dist/core/substrate/injected-store.js +19 -0
  74. package/dist/core/substrate/on-read-node.d.ts +4 -2
  75. package/dist/core/substrate/on-read-node.js +5 -3
  76. package/dist/core/substrate/on-read.d.ts +4 -2
  77. package/dist/core/substrate/on-read.js +27 -5
  78. package/dist/pi-extensions/canvas-context-intro.d.ts +1 -1
  79. package/dist/pi-extensions/canvas-context-intro.js +14 -3
  80. package/dist/pi-extensions/canvas-doc-substrate.js +13 -2
  81. package/dist/shared/generated-context.d.ts +4 -0
  82. package/dist/shared/generated-context.js +20 -1
  83. package/dist/shared/tool-groups.d.ts +3 -3
  84. package/dist/shared/tool-groups.js +1 -4
  85. package/package.json +1 -1
  86. package/runtime.lock.json +2 -2
  87. package/scripts/postinstall.mjs +1 -1
  88. package/dist/clients/attach/render/markdown-headings.js +0 -93
  89. package/dist/commands/search/answer.d.ts +0 -1
  90. package/dist/commands/search/answer.js +0 -50
  91. package/dist/commands/search/contents.d.ts +0 -1
  92. package/dist/commands/search/contents.js +0 -96
  93. package/dist/commands/search/exa.d.ts +0 -53
  94. package/dist/commands/search/exa.js +0 -157
  95. package/dist/commands/search/puremd.d.ts +0 -11
  96. package/dist/commands/search/puremd.js +0 -53
  97. package/dist/commands/search/web.d.ts +0 -1
  98. package/dist/commands/search/web.js +0 -65
  99. package/dist/commands/search.d.ts +0 -2
  100. package/dist/commands/search.js +0 -24
  101. package/dist/commands/sys/panels/keys-panel.d.ts +0 -16
  102. package/dist/commands/sys/panels/keys-panel.js +0 -60
@@ -161,23 +161,40 @@ function normalizedStrokeParts(stroke) {
161
161
  return { key: parts.pop(), modifiers: parts };
162
162
  }
163
163
  function tmuxKey(stroke) {
164
+ return tmuxKeyNames(stroke)[0];
165
+ }
166
+ /** Every tmux key name one physical chord can arrive as, most specific first.
167
+ *
168
+ * tmux derives the name from the bytes the terminal sent, and a terminal sends
169
+ * different bytes for the same chord depending on whether extended keys are
170
+ * active — which changes per pane, since the pane's application turns them on.
171
+ * tmux does not normalize across those encodings, so a chord bound under one
172
+ * name is simply dead under the others. Verified against tmux 3.7b by writing
173
+ * raw bytes to an attached client; for Alt+Shift+F:
174
+ *
175
+ * ESC F (legacy meta prefix) -> `M-F`
176
+ * ESC [70;4u / ESC [27;4;70~ (shifted keysym) -> `M-S-F`
177
+ * ESC [102;4u / ESC [27;4;102~ (base keysym) -> `M-S-f`
178
+ *
179
+ * Shift alone never survives as a modifier — every encoding of Shift+G lands
180
+ * on `G` — so only chords that pair Shift with Ctrl or Alt carry aliases. */
181
+ function tmuxKeyNames(stroke) {
164
182
  let { key, modifiers: modifierParts } = normalizedStrokeParts(stroke);
165
183
  const modifiers = new Set(modifierParts);
166
- if (modifiers.has('shift') && key.length === 1 && /[a-z]/.test(key)) {
167
- // tmux names a shifted printable with its uppercase character even when an
168
- // extended-key terminal also preserves Shift as a modifier: Alt+Shift+F is
169
- // `M-S-F`. Shift by itself needs only the uppercase character (`F`).
170
- key = key.toUpperCase();
171
- if (!modifiers.has('ctrl') && !modifiers.has('alt'))
172
- modifiers.delete('shift');
173
- }
174
- key = TMUX_NAMED_KEYS[key] ?? key;
175
- const prefix = [
184
+ const name = (character, shift) => [
176
185
  modifiers.has('ctrl') ? 'C-' : '',
177
186
  modifiers.has('alt') ? 'M-' : '',
178
- modifiers.has('shift') ? 'S-' : '',
187
+ shift ? 'S-' : '',
188
+ TMUX_NAMED_KEYS[character] ?? character,
179
189
  ].join('');
180
- return `${prefix}${key}`;
190
+ if (modifiers.has('shift') && key.length === 1 && /[a-z]/.test(key)) {
191
+ const shifted = key.toUpperCase();
192
+ // Shift-only: tmux always collapses it into the uppercase character.
193
+ if (!modifiers.has('ctrl') && !modifiers.has('alt'))
194
+ return [name(shifted, false)];
195
+ return [name(shifted, true), name(shifted, false), name(key, true)];
196
+ }
197
+ return [name(key, modifiers.has('shift'))];
181
198
  }
182
199
  function menuSelector(stroke) {
183
200
  const { key, modifiers } = normalizedStrokeParts(stroke);
@@ -386,7 +403,7 @@ export function occupiedTmuxRootGestures(gestures) {
386
403
  const parsed = parseTmuxBinding(line);
387
404
  return parsed?.table === ROOT_TABLE ? [parsed.key] : [];
388
405
  }));
389
- return gestures.filter((gesture) => !gesture.includes(' > ') && occupied.has(tmuxKey(gesture)));
406
+ return gestures.filter((gesture) => !gesture.includes(' > ') && tmuxKeyNames(gesture).some((key) => occupied.has(key)));
390
407
  }
391
408
  function isInstalledPair(value) {
392
409
  return typeof value === 'object' && value !== null
@@ -547,9 +564,10 @@ function reconcileTmuxBindings(bindingSnapshot, passthroughSnapshot = []) {
547
564
  for (const gesture of bindings.gestures(bindingId)) {
548
565
  if (gesture.includes(' > '))
549
566
  continue;
550
- const key = tmuxKey(gesture);
551
- if (!desiredWrappers.has(key))
552
- desiredWrappers.set(key, { bindingId, gesture });
567
+ for (const key of tmuxKeyNames(gesture)) {
568
+ if (!desiredWrappers.has(key))
569
+ desiredWrappers.set(key, { bindingId, gesture });
570
+ }
553
571
  }
554
572
  }
555
573
  // Restore every wrapper crouter still owns before the ordinary binding sweep.
@@ -610,7 +628,8 @@ function reconcileTmuxBindings(bindingSnapshot, passthroughSnapshot = []) {
610
628
  const menuActions = resolvedMenuActions(bindings);
611
629
  const menuCommand = buildMenuArgs(' crtr ', menuItems(bindings, menuActions));
612
630
  for (const gesture of bindings.gestures('crtr.tmux.menu.open')) {
613
- install(ROOT_TABLE, tmuxKey(gesture), menuCommand, 'crtr.tmux.menu.open', gesture);
631
+ for (const key of tmuxKeyNames(gesture))
632
+ install(ROOT_TABLE, key, menuCommand, 'crtr.tmux.menu.open', gesture);
614
633
  }
615
634
  for (const definition of BINDING_CATALOG) {
616
635
  if (definition.pair?.field !== 'menu')
@@ -625,12 +644,15 @@ function reconcileTmuxBindings(bindingSnapshot, passthroughSnapshot = []) {
625
644
  if (command === null)
626
645
  throw new Error(`no direct tmux action for paired menu binding ${menuId}`);
627
646
  for (const gesture of bindings.gestures(globalId)) {
628
- install(ROOT_TABLE, tmuxKey(gesture), command.argv, globalId, gesture);
647
+ for (const key of tmuxKeyNames(gesture))
648
+ install(ROOT_TABLE, key, command.argv, globalId, gesture);
629
649
  }
630
650
  }
631
651
  for (const id of ['crtr.tmux.node.next', 'crtr.tmux.node.previous', 'crtr.tmux.inbox.toggle']) {
632
- for (const gesture of bindings.gestures(id))
633
- install(ROOT_TABLE, tmuxKey(gesture), actionCommand(id), id, gesture);
652
+ for (const gesture of bindings.gestures(id)) {
653
+ for (const key of tmuxKeyNames(gesture))
654
+ install(ROOT_TABLE, key, actionCommand(id), id, gesture);
655
+ }
634
656
  }
635
657
  // Wrap only occupied roots. Unoccupied gestures already reach the pane and do
636
658
  // not need crouter ownership. Re-read the installed line as the fingerprint;
@@ -49,20 +49,16 @@ const SUMMARY_SYSTEM_PROMPT = 'Create a structured recap of this coding-agent to
49
49
  'Omit inbox receipt, delivery, and queueing; node-response and report bookkeeping; help lookups; status checks; and summary-generation activity. ' +
50
50
  'A node response may support a substantive outcome, but its arrival or resummarization is never itself an outcome. ' +
51
51
  'If no meaningful outcome remains after those omissions, submit the single bullet "No meaningful changes." ' +
52
- 'Count nodes created, and separately count distinct files removed from disk, only when the supplied tool arguments or results evidence them; otherwise use zero, even when the corresponding mechanics are omitted from bullets. ' +
52
+ 'Count distinct files removed from disk only when the supplied tool arguments or results evidence them; otherwise use zero, even when the corresponding mechanics are omitted from bullets. ' +
53
53
  'A deleted file is one that no longer exists after the segment, never a file that was merely edited or overwritten, and a recursive or wildcard removal of an unknown number of files counts as one. ' +
54
54
  'Do not invent success when a result shows failure. ' +
55
- 'Call submit_summary exactly once with bullets, nodesSpawned, and filesDeleted, then stop.';
55
+ 'Call submit_summary exactly once with bullets and filesDeleted, then stop.';
56
56
  const SUBMIT_SUMMARY_SCHEMA = Type.Object({
57
57
  bullets: Type.Array(Type.String({
58
58
  minLength: 1,
59
59
  maxLength: 120,
60
60
  description: 'A very short, plain-English outcome without bullet markers, identifiers, or operational bookkeeping.',
61
61
  }), { minItems: 1, maxItems: 5 }),
62
- nodesSpawned: Type.Integer({
63
- minimum: 0,
64
- description: 'Count of nodes created during the supplied segment, or zero when none are evidenced.',
65
- }),
66
62
  filesDeleted: Type.Integer({
67
63
  minimum: 0,
68
64
  description: 'Count of distinct files removed from disk during the supplied segment, or zero when none are evidenced.',
@@ -1,3 +1,9 @@
1
+ /** The process-shared dedup set for a node: loaded from disk once, then the
2
+ * SAME Set instance on every call, so all hooks in this pi process grow and
3
+ * consult one set. Daemon-side deliveries (a warm-spare claim) write the disk
4
+ * file from another process; consumers that must see those merge the plain
5
+ * `loadInjectedDocs` result in before rendering. */
6
+ export declare function sharedInjectedDocs(nodeId: string): Set<string>;
1
7
  /** Rehydrate a node's on-read dedup set from disk. Returns an empty set when the
2
8
  * file is absent, unreadable, or malformed (a fresh transcript, or a node that
3
9
  * has not yet surfaced any on-read doc). */
@@ -19,6 +19,24 @@
19
19
  // never a crash — a dedup miss must never break a read or a revive.
20
20
  import { readFileSync, writeFileSync, rmSync } from 'node:fs';
21
21
  import { injectedDocsPath } from '../canvas/paths.js';
22
+ /** Per-process cache behind `sharedInjectedDocs`: one Set instance per node,
23
+ * shared by every extension in the pi process (context-intro's workspace-open
24
+ * render and doc-substrate's on-read hook), so a doc delivered by one hook is
25
+ * already in the set the other dedups against. */
26
+ const cache = new Map();
27
+ /** The process-shared dedup set for a node: loaded from disk once, then the
28
+ * SAME Set instance on every call, so all hooks in this pi process grow and
29
+ * consult one set. Daemon-side deliveries (a warm-spare claim) write the disk
30
+ * file from another process; consumers that must see those merge the plain
31
+ * `loadInjectedDocs` result in before rendering. */
32
+ export function sharedInjectedDocs(nodeId) {
33
+ let seen = cache.get(nodeId);
34
+ if (seen === undefined) {
35
+ seen = loadInjectedDocs(nodeId);
36
+ cache.set(nodeId, seen);
37
+ }
38
+ return seen;
39
+ }
22
40
  /** Rehydrate a node's on-read dedup set from disk. Returns an empty set when the
23
41
  * file is absent, unreadable, or malformed (a fresh transcript, or a node that
24
42
  * has not yet surfaced any on-read doc). */
@@ -46,6 +64,7 @@ export function saveInjectedDocs(nodeId, seen) {
46
64
  /** Drop a node's persisted dedup set. Called by the launch paths that start a
47
65
  * FRESH transcript, so the new conversation surfaces docs from scratch. */
48
66
  export function clearInjectedDocs(nodeId) {
67
+ cache.delete(nodeId);
49
68
  try {
50
69
  rmSync(injectedDocsPath(nodeId), { force: true });
51
70
  }
@@ -3,5 +3,7 @@
3
3
  export declare function renderOnReadDocs(nodeId: string, readFilePath: string, seen?: Set<string>): string;
4
4
  /** Surface workspace-wide docs during first-message assembly, using the node's
5
5
  * recorded cwd and selected profile. `.` is a reserved applies-to target
6
- * meaning “when this project store is mounted by cwd/profile”. */
7
- export declare function renderWorkspaceOpenDocs(nodeId: string): string;
6
+ * meaning “when this project store is mounted by cwd/profile” (and, on the
7
+ * read event, “a file under the store's owning dir was read”). `seen` is the
8
+ * transcript-scoped dedup set shared with the on-read hook. */
9
+ export declare function renderWorkspaceOpenDocs(nodeId: string, seen?: Set<string>): string;
@@ -22,14 +22,16 @@ export function renderOnReadDocs(nodeId, readFilePath, seen = new Set()) {
22
22
  }
23
23
  /** Surface workspace-wide docs during first-message assembly, using the node's
24
24
  * recorded cwd and selected profile. `.` is a reserved applies-to target
25
- * meaning “when this project store is mounted by cwd/profile”. */
26
- export function renderWorkspaceOpenDocs(nodeId) {
25
+ * meaning “when this project store is mounted by cwd/profile” (and, on the
26
+ * read event, “a file under the store's owning dir was read”). `seen` is the
27
+ * transcript-scoped dedup set shared with the on-read hook. */
28
+ export function renderWorkspaceOpenDocs(nodeId, seen = new Set()) {
27
29
  try {
28
30
  const subject = assembleNodeSubject(nodeId);
29
31
  const node = getNode(nodeId);
30
32
  if (subject === null || node === null)
31
33
  return '';
32
- return renderWorkspaceOpenDocsForSubject(subject, node.cwd, node.profile_id ?? null);
34
+ return renderWorkspaceOpenDocsForSubject(subject, node.cwd, node.profile_id ?? null, seen);
33
35
  }
34
36
  catch {
35
37
  return '';
@@ -3,5 +3,7 @@ import type { NodeConfigSubject } from './subject-fields.js';
3
3
  * daemon snapshot subject. */
4
4
  export declare function renderOnReadDocsForSubject(subject: NodeConfigSubject, readFilePath: string, seen?: Set<string>): string;
5
5
  /** Workspace boot docs using the snapshot's cwd/profile instead of a canvas
6
- * lookup. */
7
- export declare function renderWorkspaceOpenDocsForSubject(subject: NodeConfigSubject, cwd: string, profileId: string | null): string;
6
+ * lookup. `seen` is the transcript-scoped dedup set shared with the on-read
7
+ * hook, so a front door delivered here never re-delivers on a later read
8
+ * beneath its store. */
9
+ export declare function renderWorkspaceOpenDocsForSubject(subject: NodeConfigSubject, cwd: string, profileId: string | null, seen?: Set<string>): string;
@@ -12,10 +12,16 @@
12
12
  // visible substrate doc. Project stores encountered between that file and
13
13
  // the filesystem root join the resolved cwd/profile corpus, so a nested
14
14
  // workspace can contribute guidance exactly when a file beneath it is read.
15
+ // On this event the reserved `applies-to: "."` target means "the read file
16
+ // is under the doc's store's owning directory" — a nested store's front
17
+ // door surfaces on the first read beneath it, delivery driven purely by
18
+ // filesystem truth.
15
19
  // • Opening a workspace evaluates the reserved `applies-to: "."` target for
16
20
  // every project store mounted by the cwd and selected profile. This happens
17
21
  // while the first-message bearings are assembled, so workspace-wide context
18
22
  // arrives before the task rather than waiting for an arbitrary first file.
23
+ // Both hooks share one transcript-scoped dedup set, so a doc delivered at
24
+ // workspace open never re-delivers on a later matching read.
19
25
  //
20
26
  // `read-when` remains an additional OR trigger over a read markdown file's own
21
27
  // frontmatter. It does not replace `applies-to`: every non-none file-read rung
@@ -25,7 +31,7 @@
25
31
  // is the whole on-read model, so directory INDEX ceilings remain a boot-catalog
26
32
  // concern and do not cap a deliberately matched document.
27
33
  import { homedir } from 'node:os';
28
- import { basename, dirname, matchesGlob, parse, relative, sep } from 'node:path';
34
+ import { basename, dirname, isAbsolute, matchesGlob, parse, relative, sep } from 'node:path';
29
35
  import { CRTR_DIR_NAME } from '../../types.js';
30
36
  import { pathExists, readText, walkFiles } from '../fs-utils.js';
31
37
  import { parseFrontmatterGeneric } from '../frontmatter.js';
@@ -175,6 +181,18 @@ function globMatches(glob, absReadFile, owningRoot) {
175
181
  }
176
182
  });
177
183
  }
184
+ /** True when the read file sits beneath the doc's store's owning directory —
185
+ * the read-event meaning of the reserved `applies-to: "."` target. Callers
186
+ * additionally guard on `doc.scope === 'project'`: a profile store's path
187
+ * computes an owning root of `~`, and user/builtin/node docs likewise have no
188
+ * project-owning dir, so `.` outside a project store must never match. */
189
+ function underOwningRoot(doc, absReadFile) {
190
+ const root = owningRootOf(doc);
191
+ if (root === null)
192
+ return false;
193
+ const rel = relative(realpathOrSelf(root), absReadFile);
194
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
195
+ }
178
196
  function readFileFrontmatter(absReadFile) {
179
197
  if (!/\.(md|mdx|markdown)$/i.test(absReadFile))
180
198
  return {};
@@ -186,7 +204,9 @@ function readFileFrontmatter(absReadFile) {
186
204
  }
187
205
  }
188
206
  function matchesReadEvent(doc, absReadFile, readFrontmatter) {
189
- const pathMatch = doc.appliesTo?.some((glob) => globMatches(glob, absReadFile, owningRootOf(doc))) === true;
207
+ const pathMatch = doc.appliesTo?.some((glob) => glob.trim() === '.'
208
+ ? doc.scope === 'project' && underOwningRoot(doc, absReadFile)
209
+ : globMatches(glob, absReadFile, owningRootOf(doc))) === true;
190
210
  const frontmatterMatch = doc.readWhen !== undefined &&
191
211
  Object.keys(readFrontmatter).length > 0 &&
192
212
  evalCondition(doc.readWhen, readFrontmatter);
@@ -245,8 +265,10 @@ export function renderOnReadDocsForSubject(subject, readFilePath, seen = new Set
245
265
  return renderCandidates(subject, docs, seen);
246
266
  }
247
267
  /** Workspace boot docs using the snapshot's cwd/profile instead of a canvas
248
- * lookup. */
249
- export function renderWorkspaceOpenDocsForSubject(subject, cwd, profileId) {
268
+ * lookup. `seen` is the transcript-scoped dedup set shared with the on-read
269
+ * hook, so a front door delivered here never re-delivers on a later read
270
+ * beneath its store. */
271
+ export function renderWorkspaceOpenDocsForSubject(subject, cwd, profileId, seen = new Set()) {
250
272
  let docs;
251
273
  try {
252
274
  docs = listProjectMemoryDocs(cwd, profileId)
@@ -264,5 +286,5 @@ export function renderWorkspaceOpenDocsForSubject(subject, cwd, profileId) {
264
286
  const depth = aRoot.split(sep).filter(Boolean).length - bRoot.split(sep).filter(Boolean).length;
265
287
  return depth || aRoot.localeCompare(bRoot) || a.path.localeCompare(b.path);
266
288
  });
267
- return renderCandidates(subject, docs, new Set());
289
+ return renderCandidates(subject, docs, seen);
268
290
  }
@@ -68,7 +68,7 @@ interface PiLike {
68
68
  }
69
69
  /** Build the full bearings baseline for a conversation without inherited
70
70
  * crouter context. Exported for testing. */
71
- export declare function buildContextIntro(nodeId: string): Promise<string>;
71
+ export declare function buildContextIntro(nodeId: string, seen?: Set<string>): Promise<string>;
72
72
  /** Build the compact node-scoped update for a fork whose copied branch already
73
73
  * carries a full crouter baseline. Exported for testing. */
74
74
  export declare function buildForkContextIntro(nodeId: string): Promise<string>;
@@ -30,6 +30,7 @@ import { CONTEXT_INTRO_CUSTOM_TYPE } from '../shared/generated-context.js';
30
30
  import { brokerExtensionState } from '../core/runtime/broker/daemon-ops.js';
31
31
  import { buildContextBearingsFromState, buildForkBearingsFromState, buildProjectContextBlockForBroker } from '../core/runtime/broker-extension-render.js';
32
32
  import { situationalContextBlock } from '../core/runtime/situational-context.js';
33
+ import { saveInjectedDocs, sharedInjectedDocs } from '../core/substrate/injected-store.js';
33
34
  import { truncateToWidth } from './truncate.js';
34
35
  /** The `customType` stamped on the injected session message. Used both to write
35
36
  * the entry and to detect it on resume (the idempotency guard). Defined beside
@@ -41,9 +42,9 @@ export { CONTEXT_INTRO_CUSTOM_TYPE };
41
42
  // ---------------------------------------------------------------------------
42
43
  /** Build the full bearings baseline for a conversation without inherited
43
44
  * crouter context. Exported for testing. */
44
- export async function buildContextIntro(nodeId) {
45
+ export async function buildContextIntro(nodeId, seen) {
45
46
  const state = await brokerExtensionState(nodeId);
46
- return buildContextBearingsFromState(state, buildProjectContextBlockForBroker(state.node.cwd), situationalContextBlock(nodeId));
47
+ return buildContextBearingsFromState(state, buildProjectContextBlockForBroker(state.node.cwd), situationalContextBlock(nodeId), seen);
47
48
  }
48
49
  /** Build the compact node-scoped update for a fork whose copied branch already
49
50
  * carries a full crouter baseline. Exported for testing. */
@@ -160,15 +161,25 @@ export function registerCanvasContextIntro(pi) {
160
161
  // this fork's new node-scoped facts. A non-crouter branch has no such entry.
161
162
  const inherited = contextEntries.length > 0;
162
163
  const forked = state.node.fork_from !== null;
164
+ // The baseline branch renders workspace-open docs, so it threads the
165
+ // PROCESS-SHARED dedup set: the on-read hook (canvas-doc-substrate) holds
166
+ // the same Set instance, so a front door delivered here never re-delivers
167
+ // on a later read beneath its store. The fork branch renders no workspace
168
+ // docs and needs no set.
169
+ const seen = inherited && forked ? undefined : sharedInjectedDocs(nodeId);
163
170
  const content = inherited && forked
164
171
  ? buildForkBearingsFromState(state, situationalContextBlock(nodeId))
165
- : buildContextBearingsFromState(state, buildProjectContextBlockForBroker(state.node.cwd), situationalContextBlock(nodeId));
172
+ : buildContextBearingsFromState(state, buildProjectContextBlockForBroker(state.node.cwd), situationalContextBlock(nodeId), seen);
166
173
  pi.sendMessage({
167
174
  customType: CONTEXT_INTRO_CUSTOM_TYPE,
168
175
  content,
169
176
  display: true,
170
177
  details: { nodeId },
171
178
  });
179
+ // Persist what the baseline delivered so the dedup survives a later
180
+ // dormancy → revive(resume) into a fresh process.
181
+ if (seen !== undefined)
182
+ saveInjectedDocs(nodeId, seen);
172
183
  });
173
184
  }
174
185
  export default registerCanvasContextIntro;
@@ -36,7 +36,7 @@ import { brokerExtensionState } from '../core/runtime/broker/daemon-ops.js';
36
36
  import { renderPreferencesFromState } from '../core/runtime/broker-extension-render.js';
37
37
  import { renderOnReadDocsForSubject } from '../core/substrate/on-read.js';
38
38
  import { clearSessionCache } from '../core/substrate/session-cache.js';
39
- import { loadInjectedDocs, saveInjectedDocs } from '../core/substrate/injected-store.js';
39
+ import { loadInjectedDocs, saveInjectedDocs, sharedInjectedDocs } from '../core/substrate/injected-store.js';
40
40
  import { autoLoadedContextInner, mergeAutoLoadedContext } from './envelope-merge.js';
41
41
  // ---------------------------------------------------------------------------
42
42
  // Extension
@@ -65,7 +65,12 @@ export function registerCanvasDocSubstrate(pi) {
65
65
  // dedup must carry forward. The one launch path that begins a FRESH transcript
66
66
  // (reviveNode with resume=false) deletes the file, so a new conversation
67
67
  // rehydrates empty here. See core/substrate/injected-store.ts.
68
- const injectedDocs = loadInjectedDocs(nodeId);
68
+ //
69
+ // The set is the PROCESS-SHARED instance (sharedInjectedDocs), so the
70
+ // context-intro extension's workspace-open render grows the same set this
71
+ // hook dedups against — a front door delivered at boot never re-delivers on
72
+ // a later read beneath its store.
73
+ const injectedDocs = sharedInjectedDocs(nodeId);
69
74
  pi.on('session_start', () => {
70
75
  // Only the per-session substrate PARSE cache resets each session (so the
71
76
  // corpus is re-scanned, picking up memory writes). injectedDocs is
@@ -112,6 +117,12 @@ export function registerCanvasDocSubstrate(pi) {
112
117
  const expanded = rawPath.startsWith('~') ? join(homedir(), rawPath.slice(1)) : rawPath;
113
118
  const absFile = resolve(cwd, expanded);
114
119
  const state = await brokerExtensionState(nodeId);
120
+ // Merge the on-disk set: an out-of-process delivery (the daemon's
121
+ // warm-spare claim pushes bearings — including workspace-open docs —
122
+ // into the live session and persists what it delivered) must dedup here
123
+ // too. Cheap: one tiny JSON read per `read`.
124
+ for (const real of loadInjectedDocs(nodeId))
125
+ injectedDocs.add(real);
115
126
  const injected = renderOnReadDocsForSubject(state.subject, absFile, injectedDocs);
116
127
  if (injected === '')
117
128
  return; // nothing surfaced — pass the read through unchanged
@@ -46,6 +46,10 @@ export declare function generatedContextText(message: GeneratedContextMessageLik
46
46
  export declare function formatContextNudge(note: string): string;
47
47
  export declare function isInboxDigest(text: string): boolean;
48
48
  export declare function extractInboxSender(text: string): string | null;
49
+ /** Node ids whose section of this digest reports a finished node. Sections are
50
+ * delimited by their `From <sender>` headers, so a final never attaches to the
51
+ * neighbouring sender. */
52
+ export declare function inboxFinishedSenders(text: string): string[];
49
53
  /** Exact producer-owned marker for the continuation a successor broker inserts
50
54
  * after a runtime restart. Other recovery bodies intentionally do not match. */
51
55
  export declare function isRuntimeRestartContinuation(message: GeneratedContextMessageLike): boolean;
@@ -101,14 +101,33 @@ function nudgeSummary(body) {
101
101
  // Senders are node ids, the two reserved words, or the daemon itself (crtrd —
102
102
  // review notes and other daemon-originated node messages).
103
103
  const INBOX_HEADER_RE = /^From ([a-z0-9]+(?:-[a-z0-9]+)+|system|human|crtrd) — (\d+) update/m;
104
+ const INBOX_HEADERS_RE = /^From ([a-z0-9]+(?:-[a-z0-9]+)+|system|human|crtrd) — (\d+) update/gm;
105
+ // Entry lines inside a section, rendered by core/runtime/broker/inbox.ts as
106
+ // ` [<kind>]`. A final is the one kind that ends the sender's node.
107
+ const FINISHED_ENTRY_RE = /^ {2}\[(?:final|completed)\]/m;
104
108
  export function isInboxDigest(text) {
105
109
  return INBOX_HEADER_RE.test(text);
106
110
  }
107
111
  export function extractInboxSender(text) {
108
112
  return INBOX_HEADER_RE.exec(text)?.[1] ?? null;
109
113
  }
114
+ /** Node ids whose section of this digest reports a finished node. Sections are
115
+ * delimited by their `From <sender>` headers, so a final never attaches to the
116
+ * neighbouring sender. */
117
+ export function inboxFinishedSenders(text) {
118
+ const headers = [...text.matchAll(INBOX_HEADERS_RE)];
119
+ const senders = [];
120
+ for (const [index, header] of headers.entries()) {
121
+ const start = header.index ?? 0;
122
+ const end = headers[index + 1]?.index ?? text.length;
123
+ const sender = header[1];
124
+ if (sender !== undefined && FINISHED_ENTRY_RE.test(text.slice(start, end)))
125
+ senders.push(sender);
126
+ }
127
+ return senders;
128
+ }
110
129
  function inboxSummary(body) {
111
- const headers = [...body.matchAll(/^From ([a-z0-9]+(?:-[a-z0-9]+)+|system|human|crtrd) — (\d+) update/gm)];
130
+ const headers = [...body.matchAll(INBOX_HEADERS_RE)];
112
131
  if (headers.length === 0)
113
132
  return 'node update';
114
133
  const updates = headers.reduce((total, match) => total + Number(match[2] ?? 0), 0);
@@ -1,9 +1,9 @@
1
1
  /** Broker-generated activity recap for one completed tool-call group. Changed
2
- * files are counted deterministically by the viewer's GroupActivityRecorder;
3
- * deletions have no tool of their own, so only the model can evidence them. */
2
+ * files and spawned/finished nodes are counted deterministically by the viewer's
3
+ * GroupActivityRecorder; deletions have no tool of their own, so only the model
4
+ * can evidence them. */
4
5
  export interface ToolGroupSummary {
5
6
  bullets: string[];
6
- nodesSpawned: number;
7
7
  filesDeleted: number;
8
8
  }
9
9
  export interface ToolGroupMessageLike {
@@ -2,7 +2,7 @@
2
2
  // Keep this independent of renderer state: both live event tracking and snapshot
3
3
  // reconstruction must agree on which prose closes a run of tools.
4
4
  import { generatedContextPresentation, generatedContextText } from './generated-context.js';
5
- const TOOL_GROUP_SUMMARY_KEYS = new Set(['bullets', 'nodesSpawned', 'filesDeleted']);
5
+ const TOOL_GROUP_SUMMARY_KEYS = new Set(['bullets', 'filesDeleted']);
6
6
  const MAX_TOOL_GROUP_BULLETS = 5;
7
7
  const MAX_TOOL_GROUP_BULLET_LENGTH = 1_000;
8
8
  /** Validate the one durable summary shape and cap its display-sized fields.
@@ -19,15 +19,12 @@ export function normalizeToolGroupSummary(value) {
19
19
  return null;
20
20
  if (!record.bullets.every((bullet) => typeof bullet === 'string' && bullet.trim() !== ''))
21
21
  return null;
22
- if (typeof record.nodesSpawned !== 'number' || !Number.isSafeInteger(record.nodesSpawned) || record.nodesSpawned < 0)
23
- return null;
24
22
  if (typeof record.filesDeleted !== 'number' || !Number.isSafeInteger(record.filesDeleted) || record.filesDeleted < 0)
25
23
  return null;
26
24
  return {
27
25
  bullets: record.bullets
28
26
  .slice(0, MAX_TOOL_GROUP_BULLETS)
29
27
  .map((bullet) => bullet.trim().slice(0, MAX_TOOL_GROUP_BULLET_LENGTH).trimEnd()),
30
- nodesSpawned: record.nodesSpawned,
31
28
  filesDeleted: record.filesDeleted,
32
29
  };
33
30
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.182",
3
+ "version": "0.3.183",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.182",
3
+ "version": "0.3.183",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.182",
9
+ "version": "0.3.183",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {
@@ -40,7 +40,7 @@ async function main() {
40
40
  console.warn('[crtr postinstall] runtime installation failed (non-fatal); no runtime generation was produced. Run `crtr` for remediation guidance or reinstall:', error?.message ?? error);
41
41
  }
42
42
  await provisionRenderer();
43
- console.log('crouter installed. Run `crtr sys setup` to install the companion pi extensions, Claude OAuth, tmux/nvim, and your Exa key.');
43
+ console.log('crouter installed. Run `crtr sys setup` to install the companion pi extensions, Claude OAuth, and tmux/nvim.');
44
44
  }
45
45
 
46
46
  await main();
@@ -1,93 +0,0 @@
1
- // Terminal-specific Markdown heading hierarchy for the attach viewer.
2
- //
3
- // pi-tui's MarkdownTheme exposes one `heading(text)` callback without the
4
- // heading depth. Its renderer does know the depth, but does not pass it to the
5
- // theme, so every heading otherwise receives the same yellow mdHeading paint.
6
- // Decorate only the source text of H3–H6 before it reaches pi-tui: pi still
7
- // owns parsing, wrapping, inline formatting, and H1/H2's established styling.
8
- // This stays attach-local — persisted messages and copied text remain ordinary
9
- // Markdown without terminal escape sequences.
10
- //
11
- // The same per-block copy carries the viewer's other source-text transform:
12
- // private-use `U+XXXX` references become their glyphs (see glyph-codepoints.ts),
13
- // so an agent that cannot type a Nerd Font character can still put one on
14
- // screen.
15
- import { expandGlyphCodepoints } from './glyph-codepoints.js';
16
- const ATX_HEADING = /^(\s{0,3})(#{3,6})(?:[ \t]+|$)(.*?)([ \t]+#+[ \t]*)?$/;
17
- const FENCE_OPEN = /^\s{0,3}(`{3,}|~{3,})/;
18
- // A constant-lightness ramp from warm yellow to cool blue, with saturation
19
- // deliberately reduced at each step so the hierarchy recedes rather than
20
- // becoming a tour of unrelated accent colors.
21
- const HEADING_COLORS = [
22
- [232, 194, 104],
23
- [207, 194, 145],
24
- [181, 184, 174],
25
- [157, 170, 180],
26
- [135, 153, 164],
27
- [116, 135, 147],
28
- ];
29
- function headingColor(level) {
30
- const [r, g, b] = HEADING_COLORS[level - 1];
31
- return (text) => `\x1b[1;38;2;${r};${g};${b}m${text}\x1b[22;39m`;
32
- }
33
- /** Style ATX headings with a scan-friendly terminal hierarchy. The Markdown
34
- * markers are syntax, not content, so omit them from the rendered line.
35
- * Fenced examples remain literal Markdown, never decorated terminal output. */
36
- export function styleAttachMarkdownHeadings(markdown) {
37
- let fence;
38
- return markdown.split('\n').map((line) => {
39
- const fenceMatch = FENCE_OPEN.exec(line);
40
- if (fence !== undefined) {
41
- if (fenceMatch?.[1][0] === fence)
42
- fence = undefined;
43
- return line;
44
- }
45
- if (fenceMatch) {
46
- fence = fenceMatch[1][0];
47
- return line;
48
- }
49
- const match = ATX_HEADING.exec(line);
50
- if (!match)
51
- return line;
52
- const [, indent, hashes, body, closing = ''] = match;
53
- if (body === '')
54
- return line;
55
- return `${indent}${headingColor(hashes.length)(body)}`;
56
- }).join('\n');
57
- }
58
- /** Every source-text transform the viewer applies to a Markdown-bearing block,
59
- * in one place: heading hierarchy, then glyph expansion. */
60
- function renderableText(markdown) {
61
- return expandGlyphCodepoints(styleAttachMarkdownHeadings(markdown));
62
- }
63
- /** Copy a pi message for terminal rendering and decorate only Markdown-bearing
64
- * text/thinking blocks. The broker's source event is never mutated. */
65
- export function styleAttachMessageMarkdown(message) {
66
- if (typeof message !== 'object' || message === null || !('content' in message))
67
- return message;
68
- const candidate = message;
69
- if (typeof candidate.content === 'string') {
70
- return { ...candidate, content: renderableText(candidate.content) };
71
- }
72
- if (!Array.isArray(candidate.content))
73
- return message;
74
- return {
75
- ...candidate,
76
- content: candidate.content.map((part) => {
77
- if (typeof part !== 'object' || part === null)
78
- return part;
79
- const block = part;
80
- if (block.type === 'text' && typeof block.text === 'string') {
81
- return { ...block, text: renderableText(block.text) };
82
- }
83
- if (block.type === 'thinking' && typeof block.thinking === 'string') {
84
- return { ...block, thinking: renderableText(block.thinking) };
85
- }
86
- return part;
87
- }),
88
- };
89
- }
90
- /** Copy an expanded pi summary without changing its persisted source. */
91
- export function styleAttachSummaryMarkdown(message) {
92
- return { ...message, summary: renderableText(message.summary) };
93
- }
@@ -1 +0,0 @@
1
- export declare const answerLeaf: import("../../core/command.js").LeafDef;