@crouton-kit/crouter 0.3.70 → 0.3.79

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 (193) hide show
  1. package/README.md +1 -1
  2. package/dist/build-root.d.ts +12 -4
  3. package/dist/build-root.js +25 -6
  4. package/dist/builtin-memory/00-runtime-base.md +3 -10
  5. package/dist/builtin-memory/04-base-worker.md +18 -0
  6. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  7. package/dist/builtin-memory/crouter-development/plugins.md +82 -5
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1115 -1
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +234 -71
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -516
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  14. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  15. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  16. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  17. package/dist/builtin-views/canvas/tui.mjs +8 -9
  18. package/dist/builtin-views/chat/tui.mjs +12 -12
  19. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  20. package/dist/builtin-views/inbox/tui.mjs +27 -41
  21. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  22. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  23. package/dist/builtin-views/settings/tui.mjs +11 -11
  24. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  25. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -6
  26. package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
  28. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  29. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  30. package/dist/clients/attach/__tests__/mermaid-render.test.js +28 -0
  31. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  32. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  33. package/dist/clients/attach/attach-cmd.d.ts +9 -1
  34. package/dist/clients/attach/attach-cmd.js +841 -792
  35. package/dist/clients/attach/auth-pickers.d.ts +0 -12
  36. package/dist/clients/attach/auth-pickers.js +64 -15
  37. package/dist/clients/attach/canvas-panels.js +2 -3
  38. package/dist/clients/attach/chat-view.d.ts +7 -8
  39. package/dist/clients/attach/chat-view.js +119 -79
  40. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  41. package/dist/clients/attach/edit-diff-render.js +263 -0
  42. package/dist/clients/attach/graph-overlay.d.ts +12 -2
  43. package/dist/clients/attach/graph-overlay.js +83 -33
  44. package/dist/clients/attach/input-controller.d.ts +28 -2
  45. package/dist/clients/attach/input-controller.js +38 -5
  46. package/dist/clients/attach/mermaid-render.js +5 -2
  47. package/dist/clients/attach/pickers.d.ts +8 -7
  48. package/dist/clients/attach/pickers.js +11 -17
  49. package/dist/clients/attach/slash-commands.d.ts +9 -0
  50. package/dist/clients/attach/slash-commands.js +127 -7
  51. package/dist/clients/attach/titled-editor.d.ts +12 -1
  52. package/dist/clients/attach/titled-editor.js +103 -8
  53. package/dist/commands/canvas-browse.js +2 -2
  54. package/dist/commands/human/queue.js +3 -4
  55. package/dist/commands/memory/lint.js +39 -5
  56. package/dist/commands/memory/write.js +1 -0
  57. package/dist/commands/node.js +9 -2
  58. package/dist/commands/pkg/plugin-inspect.js +22 -1
  59. package/dist/commands/pkg/plugin-manage.js +31 -9
  60. package/dist/commands/surface-tmux-spread.js +1 -3
  61. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  62. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  63. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  64. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  65. package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
  66. package/dist/commands/sys/config.js +18 -21
  67. package/dist/commands/sys/doctor.js +42 -4
  68. package/dist/commands/sys/setup-core.d.ts +49 -1
  69. package/dist/commands/sys/setup-core.js +161 -4
  70. package/dist/commands/sys/setup.d.ts +88 -0
  71. package/dist/commands/sys/setup.js +943 -169
  72. package/dist/commands/view-pick.d.ts +4 -0
  73. package/dist/commands/view-pick.js +17 -7
  74. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  75. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  76. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  77. package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
  78. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
  79. package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
  80. package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
  81. package/dist/core/__tests__/command-plugins.test.js +444 -0
  82. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  83. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  84. package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
  85. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  86. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  87. package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
  88. package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
  89. package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
  90. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  91. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  92. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  93. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  94. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  95. package/dist/core/canvas/browse/app.d.ts +6 -0
  96. package/dist/core/canvas/browse/app.js +230 -41
  97. package/dist/core/canvas/browse/model.d.ts +10 -6
  98. package/dist/core/canvas/browse/model.js +20 -12
  99. package/dist/core/canvas/browse/pins.d.ts +4 -0
  100. package/dist/core/canvas/browse/pins.js +29 -0
  101. package/dist/core/canvas/browse/render.d.ts +13 -1
  102. package/dist/core/canvas/browse/render.js +72 -15
  103. package/dist/core/canvas/nav-model.js +5 -11
  104. package/dist/core/canvas/paths.d.ts +5 -0
  105. package/dist/core/canvas/paths.js +7 -0
  106. package/dist/core/command-plugins/adapter.d.ts +15 -0
  107. package/dist/core/command-plugins/adapter.js +145 -0
  108. package/dist/core/command-plugins/compose.d.ts +5 -0
  109. package/dist/core/command-plugins/compose.js +56 -0
  110. package/dist/core/command-plugins/discovery.d.ts +104 -0
  111. package/dist/core/command-plugins/discovery.js +565 -0
  112. package/dist/core/config.d.ts +48 -0
  113. package/dist/core/config.js +228 -110
  114. package/dist/core/fault-classifier.js +1 -1
  115. package/dist/core/fs-utils.d.ts +1 -0
  116. package/dist/core/fs-utils.js +15 -1
  117. package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
  118. package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
  119. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  120. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  121. package/dist/core/keybindings/catalog.d.ts +14 -0
  122. package/dist/core/keybindings/catalog.js +257 -0
  123. package/dist/core/keybindings/index.d.ts +5 -0
  124. package/dist/core/keybindings/index.js +4 -0
  125. package/dist/core/keybindings/match.d.ts +29 -0
  126. package/dist/core/keybindings/match.js +99 -0
  127. package/dist/core/keybindings/persistence.d.ts +14 -0
  128. package/dist/core/keybindings/persistence.js +31 -0
  129. package/dist/core/keybindings/resolve.d.ts +14 -0
  130. package/dist/core/keybindings/resolve.js +305 -0
  131. package/dist/core/keybindings/types.d.ts +37 -0
  132. package/dist/core/keybindings/types.js +1 -0
  133. package/dist/core/memory-resolver.d.ts +1 -1
  134. package/dist/core/memory-resolver.js +10 -3
  135. package/dist/core/predicate.d.ts +5 -3
  136. package/dist/core/predicate.js +5 -3
  137. package/dist/core/profiles/select.d.ts +6 -0
  138. package/dist/core/profiles/select.js +86 -52
  139. package/dist/core/provider-management.d.ts +12 -0
  140. package/dist/core/provider-management.js +24 -0
  141. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  142. package/dist/core/runtime/broker.js +164 -14
  143. package/dist/core/runtime/launch.d.ts +39 -6
  144. package/dist/core/runtime/launch.js +78 -20
  145. package/dist/core/runtime/naming.js +3 -3
  146. package/dist/core/runtime/pi-cli.d.ts +6 -0
  147. package/dist/core/runtime/pi-cli.js +16 -2
  148. package/dist/core/runtime/pi-vendored.d.ts +8 -0
  149. package/dist/core/runtime/pi-vendored.js +14 -0
  150. package/dist/core/runtime/placement.d.ts +2 -2
  151. package/dist/core/runtime/placement.js +4 -1
  152. package/dist/core/runtime/promote.js +4 -0
  153. package/dist/core/runtime/recap.d.ts +3 -3
  154. package/dist/core/runtime/recap.js +75 -47
  155. package/dist/core/runtime/recycle.js +6 -1
  156. package/dist/core/runtime/reset.js +5 -0
  157. package/dist/core/runtime/session-list-cache.d.ts +33 -0
  158. package/dist/core/runtime/session-list-cache.js +338 -0
  159. package/dist/core/runtime/spawn.js +52 -45
  160. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  161. package/dist/core/runtime/stream-watchdog.js +75 -0
  162. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  163. package/dist/core/runtime/tmux-chrome.js +2 -2
  164. package/dist/core/runtime/tmux.d.ts +18 -15
  165. package/dist/core/runtime/tmux.js +217 -110
  166. package/dist/core/scope.js +27 -4
  167. package/dist/core/subscription-state.d.ts +90 -0
  168. package/dist/core/subscription-state.js +762 -0
  169. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  170. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  171. package/dist/core/tui/host.d.ts +30 -1
  172. package/dist/core/tui/host.js +148 -34
  173. package/dist/core/view/contract.d.ts +17 -3
  174. package/dist/daemon/crtrd.js +253 -12
  175. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  176. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  177. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  178. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  179. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  180. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  181. package/dist/pi-extensions/canvas-recap.js +73 -46
  182. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  183. package/dist/pi-extensions/canvas-stophook.js +33 -4
  184. package/dist/types.d.ts +9 -26
  185. package/dist/types.js +1 -28
  186. package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
  187. package/dist/web-client/index.html +1 -1
  188. package/dist/web-client/sw.js +1 -1
  189. package/package.json +8 -4
  190. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  191. package/dist/pi-extensions/canvas-nav.js +0 -640
  192. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  193. package/dist/pi-extensions/widget-order-bus.js +0 -34
@@ -0,0 +1,444 @@
1
+ // Contract-seam tests for the external command-plugin seam (Phase 1).
2
+ // Run with: node --import tsx/esm --test src/core/__tests__/command-plugins.test.ts
3
+ //
4
+ // All tests run against an ISOLATED home (HOME → a fresh temp dir, no ambient
5
+ // user plugins) and pass an explicit no-`.crouter` startDir, so discovery is
6
+ // deterministic and cannot pick up the developer's real machine state.
7
+ //
8
+ // Covers the spec's Tests items:
9
+ // 1. Registry composition — a fixture top-level branch + leaf composes into
10
+ // real BranchDef/LeafDef whose root/branch/leaf help carries the plugin's
11
+ // representations, schema, effects, and stable order.
12
+ // 2. Core collision — a fixture claiming a SUBTREE_NAMES name is skipped +
13
+ // recorded as a command_collision issue.
14
+ // 3. Scope/shadowing — a project copy shadows a user copy of the same plugin
15
+ // name; a disabled project copy suppresses the user copy.
16
+ // 4. Protocol integration (table-driven, one fixture executable) — request
17
+ // envelope contents, rendered success, --json mirror, stderr separation,
18
+ // recoverable error propagation, and every malformed case.
19
+ // 5. Lifecycle without restart — install/disable/edit/remove all take effect
20
+ // on the next discovery call (per-invocation, no cache).
21
+ // 6/7. Typed issues for path/exec-bit/schema/unknown-key/parent violations
22
+ // without executing the fixture binary.
23
+ import { test, describe, before, after, beforeEach } from 'node:test';
24
+ import assert from 'node:assert/strict';
25
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, chmodSync } from 'node:fs';
26
+ import { readFileSync } from 'node:fs';
27
+ import { execFileSync } from 'node:child_process';
28
+ import { tmpdir } from 'node:os';
29
+ import { join, dirname } from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+ import { discoverCommandContributions, effectiveCommandPlugins, validatePluginCommands, } from '../command-plugins/discovery.js';
32
+ import { composeExternalSubtrees } from '../command-plugins/compose.js';
33
+ import { defineRoot } from '../command.js';
34
+ import { renderRoot, renderBranch, renderLeafArgv } from '../help.js';
35
+ import { renderResult } from '../render.js';
36
+ import { CrtrError } from '../errors.js';
37
+ import { resetScopeCache } from '../scope.js';
38
+ import { listInstalledPluginsInRoot } from '../resolver.js';
39
+ // The set of reserved core names discovery must fail closed against. We don't
40
+ // import the real SUBTREE_NAMES (build-root.ts pulls the whole tree); a small
41
+ // explicit set is enough for the collision contract and keeps the test cheap.
42
+ const RESERVED = new Set(['push', 'node', 'canvas', 'sys', 'memory']);
43
+ // ---------------------------------------------------------------------------
44
+ // Fixture executable: reads one JSON request on stdin, writes one envelope.
45
+ // Behavior is selected by req.input.mode so one binary drives the whole table.
46
+ // ---------------------------------------------------------------------------
47
+ const FIXTURE_EXEC = `#!/usr/bin/env node
48
+ const fs = require('fs');
49
+ let data = '';
50
+ process.stdin.on('data', (c) => (data += c));
51
+ process.stdin.on('end', () => {
52
+ const req = JSON.parse(data);
53
+ const log = process.env.FIXTURE_REQUEST_LOG;
54
+ if (log) fs.writeFileSync(log, JSON.stringify(req));
55
+ const mode = (req.input && req.input.mode) || 'ok';
56
+ const env = (o, code) => { process.stdout.write(JSON.stringify(o)); process.exit(code); };
57
+ if (mode === 'invalid-json') { process.stdout.write('this is not json'); process.exit(0); }
58
+ if (mode === 'extra-stdout') { process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: true, result: { app_id: 'x' } }) + '\\nEXTRA JUNK'); process.exit(0); }
59
+ if (mode === 'oversize') { process.stdout.write('x'.repeat(11 * 1024 * 1024)); process.exit(0); }
60
+ if (mode === 'signal') { process.kill(process.pid, 'SIGKILL'); return; }
61
+ if (mode === 'error') return env({ protocolVersion: 1, ok: false, error: { code: 'authentication_required', message: 'No home session is available.', field: 'session', next: 'Log in, then retry.' } }, 1);
62
+ if (mode === 'reserved-code') return env({ protocolVersion: 1, ok: false, error: { code: 'internal', message: 'boom' } }, 1);
63
+ if (mode === 'missing-field') return env({ protocolVersion: 1, ok: true, result: { status: 'running' } }, 0);
64
+ if (mode === 'exit-mismatch') return env({ protocolVersion: 1, ok: true, result: { app_id: req.input.appId, status: 'running' } }, 3);
65
+ if (mode === 'diag') { process.stderr.write('a diagnostic line on stderr\\n'); return env({ protocolVersion: 1, ok: true, result: { app_id: req.input.appId, status: 'running' } }, 0); }
66
+ return env({ protocolVersion: 1, ok: true, result: { app_id: req.input.appId, status: 'running' } }, 0);
67
+ });
68
+ `;
69
+ function commandsJson(topName = 'app') {
70
+ return {
71
+ schemaVersion: 1,
72
+ executable: 'bin/cmd.js',
73
+ mounts: [
74
+ {
75
+ parent: [],
76
+ node: {
77
+ kind: 'branch',
78
+ name: topName,
79
+ description: 'application lifecycle and inspection',
80
+ whenToUse: 'you need to create, deploy, inspect, or remove an application',
81
+ rootEntry: {
82
+ concept: 'applications hosted by a crouter home',
83
+ description: 'application lifecycle and inspection',
84
+ whenToUse: 'you need to create, deploy, inspect, or remove an application',
85
+ },
86
+ summary: 'application lifecycle and inspection',
87
+ model: 'Applications are product resources owned by the selected home.',
88
+ children: [
89
+ {
90
+ kind: 'leaf',
91
+ name: 'show',
92
+ description: 'show one application',
93
+ whenToUse: 'inspect a single application by id',
94
+ tier: 'important',
95
+ summary: 'show one application details',
96
+ params: [
97
+ { kind: 'positional', name: 'app-id', required: true, constraint: 'the application id' },
98
+ { kind: 'flag', name: 'mode', type: 'string', required: false, constraint: 'fixture behavior selector' },
99
+ ],
100
+ output: [
101
+ { name: 'app_id', type: 'string', required: true, constraint: 'the echoed application id' },
102
+ { name: 'status', type: 'string', required: false, constraint: 'current application status' },
103
+ ],
104
+ outputKind: 'object',
105
+ effects: ['None. Read-only.'],
106
+ },
107
+ ],
108
+ },
109
+ },
110
+ ],
111
+ };
112
+ }
113
+ /** Write a plugin into `scopeRoot/plugins/<name>/` with a command manifest +
114
+ * executable. `manifest` is the commands.json object (default fixture). */
115
+ function installPlugin(scopeRoot, name, opts = {}) {
116
+ const root = join(scopeRoot, 'plugins', name);
117
+ mkdirSync(join(root, '.crouter-plugin'), { recursive: true });
118
+ mkdirSync(join(root, 'bin'), { recursive: true });
119
+ const pointer = opts.commandsPointer ?? 'commands.json';
120
+ writeFileSync(join(root, '.crouter-plugin', 'plugin.json'), JSON.stringify({ name, version: '0.1.0', description: 'fixture', commands: pointer }));
121
+ writeFileSync(join(root, 'commands.json'), JSON.stringify(opts.manifest ?? commandsJson()));
122
+ const execPath = join(root, 'bin', 'cmd.js');
123
+ writeFileSync(execPath, opts.exec ?? FIXTURE_EXEC);
124
+ chmodSync(execPath, opts.execBit === false ? 0o644 : 0o755);
125
+ if (opts.enabled === false) {
126
+ writeFileSync(join(scopeRoot, 'config.json'), JSON.stringify({ plugins: { [name]: { enabled: false } } }));
127
+ }
128
+ return root;
129
+ }
130
+ // ---------------------------------------------------------------------------
131
+ // Isolated home + workspace
132
+ // ---------------------------------------------------------------------------
133
+ let home; // becomes HOME → user scope root = $HOME/.crouter
134
+ let userRoot;
135
+ let projectDir; // a workspace with its own .crouter (project scope)
136
+ let projectRoot;
137
+ let emptyStart; // a startDir with NO .crouter anywhere above it
138
+ let realHome;
139
+ let realProfile;
140
+ before(() => {
141
+ realHome = process.env['HOME'];
142
+ realProfile = process.env['CRTR_PROFILE_ID'];
143
+ delete process.env['CRTR_PROFILE_ID'];
144
+ });
145
+ after(() => {
146
+ if (realHome !== undefined)
147
+ process.env['HOME'] = realHome;
148
+ else
149
+ delete process.env['HOME'];
150
+ if (realProfile !== undefined)
151
+ process.env['CRTR_PROFILE_ID'] = realProfile;
152
+ });
153
+ // Every temp dir any test mints, so cleanup removes them ALL (an
154
+ // after(...[home, ...]) would only see the LAST test's set and leak the rest).
155
+ const tempDirs = [];
156
+ function mintDir(prefix) {
157
+ const d = mkdtempSync(join(tmpdir(), prefix));
158
+ tempDirs.push(d);
159
+ return d;
160
+ }
161
+ beforeEach(() => {
162
+ home = mintDir('crtr-cmdplugin-home-');
163
+ process.env['HOME'] = home;
164
+ userRoot = join(home, '.crouter');
165
+ mkdirSync(join(userRoot, 'plugins'), { recursive: true });
166
+ projectDir = mintDir('crtr-cmdplugin-proj-');
167
+ projectRoot = join(projectDir, '.crouter');
168
+ mkdirSync(join(projectRoot, 'plugins'), { recursive: true });
169
+ emptyStart = mintDir('crtr-cmdplugin-empty-');
170
+ resetScopeCache();
171
+ });
172
+ after(() => {
173
+ for (const d of tempDirs) {
174
+ try {
175
+ rmSync(d, { recursive: true, force: true });
176
+ }
177
+ catch { /* ignore */ }
178
+ }
179
+ resetScopeCache();
180
+ });
181
+ function leafOf(branch, name) {
182
+ const c = branch.children.find((x) => x.name === name);
183
+ assert.ok(c !== undefined && c.kind === 'leaf', `expected leaf ${name}`);
184
+ return c;
185
+ }
186
+ // ---------------------------------------------------------------------------
187
+ // 1. Registry composition
188
+ // ---------------------------------------------------------------------------
189
+ describe('registry composition', () => {
190
+ test('a fixture top-level branch composes with plugin-owned help, schema, effects, stable order', () => {
191
+ installPlugin(userRoot, 'crouter-infra');
192
+ resetScopeCache();
193
+ const snapshot = discoverCommandContributions(RESERVED, emptyStart);
194
+ // user scope is discovered from any startDir (it's not a project ancestor).
195
+ assert.equal(snapshot.issues.length, 0, JSON.stringify(snapshot.issues));
196
+ const external = composeExternalSubtrees(snapshot);
197
+ assert.equal(external.length, 1);
198
+ const app = external[0];
199
+ assert.equal(app.kind, 'branch');
200
+ assert.equal(app.name, 'app');
201
+ // Root help carries the plugin-owned rootEntry (mapped description→desc,
202
+ // whenToUse→useWhen), no core file knows what an app is.
203
+ const root = defineRoot({ tagline: 'test', globals: [], subtrees: external });
204
+ const rootOut = renderRoot(root.help);
205
+ assert.match(rootOut, /<command name="app">/);
206
+ assert.match(rootOut, /applications hosted by a crouter home/);
207
+ assert.match(rootOut, /use when you need to create, deploy, inspect, or remove an application/);
208
+ // important child promoted into root with its shortform desc.
209
+ assert.match(rootOut, /app show {2,}show one application/);
210
+ // Branch help lists the leaf via its own description/whenToUse.
211
+ const branchOut = renderBranch(app.help);
212
+ assert.match(branchOut, /<command name="app" description="application lifecycle and inspection">/);
213
+ assert.match(branchOut, /Applications are product resources owned by the selected home\./);
214
+ assert.match(branchOut, /name="show"[^>]*whenToUse="inspect a single application by id"/);
215
+ // Leaf help carries the full input/output schema + effects.
216
+ const leaf = leafOf(app, 'show');
217
+ const leafOut = renderLeafArgv(leaf.help);
218
+ assert.match(leafOut, /APP-ID/);
219
+ assert.match(leafOut, /--mode MODE/);
220
+ assert.match(leafOut, /app_id {2,}string\. the echoed application id/);
221
+ assert.match(leafOut, /None\. Read-only\./);
222
+ });
223
+ });
224
+ // ---------------------------------------------------------------------------
225
+ // 2. Core collision
226
+ // ---------------------------------------------------------------------------
227
+ describe('core collision', () => {
228
+ test('a fixture claiming a core top-level name is skipped + recorded', () => {
229
+ installPlugin(userRoot, 'rogue', { manifest: commandsJson('push') });
230
+ resetScopeCache();
231
+ const snapshot = discoverCommandContributions(RESERVED, emptyStart);
232
+ assert.equal(composeExternalSubtrees(snapshot).length, 0, 'the colliding branch must not enter the tree');
233
+ const collision = snapshot.issues.find((i) => i.code === 'command_collision');
234
+ assert.ok(collision, 'a command_collision issue must be recorded for doctor/show');
235
+ assert.equal(collision.received, 'push');
236
+ });
237
+ });
238
+ // ---------------------------------------------------------------------------
239
+ // 3. Scope / shadowing
240
+ // ---------------------------------------------------------------------------
241
+ describe('scope shadowing', () => {
242
+ test('a project copy shadows a user copy of the same plugin name', () => {
243
+ installPlugin(userRoot, 'crouter-infra');
244
+ installPlugin(projectRoot, 'crouter-infra');
245
+ resetScopeCache();
246
+ const effective = effectiveCommandPlugins(projectDir);
247
+ assert.equal(effective.length, 1);
248
+ assert.equal(effective[0].scope, 'project');
249
+ assert.equal(effective[0].root, join(projectRoot, 'plugins', 'crouter-infra'));
250
+ });
251
+ test('a disabled project copy suppresses the user copy (no resurfacing)', () => {
252
+ installPlugin(userRoot, 'crouter-infra');
253
+ installPlugin(projectRoot, 'crouter-infra', { enabled: false });
254
+ resetScopeCache();
255
+ // sanity: the project copy really is disabled at that root.
256
+ const projPlugins = listInstalledPluginsInRoot('project', projectRoot);
257
+ assert.equal(projPlugins.find((p) => p.name === 'crouter-infra').enabled, false);
258
+ const effective = effectiveCommandPlugins(projectDir);
259
+ assert.equal(effective.length, 0, 'the disabled higher-precedence copy must suppress the user copy');
260
+ });
261
+ });
262
+ // ---------------------------------------------------------------------------
263
+ // 4. Protocol integration (table-driven)
264
+ // ---------------------------------------------------------------------------
265
+ describe('protocol integration', () => {
266
+ function showLeaf() {
267
+ installPlugin(userRoot, 'crouter-infra');
268
+ resetScopeCache();
269
+ const snapshot = discoverCommandContributions(RESERVED, emptyStart);
270
+ const app = composeExternalSubtrees(snapshot)[0];
271
+ return leafOf(app, 'show');
272
+ }
273
+ test('request envelope carries command path, camelCase input, cwd, plugin context', async () => {
274
+ const leaf = showLeaf();
275
+ const log = join(emptyStart, 'req.json');
276
+ process.env['FIXTURE_REQUEST_LOG'] = log;
277
+ try {
278
+ await leaf.run({ appId: 'app_123', mode: 'ok' });
279
+ const req = JSON.parse(readFileSync(log, 'utf8'));
280
+ assert.equal(req.protocolVersion, 1);
281
+ assert.deepEqual(req.command, ['app', 'show']);
282
+ assert.equal(req.input.appId, 'app_123');
283
+ assert.equal(req.context.cwd, process.cwd());
284
+ assert.equal(req.context.plugin.name, 'crouter-infra');
285
+ assert.equal(req.context.plugin.scope, 'user');
286
+ assert.equal(req.context.plugin.version, '0.1.0');
287
+ assert.equal(req.context.plugin.root, join(userRoot, 'plugins', 'crouter-infra'));
288
+ }
289
+ finally {
290
+ delete process.env['FIXTURE_REQUEST_LOG'];
291
+ }
292
+ });
293
+ test('ok:true → result validated + rendered; --json mirrors the same object', async () => {
294
+ const leaf = showLeaf();
295
+ const result = await leaf.run({ appId: 'app_123', mode: 'ok' });
296
+ assert.deepEqual(result, { app_id: 'app_123', status: 'running' });
297
+ // Rendered through the existing generic renderer (no custom renderer).
298
+ const rendered = renderResult(result, leaf.help);
299
+ assert.match(rendered, /app_id/);
300
+ assert.match(rendered, /app_123/);
301
+ });
302
+ test('valid envelope + wrong exit code is honored (soft diagnostic, not a discard)', async () => {
303
+ const leaf = showLeaf();
304
+ const result = await leaf.run({ appId: 'app_123', mode: 'exit-mismatch' });
305
+ assert.deepEqual(result, { app_id: 'app_123', status: 'running' });
306
+ });
307
+ test('stderr diagnostics do not pollute the stdout envelope', async () => {
308
+ const leaf = showLeaf();
309
+ const result = await leaf.run({ appId: 'app_123', mode: 'diag' });
310
+ assert.deepEqual(result, { app_id: 'app_123', status: 'running' });
311
+ });
312
+ test('ok:false → structured CrtrError with the plugin code', async () => {
313
+ const leaf = showLeaf();
314
+ await assert.rejects(() => leaf.run({ appId: 'app_123', mode: 'error' }), (e) => e instanceof CrtrError && e.code === 'authentication_required' && (e.details?.['next'] === 'Log in, then retry.'));
315
+ });
316
+ const malformed = [
317
+ ['invalid JSON', 'invalid-json'],
318
+ ['extra stdout after the envelope', 'extra-stdout'],
319
+ ['oversize stdout (>10 MiB)', 'oversize'],
320
+ ['signal termination', 'signal'],
321
+ ['missing declared output field', 'missing-field'],
322
+ ['reserved error code', 'reserved-code'],
323
+ ];
324
+ for (const [desc, mode] of malformed) {
325
+ test(`malformed: ${desc} → plugin_protocol_error`, async () => {
326
+ const leaf = showLeaf();
327
+ await assert.rejects(() => leaf.run({ appId: 'app_123', mode }), (e) => e instanceof CrtrError && e.code === 'plugin_protocol_error');
328
+ });
329
+ }
330
+ });
331
+ // ---------------------------------------------------------------------------
332
+ // 4b. True end-to-end CLI invocation (argv → parseArgv → adapter → renderer)
333
+ // ---------------------------------------------------------------------------
334
+ describe('end-to-end CLI invocation', () => {
335
+ // dist/cli.js is up 4 from src/core/__tests__/. Requires a prior `npm run
336
+ // build` (the harness/CI builds before tests). Drives the WHOLE seam: the
337
+ // declared positional `app-id` must arrive as `appId` in the request, and
338
+ // the rendered + --json outputs come through the real dispatcher.
339
+ const cli = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'dist', 'cli.js');
340
+ const runCrtr = (args) => {
341
+ installPlugin(userRoot, 'crouter-infra');
342
+ resetScopeCache();
343
+ return execFileSync(process.execPath, [cli, ...args], {
344
+ cwd: emptyStart,
345
+ env: { ...process.env, HOME: home, CRTR_NO_BOOTSTRAP: '1', CRTR_FRONT_DOOR: '' },
346
+ encoding: 'utf8',
347
+ });
348
+ };
349
+ test('crtr app show <id> renders the plugin result end to end', { timeout: 40000 }, () => {
350
+ const out = runCrtr(['app', 'show', 'app_123']);
351
+ assert.match(out, /app_123/);
352
+ assert.match(out, /running/);
353
+ });
354
+ test('--json mirrors the same result object', { timeout: 40000 }, () => {
355
+ const out = runCrtr(['app', 'show', 'app_123', '--json']);
356
+ const obj = JSON.parse(out);
357
+ assert.equal(obj.app_id, 'app_123');
358
+ assert.equal(obj.status, 'running');
359
+ });
360
+ });
361
+ // ---------------------------------------------------------------------------
362
+ // 5. Lifecycle without restart (per-invocation discovery)
363
+ // ---------------------------------------------------------------------------
364
+ describe('lifecycle without restart', () => {
365
+ test('install → appears; disable → gone; edit → visible; remove → gone', () => {
366
+ const discover = () => {
367
+ resetScopeCache();
368
+ return composeExternalSubtrees(discoverCommandContributions(RESERVED, emptyStart));
369
+ };
370
+ // install
371
+ installPlugin(userRoot, 'crouter-infra');
372
+ assert.equal(discover().length, 1);
373
+ // disable (config edit only — no restart)
374
+ writeFileSync(join(userRoot, 'config.json'), JSON.stringify({ plugins: { 'crouter-infra': { enabled: false } } }));
375
+ assert.equal(discover().length, 0);
376
+ // re-enable + edit the declaration (rename the top-level command)
377
+ writeFileSync(join(userRoot, 'config.json'), JSON.stringify({ plugins: { 'crouter-infra': { enabled: true } } }));
378
+ writeFileSync(join(userRoot, 'plugins', 'crouter-infra', 'commands.json'), JSON.stringify(commandsJson('deploy')));
379
+ const afterEdit = discover();
380
+ assert.equal(afterEdit.length, 1);
381
+ assert.equal(afterEdit[0].name, 'deploy');
382
+ // remove
383
+ rmSync(join(userRoot, 'plugins', 'crouter-infra'), { recursive: true, force: true });
384
+ assert.equal(discover().length, 0);
385
+ });
386
+ });
387
+ // ---------------------------------------------------------------------------
388
+ // 6/7. Typed issues without executing the binary
389
+ // ---------------------------------------------------------------------------
390
+ describe('typed validation issues', () => {
391
+ function validate(manifest, extra = {}) {
392
+ installPlugin(userRoot, 'p', { manifest, ...extra });
393
+ resetScopeCache();
394
+ const plugins = listInstalledPluginsInRoot('user', userRoot);
395
+ const p = plugins.find((x) => x.name === 'p');
396
+ return validatePluginCommands(p, RESERVED);
397
+ }
398
+ test('unknown schemaVersion → command_schema_version, no contribution', () => {
399
+ const m = commandsJson();
400
+ m['schemaVersion'] = 2;
401
+ const v = validate(m);
402
+ assert.equal(v.contributions.length, 0);
403
+ assert.ok(v.issues.some((i) => i.code === 'command_schema_version'));
404
+ });
405
+ test('unknown top-level key → command_manifest_invalid', () => {
406
+ const m = commandsJson();
407
+ m['requires'] = { crtr: '>=1' };
408
+ const v = validate(m);
409
+ assert.ok(v.issues.some((i) => i.code === 'command_manifest_invalid'));
410
+ });
411
+ test('non-empty parent → command_parent_invalid', () => {
412
+ const m = commandsJson();
413
+ m.mounts[0].parent = ['sys'];
414
+ const v = validate(m);
415
+ assert.ok(v.issues.some((i) => i.code === 'command_parent_invalid'));
416
+ assert.equal(v.contributions.length, 0);
417
+ });
418
+ test('non-executable file → command_not_executable', () => {
419
+ const v = validate(commandsJson(), { execBit: false });
420
+ assert.ok(v.issues.some((i) => i.code === 'command_not_executable'));
421
+ });
422
+ test('executable escaping the plugin root → command_path_unsafe', () => {
423
+ const m = commandsJson();
424
+ m['executable'] = '../../../../bin/sh';
425
+ const v = validate(m);
426
+ assert.ok(v.issues.some((i) => i.code === 'command_path_unsafe'));
427
+ });
428
+ test('a top-level leaf (no rootEntry) → command_node_invalid', () => {
429
+ const m = {
430
+ schemaVersion: 1,
431
+ executable: 'bin/cmd.js',
432
+ mounts: [{ parent: [], node: { kind: 'leaf', name: 'x', description: 'd', whenToUse: 'w', summary: 's', params: [], output: [], outputKind: 'object', effects: ['None. Read-only.'] } }],
433
+ };
434
+ const v = validate(m);
435
+ assert.ok(v.issues.some((i) => i.code === 'command_node_invalid'));
436
+ assert.equal(v.contributions.length, 0);
437
+ });
438
+ test('a valid manifest yields a contribution and zero issues', () => {
439
+ const v = validate(commandsJson());
440
+ assert.equal(v.issues.length, 0);
441
+ assert.equal(v.contributions.length, 1);
442
+ assert.equal(v.contributions[0].node.name, 'app');
443
+ });
444
+ });
@@ -28,3 +28,18 @@ test('HTTP 401/403 objects still classify as auth (unchanged path)', () => {
28
28
  assert.equal(classify('pi→provider', { status: 401, statusText: 'Unauthorized' }).kind, 'auth');
29
29
  assert.equal(classify('pi→provider', { status: 403 }).kind, 'auth');
30
30
  });
31
+ // Stale ⚠ triangles — undici mid-stream disconnect text (e.g. a laptop sleep/
32
+ // wake episode) must classify as a transient connection blip (disposition
33
+ // 'auto'), not fall through to 'other' (disposition 'fatal', "needs you").
34
+ test('undici stream-drop texts classify as connection/auto, not other/fatal', () => {
35
+ for (const msg of ['terminated', 'other side closed', 'premature close']) {
36
+ const c = classify('pi→provider', msg);
37
+ assert.equal(c.kind, 'connection', `"${msg}" must classify as connection`);
38
+ assert.equal(c.disposition, 'auto', `"${msg}" must be auto-retried, not fatal`);
39
+ }
40
+ });
41
+ test('"terminated" matches as a whole word only (no false positive on substrings)', () => {
42
+ // \bterminated\b must not match e.g. "unterminated" as a stray substring hit —
43
+ // guard the word-boundary choice explicitly.
44
+ assert.notEqual(classify('pi→provider', 'preterminated').kind, 'connection');
45
+ });
@@ -17,6 +17,11 @@ export declare class SessionManager {
17
17
  static open(path: string): SessionManager;
18
18
  getSessionId(): string;
19
19
  getSessionFile(): string;
20
+ /** The directory containing the session file — the real SessionManager's
21
+ * getSessionDir(). The broker prewarms the session-list cache with it at boot
22
+ * (broker.ts runBroker), so the fixture must expose it or every broker boot
23
+ * crashes before it binds view.sock. */
24
+ getSessionDir(): string;
20
25
  private leafId;
21
26
  /** Cycling support (crtr-cycle marker) — mirrors the real SDK's getLeafId/
22
27
  * resetLeaf/appendCustomEntry just enough that `buildBrokerSession`'s cycling
@@ -124,6 +129,7 @@ declare class FakeSession {
124
129
  }>;
125
130
  getSessionId: () => string;
126
131
  getSessionFile: () => string;
132
+ getSessionDir: () => string;
127
133
  };
128
134
  get sessionId(): string;
129
135
  get sessionFile(): string | undefined;
@@ -30,7 +30,7 @@
30
30
  // <nodeDir>/fake-pi.cmd — harness writes ONE JSON command (turn|stop|dialog)
31
31
  // <nodeDir>/fake-pi.dialog.jsonl — one line per resolved unattended dialog (§5.4)
32
32
  import { writeFileSync, readFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync, } from 'node:fs';
33
- import { join } from 'node:path';
33
+ import { join, dirname } from 'node:path';
34
34
  import { randomUUID } from 'node:crypto';
35
35
  import { DefaultResourceLoader, getAgentDir } from '@earendil-works/pi-coding-agent';
36
36
  /** Test seam (M-1 regression): a FRESH-start kickoff prompt carrying this token
@@ -100,6 +100,13 @@ export class SessionManager {
100
100
  getSessionFile() {
101
101
  return this.sessionFile;
102
102
  }
103
+ /** The directory containing the session file — the real SessionManager's
104
+ * getSessionDir(). The broker prewarms the session-list cache with it at boot
105
+ * (broker.ts runBroker), so the fixture must expose it or every broker boot
106
+ * crashes before it binds view.sock. */
107
+ getSessionDir() {
108
+ return dirname(this.sessionFile);
109
+ }
103
110
  leafId = null;
104
111
  /** Cycling support (crtr-cycle marker) — mirrors the real SDK's getLeafId/
105
112
  * resetLeaf/appendCustomEntry just enough that `buildBrokerSession`'s cycling
@@ -285,6 +292,7 @@ class FakeSession {
285
292
  getUserMessagesForForking: () => this.tree.getUserMessagesForForking(),
286
293
  getSessionId: () => this.sm.getSessionId(),
287
294
  getSessionFile: () => this.sm.getSessionFile(),
295
+ getSessionDir: () => this.sm.getSessionDir(),
288
296
  };
289
297
  }
290
298
  get sessionId() {
@@ -142,10 +142,15 @@ test('G6 — zero-viewer dialog resolves immediately; an unanswered attended dia
142
142
  assert.equal(r1[base].resolved, false, 'G6a: zero-viewer dialog resolves to the default (deny)');
143
143
  assert.ok(r1[base].ms < 2000, `G6a: resolved immediately (noOp), not after the 5000ms timeout — got ${r1[base].ms}ms`);
144
144
  // (b) controller attached but silent → the broker resolves on the SHORT explicit
145
- // per-dialog timeout (800ms), never the 120s default.
145
+ // per-dialog timeout (800ms), never the 120s default. On timeout the broker also
146
+ // broadcasts a correlated `extension_ui_dismiss` keyed to the dialog's id, so the
147
+ // controller's overlay (which has no independent timer) is torn down by id.
146
148
  const c = await ctrl('g6-ctrl');
147
149
  h.fakeCmd(id, { cmd: 'dialog', timeout: 800 });
148
- await c.waitFrame((f) => f.type === 'extension_ui_request', 'G6b dialog forwarded to controller'); // received, deliberately NOT answered
150
+ const req = await c.waitFrame((f) => f.type === 'extension_ui_request', 'G6b dialog forwarded to controller'); // received, deliberately NOT answered
151
+ const reqId = req.id;
152
+ const dismiss = await c.waitFrame((f) => f.type === 'extension_ui_dismiss' && f.id === reqId, 'G6b broker emits a correlated extension_ui_dismiss on timeout');
153
+ assert.equal(dismiss.id, reqId, 'G6b: the dismiss frame carries the timed-out dialog id, not another');
149
154
  const r2 = await awaitDialogs(base + 2, 'G6b attended dialog resolved on timeout');
150
155
  assert.equal(r2[base + 1].resolved, false, 'G6b: an unanswered attended dialog resolves to the default (deny)');
151
156
  assert.ok(r2[base + 1].ms >= 600 && r2[base + 1].ms < 5000, `G6b: resolved on the ~800ms per-dialog timeout, not instantly and not the 120s default — got ${r2[base + 1].ms}ms`);
@@ -62,7 +62,7 @@
62
62
  import { test, before, after } from 'node:test';
63
63
  import assert from 'node:assert/strict';
64
64
  import { spawn } from 'node:child_process';
65
- import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
65
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
66
66
  import { tmpdir } from 'node:os';
67
67
  import { join } from 'node:path';
68
68
  import { createHarness } from './helpers/harness.js';
@@ -180,9 +180,20 @@ async function spawnBrokerWithSharedGroupDescendant(tag, descendantScript = 'sle
180
180
  if (leader.pid == null)
181
181
  throw new Error('failed to spawn fake broker leader');
182
182
  trackPid(leader.pid);
183
- await h.waitFor(() => existsSync(pidFile), { label: `${tag}: descendant pid file written` });
184
- const descendantPid = Number.parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
185
- assert.ok(Number.isInteger(descendantPid) && descendantPid > 0, `${tag}: parsed a real descendant pid`);
183
+ const descendantPid = await h.waitFor(() => {
184
+ try {
185
+ const record = readFileSync(pidFile, 'utf8');
186
+ if (!/^\d+\n$/.test(record))
187
+ return false;
188
+ const pid = Number(record.slice(0, -1));
189
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : false;
190
+ }
191
+ catch (error) {
192
+ if (error.code === 'ENOENT')
193
+ return false;
194
+ throw error;
195
+ }
196
+ }, { label: `${tag}: valid descendant pid written` });
186
197
  trackPid(descendantPid);
187
198
  await h.waitFor(() => isPidAlive(descendantPid), { label: `${tag}: descendant alive before teardown` });
188
199
  return { leaderPid: leader.pid, descendantPid };
@@ -10,10 +10,39 @@
10
10
  // buildRoot()-assembled tree (safe here — this is a test, not the attach
11
11
  // bundle) and asserts the set of leaves declaring `suppressOutput: true`
12
12
  // matches the registry exactly.
13
- import { test } from 'node:test';
13
+ import { test, before, after } from 'node:test';
14
14
  import assert from 'node:assert/strict';
15
+ import { mkdtempSync, rmSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
15
18
  import { buildRoot } from '../../build-root.js';
16
19
  import { SUPPRESS_OUTPUT_LEAVES } from '../preview-registry.js';
20
+ import { resetScopeCache } from '../scope.js';
21
+ // buildRoot() composes external command-plugin branches from the effective
22
+ // enabled plugins (user scope + project ancestors). Pin HOME + profile to an
23
+ // isolated no-plugin home so ambient machine state can't inject an external
24
+ // leaf and make this drift guard nondeterministic.
25
+ let home;
26
+ let realHome;
27
+ let realProfile;
28
+ before(() => {
29
+ realHome = process.env['HOME'];
30
+ realProfile = process.env['CRTR_PROFILE_ID'];
31
+ home = mkdtempSync(join(tmpdir(), 'crtr-preview-registry-'));
32
+ process.env['HOME'] = home;
33
+ delete process.env['CRTR_PROFILE_ID'];
34
+ resetScopeCache();
35
+ });
36
+ after(() => {
37
+ if (realHome !== undefined)
38
+ process.env['HOME'] = realHome;
39
+ else
40
+ delete process.env['HOME'];
41
+ if (realProfile !== undefined)
42
+ process.env['CRTR_PROFILE_ID'] = realProfile;
43
+ rmSync(home, { recursive: true, force: true });
44
+ resetScopeCache();
45
+ });
17
46
  test('preview-registry.ts matches every leaf declaring help.preview.suppressOutput', async () => {
18
47
  const root = await buildRoot();
19
48
  const declared = new Set();