@crouton-kit/crouter 0.3.13 → 0.3.14

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 (41) hide show
  1. package/dist/commands/__tests__/human.test.js +73 -2
  2. package/dist/commands/human/queue.d.ts +1 -0
  3. package/dist/commands/human/queue.js +89 -2
  4. package/dist/commands/human/shared.d.ts +5 -0
  5. package/dist/commands/human/shared.js +15 -0
  6. package/dist/commands/human.js +4 -2
  7. package/dist/commands/node.js +195 -24
  8. package/dist/core/__tests__/passive-subscription.test.d.ts +1 -0
  9. package/dist/core/__tests__/passive-subscription.test.js +141 -0
  10. package/dist/core/__tests__/subcommand-tier.test.d.ts +1 -0
  11. package/dist/core/__tests__/subcommand-tier.test.js +97 -0
  12. package/dist/core/canvas/paths.d.ts +4 -0
  13. package/dist/core/canvas/paths.js +6 -0
  14. package/dist/core/command.js +40 -7
  15. package/dist/core/feed/feed.js +11 -9
  16. package/dist/core/feed/passive.d.ts +17 -0
  17. package/dist/core/feed/passive.js +79 -0
  18. package/dist/core/help.d.ts +45 -12
  19. package/dist/core/help.js +42 -4
  20. package/dist/core/runtime/demote.d.ts +14 -0
  21. package/dist/core/runtime/demote.js +103 -0
  22. package/dist/core/runtime/kickoff.d.ts +9 -0
  23. package/dist/core/runtime/kickoff.js +19 -1
  24. package/dist/core/runtime/launch.d.ts +12 -1
  25. package/dist/core/runtime/launch.js +18 -2
  26. package/dist/core/runtime/presence.d.ts +1 -18
  27. package/dist/core/runtime/presence.js +7 -51
  28. package/dist/core/runtime/promote.d.ts +4 -0
  29. package/dist/core/runtime/promote.js +21 -6
  30. package/dist/core/runtime/roadmap.d.ts +5 -4
  31. package/dist/core/runtime/roadmap.js +9 -16
  32. package/dist/core/runtime/spawn.js +7 -2
  33. package/dist/core/runtime/tmux.d.ts +11 -12
  34. package/dist/core/runtime/tmux.js +57 -26
  35. package/dist/pi-extensions/canvas-commands.d.ts +34 -0
  36. package/dist/pi-extensions/canvas-commands.js +100 -0
  37. package/dist/pi-extensions/canvas-goal-capture.d.ts +18 -0
  38. package/dist/pi-extensions/canvas-goal-capture.js +53 -0
  39. package/dist/pi-extensions/canvas-passive-context.d.ts +32 -0
  40. package/dist/pi-extensions/canvas-passive-context.js +114 -0
  41. package/package.json +1 -1
@@ -98,25 +98,6 @@ export function openNodeWindow(opts) {
98
98
  ]);
99
99
  return r.ok ? r.stdout : null;
100
100
  }
101
- /** Open a background window running a plain login shell (no pi) and return its
102
- * window + pane ids. Used by demote: the agent's pi is swapped OUT into this
103
- * window's slot and the shell is swapped INTO the caller's pane. `-a` keeps it
104
- * off index 0 (reserved for a dashboard), `-d` keeps it from stealing focus. */
105
- export function openShellWindow(opts) {
106
- const r = tmux([
107
- 'new-window', '-d', '-a', '-P',
108
- '-F', '#{window_id}\t#{pane_id}',
109
- '-t', `${opts.session}:`,
110
- '-n', opts.name,
111
- '-c', opts.cwd,
112
- ]);
113
- if (!r.ok)
114
- return null;
115
- const [window, pane] = r.stdout.split('\t');
116
- if (window === undefined || pane === undefined)
117
- return null;
118
- return { window, pane };
119
- }
120
101
  /** Bring a node's window forefront. Switches client across roots when needed. */
121
102
  export function focusWindow(session, window) {
122
103
  const here = currentTmux();
@@ -145,6 +126,17 @@ export function windowOfPane(pane) {
145
126
  const r = tmux(['display-message', '-p', '-t', pane, '#{window_id}']);
146
127
  return r.ok && r.stdout !== '' ? r.stdout : null;
147
128
  }
129
+ /** The session + window a pane currently lives in. Used by demote to place the
130
+ * recycled root's meta on the pane it respawns into. Null if tmux fails. */
131
+ export function paneLocation(pane) {
132
+ const r = tmux(['display-message', '-p', '-t', pane, '#{session_name}\t#{window_id}']);
133
+ if (!r.ok)
134
+ return null;
135
+ const [session, window] = r.stdout.split('\t');
136
+ if (session === undefined || session === '' || window === undefined || window === '')
137
+ return null;
138
+ return { session, window };
139
+ }
148
140
  /** Swap `targetPane` into `callerPane`'s layout slot, IN PLACE. `-d` keeps the
149
141
  * caller's window active, so the target's pane appears where the caller is
150
142
  * rather than navigating the client off to the target's window. The caller's
@@ -232,13 +224,52 @@ export function switchClient(session) {
232
224
  /** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails. */
233
225
  export function installMenuBinding() {
234
226
  const sess = nodeSession();
235
- return tmux([
227
+ const title = ' crtr ';
228
+ const items = [
229
+ // Promote types `/promote` into the agent's pane rather than shelling out:
230
+ // the slash command delivers the orchestration guidance into the node's
231
+ // context, which a bare `run-shell` (output discarded) could not.
232
+ { name: 'promote to orchestrator', key: 'o', cmd: `send-keys -t '#{pane_id}' '/promote' Enter` },
233
+ { name: 'finish agent + recycle pane', key: 'd', cmd: `run-shell "crtr node demote --pane '#{pane_id}'"` },
234
+ { name: 'browse background agents', key: 'g', cmd: `switch-client -t ${sess}` },
235
+ ];
236
+ // tmux's -x sets the menu's LEFT edge. To sit the box INSIDE the pane's
237
+ // top-right corner, shift x left by the box width (longest line + tmux chrome:
238
+ // borders + padding + the right-aligned mnemonic-key column) via format math.
239
+ const boxW = Math.max(title.length, ...items.map((i) => i.name.length)) + 6;
240
+ // Fine-tune nudges off the pane's top-right corner: a hair further left and
241
+ // one row down so the box doesn't kiss the pane border.
242
+ const nudgeX = 1; // extra columns left
243
+ const nudgeY = 3; // rows down
244
+ const args = [
236
245
  'bind-key', '-n', 'M-c', 'display-menu',
237
- '-T', '#[align=centre] crtr ',
238
- // Anchor to the top-right of the pane it was called from (tmux clamps it
239
- // back on-screen) rather than centring on the whole terminal.
240
- '-x', '#{pane_right}', '-y', '#{pane_top}',
241
- 'detach agent \u2192 background', 'd', `run-shell "crtr node demote --pane '#{pane_id}'"`,
242
- 'browse background agents', 'g', `switch-client -t ${sess}`,
246
+ '-T', `#[align=centre]${title}`,
247
+ '-x', `#{e|-:#{pane_right},${boxW + nudgeX}}`,
248
+ '-y', `#{e|+:#{pane_top},${nudgeY}}`,
249
+ ];
250
+ for (const it of items)
251
+ args.push(it.name, it.key, it.cmd);
252
+ return tmux(args).ok;
253
+ }
254
+ // ---------------------------------------------------------------------------
255
+ // Nav bindings — Alt+] / Alt+[ DFS-walk the canvas one window at a time. Each
256
+ // key shells out to `crtr node cycle`, passing the active pane so the walk is
257
+ // relative to the agent in front of you; cycle then swaps the next/prev node
258
+ // into that pane (like `node focus`). Output is discarded so the keypress never
259
+ // pops a results view. Installed at root boot alongside the Alt+C menu.
260
+ // ---------------------------------------------------------------------------
261
+ /** Bind Alt+] (forward) and Alt+[ (back) to the DFS canvas walk. Best-effort;
262
+ * false if either bind fails. NOTE: Alt+[ is only delivered cleanly when the
263
+ * terminal/tmux disambiguate it from a raw CSI introducer (`extended-keys on`).
264
+ */
265
+ export function installNavBindings() {
266
+ const next = tmux([
267
+ 'bind-key', '-n', 'M-]', 'run-shell',
268
+ `crtr node cycle --dir next --pane '#{pane_id}' >/dev/null 2>&1`,
269
+ ]).ok;
270
+ const prev = tmux([
271
+ 'bind-key', '-n', 'M-[', 'run-shell',
272
+ `crtr node cycle --dir prev --pane '#{pane_id}' >/dev/null 2>&1`,
243
273
  ]).ok;
274
+ return next && prev;
244
275
  }
@@ -0,0 +1,34 @@
1
+ interface AutocompleteItem {
2
+ value: string;
3
+ label?: string;
4
+ }
5
+ interface CommandUI {
6
+ notify(message: string, type?: 'info' | 'warning' | 'error'): void;
7
+ setStatus(key: string, text: string | undefined): void;
8
+ }
9
+ interface CommandCtx {
10
+ ui: CommandUI;
11
+ }
12
+ interface CustomMessage {
13
+ customType: string;
14
+ content: string;
15
+ display?: boolean;
16
+ }
17
+ interface PiLike {
18
+ registerCommand(name: string, options: {
19
+ description?: string;
20
+ getArgumentCompletions?: (prefix: string) => AutocompleteItem[] | null;
21
+ handler: (args: string, ctx: CommandCtx) => Promise<void>;
22
+ }): void;
23
+ sendMessage(message: CustomMessage, options?: {
24
+ triggerTurn?: boolean;
25
+ }): void;
26
+ }
27
+ /**
28
+ * Register the canvas slash-commands on `pi`.
29
+ *
30
+ * Returns immediately when CRTR_NODE_ID is absent — the extension is fully
31
+ * inert in a non-canvas pi session.
32
+ */
33
+ export declare function registerCanvasCommands(pi: PiLike): void;
34
+ export default registerCanvasCommands;
@@ -0,0 +1,100 @@
1
+ // canvas-commands.ts — pi extension registering canvas slash-commands on nodes.
2
+ //
3
+ // /promote [kind] — promote THIS node to a resident orchestrator. Runs
4
+ // `crtr node promote --json` for CRTR_NODE_ID (optionally specializing its
5
+ // kind), then injects the orchestration guidance the command returns into
6
+ // context and triggers a turn, so the node authors its roadmap immediately.
7
+ // This is the same mid-turn guidance dump the node would get by running the
8
+ // command itself by hand — surfaced as a one-keystroke affordance.
9
+ //
10
+ // The Alt+C tmux action menu's "promote to orchestrator" item (key `o`) simply
11
+ // send-keys `/promote` into the active pane, so the menu and the slash command
12
+ // share this one implementation.
13
+ //
14
+ // INERT when CRTR_NODE_ID is absent (a plain pi session, not a canvas node).
15
+ //
16
+ // Plain TS-with-types — no imports from @earendil-works/* so this compiles
17
+ // inside crouter's own tsc build without a dep on the pi packages (mirrors
18
+ // canvas-nav.ts). The only crouter import is availableKinds, used to offer
19
+ // `/promote <kind>` completions.
20
+ import { execFile } from 'node:child_process';
21
+ import { promisify } from 'node:util';
22
+ import { availableKinds } from '../core/personas/index.js';
23
+ const pexec = promisify(execFile);
24
+ // Kinds for `/promote <kind>` completions — computed once (persona dirs rarely
25
+ // change within a session), best-effort so a loader hiccup never breaks input.
26
+ let cachedKinds = null;
27
+ function kinds() {
28
+ if (cachedKinds === null) {
29
+ try {
30
+ cachedKinds = availableKinds();
31
+ }
32
+ catch {
33
+ cachedKinds = [];
34
+ }
35
+ }
36
+ return cachedKinds;
37
+ }
38
+ /**
39
+ * Register the canvas slash-commands on `pi`.
40
+ *
41
+ * Returns immediately when CRTR_NODE_ID is absent — the extension is fully
42
+ * inert in a non-canvas pi session.
43
+ */
44
+ export function registerCanvasCommands(pi) {
45
+ const nodeId = process.env['CRTR_NODE_ID'];
46
+ if (nodeId === undefined || nodeId.trim() === '')
47
+ return; // not a canvas node
48
+ pi.registerCommand('promote', {
49
+ description: 'Promote this node to a resident orchestrator — /promote, or /promote <kind> to specialize',
50
+ getArgumentCompletions: (prefix) => {
51
+ const items = kinds()
52
+ .filter((k) => k.startsWith(prefix))
53
+ .map((k) => ({ value: k, label: k }));
54
+ return items.length > 0 ? items : null;
55
+ },
56
+ handler: async (args, ctx) => {
57
+ const kind = args.trim().toLowerCase();
58
+ ctx.ui.setStatus('crtr-promote', kind ? `promoting → ${kind}…` : 'promoting…');
59
+ const argv = ['node', 'promote', '--json'];
60
+ if (kind !== '')
61
+ argv.push('--kind', kind);
62
+ // Run promote out-of-process. On a non-zero exit, crtr still prints the
63
+ // structured error to stdout, so prefer its `message` over the raw throw.
64
+ let result = null;
65
+ let errMsg = null;
66
+ try {
67
+ const { stdout } = await pexec('crtr', argv, { timeout: 15_000, maxBuffer: 4 * 1024 * 1024 });
68
+ result = JSON.parse(stdout);
69
+ }
70
+ catch (err) {
71
+ const e = err;
72
+ const stdout = typeof e.stdout === 'string' ? e.stdout : '';
73
+ try {
74
+ const payload = JSON.parse(stdout);
75
+ errMsg = typeof payload.message === 'string' ? payload.message : null;
76
+ }
77
+ catch {
78
+ /* stdout wasn't JSON */
79
+ }
80
+ if (errMsg === null)
81
+ errMsg = typeof e.message === 'string' ? e.message : String(err);
82
+ }
83
+ ctx.ui.setStatus('crtr-promote', '');
84
+ if (result === null) {
85
+ ctx.ui.notify(`promote failed: ${errMsg ?? 'unknown error'}`, 'error');
86
+ return;
87
+ }
88
+ const rmPath = (result.roadmap_path ?? '').trim();
89
+ ctx.ui.notify(`Promoted to ${result.kind ?? 'orchestrator'} orchestrator — authoring roadmap${rmPath !== '' ? ` (${rmPath})` : ''}.`, 'info');
90
+ // The guidance is operating instructions for the node, not the user.
91
+ // Inject it silently and trigger a turn so the node acts on it now —
92
+ // exactly what happens when the node runs `crtr node promote` by hand.
93
+ const guidance = (result.guidance ?? '').trim();
94
+ if (guidance === '')
95
+ return;
96
+ pi.sendMessage({ customType: 'crtr-promote', content: guidance, display: false }, { triggerTurn: true });
97
+ },
98
+ });
99
+ }
100
+ export default registerCanvasCommands;
@@ -0,0 +1,18 @@
1
+ interface InputEventLike {
2
+ type: 'input';
3
+ text: string;
4
+ images?: unknown[];
5
+ source: 'interactive' | 'rpc' | 'extension';
6
+ }
7
+ interface PiLike {
8
+ on: (event: 'input', handler: (event: InputEventLike, ctx: any) => void) => void;
9
+ }
10
+ /**
11
+ * Register the goal-capture handler on `pi`.
12
+ *
13
+ * Returns immediately (inert) when CRTR_NODE_ID is absent. The `input` handler
14
+ * is the whole extension: on the first interactive message of a goal-less node,
15
+ * persist it as the goal.
16
+ */
17
+ export declare function registerCanvasGoalCapture(pi: PiLike): void;
18
+ export default registerCanvasGoalCapture;
@@ -0,0 +1,53 @@
1
+ // canvas-goal-capture.ts — pi extension for pi-native canvas agent nodes.
2
+ //
3
+ // Loaded into every canvas node's pi process via the node's launch.extensions
4
+ // list. INERT when CRTR_NODE_ID is absent (plain pi session or legacy job agent).
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.
13
+ //
14
+ // Pure observation — it writes the goal file as a side effect and always lets
15
+ // the message through unchanged (returns nothing ⇒ continue). Registered before
16
+ // canvas-passive-context so it reads the raw user text, not a backlog-prepended
17
+ // transform.
18
+ //
19
+ // Plain TS-with-types — no imports from @earendil-works/* so this compiles inside
20
+ // crouter's own tsc build without a dep on the pi packages.
21
+ import { captureGoalIfAbsent, REVIVE_KICKOFF_SENTINEL } from '../core/runtime/kickoff.js';
22
+ /**
23
+ * Register the goal-capture handler on `pi`.
24
+ *
25
+ * Returns immediately (inert) when CRTR_NODE_ID is absent. The `input` handler
26
+ * is the whole extension: on the first interactive message of a goal-less node,
27
+ * persist it as the goal.
28
+ */
29
+ export function registerCanvasGoalCapture(pi) {
30
+ pi.on('input', (event) => {
31
+ try {
32
+ const nodeId = process.env['CRTR_NODE_ID'];
33
+ if (nodeId === undefined || nodeId.trim() === '')
34
+ return; // not a canvas node
35
+ // Only a genuine human-typed prompt seeds the mandate — never an RPC or an
36
+ // extension-injected message (inbox wakes, steering nudges, kickoffs).
37
+ if (event.source !== 'interactive')
38
+ return;
39
+ const text = (event.text ?? '').trim();
40
+ if (text === '')
41
+ return;
42
+ // A fresh-revive kickoff is delivered as the launch prompt; never let it
43
+ // masquerade as the user's first mandate.
44
+ if (text.startsWith(REVIVE_KICKOFF_SENTINEL))
45
+ return;
46
+ captureGoalIfAbsent(nodeId, text);
47
+ }
48
+ catch {
49
+ // Best-effort: a capture failure must never drop or alter the message.
50
+ }
51
+ });
52
+ }
53
+ export default registerCanvasGoalCapture;
@@ -0,0 +1,32 @@
1
+ import type { InboxEntry } from '../core/feed/inbox.js';
2
+ interface InputEventLike {
3
+ type: 'input';
4
+ text: string;
5
+ images?: unknown[];
6
+ source: 'interactive' | 'rpc' | 'extension';
7
+ }
8
+ type InputEventResultLike = {
9
+ action: 'continue';
10
+ } | {
11
+ action: 'transform';
12
+ text: string;
13
+ images?: unknown[];
14
+ } | {
15
+ action: 'handled';
16
+ };
17
+ interface PiLike {
18
+ on: (event: 'input', handler: (event: InputEventLike, ctx: any) => InputEventResultLike | void) => void;
19
+ }
20
+ /**
21
+ * Render drained passive entries (oldest first) into one XML pre-text block.
22
+ * Each accumulated message is its own timestamped `<update>` element.
23
+ */
24
+ export declare function formatPassive(entries: InboxEntry[]): string;
25
+ /**
26
+ * Register the passive-context drain on `pi`.
27
+ *
28
+ * Returns immediately (inert) when CRTR_NODE_ID is absent. The `input` handler
29
+ * is the whole extension: drain on every message, prepend when non-empty.
30
+ */
31
+ export declare function registerCanvasPassiveContext(pi: PiLike): void;
32
+ export default registerCanvasPassiveContext;
@@ -0,0 +1,114 @@
1
+ // canvas-passive-context.ts — pi extension for pi-native canvas agent nodes.
2
+ //
3
+ // Loaded into every canvas node's pi process via the node's launch.extensions
4
+ // list. INERT when CRTR_NODE_ID is absent (plain pi session or legacy job agent).
5
+ //
6
+ // The passive-subscription drain. A PASSIVE subscription (active=false edge)
7
+ // never wakes its subscriber: `push` routes its pointers to passive.jsonl, which
8
+ // the inbox-watcher never polls. They simply accumulate. This extension is what
9
+ // finally surfaces them — the moment the node is MESSAGED.
10
+ //
11
+ // pi fires an `input` event for every user message (human-typed, an RPC, or an
12
+ // extension's sendUserMessage — including the inbox-watcher's own wake). On that
13
+ // event we DRAIN the node's passive accumulator and, when non-empty, prepend
14
+ // every entry as timestamped XML to the message text via the `transform` action.
15
+ // So the backlog rides in as pre-text on whatever message next engages the node,
16
+ // before the LLM sees it — and is cleared in the same step (drain = read+clear),
17
+ // so it surfaces exactly once.
18
+ //
19
+ // Plain TS-with-types — no imports from @earendil-works/* so this compiles inside
20
+ // crouter's own tsc build without a dep on the pi packages.
21
+ import { existsSync, readFileSync } from 'node:fs';
22
+ import { drainPassive } from '../core/feed/passive.js';
23
+ // Per-entry body cap so a single fat report can't blow the context budget. The
24
+ // full report stays on disk at `ref` if the agent needs more.
25
+ const BODY_CAP = 4_000;
26
+ // ---------------------------------------------------------------------------
27
+ // Report dereference — turn a passive pointer into the message text it carries.
28
+ // ---------------------------------------------------------------------------
29
+ /** Strip the leading YAML frontmatter block a report is written with, returning
30
+ * just the body. Tolerant: no frontmatter → returns the input unchanged. */
31
+ function stripFrontmatter(raw) {
32
+ if (!raw.startsWith('---\n'))
33
+ return raw;
34
+ const end = raw.indexOf('\n---\n', 4);
35
+ return end === -1 ? raw : raw.slice(end + 5);
36
+ }
37
+ /** The content for one accumulated entry: the dereferenced report body when the
38
+ * pointer carries a `ref`, else the entry's own label/data. Capped + trimmed. */
39
+ function entryContent(e) {
40
+ if (e.ref !== undefined && e.ref !== '' && existsSync(e.ref)) {
41
+ try {
42
+ const body = stripFrontmatter(readFileSync(e.ref, 'utf8')).trim();
43
+ if (body !== '') {
44
+ return body.length > BODY_CAP
45
+ ? `${body.slice(0, BODY_CAP)}\n… (truncated; full report at ${e.ref})`
46
+ : body;
47
+ }
48
+ }
49
+ catch {
50
+ /* fall through to the label */
51
+ }
52
+ }
53
+ const data = e.data?.['body'];
54
+ if (typeof data === 'string' && data.trim() !== '')
55
+ return data.trim();
56
+ return e.label;
57
+ }
58
+ /** Minimal XML attribute escaping for the values we interpolate. */
59
+ function attr(s) {
60
+ return s
61
+ .replace(/&/g, '&amp;')
62
+ .replace(/</g, '&lt;')
63
+ .replace(/>/g, '&gt;')
64
+ .replace(/"/g, '&quot;');
65
+ }
66
+ /**
67
+ * Render drained passive entries (oldest first) into one XML pre-text block.
68
+ * Each accumulated message is its own timestamped `<update>` element.
69
+ */
70
+ export function formatPassive(entries) {
71
+ const blocks = entries
72
+ .map((e) => {
73
+ const from = attr(e.from ?? 'system');
74
+ const refAttr = e.ref !== undefined && e.ref !== '' ? ` ref="${attr(e.ref)}"` : '';
75
+ return (`<update from="${from}" kind="${attr(e.kind)}" at="${attr(e.ts)}"${refAttr}>\n` +
76
+ `${entryContent(e)}\n` +
77
+ `</update>`);
78
+ })
79
+ .join('\n');
80
+ return (`<passive-subscription-backlog count="${entries.length}" ` +
81
+ `note="Reports accumulated from nodes you passively subscribe to while you were not actively listening. ` +
82
+ `Surfaced now because you were messaged. Oldest first.">\n` +
83
+ `${blocks}\n` +
84
+ `</passive-subscription-backlog>`);
85
+ }
86
+ // ---------------------------------------------------------------------------
87
+ // Extension
88
+ // ---------------------------------------------------------------------------
89
+ /**
90
+ * Register the passive-context drain on `pi`.
91
+ *
92
+ * Returns immediately (inert) when CRTR_NODE_ID is absent. The `input` handler
93
+ * is the whole extension: drain on every message, prepend when non-empty.
94
+ */
95
+ export function registerCanvasPassiveContext(pi) {
96
+ pi.on('input', (event) => {
97
+ try {
98
+ const nodeId = process.env['CRTR_NODE_ID'];
99
+ if (nodeId === undefined || nodeId.trim() === '')
100
+ return; // not a canvas node
101
+ const drained = drainPassive(nodeId);
102
+ if (drained.length === 0)
103
+ return; // nothing accumulated → leave the message as-is
104
+ const preText = formatPassive(drained);
105
+ const text = event.text.trim() === '' ? preText : `${preText}\n\n${event.text}`;
106
+ return { action: 'transform', text, images: event.images };
107
+ }
108
+ catch {
109
+ // Best-effort: a drain/format failure must never drop the user's message.
110
+ return;
111
+ }
112
+ });
113
+ }
114
+ export default registerCanvasPassiveContext;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/crouter",
3
- "version": "0.3.13",
3
+ "version": "0.3.14",
4
4
  "description": "crtr — fast access to skills, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",