@north-light/crouter 0.3.179 → 0.3.181

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 (137) hide show
  1. package/dist/api/client.d.ts +27 -2
  2. package/dist/api/client.js +41 -1
  3. package/dist/api/dto/broker.d.ts +32 -0
  4. package/dist/api/dto/crons.d.ts +17 -0
  5. package/dist/api/dto/human.d.ts +1 -7
  6. package/dist/api/dto/inbox.d.ts +71 -1
  7. package/dist/api/dto/inbox.js +9 -1
  8. package/dist/api/dto/memory.d.ts +17 -0
  9. package/dist/api/dto/memory.js +6 -0
  10. package/dist/api/dto/messages.d.ts +5 -0
  11. package/dist/api/dto/reviews.d.ts +8 -4
  12. package/dist/api/index.d.ts +1 -0
  13. package/dist/api/index.js +1 -0
  14. package/dist/api/routes.d.ts +5 -0
  15. package/dist/api/routes.js +8 -1
  16. package/dist/build-root.d.ts +7 -0
  17. package/dist/build-root.js +21 -0
  18. package/dist/builtin-memory/insights/init.md +48 -3
  19. package/dist/builtin-pi-packages/pi-crtr-extensions/__tests__/insights-active-init.test.ts +98 -0
  20. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/claude-plugin-commands.ts +7 -50
  21. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +16 -1
  22. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/pi-shell-runner.ts +34 -0
  23. package/dist/cli.js +1 -2
  24. package/dist/clients/attach/__tests__/context-message.test.js +5 -2
  25. package/dist/clients/attach/assets/README.md +7 -0
  26. package/dist/clients/attach/assets/whip-06.mp3 +0 -0
  27. package/dist/clients/attach/assets/whip-crack.mp3 +0 -0
  28. package/dist/clients/attach/assets/whip-snap.mp3 +0 -0
  29. package/dist/clients/attach/chrome/canvas-panels.d.ts +7 -1
  30. package/dist/clients/attach/chrome/canvas-panels.js +20 -3
  31. package/dist/clients/attach/chrome/review-wait.d.ts +6 -0
  32. package/dist/clients/attach/chrome/review-wait.js +22 -0
  33. package/dist/clients/attach/chrome/roster.js +23 -2
  34. package/dist/clients/attach/chrome/widgets.js +1 -1
  35. package/dist/clients/attach/input/controller.js +4 -3
  36. package/dist/clients/attach/overlays/mcp.js +3 -1
  37. package/dist/clients/attach/render/chat-view.js +1 -1
  38. package/dist/clients/attach/session/whip.d.ts +1 -0
  39. package/dist/clients/attach/session/whip.js +26 -0
  40. package/dist/clients/attach/slash/dispatch.js +2 -0
  41. package/dist/clients/attach/viewer.js +578 -573
  42. package/dist/clients/inbox/review/document-surface.d.ts +1 -1
  43. package/dist/clients/inbox/review/document-surface.js +4 -4
  44. package/dist/clients/inbox/review/launch.js +16 -4
  45. package/dist/clients/inbox/review/review-client.d.ts +9 -4
  46. package/dist/clients/inbox/review/review-client.js +3 -0
  47. package/dist/commands/cron.js +30 -8
  48. package/dist/commands/human/prompts.d.ts +7 -2
  49. package/dist/commands/human/prompts.js +15 -10
  50. package/dist/commands/human.js +1 -2
  51. package/dist/commands/memory/find.js +11 -8
  52. package/dist/commands/memory/read.js +111 -11
  53. package/dist/commands/memory/write.js +1 -1
  54. package/dist/commands/memory.js +1 -1
  55. package/dist/commands/pkg/market-manage.d.ts +13 -0
  56. package/dist/commands/pkg/market-manage.js +39 -33
  57. package/dist/commands/pkg/plugin-inspect.js +4 -3
  58. package/dist/commands/pkg/plugin-manage.js +12 -11
  59. package/dist/commands/surface/node/focus.js +1 -2
  60. package/dist/commands/sys/doctor.js +4 -4
  61. package/dist/commands/sys/setup-core.d.ts +14 -7
  62. package/dist/commands/sys/setup-core.js +66 -11
  63. package/dist/commands/sys/setup-wizard.js +2 -2
  64. package/dist/commands/sys/setup.js +1 -1
  65. package/dist/core/__tests__/cron-held-settlement.test.d.ts +1 -0
  66. package/dist/core/__tests__/cron-held-settlement.test.js +222 -0
  67. package/dist/core/__tests__/helpers/harness.js +1 -2
  68. package/dist/core/__tests__/phase4-review-store.test.js +1 -0
  69. package/dist/core/__tests__/serial/command-plugins.test.js +88 -1
  70. package/dist/core/__tests__/session-model.test.js +5 -3
  71. package/dist/core/bootstrap.d.ts +0 -4
  72. package/dist/core/bootstrap.js +1 -55
  73. package/dist/core/canvas/crons.d.ts +54 -2
  74. package/dist/core/canvas/crons.js +48 -4
  75. package/dist/core/canvas/db.js +23 -0
  76. package/dist/core/command-manifests/manifest.d.ts +11 -0
  77. package/dist/core/command-manifests/manifest.js +45 -4
  78. package/dist/core/command-manifests/schema.d.ts +1 -1
  79. package/dist/core/command-plugins/bundle.d.ts +1 -0
  80. package/dist/core/command-plugins/bundle.js +3 -3
  81. package/dist/core/command-plugins/discovery.d.ts +5 -2
  82. package/dist/core/command-plugins/discovery.js +5 -5
  83. package/dist/core/command-plugins/help-addenda.d.ts +12 -0
  84. package/dist/core/command-plugins/help-addenda.js +30 -0
  85. package/dist/core/command.js +25 -2
  86. package/dist/core/config.js +0 -1
  87. package/dist/core/human/convention.d.ts +0 -1
  88. package/dist/core/human/convention.js +0 -6
  89. package/dist/core/keybindings/inbox.d.ts +6 -8
  90. package/dist/core/keybindings/inbox.js +6 -15
  91. package/dist/core/keybindings/index.d.ts +1 -1
  92. package/dist/core/keybindings/index.js +1 -1
  93. package/dist/core/memory/doc-link-grammar.js +4 -1
  94. package/dist/core/memory-resolver.d.ts +28 -4
  95. package/dist/core/memory-resolver.js +51 -39
  96. package/dist/core/review/stage.js +1 -0
  97. package/dist/core/review/store.d.ts +5 -0
  98. package/dist/core/review/store.js +10 -0
  99. package/dist/core/review/types.d.ts +4 -0
  100. package/dist/core/runtime/broker/event-projection.d.ts +8 -1
  101. package/dist/core/runtime/broker/event-projection.js +25 -1
  102. package/dist/core/runtime/broker/frame-dispatch.d.ts +2 -0
  103. package/dist/core/runtime/broker/frame-dispatch.js +50 -8
  104. package/dist/core/runtime/broker/message-ledger.d.ts +53 -0
  105. package/dist/core/runtime/broker/message-ledger.js +143 -0
  106. package/dist/core/runtime/broker/rebind.js +14 -0
  107. package/dist/core/runtime/broker-protocol.d.ts +46 -1
  108. package/dist/core/runtime/broker.js +11 -2
  109. package/dist/core/runtime/interactive-deliver.d.ts +5 -2
  110. package/dist/core/runtime/interactive-deliver.js +6 -3
  111. package/dist/core/runtime/shell-expansion.d.ts +32 -0
  112. package/dist/core/runtime/shell-expansion.js +102 -0
  113. package/dist/core/session-model/session-state.d.ts +9 -4
  114. package/dist/core/session-model/session-state.js +5 -1
  115. package/dist/daemon/api/handlers/broker-ops.js +8 -0
  116. package/dist/daemon/api/handlers/crons.js +14 -1
  117. package/dist/daemon/api/handlers/inbox.js +346 -2
  118. package/dist/daemon/api/handlers/memory.d.ts +2 -0
  119. package/dist/daemon/api/handlers/memory.js +48 -0
  120. package/dist/daemon/api/handlers/messages.js +7 -1
  121. package/dist/daemon/api/handlers/reviews.js +7 -5
  122. package/dist/daemon/api/map.js +3 -0
  123. package/dist/daemon/api/server.js +2 -0
  124. package/dist/daemon/cron-run.js +71 -3
  125. package/dist/daemon/crtrd.js +3 -0
  126. package/dist/daemon/reconcilers/pending-review-submit.d.ts +7 -0
  127. package/dist/daemon/reconcilers/pending-review-submit.js +35 -0
  128. package/dist/daemon/review/companion.d.ts +8 -0
  129. package/dist/daemon/review/companion.js +35 -0
  130. package/dist/daemon/review/deliver.js +2 -1
  131. package/dist/daemon/review/finish.d.ts +29 -2
  132. package/dist/daemon/review/finish.js +75 -2
  133. package/dist/shared/generated-context.d.ts +3 -4
  134. package/dist/shared/generated-context.js +24 -6
  135. package/dist/types.d.ts +0 -1
  136. package/package.json +1 -1
  137. package/runtime.lock.json +2 -2
@@ -0,0 +1,98 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+
4
+ // Integration test: /insights:init --active spawns an explorer and coordinates
5
+ // insight capture flow. This test verifies that:
6
+ // 1. The active mode doc exists and has slash: true
7
+ // 2. The active mode instructions are present and executable
8
+ // 3. A reader following the instructions can spawn an explorer
9
+
10
+ test('insights/init has active mode documentation with executable commands', async () => {
11
+ const { readFileSync } = await import('fs');
12
+ const initPath = new URL('../../../builtin-memory/insights/init.md', import.meta.url);
13
+ const content = readFileSync(initPath, 'utf-8');
14
+
15
+ // Must be marked as a slash command
16
+ assert(content.includes('slash: true'), 'init.md must have slash: true frontmatter');
17
+
18
+ // Must have active mode section
19
+ assert(
20
+ content.includes('## Active mode: research and surface claims'),
21
+ 'init.md must document active mode'
22
+ );
23
+
24
+ // Must have active flag check
25
+ assert(
26
+ content.includes('Check for --active flag') || content.includes('--active'),
27
+ 'init.md must explain --active flag'
28
+ );
29
+
30
+ // Must have executable crtr commands for spawning explorer
31
+ assert(
32
+ content.includes('crtr node new --kind explore'),
33
+ 'init.md must include crtr command to spawn explorer'
34
+ );
35
+
36
+ // Must reference the capture workflow
37
+ assert(
38
+ content.includes('[[insights/capture]]'),
39
+ 'init.md must link to capture workflow'
40
+ );
41
+
42
+ // Must document waiting for explorer report
43
+ assert(
44
+ content.includes('Wait for the explorer to report') || content.includes('explorer'),
45
+ 'init.md must explain waiting for explorer'
46
+ );
47
+
48
+ // Must document coordinating user responses
49
+ assert(
50
+ content.includes('user response') || content.includes('Turn user responses'),
51
+ 'init.md must explain handling user responses'
52
+ );
53
+ });
54
+
55
+ test('active mode provides concrete bash commands agent can execute', async () => {
56
+ const { readFileSync } = await import('fs');
57
+ const initPath = new URL('../../../builtin-memory/insights/init.md', import.meta.url);
58
+ const content = readFileSync(initPath, 'utf-8');
59
+
60
+ // The active mode section should have a bash code block
61
+ const bashBlockMatch = content.match(/```bash\n([\s\S]*?)\n```/);
62
+ assert(bashBlockMatch, 'active mode must have a bash code block');
63
+
64
+ const bashContent = bashBlockMatch[1];
65
+
66
+ // Must have the crtr node new command
67
+ assert(bashContent.includes('crtr node new'), 'bash block must invoke crtr node new');
68
+
69
+ // Must specify --kind explore
70
+ assert(
71
+ bashContent.includes('--kind explore'),
72
+ 'bash command must spawn explore kind'
73
+ );
74
+
75
+ // Must have the task heredoc
76
+ assert(
77
+ bashContent.includes('<<') || bashContent.includes('TASK'),
78
+ 'bash command must include task definition'
79
+ );
80
+
81
+ // Must document the explorer task
82
+ assert(
83
+ bashContent.includes('investigate') || bashContent.includes('domain'),
84
+ 'explorer task must mention domain investigation'
85
+ );
86
+
87
+ // Must ask explorer to surface claims
88
+ assert(
89
+ bashContent.includes('claim') || bashContent.includes('surface'),
90
+ 'explorer task must ask for claims/surface evidence'
91
+ );
92
+
93
+ // Must reference output location
94
+ assert(
95
+ bashContent.includes('$CRTR_CONTEXT_DIR') || bashContent.includes('context'),
96
+ 'explorer task must reference context directory output'
97
+ );
98
+ });
@@ -1,5 +1,4 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { truncateTail, formatSize, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
3
2
  import {
4
3
  readFileSync,
5
4
  readdirSync,
@@ -14,6 +13,8 @@ import { homedir } from "node:os";
14
13
  import { join, resolve, dirname } from "node:path";
15
14
  import { loadProfileManifest } from "../../../core/profiles/manifest.js";
16
15
  import { stripMarkdownComments } from "../../../core/runtime/command-expansion.js";
16
+ import { expandShellBlocks, DEFAULT_SHELL_TIMEOUT_MS } from "../../../core/runtime/shell-expansion.js";
17
+ import { piShellRunner } from "./pi-shell-runner.js";
17
18
 
18
19
  // ---------------------------------------------------------------------------
19
20
  // claude-plugin-commands: surfaces Claude Code commands AND skills as pi slash
@@ -367,57 +368,13 @@ function shellTimeoutMs(fm: string): number {
367
368
  }
368
369
 
369
370
  // ---- shell execution (mirrors @juicesharp/rpiv-args) -----------------------
370
-
371
- const SHELL_INLINE = /!`([^`\n]+)`/g;
372
- const SHELL_BLOCK = /```!\n([\s\S]*?)\n```/g;
373
- const DEFAULT_SHELL_TIMEOUT_MS = 120_000;
374
-
375
- function truncateForLLM(content: string): string {
376
- const t = truncateTail(content, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
377
- let out = t.content;
378
- if (t.truncated) {
379
- const limit = t.truncatedBy === "lines" ? `${t.maxLines} lines` : formatSize(t.maxBytes);
380
- out += `\n[truncated: hit ${limit}]`;
381
- }
382
- return out;
383
- }
384
-
385
- async function runShell(cmd: string, pi: ExtensionAPI, cwd: string, timeoutMs: number): Promise<string> {
386
- const [sh, flag] = process.platform === "win32" ? ["powershell.exe", "-Command"] : ["sh", "-c"];
387
- const res: any = await pi.exec(sh, [flag, cmd], { cwd, timeout: timeoutMs });
388
- if (res.killed) return `[Shell error: timed out after ${Math.max(1, Math.round(timeoutMs / 1000))}s]`;
389
- if (res.code !== 0) return `[Shell error: exit code ${res.code}]\n${truncateForLLM(res.stderr ?? "")}`;
390
- let combined = res.stdout ?? "";
391
- if (res.stderr) {
392
- const sep = combined.length === 0 || combined.endsWith("\n") ? "" : "\n";
393
- combined = `${combined}${sep}[stderr]\n${res.stderr}`;
394
- }
395
- return truncateForLLM(combined);
396
- }
371
+ //
372
+ // The `!`cmd`` / ```! grammar and substitution order are shared with every
373
+ // other surface that expands a Markdown body (`core/runtime/shell-expansion`);
374
+ // the pi-engine execution strategy is shared through `pi-shell-runner`.
397
375
 
398
376
  async function executeShell(body: string, pi: ExtensionAPI, cwd: string, timeoutMs: number): Promise<string> {
399
- // blocks first (mask with sentinels), then inlines, then restore.
400
- const blockOut: string[] = [];
401
- let masked = "";
402
- let last = 0;
403
- for (const m of body.matchAll(SHELL_BLOCK)) {
404
- const idx = m.index ?? 0;
405
- masked += body.slice(last, idx) + `\x00B${blockOut.length}\x00`;
406
- blockOut.push(await runShell(m[1] ?? "", pi, cwd, timeoutMs));
407
- last = idx + m[0].length;
408
- }
409
- masked += body.slice(last);
410
-
411
- let inlined = "";
412
- last = 0;
413
- for (const m of masked.matchAll(SHELL_INLINE)) {
414
- const idx = m.index ?? 0;
415
- inlined += masked.slice(last, idx) + (await runShell(m[1] ?? "", pi, cwd, timeoutMs));
416
- last = idx + m[0].length;
417
- }
418
- inlined += masked.slice(last);
419
-
420
- return inlined.replace(/\x00B(\d+)\x00/g, (_, n) => blockOut[parseInt(n, 10)] ?? "");
377
+ return expandShellBlocks(body, piShellRunner(pi, cwd, timeoutMs));
421
378
  }
422
379
 
423
380
  // ---------------------------------------------------------------------------
@@ -5,6 +5,8 @@ import {
5
5
  expandDeterministicCommand,
6
6
  type DeterministicCommandExpansion,
7
7
  } from "../../../core/runtime/command-expansion.js";
8
+ import { expandShellBlocks, hasShellBlocks, DEFAULT_SHELL_TIMEOUT_MS } from "../../../core/runtime/shell-expansion.js";
9
+ import { piShellRunner } from "./pi-shell-runner.js";
8
10
 
9
11
  // ---------------------------------------------------------------------------
10
12
  // memory-slash-commands: any memory doc frontmatter-flagged `slash: true`
@@ -136,10 +138,23 @@ export default async function (pi: ExtensionAPI) {
136
138
  );
137
139
  return;
138
140
  }
141
+ // Shell runs HERE and only here. `expandDeterministicCommand` stays
142
+ // inert because it also drives the attach viewer's pre-submit preview,
143
+ // which re-expands on every keystroke — executing there would run the
144
+ // document's commands repeatedly while the user is still typing. This
145
+ // handler fires once, at submit, so the preview shows the command text
146
+ // and the submitted turn carries its output.
147
+ const expanded = expandDeterministicCommand(expansion, args ?? "");
148
+ const content = hasShellBlocks(expanded)
149
+ ? await expandShellBlocks(
150
+ expanded,
151
+ piShellRunner(pi, (ctx as { cwd?: string }).cwd ?? process.cwd(), DEFAULT_SHELL_TIMEOUT_MS),
152
+ )
153
+ : expanded;
139
154
  pi.sendMessage(
140
155
  {
141
156
  customType: "memory-slash-command",
142
- content: expandDeterministicCommand(expansion, args ?? ""),
157
+ content,
143
158
  display: true,
144
159
  },
145
160
  { triggerTurn: true },
@@ -0,0 +1,34 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { truncateTail, formatSize, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
3
+
4
+ // The broker-side execution strategy for `core/runtime/shell-expansion`. Every
5
+ // command surface that expands a Markdown body inside a pi engine shares it, so
6
+ // the engine owns the subprocess and truncation uses pi's own limits. The
7
+ // grammar and substitution order live in the shared module; only this strategy
8
+ // is pi-specific, and it stays out of that module so CLI leaves can import the
9
+ // expander without loading the pi runtime.
10
+
11
+ function truncateForLLM(content: string): string {
12
+ const t = truncateTail(content, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
13
+ let out = t.content;
14
+ if (t.truncated) {
15
+ const limit = t.truncatedBy === "lines" ? `${t.maxLines} lines` : formatSize(t.maxBytes);
16
+ out += `\n[truncated: hit ${limit}]`;
17
+ }
18
+ return out;
19
+ }
20
+
21
+ export function piShellRunner(pi: ExtensionAPI, cwd: string, timeoutMs: number) {
22
+ const [sh, flag] = process.platform === "win32" ? ["powershell.exe", "-Command"] : ["sh", "-c"];
23
+ return async (cmd: string): Promise<string> => {
24
+ const res: any = await pi.exec(sh, [flag, cmd], { cwd, timeout: timeoutMs });
25
+ if (res.killed) return `[Shell error: timed out after ${Math.max(1, Math.round(timeoutMs / 1000))}s]`;
26
+ if (res.code !== 0) return `[Shell error: exit code ${res.code}]\n${truncateForLLM(res.stderr ?? "")}`;
27
+ let combined = res.stdout ?? "";
28
+ if (res.stderr) {
29
+ const sep = combined.length === 0 || combined.endsWith("\n") ? "" : "\n";
30
+ combined = `${combined}${sep}[stderr]\n${res.stderr}`;
31
+ }
32
+ return truncateForLLM(combined);
33
+ };
34
+ }
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { runCli } from './core/command.js';
7
7
  import { resolveRoot } from './build-root.js';
8
8
  import { maybeBootRoot } from './core/runtime/front-door.js';
9
9
  import { maybeAutoUpdate } from './core/auto-update.js';
10
- import { ensureOfficialMarketplace, ensureProjectScope } from './core/bootstrap.js';
10
+ import { ensureProjectScope } from './core/bootstrap.js';
11
11
  import { provisionExports, pruneLegacyExports } from './core/host-exports/export.js';
12
12
  import { installStdoutErrorPolicy } from './core/stdout-errors.js';
13
13
  import { handle } from './core/io.js';
@@ -54,7 +54,6 @@ async function main() {
54
54
  const root = await resolveRoot(process.argv[2]);
55
55
  mark('cli.root_resolved');
56
56
  provisionExports(root, process.argv);
57
- ensureOfficialMarketplace(process.argv);
58
57
  ensureProjectScope(process.argv);
59
58
  maybeAutoUpdate(process.argv);
60
59
  mark('cli.dispatch_start');
@@ -11,7 +11,7 @@ import test from 'node:test';
11
11
  import { Container } from '@earendil-works/pi-tui';
12
12
  import { applyTheme } from '../config.js';
13
13
  import { ChatView } from '../render/chat-view.js';
14
- import { CONTEXT_INTRO_CUSTOM_TYPE, CONTEXT_NUDGE_CUSTOM_TYPE, REVIVE_KICKOFF_SENTINEL, RUNTIME_RESTART_CONTINUATION, formatModelFallbackRecovery, generatedContextPresentation, } from '../../../shared/generated-context.js';
14
+ import { CONTEXT_INTRO_CUSTOM_TYPE, CONTEXT_NUDGE_CUSTOM_TYPE, REVIVE_KICKOFF_SENTINEL, RUNTIME_RESTART_CONTINUATION, formatModelFallbackRecovery, formatReviewApproval, generatedContextPresentation, } from '../../../shared/generated-context.js';
15
15
  applyTheme();
16
16
  test('collapsed generated context never leaks its body; Ctrl+O restores it', async () => {
17
17
  const container = new Container();
@@ -38,11 +38,12 @@ test('collapsed generated context never leaks its body; Ctrl+O restores it', asy
38
38
  content: '[crtr] Context ~150k. private context reminder',
39
39
  },
40
40
  { role: 'user', content: 'From child-node-123 — 1 update:\n [update] private inbox detail' },
41
+ { role: 'user', content: formatReviewApproval('The human reviewed and approved private-review.md.') },
41
42
  ],
42
43
  stats: {},
43
44
  state: { isStreaming: false },
44
45
  });
45
- assert.doesNotMatch(container.render(80).join('\n'), /loaded INDEX\.md content|resume from this note|runtime-restart-continuation|private context reminder|private inbox detail/);
46
+ assert.doesNotMatch(container.render(80).join('\n'), /loaded INDEX\.md content|resume from this note|runtime-restart-continuation|private context reminder|private inbox detail|private-review\.md/);
46
47
  assert.deepEqual(view.toggleToolsExpanded(), { kind: 'summaries', shown: false });
47
48
  assert.equal(internals.toolOutputExpanded, true);
48
49
  const expanded = container.render(80).join('\n');
@@ -50,6 +51,7 @@ test('collapsed generated context never leaks its body; Ctrl+O restores it', asy
50
51
  assert.match(expanded, /resume from this note/);
51
52
  assert.doesNotMatch(expanded, /runtime-restart-continuation|private context reminder/);
52
53
  assert.match(expanded, /private inbox detail/, 'an expanded inbox card shows the actual delivered update');
54
+ assert.match(expanded, /private-review\.md/, 'an expanded review card shows the approval');
53
55
  assert.deepEqual(view.toggleToolsExpanded(), { kind: 'summaries', shown: true });
54
56
  assert.equal(internals.toolOutputExpanded, false);
55
57
  assert.doesNotMatch(container.render(80).join('\n'), /loaded INDEX\.md content|resume from this note|runtime-restart-continuation|private context reminder|private inbox detail/);
@@ -61,4 +63,5 @@ test('a human message that reads like runtime recovery is not folded as generate
61
63
  const marked = formatModelFallbackRecovery('custom(provider)/model', 'new/model:high', 'not-found');
62
64
  assert.equal(generatedContextPresentation({ role: 'user', content: marked })?.kind, 'recovery');
63
65
  assert.equal(generatedContextPresentation({ role: 'user', content: marked.split('\n').slice(1, -1).join('\n') }), null, 'a human message matching the recovery prose lacks the producer marker');
66
+ assert.equal(generatedContextPresentation({ role: 'user', content: 'The human reviewed and approved private-review.md.' }), null, 'a human message matching the approval prose lacks the producer marker');
64
67
  });
@@ -0,0 +1,7 @@
1
+ # Whip sounds
2
+
3
+ These files are the first three results for [“whip crack” on Pixabay](https://pixabay.com/sound-effects/search/whip%20crack/), downloaded from the playable audio URLs embedded in each result page on 2026-08-04. They are available under the [Pixabay Content License](https://pixabay.com/service/license-summary/).
4
+
5
+ - `whip-snap.mp3` — [“Whip Snap” by Universfield](https://pixabay.com/sound-effects/film-special-effects-whip-snap-242215/), with 100ms removed from its start to align its crack with the animation flash
6
+ - `whip-crack.mp3` — [“Whip Crack” by Universfield](https://pixabay.com/sound-effects/film-special-effects-whip-crack-123738/)
7
+ - `whip-06.mp3` — [“Whip 06” by Universfield](https://pixabay.com/sound-effects/film-special-effects-whip-06-487886/)
@@ -1,5 +1,6 @@
1
1
  import type { CanvasSource } from '../../../core/canvas/source.js';
2
2
  import type { CronDTO } from '../../../api/dto/crons.js';
3
+ import type { ReviewDTO } from '../../../api/dto/reviews.js';
3
4
  import type { BashJobStatus } from '../../../core/bash-jobs.js';
4
5
  import type { AttachPalette } from '../config.js';
5
6
  export type CanvasPanelItem = {
@@ -11,6 +12,11 @@ export type CanvasPanelItem = {
11
12
  key: 'human';
12
13
  kind: 'human';
13
14
  line: string;
15
+ } | {
16
+ key: string;
17
+ kind: 'review';
18
+ companionNodeId: string;
19
+ line: string;
14
20
  } | {
15
21
  key: 'bash';
16
22
  kind: 'bash';
@@ -33,4 +39,4 @@ export interface CanvasPanelLines {
33
39
  * `{}` to omit the ⚑ badges. `crons` are this node's armed schedules.
34
40
  * `palette`
35
41
  * drives the headers + dim cells; omit it and the panels render uncolored. */
36
- export declare function buildCanvasPanelLines(nodeId: string, asks: Record<string, number>, palette: AttachPalette | undefined, source: CanvasSource, crons?: readonly CronDTO[], jobs?: readonly BashJobStatus[]): Promise<CanvasPanelLines>;
42
+ export declare function buildCanvasPanelLines(nodeId: string, asks: Record<string, number>, palette: AttachPalette | undefined, source: CanvasSource, crons?: readonly CronDTO[], jobs?: readonly BashJobStatus[], reviews?: readonly ReviewDTO[]): Promise<CanvasPanelLines>;
@@ -5,8 +5,8 @@
5
5
  // into Containers around the editor:
6
6
  // reports → BELOW the editor: this node's manager (↑), its live
7
7
  // (↓, active|idle) reports, and trailing wait rows for human
8
- // tickets, live background bash jobs, and armed crons. Backs
9
- // inline scroll+open/swap.
8
+ // tickets, open document reviews, live background bash jobs, and
9
+ // armed crons. Backs inline scroll+open/swap.
10
10
  //
11
11
  // Pure: given a node id + the per-node ticket-count map it returns ANSI strings; the
12
12
  // caller owns the Containers and the refresh trigger. beginFrameTelem() clears the
@@ -16,9 +16,13 @@ import { beginFrameTelem, navLabel, nodeGlyph, truncate, tokensCell, cycleBadge,
16
16
  /** Columns the bash row spends on everything except the command itself:
17
17
  * ` ⚙ ` + `bash job · ` + elapsed + ` · ` + ` · ↵ details`. */
18
18
  const BASH_ROW_CHROME = 34;
19
+ /** Columns the review row spends on everything except the file name:
20
+ * ` ✎ ` + the longest state prefix + ` · ` + ` · ↵ companion`. */
21
+ const REVIEW_ROW_CHROME = 40;
19
22
  import { RemoteCanvasSource } from '../../../core/canvas/remote-canvas-source.js';
20
23
  import { cronWaitSummary } from './cron-wait.js';
21
24
  import { bashJobsWaitSummary } from './bash-jobs.js';
25
+ import { reviewWaitSummary } from './review-wait.js';
22
26
  /** Column widths for the aligned panel rows (visible columns). name is sized to
23
27
  * the widest label across BOTH panels (capped); kind is wide enough for the
24
28
  * longest persona kind (`orchestrator` = 12) so real kinds never truncate;
@@ -39,7 +43,7 @@ function padStartVisible(s, w) {
39
43
  * `{}` to omit the ⚑ badges. `crons` are this node's armed schedules.
40
44
  * `palette`
41
45
  * drives the headers + dim cells; omit it and the panels render uncolored. */
42
- export async function buildCanvasPanelLines(nodeId, asks, palette, source, crons = [], jobs = []) {
46
+ export async function buildCanvasPanelLines(nodeId, asks, palette, source, crons = [], jobs = [], reviews = []) {
43
47
  // A remote source provides only graph structure + node metadata; tokens/
44
48
  // activity/status-glyph adornments are local disk reads (telemetry.json,
45
49
  // fault state, pid liveness) and are suppressed to safe defaults below —
@@ -159,6 +163,19 @@ export async function buildCanvasPanelLines(nodeId, asks, palette, source, crons
159
163
  const selfAsks = asks[nodeId] ?? 0;
160
164
  if (selfAsks > 0)
161
165
  items.push({ key: 'human', kind: 'human', line: ` ${YELLOW}⚑ waiting on human · ${selfAsks} pending${RESET}` });
166
+ // Review rows sit with the human row: an open review is either waiting on the
167
+ // person or already submitted and finishing behind its companion, and either
168
+ // way this node resumes only when the review completes.
169
+ for (const review of reviews) {
170
+ const pen = palette ? palette.info('✎') : '✎';
171
+ const summary = reviewWaitSummary(review, Math.max(8, fillWidth() - REVIEW_ROW_CHROME));
172
+ items.push({
173
+ key: `review:${review.review_id}`,
174
+ kind: 'review',
175
+ companionNodeId: review.companion_node_id,
176
+ line: ` ${pen} ${muted(`${summary} · ↵ companion`)}${RESET}`,
177
+ });
178
+ }
162
179
  // Bash wait row: attention (human) -> active-now (running background work) ->
163
180
  // scheduled-future (armed crons). The leading glyph distinguishes the row type.
164
181
  // `x stop` is advertised on the row because it is the roster's only
@@ -0,0 +1,6 @@
1
+ import type { ReviewDTO } from '../../../api/dto/reviews.js';
2
+ /** Compact cockpit row for one open review: state first (matching the cron and
3
+ * bash rows), then the file it is about. A review whose submit is already in
4
+ * reads as off the human's hands — the daemon approves it the moment the
5
+ * companion stops editing, and the origin node resumes on that approval. */
6
+ export declare function reviewWaitSummary(review: ReviewDTO, fileCap?: number): string;
@@ -0,0 +1,22 @@
1
+ // chrome/review-wait.ts — the attach-side formatter for this node's open
2
+ // document reviews, mirroring chrome/cron-wait.ts and chrome/bash-jobs.ts: one
3
+ // compact cockpit row per awaited review. Pure — takes the already-listed
4
+ // reviews, never calls the daemon itself (that's the roster's job). Enter on
5
+ // the row opens the review's companion viewer, which is where the editing the
6
+ // row reports on is actually happening.
7
+ import { basename } from 'node:path';
8
+ /** The reviewed file as one cell, ellipsized to `cap` columns. */
9
+ function fileCell(file, cap) {
10
+ const name = basename(file);
11
+ return name.length <= cap ? name : `${name.slice(0, Math.max(1, cap - 1))}…`;
12
+ }
13
+ /** Compact cockpit row for one open review: state first (matching the cron and
14
+ * bash rows), then the file it is about. A review whose submit is already in
15
+ * reads as off the human's hands — the daemon approves it the moment the
16
+ * companion stops editing, and the origin node resumes on that approval. */
17
+ export function reviewWaitSummary(review, fileCap = 28) {
18
+ const file = fileCell(review.file, Math.max(8, fileCap));
19
+ return review.submit_requested_at === undefined
20
+ ? `review open · ${file} · waiting on you`
21
+ : `review submitted · ${file} · companion still editing`;
22
+ }
@@ -5,7 +5,8 @@
5
5
  // highlight, ↑ off the TOP row returns to the editor, ↓ CLAMPS at the bottom,
6
6
  // Enter opens the row (SWAPS this pane in place onto a node, or opens the wait
7
7
  // detail surface for a human/cron/bash row: a human row opens the inbox, a cron
8
- // row the Inspector's schedule section, a bash row the Inspector's jobs section),
8
+ // row the Inspector's schedule section, a bash row the Inspector's jobs section,
9
+ // a review row the review's companion viewer),
9
10
  // Esc / any typed key returns to the editor. Disabled entirely for a REMOTE
10
11
  // attach: a remote graph id has no local pane to swap into and no local
11
12
  // jobs/crons to inspect.
@@ -91,7 +92,13 @@ export function createRoster(s, hooks) {
91
92
  // Local-only, same gate as ownedCrons: a remote graph has no local jobs dir
92
93
  // to scan (and a local nodeId collision must never leak background jobs).
93
94
  const bashJobs = remote ? [] : activeBackgroundBashJobs(contextDir(nodeId));
94
- const built = await buildCanvasPanelLines(nodeId, hooks.asks(), pal, canvasSource, ownedCrons, bashJobs);
95
+ // Reviews this node is the origin of: the person still holds an open one, or
96
+ // the daemon is finishing a submitted one behind its companion. Local-only,
97
+ // like the crons above — a remote graph id has no local review surface.
98
+ const reviews = remote
99
+ ? []
100
+ : (await cliClient().listReviews({ state: 'open', origin_node_id: nodeId })).reviews;
101
+ const built = await buildCanvasPanelLines(nodeId, hooks.asks(), pal, canvasSource, ownedCrons, bashJobs, reviews);
95
102
  items = built.items;
96
103
  // After an in-place swap, land on the previously open node, which has taken
97
104
  // the swapped-to node's place in this self-excluding roster.
@@ -198,6 +205,20 @@ export function createRoster(s, hooks) {
198
205
  hooks.openInspector('schedule');
199
206
  return;
200
207
  }
208
+ if (item.kind === 'review') {
209
+ if (role !== 'controller') {
210
+ hooks.setNotice('Read-only observer attach — cannot open the review companion');
211
+ return;
212
+ }
213
+ exit();
214
+ // Beside this pane, never in place: the person is asking to watch the
215
+ // companion, not to give up the view they are asking from.
216
+ execFile('crtr', ['surface', 'node', 'focus', item.companionNodeId], (err) => {
217
+ if (err !== null)
218
+ hooks.setNotice(`Could not open the companion: ${err.message.split('\n')[0]}`);
219
+ });
220
+ return;
221
+ }
201
222
  if (item.kind === 'bash') {
202
223
  if (activeBackgroundBashJobs(contextDir(nodeId)).length === 0) {
203
224
  hooks.setNotice('Background job already finished');
@@ -41,7 +41,7 @@ export function createChromeWidgets(ctx, state) {
41
41
  const span = Math.max(1, (process.stdout.columns ?? 80) - 4);
42
42
  queued.addChild(new Text(pal.muted(`queued (${session.queued.length}):`), 1, 0));
43
43
  for (const msg of session.queued) {
44
- const oneLine = msg.replace(/\s+/g, ' ').trim();
44
+ const oneLine = msg.text.replace(/\s+/g, ' ').trim();
45
45
  queued.addChild(new Text(pal.faint(` ⋯ ${truncateToWidth(oneLine, span, '…')}`), 1, 0));
46
46
  }
47
47
  }
@@ -2,6 +2,7 @@
2
2
  // lifetime belongs to InputOverlayOwner; viewer permission belongs to the single
3
3
  // ViewerCapabilities value supplied by session/input-wiring.
4
4
  import { spawn } from 'node:child_process';
5
+ import { randomUUID } from 'node:crypto';
5
6
  import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
6
7
  import { tmpdir } from 'node:os';
7
8
  import { join } from 'node:path';
@@ -45,7 +46,7 @@ export class InputController {
45
46
  this.emitCommand({ type: 'cycle_ladder', direction });
46
47
  }
47
48
  sendInterrupting(text) {
48
- return this.emitDrive({ type: 'prompt', text, interrupt: true });
49
+ return this.emitDrive({ type: 'prompt', text, interrupt: true, message_id: randomUUID() });
49
50
  }
50
51
  attachDialog(request) {
51
52
  this.overlays.attachDialog(request);
@@ -181,7 +182,7 @@ export class InputController {
181
182
  if (delivery !== 'steer' && delivery !== 'prompt')
182
183
  return;
183
184
  const expanded = this.expandImagePlaceholders(classification.kind === 'slash' ? text : classification.text);
184
- if (!this.emitDrive(delivery === 'steer' ? { type: 'steer', text: expanded } : { type: 'prompt', text: expanded }))
185
+ if (!this.emitDrive(delivery === 'steer' ? { type: 'steer', text: expanded, message_id: randomUUID() } : { type: 'prompt', text: expanded, message_id: randomUUID() }))
185
186
  return;
186
187
  this.editor.addToHistory(classification.trimmed);
187
188
  this.editor.setText('');
@@ -193,7 +194,7 @@ export class InputController {
193
194
  const trimmed = text.trim();
194
195
  if (!trimmed)
195
196
  return;
196
- if (!this.emitDrive({ type: 'follow_up', text: this.expandImagePlaceholders(text) }))
197
+ if (!this.emitDrive({ type: 'follow_up', text: this.expandImagePlaceholders(text), message_id: randomUUID() }))
197
198
  return;
198
199
  this.editor.addToHistory(trimmed);
199
200
  this.editor.setText('');
@@ -39,7 +39,7 @@
39
39
  // as cached but fails to reconnect). Action names verified against the
40
40
  // installed adapter (pi-mcp-adapter index.ts: "auth-start" / "auth-complete" /
41
41
  // "ui-messages"; `connect: <name>`).
42
- import { createHash } from 'node:crypto';
42
+ import { createHash, randomUUID } from 'node:crypto';
43
43
  import { existsSync } from 'node:fs';
44
44
  import { homedir } from 'node:os';
45
45
  import { join, resolve } from 'node:path';
@@ -257,6 +257,7 @@ export function startMcpServerAction(row, send, notify) {
257
257
  send({
258
258
  type: 'prompt',
259
259
  text: `Run mcp({ action: "auth-start", server: ${nameJson} }) and show me the returned OAuth URL verbatim (with any instructions). If the server does not need OAuth, run mcp({ connect: ${nameJson} }) instead and report the result.`,
260
+ message_id: randomUUID(),
260
261
  });
261
262
  notify(`Asking the engine to start OAuth for ${row.name}…`);
262
263
  }
@@ -264,6 +265,7 @@ export function startMcpServerAction(row, send, notify) {
264
265
  send({
265
266
  type: 'prompt',
266
267
  text: `Run mcp({ connect: ${nameJson} }) to reconnect that MCP server and report the result. If it fails with an authentication or authorization error, run mcp({ action: "auth-start", server: ${nameJson} }) and show me the returned OAuth URL verbatim (with any instructions).`,
268
+ message_id: randomUUID(),
267
269
  });
268
270
  notify(`Asking the engine to reconnect ${row.name}…`);
269
271
  }
@@ -1018,7 +1018,7 @@ export class ChatView {
1018
1018
  if (generated !== null &&
1019
1019
  (message.role !== 'custom' || message.display === true)) {
1020
1020
  this.pushTranscriptText(generated.body);
1021
- const component = new ContextMessageComponent(generatedContextExpandedBody(generated), this.toolOutputExpanded, this.dimStyle, (s) => this.spinnerStyle(this.labelStyle(s)), generated.label, ` ${generated.summary} — ctrl+o to expand`, generated.label === 'crtr context' || generated.kind === 'revive' || generated.kind === 'inbox');
1021
+ const component = new ContextMessageComponent(generatedContextExpandedBody(generated), this.toolOutputExpanded, this.dimStyle, (s) => this.spinnerStyle(this.labelStyle(s)), generated.label, ` ${generated.summary} — ctrl+o to expand`, generated.label === 'crtr context' || generated.kind === 'revive' || generated.kind === 'inbox' || generated.kind === 'review-approval');
1022
1022
  // A successor broker's exact restart continuation is a visible seam, not
1023
1023
  // a group member: the preceding span keeps its own recap and the next tool
1024
1024
  // call begins a broker-aligned fresh group.
@@ -7,6 +7,7 @@ export declare class WhipAnimation implements Component {
7
7
  private readonly tui;
8
8
  private readonly pal;
9
9
  private frame;
10
+ private nextSound;
10
11
  private timer;
11
12
  constructor(tui: TUI, pal: Pick<AttachPalette, 'accent'>);
12
13
  play(): void;
@@ -1,6 +1,29 @@
1
1
  // session/whip.ts — viewer-local Sisyphus whip animation and steering cues.
2
+ import { spawn } from 'node:child_process';
3
+ import { fileURLToPath } from 'node:url';
2
4
  import { truncateToWidth } from '@earendil-works/pi-tui';
3
5
  const FRAME_MS = 40;
6
+ // The build copies this beside bundled viewer.js, whose import.meta.url is the
7
+ // runtime location after esbuild folds this module into that entrypoint.
8
+ const WHIP_SOUND_PATHS = [
9
+ fileURLToPath(new URL('./assets/whip-snap.mp3', import.meta.url)),
10
+ fileURLToPath(new URL('./assets/whip-crack.mp3', import.meta.url)),
11
+ fileURLToPath(new URL('./assets/whip-06.mp3', import.meta.url)),
12
+ ];
13
+ /** Play a bundled crack without delaying the animation or input handling. */
14
+ function playWhipSound(soundPath) {
15
+ const command = process.platform === 'darwin' ? 'afplay' : process.platform === 'linux' ? 'aplay' : undefined;
16
+ if (command === undefined)
17
+ return;
18
+ try {
19
+ const child = spawn(command, [soundPath], { stdio: 'ignore', detached: true });
20
+ child.on('error', () => { });
21
+ child.unref();
22
+ }
23
+ catch {
24
+ /* Sound playback must never interfere with the whip action. */
25
+ }
26
+ }
4
27
  const WHIP_FRAMES = [
5
28
  [
6
29
  "",
@@ -296,6 +319,7 @@ export class WhipAnimation {
296
319
  tui;
297
320
  pal;
298
321
  frame = -1;
322
+ nextSound = 0;
299
323
  timer;
300
324
  constructor(tui, pal) {
301
325
  this.tui = tui;
@@ -303,6 +327,8 @@ export class WhipAnimation {
303
327
  }
304
328
  play() {
305
329
  this.stop();
330
+ playWhipSound(WHIP_SOUND_PATHS[this.nextSound]);
331
+ this.nextSound = (this.nextSound + 1) % WHIP_SOUND_PATHS.length;
306
332
  this.frame = 0;
307
333
  this.tui.requestRender();
308
334
  this.timer = setInterval(() => {
@@ -22,6 +22,7 @@
22
22
  // SCOPED OUT (no engine method): `/login`, `/logout`, `/share`,
23
23
  // `/trust` — a brief "not supported in attach" notice, never a frame.
24
24
  import { execFile } from 'node:child_process';
25
+ import { randomUUID } from 'node:crypto';
25
26
  import { BUILTIN_SLASH_COMMANDS } from '../../../core/runtime/pi-vendored.js';
26
27
  import { surfaceTmuxStyleArgs } from '../../../core/runtime/surface-bg.js';
27
28
  import { viewerOperationForSlash } from '../input/capabilities.js';
@@ -448,6 +449,7 @@ function promoteNode(arg, ctx) {
448
449
  ctx.send({
449
450
  type: 'prompt',
450
451
  text: 'You have just been promoted to an orchestrator. Your new-role guidance is arriving — read it, author your roadmap, and start delegating.',
452
+ message_id: randomUUID(),
451
453
  });
452
454
  });
453
455
  child.stdin?.end();