@crouton-kit/crouter 0.3.26 → 0.3.28

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 (68) hide show
  1. package/dist/builtin-memory/internal/INDEX.md +1 -1
  2. package/dist/builtin-personas/runtime-base.md +3 -0
  3. package/dist/clients/attach/__tests__/reconnect-giveup.test.d.ts +1 -0
  4. package/dist/clients/attach/__tests__/reconnect-giveup.test.js +30 -0
  5. package/dist/clients/attach/attach-cmd.js +187 -19
  6. package/dist/clients/attach/canvas-panels.d.ts +10 -0
  7. package/dist/clients/attach/canvas-panels.js +50 -0
  8. package/dist/clients/attach/graph-overlay.d.ts +34 -0
  9. package/dist/clients/attach/graph-overlay.js +266 -0
  10. package/dist/clients/attach/input-controller.d.ts +6 -0
  11. package/dist/clients/attach/input-controller.js +2 -0
  12. package/dist/clients/attach/slash-commands.d.ts +22 -1
  13. package/dist/clients/attach/slash-commands.js +160 -3
  14. package/dist/clients/attach/view-socket.d.ts +19 -1
  15. package/dist/clients/attach/view-socket.js +61 -6
  16. package/dist/commands/human/prompts.js +3 -3
  17. package/dist/commands/human/queue.d.ts +17 -0
  18. package/dist/commands/human/queue.js +111 -4
  19. package/dist/commands/memory/__tests__/lint-schema.test.js +24 -1
  20. package/dist/commands/memory/lint.d.ts +5 -4
  21. package/dist/commands/memory/lint.js +9 -5
  22. package/dist/commands/memory/write.js +19 -2
  23. package/dist/commands/memory.js +1 -1
  24. package/dist/commands/sys/feedback.d.ts +1 -0
  25. package/dist/commands/sys/feedback.js +163 -0
  26. package/dist/commands/sys.js +3 -2
  27. package/dist/core/__tests__/broker-snapshot-history.test.d.ts +1 -0
  28. package/dist/core/__tests__/broker-snapshot-history.test.js +105 -0
  29. package/dist/core/__tests__/fixtures/fake-engine.d.ts +7 -0
  30. package/dist/core/__tests__/fixtures/fake-engine.js +10 -0
  31. package/dist/core/__tests__/full/placement-teardown.test.js +76 -0
  32. package/dist/core/__tests__/human-stranded-deliver.test.d.ts +1 -0
  33. package/dist/core/__tests__/human-stranded-deliver.test.js +108 -0
  34. package/dist/core/__tests__/on-read-dedup-resume.test.d.ts +1 -0
  35. package/dist/core/__tests__/on-read-dedup-resume.test.js +81 -0
  36. package/dist/core/canvas/nav-model.d.ts +162 -0
  37. package/dist/core/canvas/nav-model.js +486 -0
  38. package/dist/core/canvas/paths.d.ts +7 -0
  39. package/dist/core/canvas/paths.js +9 -0
  40. package/dist/core/runtime/broker-sdk.d.ts +0 -12
  41. package/dist/core/runtime/broker-sdk.js +77 -6
  42. package/dist/core/runtime/broker.d.ts +2 -1
  43. package/dist/core/runtime/broker.js +26 -1
  44. package/dist/core/runtime/front-door.js +23 -8
  45. package/dist/core/runtime/naming.d.ts +1 -5
  46. package/dist/core/runtime/naming.js +33 -49
  47. package/dist/core/runtime/placement.d.ts +7 -6
  48. package/dist/core/runtime/placement.js +24 -12
  49. package/dist/core/runtime/revive.js +9 -0
  50. package/dist/core/runtime/spawn.d.ts +5 -0
  51. package/dist/core/runtime/spawn.js +69 -11
  52. package/dist/core/runtime/tmux.d.ts +9 -0
  53. package/dist/core/runtime/tmux.js +12 -0
  54. package/dist/core/spawn.d.ts +14 -0
  55. package/dist/core/spawn.js +29 -9
  56. package/dist/core/substrate/index.d.ts +1 -1
  57. package/dist/core/substrate/index.js +6 -6
  58. package/dist/core/substrate/injected-store.d.ts +10 -0
  59. package/dist/core/substrate/injected-store.js +55 -0
  60. package/dist/core/substrate/schema.d.ts +6 -8
  61. package/dist/core/substrate/schema.js +26 -28
  62. package/dist/pi-extensions/canvas-doc-substrate.js +16 -7
  63. package/dist/pi-extensions/canvas-goal-capture.js +38 -22
  64. package/dist/pi-extensions/canvas-nav.js +30 -385
  65. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  66. package/dist/pi-extensions/canvas-stophook.js +32 -2
  67. package/package.json +1 -1
  68. package/dist/builtin-memory/memory-authoring.md +0 -100
@@ -3,13 +3,30 @@
3
3
  // A small set of tmux primitives used by the `human` command tree to put the
4
4
  // humanloop TUI in a detached pane: spawnAndDetach (open a pane running a given
5
5
  // command), countPanesInCurrentWindow (placement decision), plus shellQuote and
6
- // isInTmux. The canvas runtime has its own one-window-per-node machinery in
7
- // core/runtime/tmux.ts; this module is only the pane-split path the human TUI
8
- // needs.
6
+ // tmuxServerReachable. The canvas runtime has its own one-window-per-node
7
+ // machinery in core/runtime/tmux.ts; this module is only the pane-split path the
8
+ // human TUI needs.
9
9
  import { spawnSync } from 'node:child_process';
10
+ /** Does THIS process sit inside a tmux client (its own $TMUX is set)? NOT the
11
+ * gate for the human surface — see tmuxServerReachable. A headless-broker child
12
+ * strips TMUX/TMUX_PANE (broker.ts, anti-stophook-hijack) yet can still drive
13
+ * the canvas tmux server, so gating the surface on this wrongly starves broker
14
+ * nodes of human-in-the-loop. */
10
15
  export function isInTmux() {
11
16
  return Boolean(process.env.TMUX);
12
17
  }
18
+ /** Is the canvas tmux SERVER reachable? The canvas runs on the DEFAULT tmux
19
+ * socket (no -L/-S in core/runtime/tmux.ts), so any `crtr` child — including a
20
+ * headless-broker child that has deleted its own $TMUX — can split panes into
21
+ * it. The human surface gates on THIS (can I reach the server to open a pane
22
+ * the user is watching?), not on the caller's $TMUX. `list-clients` exits 0
23
+ * whenever a server is up (even with zero attached clients) and non-zero when
24
+ * no server is running — the true "no tmux to surface into" case that should
25
+ * degrade to the inbox-drain follow-up. */
26
+ export function tmuxServerReachable() {
27
+ const r = spawnSync('tmux', ['list-clients', '-F', '#{client_name}'], { encoding: 'utf8' });
28
+ return r.status === 0;
29
+ }
13
30
  export function shellQuote(s) {
14
31
  return `'${s.replace(/'/g, "'\\''")}'`;
15
32
  }
@@ -34,7 +51,7 @@ export function countPanesInCurrentWindow() {
34
51
  * output on an unresolvable pane, so test for non-empty stdout, not just `.ok`.
35
52
  * False outside tmux / on error. */
36
53
  export function paneAlive(pane) {
37
- if (!isInTmux() || !/^%\d+$/.test(pane))
54
+ if (!/^%\d+$/.test(pane))
38
55
  return false;
39
56
  const r = spawnSync('tmux', ['display-message', '-p', '-t', pane, '#{pane_id}'], {
40
57
  encoding: 'utf8',
@@ -46,7 +63,7 @@ export function paneAlive(pane) {
46
63
  * specify pane here"); only split-window -t takes a pane. null outside tmux /
47
64
  * on a bad pane id / on error / empty. */
48
65
  export function paneWindowTarget(pane) {
49
- if (!isInTmux() || !/^%\d+$/.test(pane))
66
+ if (!/^%\d+$/.test(pane))
50
67
  return null;
51
68
  const r = spawnSync('tmux', ['display-message', '-p', '-t', pane, '#{session_name}:#{window_index}'], { encoding: 'utf8' });
52
69
  if (r.status !== 0)
@@ -59,8 +76,6 @@ export function paneWindowTarget(pane) {
59
76
  * to surface a human prompt in the user's view when nothing in the asking
60
77
  * node's graph is focused. null outside tmux / no client / on error. */
61
78
  export function attachedClientPane() {
62
- if (!isInTmux())
63
- return null;
64
79
  const clients = spawnSync('tmux', ['list-clients', '-F', '#{client_name}'], {
65
80
  encoding: 'utf8',
66
81
  });
@@ -102,10 +117,15 @@ export function scheduleKillCurrentPane(delaySeconds) {
102
117
  * as soon as the new pane is up; does NOT wait for the command to finish.
103
118
  */
104
119
  export function spawnAndDetach(opts) {
105
- if (!isInTmux()) {
120
+ // Gate on the CANVAS tmux server's reachability, NOT the caller's $TMUX. A
121
+ // headless-broker child strips its own $TMUX (broker.ts) but the canvas server
122
+ // is on the default socket, so `split-window -t <pane>` against a resolved
123
+ // target pane (resolveHumanTarget) works fine from it. Only a genuine
124
+ // no-server case degrades to the inbox-drain follow-up.
125
+ if (!tmuxServerReachable()) {
106
126
  return {
107
127
  status: 'not-in-tmux',
108
- message: 'handoff requires tmux (TMUX env var not set)',
128
+ message: 'handoff requires a reachable tmux server',
109
129
  };
110
130
  }
111
131
  const splitArgs = [];
@@ -1,4 +1,4 @@
1
- export { KINDS, isDocKind, RUNGS, rungRank, rungAtLeast, KIND_DEFAULT_RUNGS, parseSubstrateFrontmatter, parseSubstrateDoc, previewLine, } from './schema.js';
1
+ export { KINDS, isDocKind, RUNGS, rungRank, rungAtLeast, FALLBACK_RUNG, parseSubstrateFrontmatter, parseSubstrateDoc, previewLine, } from './schema.js';
2
2
  export type { DocKind, Rung, GatePredicate, SubstrateSchema, SubstrateDoc } from './schema.js';
3
3
  export { scopeForCwd, spineDepth, assembleNodeSubject } from './subject.js';
4
4
  export type { NodeConfigSubject } from './subject.js';
@@ -1,15 +1,15 @@
1
1
  // substrate/ — the unified document-substrate keystone: the frontmatter schema,
2
- // the 4-rung visibility ladder, the kind→default-rungs table, the node-config
3
- // subject assembly, and gate evaluation. Pure + well-typed; the shared base the
4
- // CLI verbs, boot render, on-read render, and migrator all build on. See
5
- // design-substrate.md §4/§9 + plan-substrate.md §2.
2
+ // the 4-rung visibility ladder, the node-config subject assembly, and gate
3
+ // evaluation. Pure + well-typed; the shared base the CLI verbs, boot render,
4
+ // on-read render, and migrator all build on. See design-substrate.md §4 +
5
+ // plan-substrate.md §2.
6
6
  export {
7
7
  // kinds
8
8
  KINDS, isDocKind,
9
9
  // ladder
10
10
  RUNGS, rungRank, rungAtLeast,
11
- // defaults
12
- KIND_DEFAULT_RUNGS,
11
+ // fallback floor
12
+ FALLBACK_RUNG,
13
13
  // parse + render-shared helpers
14
14
  parseSubstrateFrontmatter, parseSubstrateDoc, previewLine, } from './schema.js';
15
15
  export { scopeForCwd, spineDepth, assembleNodeSubject } from './subject.js';
@@ -0,0 +1,10 @@
1
+ /** Rehydrate a node's on-read dedup set from disk. Returns an empty set when the
2
+ * file is absent, unreadable, or malformed (a fresh transcript, or a node that
3
+ * has not yet surfaced any on-read doc). */
4
+ export declare function loadInjectedDocs(nodeId: string): Set<string>;
5
+ /** Persist a node's on-read dedup set. Called after each read that surfaced a
6
+ * new doc, so the grown set survives a later dormancy. */
7
+ export declare function saveInjectedDocs(nodeId: string, seen: Set<string>): void;
8
+ /** Drop a node's persisted dedup set. Called by the launch paths that start a
9
+ * FRESH transcript, so the new conversation surfaces docs from scratch. */
10
+ export declare function clearInjectedDocs(nodeId: string): void;
@@ -0,0 +1,55 @@
1
+ // injected-store.ts — durable per-node dedup set for on-read doc injection.
2
+ //
3
+ // The on-read substrate hook (canvas-doc-substrate.ts → renderOnReadDocs) dedups
4
+ // so a given doc surfaces at most once per conversation. That set USED to live
5
+ // only in the pi process heap, cleared on session_start. But a node's logical
6
+ // session — the .jsonl transcript — spans MULTIPLE pi processes: a dormancy →
7
+ // revive(resume) cycle exits the old process and launches a fresh `pi --session`
8
+ // that REUSES the same transcript. The fresh process started with an empty set,
9
+ // so any doc already injected before dormancy got injected AGAIN on the next
10
+ // read — the "fires a second time per session" bug.
11
+ //
12
+ // This module persists the set to `nodes/<id>/injected-docs.json` so the resumed
13
+ // process rehydrates it and skips docs already present in the transcript. The
14
+ // launch paths that begin a FRESH transcript (reviveNode resume=false,
15
+ // reviveInPlace, relaunchRootInPane — all in runtime/revive.ts) call
16
+ // clearInjectedDocs(), so a new conversation starts with an empty set.
17
+ //
18
+ // All ops are best-effort: a failed read/write degrades to a possible re-inject,
19
+ // never a crash — a dedup miss must never break a read or a revive.
20
+ import { readFileSync, writeFileSync, rmSync } from 'node:fs';
21
+ import { injectedDocsPath } from '../canvas/paths.js';
22
+ /** Rehydrate a node's on-read dedup set from disk. Returns an empty set when the
23
+ * file is absent, unreadable, or malformed (a fresh transcript, or a node that
24
+ * has not yet surfaced any on-read doc). */
25
+ export function loadInjectedDocs(nodeId) {
26
+ try {
27
+ const arr = JSON.parse(readFileSync(injectedDocsPath(nodeId), 'utf8'));
28
+ if (!Array.isArray(arr))
29
+ return new Set();
30
+ return new Set(arr.filter((x) => typeof x === 'string'));
31
+ }
32
+ catch {
33
+ return new Set();
34
+ }
35
+ }
36
+ /** Persist a node's on-read dedup set. Called after each read that surfaced a
37
+ * new doc, so the grown set survives a later dormancy. */
38
+ export function saveInjectedDocs(nodeId, seen) {
39
+ try {
40
+ writeFileSync(injectedDocsPath(nodeId), JSON.stringify([...seen]));
41
+ }
42
+ catch {
43
+ // best-effort — a failed persist only risks a re-inject, never a crash
44
+ }
45
+ }
46
+ /** Drop a node's persisted dedup set. Called by the launch paths that start a
47
+ * FRESH transcript, so the new conversation surfaces docs from scratch. */
48
+ export function clearInjectedDocs(nodeId) {
49
+ try {
50
+ rmSync(injectedDocsPath(nodeId), { force: true });
51
+ }
52
+ catch {
53
+ // ignore — absence is the desired state anyway
54
+ }
55
+ }
@@ -11,10 +11,7 @@ export declare function rungRank(r: Rung): number;
11
11
  /** Does rung `r` disclose at least as much as `min`? — e.g.
12
12
  * `rungAtLeast(doc.systemPromptVisibility, 'name')` ⇒ "shows at boot at all". */
13
13
  export declare function rungAtLeast(r: Rung, min: Rung): boolean;
14
- export declare const KIND_DEFAULT_RUNGS: Record<DocKind, {
15
- systemPrompt: Rung;
16
- fileRead: Rung;
17
- }>;
14
+ export declare const FALLBACK_RUNG: Rung;
18
15
  /** A gate predicate tree, evaluated by predicate.ts (`evalCondition`) against
19
16
  * the node-config subject. Typed loosely on purpose — the matcher engine owns
20
17
  * validation; structurally it is a field→matcher map with optional
@@ -63,10 +60,11 @@ export interface SubstrateDoc extends SubstrateSchema {
63
60
  /** Parse a raw frontmatter record (from `parseFrontmatterGeneric`, via the
64
61
  * resolver) into a typed schema with defaults applied. Returns `null` when the
65
62
  * record is absent or carries no valid `kind` — i.e. it is not a substrate
66
- * document and cannot be classified or defaulted. Tolerant of every other
67
- * imperfection (a missing `when-and-why-to-read` defaults to '', a bad rung
68
- * falls back to the kind default), so a renderer mapping over many docs never
69
- * throws. Authoring-time enforcement of the field lives in `crtr memory lint`. */
63
+ * document and cannot be classified. Tolerant of every other imperfection (a
64
+ * missing `when-and-why-to-read` defaults to '', a missing/bad rung falls back
65
+ * to the neutral floor `none`), so a renderer mapping over many docs never
66
+ * throws. Authoring-time enforcement of these fields lives in `crtr memory
67
+ * write` (on create) and `crtr memory lint`. */
70
68
  export declare function parseSubstrateFrontmatter(fm: Record<string, unknown> | null): SubstrateSchema | null;
71
69
  /** Parse a resolved MemoryDoc into a fully-typed SubstrateDoc (schema + the
72
70
  * resolver's name/scope/path/body). Returns `null` for a non-substrate doc
@@ -1,9 +1,16 @@
1
1
  // schema.ts — the typed shape of a substrate document's frontmatter, the
2
- // visibility ladder, the kind→default-rungs table, and the parse/validate
3
- // function that turns a resolved MemoryDoc into a fully-typed SubstrateDoc with
4
- // defaults applied. This is the keystone every downstream track (CLI verbs,
5
- // boot render, on-read render, migrator) builds against — pure and side-effect
6
- // free. See design-substrate.md §4 (schema) + §9 (defaults).
2
+ // visibility ladder, and the parse/validate function that turns a resolved
3
+ // MemoryDoc into a fully-typed SubstrateDoc. This is the keystone every
4
+ // downstream track (CLI verbs, boot render, on-read render, migrator) builds
5
+ // against — pure and side-effect free. See design-substrate.md §4 (schema).
6
+ //
7
+ // There is NO kind-based default for visibility: the right rung is a
8
+ // case-by-case authoring call, so both rungs are required at authoring time
9
+ // (enforced by `crtr memory write` on create and by `crtr memory lint`). The
10
+ // runtime parser is tolerant by contract (it maps over many docs and must
11
+ // never throw), so a doc missing/with an invalid rung falls back to the neutral
12
+ // floor `none` — a malformed doc renders invisible rather than crashing, and
13
+ // lint flags it. Valid docs never hit the fallback.
7
14
  // ---------------------------------------------------------------------------
8
15
  // Kinds — the three semantic kinds (design §3). `kind` is data, not a fork.
9
16
  // ---------------------------------------------------------------------------
@@ -28,44 +35,35 @@ export function rungAtLeast(r, min) {
28
35
  return rungRank(r) >= rungRank(min);
29
36
  }
30
37
  // ---------------------------------------------------------------------------
31
- // The kind→default-rungs table (design §9). When an author omits a visibility
32
- // field, its default is a function of `kind` chosen so the common case is
33
- // correct with no thought and the migration is behavior-preserving for skills
34
- // and preferences. One data row per kind, NOT a code fork.
35
- //
36
- // kind system-prompt-visibility file-read-visibility
37
- // skill name none
38
- // preference preview none
39
- // reference none preview
38
+ // The neutral fallback rung used only when a doc omits a visibility field or
39
+ // carries an invalid one. NOT a default an author may lean on authoring-time
40
+ // enforcement requires explicit rungs; this is purely the runtime parser's
41
+ // never-throw floor (a malformed doc renders invisible, and lint flags it).
40
42
  // ---------------------------------------------------------------------------
41
- export const KIND_DEFAULT_RUNGS = {
42
- skill: { systemPrompt: 'name', fileRead: 'none' },
43
- preference: { systemPrompt: 'preview', fileRead: 'none' },
44
- reference: { systemPrompt: 'none', fileRead: 'preview' },
45
- };
43
+ export const FALLBACK_RUNG = 'none';
46
44
  // ---------------------------------------------------------------------------
47
45
  // Parse / validate.
48
46
  // ---------------------------------------------------------------------------
49
47
  /** Parse a raw frontmatter record (from `parseFrontmatterGeneric`, via the
50
48
  * resolver) into a typed schema with defaults applied. Returns `null` when the
51
49
  * record is absent or carries no valid `kind` — i.e. it is not a substrate
52
- * document and cannot be classified or defaulted. Tolerant of every other
53
- * imperfection (a missing `when-and-why-to-read` defaults to '', a bad rung
54
- * falls back to the kind default), so a renderer mapping over many docs never
55
- * throws. Authoring-time enforcement of the field lives in `crtr memory lint`. */
50
+ * document and cannot be classified. Tolerant of every other imperfection (a
51
+ * missing `when-and-why-to-read` defaults to '', a missing/bad rung falls back
52
+ * to the neutral floor `none`), so a renderer mapping over many docs never
53
+ * throws. Authoring-time enforcement of these fields lives in `crtr memory
54
+ * write` (on create) and `crtr memory lint`. */
56
55
  export function parseSubstrateFrontmatter(fm) {
57
56
  if (fm === null)
58
57
  return null;
59
58
  if (!isDocKind(fm.kind))
60
59
  return null;
61
60
  const kind = fm.kind;
62
- const defaults = KIND_DEFAULT_RUNGS[kind];
63
61
  return {
64
62
  kind,
65
63
  whenAndWhyToRead: strField(fm['when-and-why-to-read']),
66
64
  shortForm: strField(fm['short-form']),
67
- systemPromptVisibility: parseRung(fm['system-prompt-visibility'], defaults.systemPrompt),
68
- fileReadVisibility: parseRung(fm['file-read-visibility'], defaults.fileRead),
65
+ systemPromptVisibility: parseRung(fm['system-prompt-visibility'], FALLBACK_RUNG),
66
+ fileReadVisibility: parseRung(fm['file-read-visibility'], FALLBACK_RUNG),
69
67
  gate: parseGate(fm.gate),
70
68
  appliesTo: parseAppliesTo(fm['applies-to']),
71
69
  };
@@ -95,8 +93,8 @@ export function previewLine(doc) {
95
93
  function strField(v) {
96
94
  return typeof v === 'string' ? v : '';
97
95
  }
98
- /** Resolve a visibility field to a ladder rung, falling back to the kind
99
- * default when absent/invalid. */
96
+ /** Resolve a visibility field to a ladder rung, falling back to the neutral
97
+ * floor when absent/invalid. */
100
98
  function parseRung(v, fallback) {
101
99
  return typeof v === 'string' && RUNGS.includes(v)
102
100
  ? v
@@ -36,6 +36,7 @@ import { homedir } from 'node:os';
36
36
  import { join, resolve } from 'node:path';
37
37
  import { renderMemoryGuidance, renderOnReadDocs, renderPreferencesSection, renderSkillsSection, } from '../core/substrate/index.js';
38
38
  import { clearSessionCache } from '../core/substrate/session-cache.js';
39
+ import { loadInjectedDocs, saveInjectedDocs } from '../core/substrate/injected-store.js';
39
40
  // ---------------------------------------------------------------------------
40
41
  // Extension
41
42
  // ---------------------------------------------------------------------------
@@ -55,14 +56,19 @@ export function registerCanvasDocSubstrate(pi) {
55
56
  const nodeId = process.env['CRTR_NODE_ID'];
56
57
  if (nodeId === undefined || nodeId.trim() === '')
57
58
  return; // not a canvas node — inert
58
- // Per-session set of injected doc realpaths → a doc surfaces at most once per
59
- // session across repeated reads. Cleared on every session_start (so a resume
60
- // starts fresh, matching the on-read precedent).
61
- // Also clears the per-session substrate parse cache so the corpus is re-scanned
62
- // fresh for each new session (avoids stale docs after a skill/memory write).
63
- const injectedDocs = new Set();
59
+ // Per-TRANSCRIPT set of injected doc realpaths → a doc surfaces at most once
60
+ // across the node's conversation, EVEN across a dormancy revive(resume)
61
+ // cycle. A resume reuses the same .jsonl transcript in a NEW pi process, so
62
+ // the set is REHYDRATED from disk at process start (not started empty) and is
63
+ // NOT cleared on session_start a resume continues the transcript, so the
64
+ // dedup must carry forward. The launch paths that begin a FRESH transcript
65
+ // (revive resume=false, reviveInPlace, relaunchRootInPane) delete the file, so
66
+ // a new conversation rehydrates empty here. See core/substrate/injected-store.ts.
67
+ const injectedDocs = loadInjectedDocs(nodeId);
64
68
  pi.on('session_start', () => {
65
- injectedDocs.clear();
69
+ // Only the per-session substrate PARSE cache resets each session (so the
70
+ // corpus is re-scanned, picking up skill/memory writes). injectedDocs is
71
+ // transcript-scoped, not session-scoped — deliberately NOT cleared here.
66
72
  clearSessionCache();
67
73
  });
68
74
  // 1. BOOT system-prompt half — splice `<skills>` + `<preferences>` +
@@ -109,6 +115,9 @@ export function registerCanvasDocSubstrate(pi) {
109
115
  const injected = renderOnReadDocs(nodeId, absFile, injectedDocs);
110
116
  if (injected === '')
111
117
  return; // nothing surfaced — pass the read through unchanged
118
+ // A doc surfaced → renderOnReadDocs grew injectedDocs; persist the set so
119
+ // the dedup survives a later dormancy → revive(resume).
120
+ saveInjectedDocs(nodeId, injectedDocs);
112
121
  // Prepend the surfacing docs ahead of the file contents.
113
122
  return { content: [{ type: 'text', text: injected }, ...event.content] };
114
123
  }
@@ -3,13 +3,23 @@
3
3
  // Loaded into every canvas node's pi process via the node's launch.extensions
4
4
  // list. INERT when CRTR_NODE_ID is absent (plain pi session or legacy job agent).
5
5
  //
6
- // A node spawned with a prompt has its goal persisted at birth (writeGoal in
7
- // spawn.ts). A bare root (`crtr` with no prompt) starts goal-less — its mandate
8
- // only arrives when the human types their first message. This extension closes
9
- // that gap: on the FIRST interactive user message, if the node has no goal yet,
10
- // it persists that message as context/initial-prompt.md. Subsequent messages
11
- // never clobber it (captureGoalIfAbsent is guarded), and a fresh-revive kickoff
12
- // prompt is skipped via its sentinel so it can never be mistaken for a mandate.
6
+ // Two first-message jobs, both keyed off the node's first real `input` event:
7
+ //
8
+ // 1. Goal capture (bare roots). A node spawned with a prompt has its goal
9
+ // persisted at birth (writeGoal in spawn.ts). A bare root (`crtr` with no
10
+ // prompt) starts goal-less its mandate only arrives when the human types
11
+ // their first message; this persists that as context/initial-prompt.md.
12
+ // Guarded so later messages never clobber it.
13
+ //
14
+ // 2. Naming (every node). Naming is async + event-driven — it does NOT run on
15
+ // the spawn path (that blocking LLM call froze the caller's terminal for
16
+ // 2-3s on every spawn). On the first real message, if the node has no name
17
+ // yet, ask pi headlessly (async, non-blocking) for a kebab-case name and
18
+ // live-update the editor label. The first message may be a human's line OR
19
+ // a delegated child's kickoff task — naming off the agent prompt is fine.
20
+ //
21
+ // Both skip extension-injected messages (inbox wakes, steering) and the
22
+ // fresh-revive kickoff (its sentinel), so neither is mistaken for a first input.
13
23
  //
14
24
  // Pure observation — it writes the goal file as a side effect and always lets
15
25
  // the message through unchanged (returns nothing ⇒ continue). Registered before
@@ -20,7 +30,7 @@
20
30
  // crouter's own tsc build without a dep on the pi packages.
21
31
  import { captureGoalIfAbsent, REVIVE_KICKOFF_SENTINEL } from '../core/runtime/kickoff.js';
22
32
  import { generateAndPersistName } from '../core/runtime/naming.js';
23
- import { editorLabel } from '../core/canvas/index.js';
33
+ import { editorLabel, getNode } from '../core/canvas/index.js';
24
34
  /**
25
35
  * Register the goal-capture handler on `pi`.
26
36
  *
@@ -34,26 +44,32 @@ export function registerCanvasGoalCapture(pi) {
34
44
  const nodeId = process.env['CRTR_NODE_ID'];
35
45
  if (nodeId === undefined || nodeId.trim() === '')
36
46
  return; // not a canvas node
37
- // Only a genuine human-typed prompt seeds the mandate — never an RPC or an
38
- // extension-injected message (inbox wakes, steering nudges, kickoffs).
39
- if (event.source !== 'interactive')
40
- return;
41
47
  const text = (event.text ?? '').trim();
42
48
  if (text === '')
43
49
  return;
44
- // A fresh-revive kickoff is delivered as the launch prompt; never let it
45
- // masquerade as the user's first mandate.
50
+ // Never seed a mandate or a name from an extension-injected message (inbox
51
+ // wakes, steering nudges) or a fresh-revive kickoff (the node is already
52
+ // named). Both would otherwise masquerade as the node's first real input.
53
+ if (event.source === 'extension')
54
+ return;
46
55
  if (text.startsWith(REVIVE_KICKOFF_SENTINEL))
47
56
  return;
48
- // First mandate for a bare root: persist it as the goal, and ask pi
49
- // (async, non-blocking) to name the session from it. The name lands on
50
- // meta.description; the onNamed callback pushes the new editor label into
51
- // THIS live session via setSessionName, so it updates immediately instead
52
- // of only on the next cycle.
53
- if (captureGoalIfAbsent(nodeId, text)) {
54
- generateAndPersistName(nodeId, text, (meta) => {
57
+ // Goal capture is bare-root only: a delegated child already had its goal
58
+ // persisted at birth (writeGoal), so only a genuine human-typed prompt
59
+ // seeds a mandate here.
60
+ if (event.source === 'interactive')
61
+ captureGoalIfAbsent(nodeId, text);
62
+ // Naming: name the node from its FIRST real message — a human's first line
63
+ // OR a delegated child's kickoff task (naming off the agent prompt is
64
+ // fine) — whenever it has no name yet. Async headless `pi -p` with no
65
+ // canvas extensions, so it never recurses into another spawn/name. The
66
+ // onNamed callback live-updates THIS session's label instead of waiting
67
+ // for the next cycle. The unnamed-guard keeps it to one call per node.
68
+ const meta = getNode(nodeId);
69
+ if (meta !== null && (meta.description ?? '').trim() === '') {
70
+ generateAndPersistName(nodeId, text, (named) => {
55
71
  try {
56
- pi.setSessionName?.(editorLabel(meta));
72
+ pi.setSessionName?.(editorLabel(named));
57
73
  }
58
74
  catch { /* best-effort */ }
59
75
  });