@north-light/crouter 0.3.258 → 0.3.260

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 (48) hide show
  1. package/dist/api/dto/attach.d.ts +6 -0
  2. package/dist/api/dto/nodes.d.ts +15 -0
  3. package/dist/clients/attach/command.js +3 -1
  4. package/dist/clients/attach/input/capabilities.d.ts +3 -0
  5. package/dist/clients/attach/input/capabilities.js +7 -3
  6. package/dist/clients/attach/input/controller.d.ts +1 -1
  7. package/dist/clients/attach/input/controller.js +31 -17
  8. package/dist/clients/attach/session/connection.d.ts +12 -3
  9. package/dist/clients/attach/session/connection.js +40 -25
  10. package/dist/clients/attach/session/input-wiring.d.ts +1 -1
  11. package/dist/clients/attach/session/input-wiring.js +6 -2
  12. package/dist/clients/attach/session/reconnect.d.ts +7 -3
  13. package/dist/clients/attach/session/reconnect.js +13 -2
  14. package/dist/clients/attach/slash/dispatch.js +1 -1
  15. package/dist/clients/attach/viewer.d.ts +1 -1
  16. package/dist/clients/attach/viewer.js +742 -742
  17. package/dist/commands/pkg/plugin-inspect.js +2 -1
  18. package/dist/commands/surface/node/cycle.js +2 -2
  19. package/dist/commands/surface/node/focus.js +11 -11
  20. package/dist/commands/surface/node/placement.js +14 -5
  21. package/dist/commands/sys/doctor.d.ts +35 -0
  22. package/dist/commands/sys/doctor.js +22 -2
  23. package/dist/core/__tests__/integration/command-plugins.test.js +317 -2
  24. package/dist/core/broker-client/client.d.ts +4 -5
  25. package/dist/core/broker-client/client.js +9 -5
  26. package/dist/core/canvas/browse/model.d.ts +3 -3
  27. package/dist/core/canvas/browse/model.js +3 -3
  28. package/dist/core/canvas/browse/render.js +3 -3
  29. package/dist/core/command-manifests/manifest.d.ts +5 -3
  30. package/dist/core/command-manifests/manifest.js +20 -31
  31. package/dist/core/command-manifests/registry.d.ts +3 -0
  32. package/dist/core/command-manifests/schema.d.ts +12 -2
  33. package/dist/core/command-manifests/schema.js +24 -11
  34. package/dist/core/command-plugins/compose.js +1 -1
  35. package/dist/core/command-plugins/discovery.d.ts +1 -1
  36. package/dist/core/command-plugins/discovery.js +10 -4
  37. package/dist/core/command-plugins/extensions.d.ts +20 -0
  38. package/dist/core/command-plugins/extensions.js +166 -0
  39. package/dist/core/command-plugins/transport/exec-invoke.d.ts +15 -2
  40. package/dist/core/command-plugins/transport/exec-invoke.js +11 -7
  41. package/dist/core/help.d.ts +5 -0
  42. package/dist/core/help.js +3 -0
  43. package/dist/core/runtime/revive.d.ts +19 -6
  44. package/dist/core/runtime/revive.js +34 -9
  45. package/dist/daemon/api/handlers/attach.js +24 -20
  46. package/dist/daemon/api/handlers/nodes.js +7 -0
  47. package/package.json +1 -1
  48. package/runtime.lock.json +2 -2
@@ -106,7 +106,7 @@ export const pluginShow = defineLeaf({
106
106
  { name: 'enabled', type: 'boolean', required: true, constraint: 'Whether the plugin is active.' },
107
107
  { name: 'manifest', type: 'object', required: true, constraint: 'Full plugin.json contents.' },
108
108
  { name: 'docs', type: 'object[]', required: true, constraint: 'Each: {name, path}. Memory docs provided by the plugin (its `<pluginName>/` memory subtree).' },
109
- { name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest (manifest.commands). {manifestPath: string, mounts: string[] (accepted top-level command names), issues: object[] ({code, path?, message, received, expected, next})}. Validated statically; command execution never occurs.' },
109
+ { name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest (manifest.commands). {manifestPath: string, mounts: string[] (accepted top-level command names), extensibleBranches: string[] (mount names accepting repository fragments), issues: object[] ({code, path?, message, received, expected, next})}. Validated statically; command execution never occurs.' },
110
110
  { name: 'hooks', type: 'object', required: false, constraint: 'Present for every hook declaration, including hook-only plugins. {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], issues: object[] ({code, path?, message, received, expected, next}), trust: string}. Static only: no hook executable runs.' },
111
111
  ],
112
112
  outputKind: 'object',
@@ -146,6 +146,7 @@ export const pluginShow = defineLeaf({
146
146
  commands = {
147
147
  manifestPath: v.manifestPath,
148
148
  mounts: v.contributions.map((c) => c.node.name),
149
+ extensibleBranches: v.contributions.filter((c) => c.node.extensible === true).map((c) => c.node.name),
149
150
  issues: v.issues,
150
151
  };
151
152
  }
@@ -55,7 +55,7 @@ export const surfaceNodeCycleLeaf = defineLeaf({
55
55
  { name: 'from', type: 'string', required: false, constraint: 'The node you cycled away from.' },
56
56
  ],
57
57
  outputKind: 'object',
58
- effects: ['Brings the neighbor\'s viewer forefront beside the caller (like `surface node focus`); the viewer you were watching drops to the background.', 'Relaunches the neighbor\'s broker first if it was dormant.'],
58
+ effects: ['Brings the neighbor\'s viewer forefront beside the caller (like `surface node focus`); the viewer you were watching drops to the background.', 'Opens a parked resident from its saved snapshot without waking it; submitted input wakes it through the normal message path.'],
59
59
  },
60
60
  run: async (input) => {
61
61
  const pane = input['pane'] ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane ?? undefined;
@@ -73,7 +73,7 @@ export const surfaceNodeCycleLeaf = defineLeaf({
73
73
  if (target === null)
74
74
  return { focused: false, from: fromId };
75
75
  // Placement brings the neighbor's viewer forefront beside the caller; the
76
- // server (ensureAttach, inside focusViewer) relaunches its broker if dormant.
76
+ // server keeps an exact parked resident offline until submitted input.
77
77
  const res = await focusViewer(targetId, { cwd: target.cwd, name: target.name, pane });
78
78
  return { focused: res.focused, node_id: targetId, name: target.name, from: fromId };
79
79
  },
@@ -6,11 +6,11 @@ import { cliClient, rethrowAsCliError } from '../../api-client.js';
6
6
  import { currentTmux, displayPopup, focusViewer, shellQuote } from './placement.js';
7
7
  export const surfaceNodeFocusLeaf = defineLeaf({
8
8
  name: 'focus',
9
- description: 'bring a node into view without accidentally reviving finished work',
10
- whenToUse: 'you want to bring a specific node into view — a live node, or a resident agent that merely stopped, opens (resuming its saved conversation in place) beside your current pane; FINISHED work — a terminal worker, or any node that pushed a final report — opens its saved transcript instead of being put back to work. Use `node lifecycle revive` to deliberately relaunch finished work anyway. Use `surface node cycle` to walk neighbors, and `node message send` to steer without leaving where you are',
9
+ description: 'bring a node into view without waking parked or finished work',
10
+ whenToUse: 'you want to bring a specific node into view — a live node opens beside your pane, while a parked resident opens its saved conversation without waking until input is submitted. FINISHED work — a terminal worker, or any node that pushed a final report — opens its saved transcript instead of being put back to work. Use `node lifecycle revive` to deliberately relaunch finished work anyway. Use `surface node cycle` to walk neighbors, and `node message send` to steer without leaving where you are',
11
11
  help: {
12
12
  name: 'surface node focus',
13
- summary: 'bring a node into view — attach to a live broker, resume a resident agent that stopped, or open a saved transcript for finished work without reviving it',
13
+ summary: 'bring a node into view — attach live, open a parked resident without waking it, or inspect finished work as a saved transcript',
14
14
  params: [
15
15
  { kind: 'positional', name: 'node', required: true, constraint: 'Node id to focus.' },
16
16
  { kind: 'flag', name: 'new-pane', type: 'bool', required: false, constraint: 'Always open a NEW viewer SIDE-BY-SIDE with your current pane instead of moving/reusing the node\'s existing viewer. Two agents on screen at once (F4).' },
@@ -20,11 +20,11 @@ export const surfaceNodeFocusLeaf = defineLeaf({
20
20
  output: [
21
21
  { name: 'focused', type: 'boolean', required: true, constraint: 'True when the node\'s viewer was brought into view.' },
22
22
  { name: 'session', type: 'string', required: false, constraint: 'The tmux session the viewer lives in.' },
23
- { name: 'revived', type: 'boolean', required: true, constraint: 'True when focusing had to relaunch the node\'s broker (a live node whose engine was gone, or a stopped resident agent resumed in place); false when its viewer was already attached, and false for a saved-transcript inspection.' },
23
+ { name: 'revived', type: 'boolean', required: true, constraint: 'True when focusing had to recover a non-parked node\'s broker; false for an already attached viewer, an offline parked resident, or saved-transcript inspection.' },
24
24
  { name: 'in_place', type: 'boolean', required: true, constraint: 'True when an existing viewer was navigated to (no new pane); false when a fresh viewer pane was opened or moved beside you.' },
25
25
  ],
26
26
  outputKind: 'object',
27
- effects: ['For active/idle nodes: opens/moves the one surface-attach viewer beside the caller, or navigates to its existing viewer.', 'For a resident node that stopped without finalizing: resumes its saved conversation in place (status returns to active) and opens its viewer — the same path as a live node.', 'For finished work (a terminal node, or any node with a final report): opens a read-only saved transcript popup. It does not launch a broker or change node status.', 'With --new-pane/--in-place on a live node: controls whether the viewer splits beside or replaces the caller pane.'],
27
+ effects: ['For active/idle nodes: opens/moves the one surface-attach viewer beside the caller, or navigates to its existing viewer.', 'For an unfinalized parked resident: opens the normal conversation viewer from its saved snapshot without launching a broker or changing lifecycle; submitting input sends the durable human message that wakes it.', 'For finished work (a terminal node, or any node with a final report): opens a read-only saved transcript popup. It does not launch a broker or change node status.', 'With --new-pane/--in-place on a conversation viewer: controls whether the viewer splits beside or replaces the caller pane.'],
28
28
  },
29
29
  run: async (input) => {
30
30
  const id = input['node'];
@@ -59,16 +59,16 @@ export const surfaceNodeFocusLeaf = defineLeaf({
59
59
  // any node that pushed a final report) is archive: show its persisted
60
60
  // transcript, because reviving it would set completed work back in motion, and
61
61
  // `node lifecycle revive` remains the explicit way to do that on purpose.
62
- // A RESIDENT node with no final report never finished — its `done` is just a
63
- // clean broker exit — so it falls through to the live path below, where
64
- // ensureAttach's true resume replays the conversation exactly as it stood.
62
+ // A RESIDENT node with no final report never finished — its parked `done`
63
+ // falls through to the conversation path, where focus can seat its saved
64
+ // snapshot without launching an engine.
65
65
  if (!isLiveStatus(node.status) && isFinishedWork({ lifecycle: node.lifecycle, finalized: node.final_report != null })) {
66
66
  const focused = displayPopup(pane, node.cwd, `crtr node inspect transcript ${shellQuote(id)}`);
67
67
  return { focused, session: null, revived: false, in_place: false };
68
68
  }
69
- // Local viewer tail: open/move the broker-backed viewer beside the caller.
70
- // The server (ensureAttach) owns the revive + view.sock wait; pure tmux ops
71
- // own the pane placement.
69
+ // Local viewer tail: open/move the normal conversation viewer beside the
70
+ // caller. The server owns the serialized live-vs-parked decision; pure tmux
71
+ // operations own pane placement.
72
72
  const res = await focusViewer(id, {
73
73
  cwd: node.cwd,
74
74
  name: node.name,
@@ -7,6 +7,11 @@ export function viewerEnv() {
7
7
  const crtrHome = envHomeOverride();
8
8
  return crtrHome !== undefined ? { CRTR_HOME: crtrHome } : {};
9
9
  }
10
+ /** Internal startup policy for viewers opened by focus/navigation. Direct
11
+ * `surface attach` has no marker and keeps its activating default. */
12
+ function focusViewerEnv() {
13
+ return { ...viewerEnv(), CRTR_ATTACH_WAKE_PARKED: '0' };
14
+ }
10
15
  /** The live viewer pane for `nodeId` (the pane a surface-attach viewer tagged
11
16
  * `@crtr_node`), or undefined. Pure tag scan over live panes. */
12
17
  export function viewerPaneOf(nodeId) {
@@ -22,7 +27,7 @@ export function viewerPaneOf(nodeId) {
22
27
  /** Open a fresh surface-attach viewer SPLIT beside `callerPane` and rebalance
23
28
  * its window to even side-by-side widths. Pure tmux. */
24
29
  function openBesideCaller(callerPane, nodeId, cwd, callerSession, revived) {
25
- const opened = splitWindow(callerPane, { cwd, env: viewerEnv(), command: `crtr surface attach to ${nodeId}` });
30
+ const opened = splitWindow(callerPane, { cwd, env: focusViewerEnv(), command: `crtr surface attach to ${nodeId}` });
26
31
  if (opened === null)
27
32
  return { focused: false, session: null, inPlace: false, revived };
28
33
  const loc = paneLocation(opened);
@@ -40,8 +45,12 @@ export async function focusViewer(nodeId, opts) {
40
45
  if (callerPane === undefined || callerPane === '') {
41
46
  return { focused: false, session: null, inPlace: false, revived: false };
42
47
  }
43
- // (a) Server ensures the broker engine is alive + its view.sock accepts.
44
- const attach = await cliClient().ensureAttach(nodeId).catch(rethrowAsCliError);
48
+ // (a) Server ensures a live broker is attachable, but leaves an exact parked
49
+ // resident untouched. This policy is unconditional so an active→parked race
50
+ // is decided at the daemon's synchronous fleet/launch boundary.
51
+ const attach = await cliClient()
52
+ .ensureAttach(nodeId, { resume: true, revive: true, wakeParked: false })
53
+ .catch(rethrowAsCliError);
45
54
  const revived = attach.revived;
46
55
  const callerSession = paneLocation(callerPane)?.session ?? null;
47
56
  // (b0) --in-place → the caller pane BECOMES this node's viewer, replacing what
@@ -57,8 +66,8 @@ export async function focusViewer(nodeId, opts) {
57
66
  catch { /* best-effort */ }
58
67
  const window = windowOfPane(callerPane);
59
68
  const env = priorNodeId !== undefined && priorNodeId !== ''
60
- ? { ...viewerEnv(), CRTR_ATTACH_ROSTER: priorNodeId }
61
- : viewerEnv();
69
+ ? { ...focusViewerEnv(), CRTR_ATTACH_ROSTER: priorNodeId }
70
+ : focusViewerEnv();
62
71
  const ok = respawnPaneSync({ pane: callerPane, cwd: opts.cwd, env, command: `crtr surface attach to ${nodeId}` });
63
72
  if (ok && window !== null)
64
73
  renameWindow(window, opts.name);
@@ -1 +1,36 @@
1
+ import { type Scope } from '../../types.js';
2
+ type CheckStatus = 'pass' | 'fail';
3
+ type CheckScope = Scope | 'canvas';
4
+ /**
5
+ * Structured fix action for a failing check. Surfaced on each
6
+ * non-pass result so an agent reading doctor output can apply it directly
7
+ * when `--fix` is not used (or when --fix did not auto-apply). Every
8
+ * remediation includes the exact action and payload — no inference required.
9
+ */
10
+ interface Remediation {
11
+ kind: 'remove_config_key' | 'rm_path' | 'human_action';
12
+ description: string;
13
+ scope?: Scope;
14
+ configKey?: string;
15
+ path?: string;
16
+ command?: string;
17
+ }
18
+ interface CheckResult {
19
+ scope: CheckScope;
20
+ name: string;
21
+ status: CheckStatus;
22
+ message: string;
23
+ fixed?: boolean;
24
+ remediation?: Remediation;
25
+ }
26
+ /**
27
+ * Validate the command manifest + executable path of every effective command
28
+ * plugin (the deduped, enabled set that actually composes into the tree).
29
+ * Purely static — the plugin binary is NEVER executed, and no remediation ever
30
+ * chmods or rewrites third-party plugin content (every fix is a human_action:
31
+ * disable/update/remove the plugin). Only plugins whose scope is in `scopes`
32
+ * are reported, so `--scope` narrows these checks too.
33
+ */
34
+ export declare function runCommandPluginChecks(scopes: Scope[]): Promise<CheckResult[]>;
1
35
  export declare const sysDoctorLeaf: import("../../core/command.js").LeafDef;
36
+ export {};
@@ -14,6 +14,9 @@ import { readFault } from '../../core/runtime/fault.js';
14
14
  import { detectNerdFont, nerdFontInstallCommand, NERD_FONT_SELECT_HINT } from '../../core/runtime/nerd-font.js';
15
15
  import { detectPackageManager } from './setup-core.js';
16
16
  import { validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
17
+ import { adaptPluginContributions } from '../../core/command-plugins/compose.js';
18
+ import { resolveCommandRegistry } from '../../core/command-manifests/registry.js';
19
+ import { applyCommandExtensions, orphanCommandExtensionValidations } from '../../core/command-plugins/extensions.js';
17
20
  import { discoverHookRegistry } from '../../core/command-hooks/discovery.js';
18
21
  import { resolveBinContributions } from '../../core/runtime/bin-contributions.js';
19
22
  import { inspectHumanActions } from '../../core/human-actions.js';
@@ -116,7 +119,7 @@ function faultRemediation(fault, nodeId) {
116
119
  * disable/update/remove the plugin). Only plugins whose scope is in `scopes`
117
120
  * are reported, so `--scope` narrows these checks too.
118
121
  */
119
- async function runCommandPluginChecks(scopes) {
122
+ export async function runCommandPluginChecks(scopes) {
120
123
  const results = [];
121
124
  const inScope = new Set(scopes);
122
125
  const reserved = new Set(SUBTREE_NAMES);
@@ -124,7 +127,8 @@ async function runCommandPluginChecks(scopes) {
124
127
  // collisions surface here. A collision no longer costs anyone their command:
125
128
  // each claimant mounts under an origin-qualified name, so it is reported as a
126
129
  // healthy mount rather than a failure demanding the user remove a plugin.
127
- for (const v of validateEffectiveCommandPlugins(reserved, undefined, undefined, await coreCommandPaths())) {
130
+ const validations = validateEffectiveCommandPlugins(reserved, undefined, undefined, await coreCommandPaths());
131
+ for (const v of validations) {
128
132
  const plugin = v.plugin;
129
133
  if (!inScope.has(plugin.scope))
130
134
  continue;
@@ -143,6 +147,22 @@ async function runCommandPluginChecks(scopes) {
143
147
  results.push(failCheck(plugin.scope, `plugin:${plugin.name}:commands[${i}]`, `${issue.code}${detail}: ${issue.message} (received ${issue.received}, expected ${issue.expected})`, remediation));
144
148
  });
145
149
  }
150
+ if (inScope.has('project')) {
151
+ const registry = resolveCommandRegistry(adaptPluginContributions(validations.flatMap((validation) => validation.contributions)).contributions, reserved);
152
+ const extensions = applyCommandExtensions(registry.contributions);
153
+ const allExtensionChecks = [...extensions.validations, ...orphanCommandExtensionValidations(registry.contributions)];
154
+ for (const extension of allExtensionChecks) {
155
+ if (extension.issue === undefined) {
156
+ results.push(pass('project', `extension:${extension.branch}`, `repository fragment valid: ${extension.fragmentPath}`));
157
+ continue;
158
+ }
159
+ const issue = extension.issue;
160
+ results.push(failCheck('project', `extension:${extension.branch}`, `${issue.code} at ${issue.path ?? extension.fragmentPath}: ${issue.message} (received ${issue.received}, expected ${issue.expected})`, {
161
+ kind: 'human_action',
162
+ description: issue.next,
163
+ }));
164
+ }
165
+ }
146
166
  return results;
147
167
  }
148
168
  /** Statically inspect the complete effective hook registry. This shares the
@@ -29,7 +29,7 @@ import { execFileSync } from 'node:child_process';
29
29
  import { tmpdir } from 'node:os';
30
30
  import { join, dirname } from 'node:path';
31
31
  import { fileURLToPath } from 'node:url';
32
- import { discoverCommandContributions, effectiveCommandPlugins, validatePluginCommands, } from '../../command-plugins/discovery.js';
32
+ import { discoverCommandContributions, effectiveCommandPlugins, validatePluginCommands, buildExternalCommandSnapshot, } from '../../command-plugins/discovery.js';
33
33
  import { composeExternalSubtrees, adaptPluginContributions } from '../../command-plugins/compose.js';
34
34
  import { collectHelpAddenda } from '../../command-plugins/help-addenda.js';
35
35
  import { resolveCommandRegistry } from '../../command-manifests/registry.js';
@@ -38,9 +38,11 @@ import { renderRoot, renderBranch, renderLeafArgv } from '../../help.js';
38
38
  import { renderResult } from '../../render.js';
39
39
  import { CrtrError } from '../../errors.js';
40
40
  import { resetScopeCache } from '../../scope.js';
41
+ import { createProfile } from '../../profiles/manifest.js';
41
42
  import { listInstalledPluginsInRoot } from '../../resolver.js';
42
43
  import { pluginShow } from '../../../commands/pkg/plugin-inspect.js';
43
- import { pluginDisable, pluginEnable, pluginInstall } from '../../../commands/pkg/plugin-manage.js';
44
+ import { pluginDisable, pluginEnable, pluginInstall, pluginUpdate } from '../../../commands/pkg/plugin-manage.js';
45
+ import { runCommandPluginChecks } from '../../../commands/sys/doctor.js';
44
46
  // The set of reserved core names discovery must fail closed against. We don't
45
47
  // import the real SUBTREE_NAMES (build-root.ts pulls the whole tree); a small
46
48
  // explicit set is enough for the collision contract and keeps the test cheap.
@@ -112,6 +114,45 @@ function commandsJson(topName = 'app') {
112
114
  ],
113
115
  };
114
116
  }
117
+ function extensibleDemoCommandsJson(extensible = true) {
118
+ return {
119
+ schemaVersion: 1,
120
+ mounts: [{
121
+ parent: [],
122
+ node: {
123
+ kind: 'branch',
124
+ name: 'demo',
125
+ description: 'fixture extensible branch',
126
+ whenToUse: 'testing repository command contributions',
127
+ ...(extensible ? { extensible: true } : {}),
128
+ rootEntry: { concept: 'fixture command contributions', description: 'fixture extensible branch', whenToUse: 'testing repository command contributions' },
129
+ summary: 'fixture extensible branch',
130
+ children: [{ kind: 'branch', name: 'built-in', description: 'plugin-owned branch', whenToUse: 'using the plugin-owned command', summary: 'plugin-owned branch', children: [] }],
131
+ },
132
+ }],
133
+ };
134
+ }
135
+ function extensionFragment(rootName = 'repo-branch') {
136
+ return {
137
+ schemaVersion: 1,
138
+ transport: { kind: 'exec', executable: 'scripts/extension.js' },
139
+ mounts: [
140
+ { parent: [], node: { kind: 'branch', name: rootName, description: 'repository branch', whenToUse: 'using repository-defined commands', summary: 'repository branch', children: [] } },
141
+ { parent: [rootName], node: { kind: 'leaf', name: 'run', description: 'run repository operation', whenToUse: 'testing extension execution', summary: 'run repository operation', params: [{ kind: 'flag', name: 'thing', type: 'string', required: true, constraint: 'fixture input' }], output: [{ name: 'value', type: 'string', required: true, constraint: 'echoed fixture input' }], outputKind: 'object', effects: ['None. Read-only.'] } },
142
+ ],
143
+ };
144
+ }
145
+ const EXTENSION_EXEC = `#!/usr/bin/env node
146
+ const fs = require('fs');
147
+ let data = '';
148
+ process.stdin.on('data', (chunk) => data += chunk);
149
+ process.stdin.on('end', () => {
150
+ const request = JSON.parse(data);
151
+ if (process.env.EXTENSION_REQUEST_LOG) fs.writeFileSync(process.env.EXTENSION_REQUEST_LOG, JSON.stringify(request));
152
+ if (process.env.EXTENSION_SENTINEL) fs.writeFileSync(process.env.EXTENSION_SENTINEL, 'executed');
153
+ process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: true, result: { value: request.input.thing } }));
154
+ });
155
+ `;
115
156
  function passthroughCommandsJson() {
116
157
  return {
117
158
  schemaVersion: 1,
@@ -210,6 +251,26 @@ after(() => {
210
251
  }
211
252
  resetScopeCache();
212
253
  });
254
+ function installExtensionFixture(manifest = extensionFragment()) {
255
+ installPlugin(userRoot, 'extension-fixture', { manifest: extensibleDemoCommandsJson() });
256
+ const commandsDir = join(projectRoot, 'commands');
257
+ const scriptsDir = join(projectDir, 'scripts');
258
+ mkdirSync(commandsDir, { recursive: true });
259
+ mkdirSync(scriptsDir, { recursive: true });
260
+ const fragmentPath = join(commandsDir, 'demo.json');
261
+ const executable = join(scriptsDir, 'extension.js');
262
+ writeFileSync(fragmentPath, JSON.stringify(manifest));
263
+ writeFileSync(executable, EXTENSION_EXEC);
264
+ chmodSync(executable, 0o755);
265
+ return { fragmentPath, executable };
266
+ }
267
+ function extensionBranch(startDir = projectDir, name = 'demo') {
268
+ resetScopeCache();
269
+ const snapshot = buildExternalCommandSnapshot(RESERVED, startDir);
270
+ const branch = composeExternalSubtrees(snapshot).find((node) => node.name === name);
271
+ assert.ok(branch, `expected the extensible ${name} branch`);
272
+ return branch;
273
+ }
213
274
  function leafOf(branch, name) {
214
275
  const c = branch.children.find((x) => x.name === name);
215
276
  assert.ok(c !== undefined && c.kind === 'leaf', `expected leaf ${name}`);
@@ -384,6 +445,260 @@ describe('end-to-end CLI invocation', () => {
384
445
  assert.equal(obj.status, 'running');
385
446
  });
386
447
  });
448
+ // Extensible plugin command branches — repository fragments attach to a marked
449
+ // top-level branch as one native forest and stay entirely inside the existing
450
+ // parser/help/exec contract.
451
+ describe('extensible plugin command branches', () => {
452
+ test('merges native help, executes with extension context, and mirrors JSON', async () => {
453
+ const { fragmentPath } = installExtensionFixture();
454
+ const branch = extensionBranch();
455
+ const help = renderBranch(branch.help);
456
+ assert.match(help, /<command name="demo"/);
457
+ assert.match(help, /<subcommand name="built-in"/);
458
+ assert.match(help, /<subcommand name="repo-branch"[^>]*subcommands="1"/);
459
+ assert.ok(help.indexOf('name="built-in"') < help.indexOf('name="repo-branch"'));
460
+ assert.equal((help.match(/<command name=/g) ?? []).length, 1);
461
+ const repoBranch = branch.children.find((child) => child.name === 'repo-branch');
462
+ assert.ok(repoBranch !== undefined && repoBranch.kind === 'branch');
463
+ const leaf = leafOf(repoBranch, 'run');
464
+ const leafHelp = renderLeafArgv(leaf.help);
465
+ assert.match(leafHelp, /^demo repo-branch run: run repository operation\./);
466
+ assert.match(leafHelp, /Input/);
467
+ assert.match(leafHelp, /Output \(fields carried in the rendered result\)/);
468
+ assert.match(leafHelp, /Effects/);
469
+ const requestLog = join(projectDir, 'extension-request.json');
470
+ process.env['EXTENSION_REQUEST_LOG'] = requestLog;
471
+ const priorCwd = process.cwd();
472
+ process.chdir(projectDir);
473
+ try {
474
+ assert.deepEqual(await leaf.run({ thing: 'x' }), { value: 'x' });
475
+ }
476
+ finally {
477
+ process.chdir(priorCwd);
478
+ delete process.env['EXTENSION_REQUEST_LOG'];
479
+ }
480
+ const request = JSON.parse(readFileSync(requestLog, 'utf8'));
481
+ assert.deepEqual(request.command, ['demo', 'repo-branch', 'run']);
482
+ assert.deepEqual(request.input, { thing: 'x' });
483
+ assert.deepEqual(request.context.extension, { branch: 'demo', root: projectDir });
484
+ assert.equal(request.context.plugin, undefined);
485
+ const root = defineRoot({ tagline: 'test', globals: [], subtrees: [branch] });
486
+ assert.match(renderRoot(root.help), /\[\+2 subcommands — `crtr demo -h`\]/);
487
+ assert.equal(existsSync(join(projectDir, 'EXECUTED')), false);
488
+ assert.ok(existsSync(fragmentPath));
489
+ });
490
+ test('uses a collision-qualified branch name for fragment discovery, doctor, and exec context', async () => {
491
+ installPlugin(userRoot, 'first', { manifest: extensibleDemoCommandsJson() });
492
+ installPlugin(userRoot, 'second', { manifest: extensibleDemoCommandsJson() });
493
+ const commandsDir = join(projectRoot, 'commands');
494
+ const scriptsDir = join(projectDir, 'scripts');
495
+ mkdirSync(commandsDir, { recursive: true });
496
+ mkdirSync(scriptsDir, { recursive: true });
497
+ writeFileSync(join(commandsDir, 'first:demo.json'), JSON.stringify(extensionFragment()));
498
+ writeFileSync(join(scriptsDir, 'extension.js'), EXTENSION_EXEC);
499
+ chmodSync(join(scriptsDir, 'extension.js'), 0o755);
500
+ const first = extensionBranch(projectDir, 'first:demo');
501
+ const second = extensionBranch(projectDir, 'second:demo');
502
+ assert.ok(first.children.some((child) => child.name === 'repo-branch'));
503
+ assert.ok(!second.children.some((child) => child.name === 'repo-branch'));
504
+ const repoBranch = first.children.find((child) => child.name === 'repo-branch');
505
+ assert.ok(repoBranch !== undefined && repoBranch.kind === 'branch');
506
+ const requestLog = join(projectDir, 'collision-extension-request.json');
507
+ process.env['EXTENSION_REQUEST_LOG'] = requestLog;
508
+ const priorCwd = process.cwd();
509
+ process.chdir(projectDir);
510
+ try {
511
+ assert.deepEqual(await leafOf(repoBranch, 'run').run({ thing: 'x' }), { value: 'x' });
512
+ const checks = await runCommandPluginChecks(['project']);
513
+ const extension = checks.find((check) => check.name === 'extension:first:demo');
514
+ assert.ok(extension, JSON.stringify(checks));
515
+ assert.equal(extension.status, 'pass');
516
+ }
517
+ finally {
518
+ process.chdir(priorCwd);
519
+ delete process.env['EXTENSION_REQUEST_LOG'];
520
+ }
521
+ const request = JSON.parse(readFileSync(requestLog, 'utf8'));
522
+ assert.deepEqual(request.command, ['first:demo', 'repo-branch', 'run']);
523
+ assert.deepEqual(request.context.extension, { branch: 'first:demo', root: projectDir });
524
+ assert.equal(request.context.plugin, undefined);
525
+ });
526
+ test('runs through the real CLI with native help and --json output', { timeout: 40000 }, () => {
527
+ installExtensionFixture();
528
+ const cli = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..', 'dist', 'cli.js');
529
+ const run = (args) => execFileSync(process.execPath, [cli, ...args], {
530
+ cwd: projectDir,
531
+ env: { ...process.env, HOME: home, CRTR_FRONT_DOOR: '' },
532
+ encoding: 'utf8',
533
+ });
534
+ const branchHelp = run(['demo', '-h']);
535
+ assert.match(branchHelp, /<command name="demo"/);
536
+ assert.match(branchHelp, /<subcommand name="built-in"/);
537
+ assert.match(branchHelp, /<subcommand name="repo-branch"/);
538
+ assert.deepEqual(JSON.parse(run(['demo', 'repo-branch', 'run', '--thing', 'x', '--json'])), { value: 'x' });
539
+ });
540
+ test('discovers per invocation, scopes by cwd, and nearest root wins', () => {
541
+ const { fragmentPath } = installExtensionFixture();
542
+ assert.ok(renderBranch(extensionBranch().help).includes('repo-branch'));
543
+ writeFileSync(fragmentPath, JSON.stringify(extensionFragment('changed')));
544
+ assert.ok(renderBranch(extensionBranch().help).includes('name="changed"'));
545
+ rmSync(fragmentPath);
546
+ assert.ok(!renderBranch(extensionBranch().help).includes('repo-branch'));
547
+ writeFileSync(fragmentPath, JSON.stringify(extensionFragment('outer')));
548
+ const inner = join(projectDir, 'nested');
549
+ const innerScope = join(inner, '.crouter', 'commands');
550
+ mkdirSync(innerScope, { recursive: true });
551
+ mkdirSync(join(inner, 'scripts'), { recursive: true });
552
+ writeFileSync(join(inner, 'scripts', 'extension.js'), EXTENSION_EXEC);
553
+ chmodSync(join(inner, 'scripts', 'extension.js'), 0o755);
554
+ writeFileSync(join(innerScope, 'demo.json'), JSON.stringify(extensionFragment('inner')));
555
+ assert.ok(renderBranch(extensionBranch(inner).help).includes('name="inner"'));
556
+ assert.ok(!renderBranch(extensionBranch(inner).help).includes('name="outer"'));
557
+ assert.ok(!renderBranch(extensionBranch(emptyStart).help).includes('name="outer"'));
558
+ });
559
+ test('does not discover fragments from selected-profile projects outside the cwd chain', () => {
560
+ installPlugin(userRoot, 'extension-fixture', { manifest: extensibleDemoCommandsJson() });
561
+ const profileProject = mintDir('crtr-cmdplugin-profile-project-');
562
+ const fragmentDir = join(profileProject, '.crouter', 'commands');
563
+ const scriptDir = join(profileProject, 'scripts');
564
+ mkdirSync(fragmentDir, { recursive: true });
565
+ mkdirSync(scriptDir, { recursive: true });
566
+ writeFileSync(join(fragmentDir, 'demo.json'), JSON.stringify(extensionFragment()));
567
+ writeFileSync(join(scriptDir, 'extension.js'), EXTENSION_EXEC);
568
+ chmodSync(join(scriptDir, 'extension.js'), 0o755);
569
+ process.env['CRTR_PROFILE_ID'] = createProfile('extension-fragment-scope', [{ path: profileProject, memory: 'content' }]).profileId;
570
+ resetScopeCache();
571
+ assert.ok(!renderBranch(extensionBranch(emptyStart).help).includes('repo-branch'));
572
+ });
573
+ test('rejects absolute executable paths and identifies the offending fragment node', async () => {
574
+ const { fragmentPath, executable } = installExtensionFixture();
575
+ const fragment = extensionFragment();
576
+ fragment.transport.executable = executable;
577
+ writeFileSync(fragmentPath, JSON.stringify(fragment));
578
+ const help = renderBranch(extensionBranch().help);
579
+ assert.match(help, /<extension-issue/);
580
+ assert.ok(!help.includes('repo-branch'));
581
+ const priorCwd = process.cwd();
582
+ process.chdir(projectDir);
583
+ try {
584
+ const checks = await runCommandPluginChecks(['project']);
585
+ const check = checks.find((candidate) => candidate.name === 'extension:demo');
586
+ assert.ok(check, JSON.stringify(checks));
587
+ assert.equal(check.status, 'fail');
588
+ assert.match(check.message, new RegExp(`${fragmentPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:transport\\.executable`));
589
+ }
590
+ finally {
591
+ process.chdir(priorCwd);
592
+ }
593
+ });
594
+ test('rejects the complete fragment with scoped help notice and doctor remediation', async () => {
595
+ const { fragmentPath } = installExtensionFixture();
596
+ const bad = extensionFragment();
597
+ bad['unknown'] = true;
598
+ writeFileSync(fragmentPath, JSON.stringify(bad));
599
+ const help = renderBranch(extensionBranch().help);
600
+ assert.match(help, /name="built-in"/);
601
+ assert.ok(!help.includes('repo-branch'));
602
+ assert.match(help, new RegExp(`<extension-issue fragment="${fragmentPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`));
603
+ assert.match(help, /unknown top-level keys/);
604
+ const sentinel = join(projectDir, 'extension-executed');
605
+ process.env['EXTENSION_SENTINEL'] = sentinel;
606
+ const priorCwd = process.cwd();
607
+ process.chdir(projectDir);
608
+ try {
609
+ const checks = await runCommandPluginChecks(['project']);
610
+ const check = checks.find((candidate) => candidate.name === 'extension:demo');
611
+ assert.ok(check, JSON.stringify(checks));
612
+ assert.equal(check.status, 'fail');
613
+ assert.match(check.message, /command_extension_invalid/);
614
+ assert.match(check.message, new RegExp(fragmentPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
615
+ assert.match(check.remediation.description, /regenerate/i);
616
+ }
617
+ finally {
618
+ process.chdir(priorCwd);
619
+ delete process.env['EXTENSION_SENTINEL'];
620
+ }
621
+ assert.equal(existsSync(sentinel), false);
622
+ });
623
+ test('requires the marker, rejects passthrough/rootEntry/conflicts, and leaves unmarked passthrough inert', async () => {
624
+ const { fragmentPath } = installExtensionFixture();
625
+ writeFileSync(join(userRoot, 'plugins', 'extension-fixture', 'commands.json'), JSON.stringify(extensibleDemoCommandsJson(false)));
626
+ assert.ok(!renderBranch(extensionBranch().help).includes('repo-branch'));
627
+ const priorCwd = process.cwd();
628
+ process.chdir(projectDir);
629
+ try {
630
+ const checks = await runCommandPluginChecks(['project']);
631
+ const orphan = checks.find((candidate) => candidate.name === 'extension:demo');
632
+ assert.ok(orphan, JSON.stringify(checks));
633
+ assert.equal(orphan.status, 'fail');
634
+ assert.match(orphan.message, /no effective plugin branch is extensible/);
635
+ }
636
+ finally {
637
+ process.chdir(priorCwd);
638
+ }
639
+ writeFileSync(join(userRoot, 'plugins', 'extension-fixture', 'commands.json'), JSON.stringify(extensibleDemoCommandsJson()));
640
+ const variants = [
641
+ ['passthrough', (fragment) => { fragment['mounts'][0]['node']['passthrough'] = { bin: 'x', installHint: 'x' }; }],
642
+ ['rootEntry', (fragment) => { fragment['mounts'][0]['node']['rootEntry'] = { concept: 'x', description: 'x', whenToUse: 'x' }; }],
643
+ ];
644
+ for (const [name, mutate] of variants) {
645
+ const fragment = extensionFragment();
646
+ mutate(fragment);
647
+ writeFileSync(fragmentPath, JSON.stringify(fragment));
648
+ const help = renderBranch(extensionBranch().help);
649
+ assert.match(help, /<extension-issue/);
650
+ assert.ok(!help.includes('repo-branch'), name);
651
+ }
652
+ writeFileSync(fragmentPath, JSON.stringify(extensionFragment('built-in')));
653
+ const conflictHelp = renderBranch(extensionBranch().help);
654
+ assert.match(conflictHelp, /<extension-issue/);
655
+ assert.match(conflictHelp, /conflicts with a child/);
656
+ installPlugin(userRoot, 'passthrough-fixture', { manifest: passthroughCommandsJson() });
657
+ writeFileSync(join(projectRoot, 'commands', 'capture.json'), JSON.stringify(extensionFragment()));
658
+ resetScopeCache();
659
+ const snapshot = buildExternalCommandSnapshot(RESERVED, projectDir);
660
+ const capture = composeExternalSubtrees(snapshot).find((branch) => branch.name === 'capture');
661
+ assert.ok(capture?.passthrough);
662
+ assert.equal(capture.children.length, 0);
663
+ });
664
+ test('install and update validate the marker but never inspect repository fragments', async () => {
665
+ const source = join(mintDir('crtr-extensible-source-'), 'fixture');
666
+ mkdirSync(join(source, '.crouter-plugin'), { recursive: true });
667
+ mkdirSync(join(source, 'bin'), { recursive: true });
668
+ const manifest = extensibleDemoCommandsJson();
669
+ manifest.mounts[0].node['name'] = 'install-demo';
670
+ const rootEntry = manifest.mounts[0].node['rootEntry'];
671
+ rootEntry['concept'] = 'install fixture';
672
+ writeFileSync(join(source, '.crouter-plugin', 'plugin.json'), JSON.stringify({ name: 'install-extension-fixture', version: '0.1.0', description: 'fixture', commands: 'commands.json', transport: { kind: 'exec', executable: 'bin/cmd.js' } }));
673
+ writeFileSync(join(source, 'commands.json'), JSON.stringify(manifest));
674
+ writeFileSync(join(source, 'bin', 'cmd.js'), EXTENSION_EXEC);
675
+ chmodSync(join(source, 'bin', 'cmd.js'), 0o755);
676
+ mkdirSync(join(projectRoot, 'commands'), { recursive: true });
677
+ writeFileSync(join(projectRoot, 'commands', 'install-demo.json'), JSON.stringify({ unknown: true }));
678
+ await pluginInstall.run({ installRef: source, scope: 'user' });
679
+ (manifest.mounts[0].node['children'][0]['extensible'] = true);
680
+ writeFileSync(join(source, 'commands.json'), JSON.stringify(manifest));
681
+ await assert.rejects(pluginUpdate.run({ name: 'install-extension-fixture' }), /extensible is valid only on a top-level branch mount/);
682
+ });
683
+ test('plugin inspection identifies extensible branches without executing the fragment executable', async () => {
684
+ const { executable } = installExtensionFixture();
685
+ const sentinel = join(projectDir, 'inspect-executed');
686
+ process.env['EXTENSION_SENTINEL'] = sentinel;
687
+ const priorCwd = process.cwd();
688
+ process.chdir(projectDir);
689
+ try {
690
+ const shown = await pluginShow.run({ name: 'extension-fixture' });
691
+ const commands = shown['commands'];
692
+ assert.deepEqual(commands.extensibleBranches, ['demo']);
693
+ }
694
+ finally {
695
+ process.chdir(priorCwd);
696
+ delete process.env['EXTENSION_SENTINEL'];
697
+ }
698
+ assert.equal(existsSync(sentinel), false);
699
+ assert.ok(existsSync(executable));
700
+ });
701
+ });
387
702
  // 5. Lifecycle without restart (per-invocation discovery)
388
703
  describe('lifecycle without restart', () => {
389
704
  test('install → appears; disable → gone; edit → visible; remove → gone', () => {
@@ -22,11 +22,10 @@ export interface BrokerClientOptions {
22
22
  * re-dialing the same target while the node is still alive — a yield
23
23
  * leaves `status='active'` (intent='refresh') and the daemon revives a fresh
24
24
  * broker on the same path. It gives up only when the node is genuinely gone:
25
- * a terminal status (done/dead/canceled) or a reaped row (null). `idle` is NOT
26
- * terminal — an idle-release node revives on its next inbox wake, so keep
27
- * trying (the supervisor waits at a relaxed cadence for as long as the row
28
- * stays live). Local canvas rows only — a remote target has no local row, so
29
- * callers driving a remote attach must not consult this (see viewer.ts). */
25
+ * a terminal status (done/dead/canceled) or a reaped row (null). `idle` and an
26
+ * exact unfinalized parked resident are NOT terminal — either can wake while
27
+ * the viewer remains seated. Local canvas rows only — a remote target has no
28
+ * local row, so callers driving a remote attach must not consult this. */
30
29
  export declare function reconnectShouldGiveUp(row: NodeRow | null): boolean;
31
30
  /** The plan-fixed interface (Wave 3): `connect()`, `on('frame', …)`,
32
31
  * `send(frame)`, `on('close', …)`, plus `connect`/`error` events. */
@@ -41,14 +41,18 @@ const REQUEST_TIMEOUT_MS = 10_000;
41
41
  * re-dialing the same target while the node is still alive — a yield
42
42
  * leaves `status='active'` (intent='refresh') and the daemon revives a fresh
43
43
  * broker on the same path. It gives up only when the node is genuinely gone:
44
- * a terminal status (done/dead/canceled) or a reaped row (null). `idle` is NOT
45
- * terminal — an idle-release node revives on its next inbox wake, so keep
46
- * trying (the supervisor waits at a relaxed cadence for as long as the row
47
- * stays live). Local canvas rows only — a remote target has no local row, so
48
- * callers driving a remote attach must not consult this (see viewer.ts). */
44
+ * a terminal status (done/dead/canceled) or a reaped row (null). `idle` and an
45
+ * exact unfinalized parked resident are NOT terminal — either can wake while
46
+ * the viewer remains seated. Local canvas rows only — a remote target has no
47
+ * local row, so callers driving a remote attach must not consult this. */
49
48
  export function reconnectShouldGiveUp(row) {
50
49
  if (row === null)
51
50
  return true;
51
+ if (row.lifecycle === 'resident'
52
+ && row.status === 'done'
53
+ && row.intent === 'parked'
54
+ && row.final_report == null)
55
+ return false;
52
56
  return row.status === 'done' || row.status === 'dead' || row.status === 'canceled';
53
57
  }
54
58
  export class BrokerClient extends EventEmitter {