@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
@@ -12,11 +12,11 @@ export declare const SORTS: readonly SortMode[];
12
12
  /** Does a node belong to this tab's slice?
13
13
  * All — every node.
14
14
  * Live — what you can pick up right now: active | idle, PLUS a resident
15
- * agent that stopped without finishing (its broker exited, but
16
- * Enter resumes the conversation in place — see `isFinishedWork`).
15
+ * agent that stopped without finishing (Enter opens its saved
16
+ * conversation; submitted input wakes it — see `isFinishedWork`).
17
17
  * Finished work never appears here.
18
18
  * Dormant — done | dead | canceled: literally "no engine running". A stopped
19
- * resident agent is deliberately on BOTH tabs — it is resumable
19
+ * resident agent is deliberately on BOTH tabs — it is openable
20
20
  * (Live) and it is not running (Dormant); both are true and each
21
21
  * tab is the one a different search starts from.
22
22
  * Attention — needs a human: has pending asks OR is parked on a fault.
@@ -11,11 +11,11 @@ export const SORTS = ['tree', 'attention', 'relevance', 'recency'];
11
11
  /** Does a node belong to this tab's slice?
12
12
  * All — every node.
13
13
  * Live — what you can pick up right now: active | idle, PLUS a resident
14
- * agent that stopped without finishing (its broker exited, but
15
- * Enter resumes the conversation in place — see `isFinishedWork`).
14
+ * agent that stopped without finishing (Enter opens its saved
15
+ * conversation; submitted input wakes it — see `isFinishedWork`).
16
16
  * Finished work never appears here.
17
17
  * Dormant — done | dead | canceled: literally "no engine running". A stopped
18
- * resident agent is deliberately on BOTH tabs — it is resumable
18
+ * resident agent is deliberately on BOTH tabs — it is openable
19
19
  * (Live) and it is not running (Dormant); both are true and each
20
20
  * tab is the one a different search starts from.
21
21
  * Attention — needs a human: has pending asks OR is parked on a fault.
@@ -435,8 +435,8 @@ const HELP_ROWS = [
435
435
  ['Tabs', 'Tab / 1-5', 'All · Live · Dormant · Attention · Pinned'],
436
436
  ['Find', '/', 'fuzzy search (name · kind · id · prompts)'],
437
437
  ['Pin', 'f', 'star / unstar — floats to top, Pinned tab'],
438
- ['Open', '⏎ o', 'resume the node (closes navigator)'],
439
- ['', 'O', 'resume, keep browsing (open in background)'],
438
+ ['Open', '⏎ o', 'open conversation (closes navigator)'],
439
+ ['', 'O', 'open, keep browsing (background)'],
440
440
  ['Act', 'y', 'copy the node id to the clipboard'],
441
441
  ['', 'm', 'message the node (no resume)'],
442
442
  ['', 'R', 'rename the node'],
@@ -557,7 +557,7 @@ export function renderFrame(state, size, caps = { color: false, color256: false
557
557
  ? '⏎ commit Esc cancel ⌫ delete'
558
558
  : state.readOnly === true
559
559
  ? '↑↓ move →/← tree / search f pin Tab tabs ? more q quit — read-only remote canvas'
560
- : `${hangingHint}↑↓ move ⏎ resume O bg / search f pin m msg x close R rename ? more q quit`;
560
+ : `${hangingHint}↑↓ move ⏎ open O bg / search f pin m msg x close R rename ? more q quit`;
561
561
  lines.push(`${DIM}${clip(footer, width)}${RESET}`);
562
562
  }
563
563
  // Assemble: home, each line cleared to EOL, then clear below.
@@ -1,10 +1,10 @@
1
- import type { DeclLeaf, TransportKind, DeclBranch, ManifestTimeouts, CommandManifestIssue } from './schema.js';
1
+ import type { DeclLeaf, DeclNode, TransportKind, ManifestTimeouts, CommandManifestIssue } from './schema.js';
2
2
  export interface ValidatedCommandManifest {
3
3
  schemaVersion: 1;
4
4
  baseUrl?: string;
5
5
  timeouts?: ManifestTimeouts;
6
- /** Top-level branches, each ready to mount at parent []. */
7
- roots: DeclBranch<DeclLeaf>[];
6
+ /** Top-level plugin branches or repository-fragment nodes, ready to mount. */
7
+ roots: DeclNode<DeclLeaf>[];
8
8
  /** Core command path (space-joined, e.g. "cron add") → attributed addendum
9
9
  * text appended beneath that core command's help. Append-only product
10
10
  * guidance — a plugin can never alter core contract text. */
@@ -32,4 +32,6 @@ export declare function validateCommandManifest(raw: unknown, options: {
32
32
  * and ingress report can never disagree. Omitted only on the tolerant
33
33
  * compose path, where mounting ignores addenda entirely. */
34
34
  coreCommandPaths?: ReadonlySet<string>;
35
+ /** A repository fragment attaches roots below an existing extensible branch. */
36
+ extensionFragment?: boolean;
35
37
  }): CommandManifestValidation;
@@ -33,17 +33,19 @@ export function validateCommandManifest(raw, options) {
33
33
  };
34
34
  // Top-level structure check
35
35
  if (!isRecord(raw)) {
36
- issue('command_manifest_invalid', 'manifest must be an object', typeName(raw), options.transport === 'http' ? '{ schemaVersion, baseUrl?, timeouts?, mounts, helpAddenda? }' : '{ schemaVersion, mounts, helpAddenda? }', 'Provide a valid JSON manifest object.');
36
+ issue('command_manifest_invalid', 'manifest must be an object', typeName(raw), options.extensionFragment === true ? '{ schemaVersion, transport, mounts }' : options.transport === 'http' ? '{ schemaVersion, baseUrl?, timeouts?, mounts, helpAddenda? }' : '{ schemaVersion, mounts, helpAddenda? }', 'Provide a valid JSON manifest object.');
37
37
  return { issues };
38
38
  }
39
39
  // Check for unknown top-level keys
40
40
  const topKeys = Object.keys(raw);
41
- const allowedTopKeys = options.transport === 'http'
42
- ? new Set(['schemaVersion', 'baseUrl', 'timeouts', 'mounts', 'helpAddenda'])
43
- : new Set(['schemaVersion', 'mounts', 'helpAddenda']);
41
+ const allowedTopKeys = options.extensionFragment === true
42
+ ? new Set(['schemaVersion', 'transport', 'mounts'])
43
+ : options.transport === 'http'
44
+ ? new Set(['schemaVersion', 'baseUrl', 'timeouts', 'mounts', 'helpAddenda'])
45
+ : new Set(['schemaVersion', 'mounts', 'helpAddenda']);
44
46
  const unknownKeys = topKeys.filter((k) => !allowedTopKeys.has(k));
45
47
  if (unknownKeys.length > 0) {
46
- issue('command_manifest_invalid', `unknown top-level keys`, unknownKeys.join(', '), options.transport === 'http' ? 'only: schemaVersion, baseUrl, timeouts, mounts, helpAddenda' : 'only: schemaVersion, mounts, helpAddenda', 'Remove the unknown keys.');
48
+ issue('command_manifest_invalid', `unknown top-level keys`, unknownKeys.join(', '), options.extensionFragment === true ? 'only: schemaVersion, transport, mounts' : options.transport === 'http' ? 'only: schemaVersion, baseUrl, timeouts, mounts, helpAddenda' : 'only: schemaVersion, mounts, helpAddenda', 'Remove the unknown keys.');
47
49
  return { issues };
48
50
  }
49
51
  // Validate schemaVersion
@@ -101,7 +103,7 @@ export function validateCommandManifest(raw, options) {
101
103
  // Validate and collect all mounts
102
104
  const validatedMounts = [];
103
105
  for (let i = 0; i < mounts.length; i++) {
104
- const m = validateMount(mounts[i], i, options.transport, issue);
106
+ const m = validateMount(mounts[i], i, options.transport, issue, options.extensionFragment === true ? { topLevelRootEntry: 'forbidden', allowPassthrough: false, allowExtensible: false } : undefined);
105
107
  if (m === null)
106
108
  return { issues };
107
109
  validatedMounts.push(m);
@@ -179,7 +181,7 @@ function validateHelpAddenda(raw, coreCommandPaths, issue) {
179
181
  }
180
182
  return out;
181
183
  }
182
- function validateMount(raw, index, transport, issue) {
184
+ function validateMount(raw, index, transport, issue, nodeOptions) {
183
185
  const path = `mounts[${index}]`;
184
186
  if (!isRecord(raw)) {
185
187
  issue('command_manifest_invalid', 'mount must be an object', typeName(raw), '{ parent, node }', 'Fix the mount.', path);
@@ -208,17 +210,10 @@ function validateMount(raw, index, transport, issue) {
208
210
  // Validate node
209
211
  const nodeRaw = raw['node'];
210
212
  const topLevel = parent.length === 0;
211
- const node = validateCommandNode(nodeRaw, [`${path}.node`], topLevel, transport, issue);
213
+ const node = validateCommandNode(nodeRaw, [`${path}.node`], topLevel, transport, issue, nodeOptions);
212
214
  if (node === null)
213
215
  return null;
214
- // Top-level mount must be a branch with rootEntry
215
- if (topLevel && node.kind !== 'branch') {
216
- issue('command_node_invalid', 'top-level mount node must be a branch', node.kind, 'a branch with a rootEntry', 'Wrap the command in a top-level branch.', `${path}.node.kind`);
217
- return null;
218
- }
219
- // Ensure the node is properly typed (should only be DeclLeaf at this point)
220
- const typedNode = node;
221
- return { parent, node: typedNode };
216
+ return { parent, node };
222
217
  }
223
218
  // Forest materialization (order-independent)
224
219
  /**
@@ -236,19 +231,10 @@ function materializeForest(mounts, reservedCoreNames, issue) {
236
231
  return null;
237
232
  }
238
233
  }
239
- // Build the forest: all top-level nodes with inline children, then attach nested mounts
240
- const roots = topLevel.map((m) => ({
241
- kind: 'branch',
242
- name: m.node.name,
243
- description: m.node.description,
244
- whenToUse: m.node.whenToUse,
245
- ...(m.node.tier !== undefined ? { tier: m.node.tier } : {}),
246
- ...(m.node.rootEntry !== undefined ? { rootEntry: m.node.rootEntry } : {}),
247
- summary: m.node.summary,
248
- ...(m.node.model !== undefined ? { model: m.node.model } : {}),
249
- ...(m.node.passthrough !== undefined ? { passthrough: m.node.passthrough } : {}),
250
- children: [...m.node.children],
251
- }));
234
+ // Build the forest: all top-level nodes with inline children, then attach nested mounts.
235
+ const roots = topLevel.map((m) => m.node.kind === 'branch'
236
+ ? { ...m.node, children: [...m.node.children] }
237
+ : { ...m.node });
252
238
  // Attach nested mounts
253
239
  for (const mount of nested) {
254
240
  const resolved = resolveAndAttachMount(roots, mount, issue);
@@ -270,8 +256,7 @@ function materializeForest(mounts, reservedCoreNames, issue) {
270
256
  return true;
271
257
  };
272
258
  for (const root of roots) {
273
- const child = root;
274
- if (!indexPaths(child, [root.name]))
259
+ if (!indexPaths(root, [root.name]))
275
260
  return null;
276
261
  }
277
262
  return roots;
@@ -287,6 +272,10 @@ function resolveAndAttachMount(roots, mount, issue) {
287
272
  issue('command_parent_invalid', `parent path starts with unknown branch`, first, 'a branch this manifest contributes', 'Fix the parent path or add the parent branch.', mount.parent.join('.'));
288
273
  return false;
289
274
  }
275
+ if (root.kind === 'leaf') {
276
+ issue('command_parent_invalid', 'parent path resolves to a leaf', first, 'a branch', 'Change the parent to point to a branch.', mount.parent.join('.'));
277
+ return false;
278
+ }
290
279
  let current = root;
291
280
  for (const token of rest) {
292
281
  const child = current.children.find((c) => c.name === token);
@@ -1,5 +1,6 @@
1
1
  import type { LeafDef } from '../command.js';
2
2
  import type { DeclLeafBase, DeclBranch, CommandManifestIssue } from './schema.js';
3
+ import type { ExtensionIssueNotice } from '../command-plugins/extensions.js';
3
4
  /** Reference to an external contributor (plugin or HTTP-transport plugin). */
4
5
  export type CommandContributorRef = {
5
6
  name: string;
@@ -27,6 +28,8 @@ export interface CommandContribution {
27
28
  /** Root name from the manifest, before collision qualification. */
28
29
  manifestName: string;
29
30
  adaptLeaf: LeafAdapter;
31
+ /** Present only on an extensible branch whose nearest repository fragment was rejected. */
32
+ extensionIssue?: ExtensionIssueNotice;
30
33
  }
31
34
  /** Per-contributor collision issue. */
32
35
  export interface ContributorIssue extends CommandManifestIssue {
@@ -12,6 +12,8 @@ export interface DeclBranch<L = DeclLeafBase> {
12
12
  tier?: 'normal' | 'common' | 'important';
13
13
  /** Required on a top-level branch, forbidden on a nested one. */
14
14
  rootEntry?: DeclRootEntry;
15
+ /** Allows the nearest repository fragment to contribute children below this top-level branch. */
16
+ extensible?: true;
15
17
  summary: string;
16
18
  model?: string;
17
19
  /** Exec transport only: forward every argv token after this branch to an
@@ -78,8 +80,16 @@ export interface CommandManifestIssue {
78
80
  expected: string;
79
81
  next: string;
80
82
  }
81
- export type CommandIssueCode = 'command_manifest_unreadable' | 'command_manifest_invalid' | 'command_schema_version' | 'command_path_unsafe' | 'command_not_executable' | 'command_parent_invalid' | 'command_node_invalid' | 'command_collision' | 'command_help_addendum_invalid' | 'command_rest_invalid';
83
+ export type CommandIssueCode = 'command_manifest_unreadable' | 'command_manifest_invalid' | 'command_schema_version' | 'command_path_unsafe' | 'command_not_executable' | 'command_parent_invalid' | 'command_node_invalid' | 'command_collision' | 'command_help_addendum_invalid' | 'command_rest_invalid' | 'command_extension_invalid';
82
84
  type IssueFn = (code: CommandIssueCode, message: string, received: string, expected: string, next: string, path?: string) => void;
83
85
  export type TransportKind = 'exec' | 'http';
84
- export declare function validateCommandNode(raw: unknown, path: string[], topLevel: boolean, transport: TransportKind, issue: IssueFn): DeclBranch<DeclLeaf> | DeclLeaf | null;
86
+ export interface CommandNodeValidationOptions {
87
+ /** Plugin roots need rootEntry; repository-fragment roots attach under an existing branch and forbid it. */
88
+ topLevelRootEntry?: 'required' | 'forbidden';
89
+ /** Repository fragments have no passthrough escape hatch. */
90
+ allowPassthrough?: boolean;
91
+ /** Only plugin manifests may mark their own top-level branch extensible. */
92
+ allowExtensible?: boolean;
93
+ }
94
+ export declare function validateCommandNode(raw: unknown, path: string[], topLevel: boolean, transport: TransportKind, issue: IssueFn, options?: CommandNodeValidationOptions): DeclBranch<DeclLeaf> | DeclLeaf | null;
85
95
  export {};
@@ -5,7 +5,7 @@ const TIERS = new Set(['normal', 'common', 'important']);
5
5
  const FLAG_TYPES = new Set(['string', 'int', 'bool', 'path', 'enum']);
6
6
  const KEBAB = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
7
7
  const BRANCH_KEYS = new Set([
8
- 'kind', 'name', 'description', 'whenToUse', 'tier', 'rootEntry', 'summary', 'model', 'children',
8
+ 'kind', 'name', 'description', 'whenToUse', 'tier', 'rootEntry', 'extensible', 'summary', 'model', 'children',
9
9
  ]);
10
10
  /** Only exec transport may declare `passthrough`; HTTP manifests keep
11
11
  * BRANCH_KEYS, so a declared passthrough fails as an unknown key. */
@@ -331,7 +331,7 @@ function checkCommon(raw, path, issue) {
331
331
  }
332
332
  return true;
333
333
  }
334
- export function validateCommandNode(raw, path, topLevel, transport, issue) {
334
+ export function validateCommandNode(raw, path, topLevel, transport, issue, options = {}) {
335
335
  const pathString = path.join('.');
336
336
  if (!isRecord(raw)) {
337
337
  issue('command_node_invalid', 'node must be an object', typeName(raw), 'a branch or leaf node object', 'Fix the node.', pathString);
@@ -339,9 +339,9 @@ export function validateCommandNode(raw, path, topLevel, transport, issue) {
339
339
  }
340
340
  const kind = raw['kind'];
341
341
  if (kind === 'branch')
342
- return validateBranch(raw, pathString, topLevel, transport, issue);
342
+ return validateBranch(raw, pathString, topLevel, transport, issue, options);
343
343
  if (kind === 'leaf') {
344
- if (topLevel) {
344
+ if (topLevel && options.topLevelRootEntry !== 'forbidden') {
345
345
  issue('command_node_invalid', 'top-level node must be a branch', 'leaf', 'a branch node with a rootEntry', 'Wrap the command in a top-level branch (noun) with a rootEntry.', `${pathString}.kind`);
346
346
  return null;
347
347
  }
@@ -350,18 +350,26 @@ export function validateCommandNode(raw, path, topLevel, transport, issue) {
350
350
  issue('command_node_invalid', 'node kind must be "branch" or "leaf"', String(kind), 'branch | leaf', 'Set kind to branch or leaf.', `${pathString}.kind`);
351
351
  return null;
352
352
  }
353
- function validateBranch(raw, path, topLevel, transport, issue) {
354
- if (!checkKeys(raw, transport === 'exec' ? EXEC_BRANCH_KEYS : BRANCH_KEYS, path, issue))
353
+ function validateBranch(raw, path, topLevel, transport, issue, options) {
354
+ const allowsPassthrough = options.allowPassthrough !== false;
355
+ const allowedKeys = transport === 'exec' && allowsPassthrough ? EXEC_BRANCH_KEYS : BRANCH_KEYS;
356
+ if (!checkKeys(raw, allowedKeys, path, issue))
355
357
  return null;
356
358
  if (!checkCommon(raw, path, issue))
357
359
  return null;
358
360
  const hasRoot = raw['rootEntry'] !== undefined;
359
- if (topLevel && !hasRoot) {
361
+ const rootEntryPolicy = options.topLevelRootEntry ?? 'required';
362
+ if (topLevel && rootEntryPolicy === 'required' && !hasRoot) {
360
363
  issue('command_node_invalid', 'top-level branch requires a rootEntry', '(missing)', 'a rootEntry { concept, description, whenToUse }', 'Add a rootEntry to the top-level branch.', `${path}.rootEntry`);
361
364
  return null;
362
365
  }
363
- if (!topLevel && hasRoot) {
364
- issue('command_node_invalid', 'nested branch must not declare a rootEntry', 'rootEntry', 'no rootEntry (only top-level branches carry one)', 'Remove rootEntry from the nested branch.', `${path}.rootEntry`);
366
+ if ((!topLevel || rootEntryPolicy === 'forbidden') && hasRoot) {
367
+ issue('command_node_invalid', topLevel ? 'repository fragment root must not declare a rootEntry' : 'nested branch must not declare a rootEntry', 'rootEntry', topLevel ? 'no rootEntry (the owning plugin branch already has one)' : 'no rootEntry (only top-level branches carry one)', topLevel ? 'Remove rootEntry from the repository fragment root.' : 'Remove rootEntry from the nested branch.', `${path}.rootEntry`);
368
+ return null;
369
+ }
370
+ const hasExtensible = raw['extensible'] !== undefined;
371
+ if (hasExtensible && (!topLevel || options.allowExtensible === false || raw['extensible'] !== true)) {
372
+ issue('command_node_invalid', !topLevel ? 'extensible is valid only on a top-level branch mount' : 'extensible must be exactly true on a plugin top-level branch', String(raw['extensible']), !topLevel ? 'no extensible marker on nested branches' : 'extensible: true', !topLevel ? 'Move the marker to the plugin top-level branch or remove it.' : 'Set extensible to true or remove it.', `${path}.extensible`);
365
373
  return null;
366
374
  }
367
375
  let rootEntry;
@@ -386,6 +394,10 @@ function validateBranch(raw, path, topLevel, transport, issue) {
386
394
  return null;
387
395
  passthrough = validated;
388
396
  }
397
+ if (hasExtensible && passthrough !== undefined) {
398
+ issue('command_node_invalid', 'an extensible branch cannot declare passthrough', 'extensible and passthrough', 'one branch mode: extensible or passthrough', 'Remove passthrough or remove extensible.', path);
399
+ return null;
400
+ }
389
401
  const children = raw['children'];
390
402
  if (passthrough !== undefined) {
391
403
  if (!Array.isArray(children) || children.length !== 0) {
@@ -400,7 +412,7 @@ function validateBranch(raw, path, topLevel, transport, issue) {
400
412
  const validated = [];
401
413
  const childNames = new Set();
402
414
  for (let i = 0; i < children.length; i++) {
403
- const child = validateCommandNode(children[i], [...path.split('.'), `children[${i}]`], false, transport, issue);
415
+ const child = validateCommandNode(children[i], [...path.split('.'), `children[${i}]`], false, transport, issue, options);
404
416
  if (child === null)
405
417
  return null;
406
418
  if (childNames.has(child.name)) {
@@ -413,7 +425,8 @@ function validateBranch(raw, path, topLevel, transport, issue) {
413
425
  return {
414
426
  kind: 'branch', name: raw['name'], description: raw['description'], whenToUse: raw['whenToUse'],
415
427
  ...(raw['tier'] !== undefined ? { tier: raw['tier'] } : {}),
416
- ...(rootEntry !== undefined ? { rootEntry } : {}), summary: raw['summary'],
428
+ ...(rootEntry !== undefined ? { rootEntry } : {}),
429
+ ...(hasExtensible ? { extensible: true } : {}), summary: raw['summary'],
417
430
  ...(raw['model'] !== undefined ? { model: raw['model'] } : {}),
418
431
  ...(passthrough !== undefined ? { passthrough } : {}), children: validated,
419
432
  };
@@ -39,7 +39,7 @@ export function composeExternalSubtrees(snapshot) {
39
39
  }
40
40
  function buildBranch(contribution, node, path) {
41
41
  const rootEntry = node.rootEntry === undefined ? undefined : { concept: node.rootEntry.concept, desc: node.rootEntry.description, useWhen: node.rootEntry.whenToUse };
42
- return defineBranch({ name: node.name, description: node.description, whenToUse: node.whenToUse, ...(node.tier !== undefined ? { tier: node.tier } : {}), ...(rootEntry !== undefined ? { rootEntry } : {}), help: { name: path.join(' '), summary: node.summary, ...(node.model !== undefined ? { model: node.model } : {}) }, ...(node.passthrough !== undefined ? { passthrough: node.passthrough } : {}), children: node.children.map((child) => child.kind === 'branch' ? buildBranch(contribution, child, [...path, child.name]) : buildLeaf(contribution, child, [...path, child.name])) });
42
+ return defineBranch({ name: node.name, description: node.description, whenToUse: node.whenToUse, ...(node.tier !== undefined ? { tier: node.tier } : {}), ...(rootEntry !== undefined ? { rootEntry } : {}), help: { name: path.join(' '), summary: node.summary, ...(node.model !== undefined ? { model: node.model } : {}), ...(path.length === 1 && contribution.extensionIssue !== undefined ? { extensionIssue: contribution.extensionIssue } : {}) }, ...(node.passthrough !== undefined ? { passthrough: node.passthrough } : {}), children: node.children.map((child) => child.kind === 'branch' ? buildBranch(contribution, child, [...path, child.name]) : buildLeaf(contribution, child, [...path, child.name])) });
43
43
  }
44
44
  function buildLeaf(contribution, node, path) {
45
45
  const adapted = contribution.adaptLeaf(node, path);
@@ -34,4 +34,4 @@ export declare function discoverCommandContributions(reservedNames: ReadonlySet<
34
34
  contributions: ValidatedContribution[];
35
35
  issues: CommandDiscoveryIssue[];
36
36
  };
37
- export declare function buildExternalCommandSnapshot(reservedCoreNames: ReadonlySet<string>): CommandRegistrySnapshot;
37
+ export declare function buildExternalCommandSnapshot(reservedCoreNames: ReadonlySet<string>, startDir?: string, profileId?: string | null): CommandRegistrySnapshot;
@@ -6,6 +6,7 @@ import { validateCommandManifest } from '../command-manifests/manifest.js';
6
6
  import { qualifiedTopLevelCommandName, resolveCommandRegistry } from '../command-manifests/registry.js';
7
7
  import { adaptPluginContributions } from './compose.js';
8
8
  import { validateHttpPluginTransport } from './endpoint.js';
9
+ import { applyCommandExtensions } from './extensions.js';
9
10
  export function effectiveCommandPlugins(startDir = process.cwd(), profileId) {
10
11
  const seen = new Set();
11
12
  const result = [];
@@ -130,7 +131,11 @@ export function validatePluginCommands(plugin, reservedNames = new Set(), coreCo
130
131
  : transport;
131
132
  if (resolvedTransport === undefined)
132
133
  return { plugin, manifestPath, transport, contributions: [], issues };
133
- return { plugin, manifestPath, transport: resolvedTransport, manifest: validation.manifest, contributions: validation.manifest.roots.map((node) => ({ plugin, transport: resolvedTransport, node, manifest: validation.manifest, manifestName: node.name })), issues };
134
+ const roots = validation.manifest.roots;
135
+ if (!roots.every((node) => node.kind === 'branch')) {
136
+ throw new Error('plugin manifest validation returned a top-level leaf');
137
+ }
138
+ return { plugin, manifestPath, transport: resolvedTransport, manifest: validation.manifest, contributions: roots.map((node) => ({ plugin, transport: resolvedTransport, node, manifest: validation.manifest, manifestName: node.name })), issues };
134
139
  }
135
140
  export function discoverPluginCommandCandidates(startDir = process.cwd(), profileId) {
136
141
  return effectiveCommandPlugins(startDir, profileId).map((plugin) => validatePluginCommands(plugin));
@@ -164,9 +169,10 @@ export function discoverCommandContributions(reservedNames, startDir = process.c
164
169
  const validations = validateEffectiveCommandPlugins(reservedNames, startDir, profileId);
165
170
  return { contributions: validations.flatMap((validation) => validation.contributions), issues: validations.flatMap((validation) => validation.issues) };
166
171
  }
167
- export function buildExternalCommandSnapshot(reservedCoreNames) {
168
- const validations = discoverPluginCommandCandidates();
172
+ export function buildExternalCommandSnapshot(reservedCoreNames, startDir = process.cwd(), profileId) {
173
+ const validations = discoverPluginCommandCandidates(startDir, profileId);
169
174
  const candidates = adaptPluginContributions(validations.flatMap((validation) => validation.contributions)).contributions;
170
175
  const resolved = resolveCommandRegistry(candidates, reservedCoreNames);
171
- return { contributions: resolved.contributions, issues: [...validations.flatMap((validation) => validation.issues.map((issue) => contributorIssue(validation.plugin, issue))), ...resolved.issues] };
176
+ const extensions = applyCommandExtensions(resolved.contributions, startDir);
177
+ return { contributions: extensions.contributions, issues: [...validations.flatMap((validation) => validation.issues.map((issue) => contributorIssue(validation.plugin, issue))), ...resolved.issues] };
172
178
  }
@@ -0,0 +1,20 @@
1
+ import type { CommandManifestIssue } from '../command-manifests/schema.js';
2
+ import type { CommandContribution } from '../command-manifests/registry.js';
3
+ export interface ExtensionIssueNotice {
4
+ fragment: string;
5
+ message: string;
6
+ }
7
+ export interface CommandExtensionValidation {
8
+ branch: string;
9
+ fragmentPath: string;
10
+ projectRoot: string;
11
+ issue?: CommandManifestIssue;
12
+ }
13
+ /** Attach the nearest repository-owned fragment to each marked plugin branch.
14
+ * Discovery reads only; execution remains in the leaf adapter. */
15
+ export declare function applyCommandExtensions(contributions: readonly CommandContribution[], startDir?: string): {
16
+ contributions: CommandContribution[];
17
+ validations: CommandExtensionValidation[];
18
+ };
19
+ /** Doctor's only directory scan: fragments with no matching effective marked branch are otherwise inert by design. */
20
+ export declare function orphanCommandExtensionValidations(contributions: readonly CommandContribution[], startDir?: string): CommandExtensionValidation[];
@@ -0,0 +1,166 @@
1
+ import { existsSync, realpathSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
3
+ import { projectScopeRoots } from '../scope.js';
4
+ import { validateCommandManifest } from '../command-manifests/manifest.js';
5
+ import { executeExternalLeaf } from './transport/exec-invoke.js';
6
+ import { isRecord } from '../../shared/predicates.js';
7
+ function issue(fragmentPath, message, received, expected, next, nodePath) {
8
+ return {
9
+ code: 'command_extension_invalid',
10
+ message,
11
+ received,
12
+ expected,
13
+ next,
14
+ path: nodePath === undefined ? fragmentPath : `${fragmentPath}:${nodePath}`,
15
+ };
16
+ }
17
+ function regenerate(fragmentPath) {
18
+ return `Fix the repository CLI definition and regenerate ${fragmentPath}.`;
19
+ }
20
+ function safeExecutable(fragmentPath, root, executable) {
21
+ if (isAbsolute(executable)) {
22
+ return { issue: issue(fragmentPath, 'fragment transport executable must be project-relative', executable, 'a project-root-relative regular file', 'Fix transport.executable in the repository CLI definition and regenerate the fragment.', 'transport.executable') };
23
+ }
24
+ try {
25
+ const rootReal = realpathSync(root);
26
+ const resolved = realpathSync(resolve(root, executable));
27
+ if ((resolved !== rootReal && !resolved.startsWith(rootReal + sep)) || !statSync(resolved).isFile()) {
28
+ return { issue: issue(fragmentPath, 'fragment transport executable escapes the project root or is not a regular file', executable, 'a project-root-relative regular file', 'Fix transport.executable in the repository CLI definition and regenerate the fragment.', 'transport.executable') };
29
+ }
30
+ if ((statSync(resolved).mode & 0o111) === 0) {
31
+ return { issue: issue(fragmentPath, 'fragment transport executable lacks the POSIX exec bit', executable, 'a file with an executable permission bit', 'chmod +x the repository command executable, then regenerate the fragment.', 'transport.executable') };
32
+ }
33
+ return { executable: resolved };
34
+ }
35
+ catch {
36
+ return { issue: issue(fragmentPath, 'fragment transport executable escapes the project root or is not a regular file', executable, 'a project-root-relative regular file', 'Fix transport.executable in the repository CLI definition and regenerate the fragment.', 'transport.executable') };
37
+ }
38
+ }
39
+ function validateFragment(fragmentPath, projectRoot, branch, ownChildren) {
40
+ let raw;
41
+ try {
42
+ raw = JSON.parse(readFileSync(fragmentPath, 'utf8'));
43
+ }
44
+ catch {
45
+ return { issue: issue(fragmentPath, 'extension fragment is missing, unreadable, or not valid JSON', fragmentPath, 'a readable generated JSON fragment', `Regenerate ${fragmentPath}.`) };
46
+ }
47
+ if (!isRecord(raw)) {
48
+ return { issue: issue(fragmentPath, 'extension fragment must be an object', raw === null ? 'null' : Array.isArray(raw) ? 'array' : typeof raw, '{ schemaVersion, transport, mounts }', `Regenerate ${fragmentPath}.`) };
49
+ }
50
+ const unknown = Object.keys(raw).filter((key) => !['schemaVersion', 'transport', 'mounts'].includes(key));
51
+ if (unknown.length > 0) {
52
+ return { issue: issue(fragmentPath, 'extension fragment has unknown top-level keys', unknown.join(', '), 'only: schemaVersion, transport, mounts', `Regenerate ${fragmentPath}.`) };
53
+ }
54
+ const transport = raw['transport'];
55
+ if (!isRecord(transport) || Object.keys(transport).some((key) => !['kind', 'executable'].includes(key)) || transport['kind'] !== 'exec' || typeof transport['executable'] !== 'string' || transport['executable'].length === 0) {
56
+ return { issue: issue(fragmentPath, 'extension fragment transport must be { kind: "exec", executable: "project-relative path" }', JSON.stringify(transport), 'an exec transport with an executable', `Regenerate ${fragmentPath}.`, 'transport') };
57
+ }
58
+ const executableResult = safeExecutable(fragmentPath, projectRoot, transport['executable']);
59
+ if (executableResult.issue !== undefined)
60
+ return { issue: executableResult.issue };
61
+ const validation = validateCommandManifest(raw, {
62
+ transport: 'exec',
63
+ reservedCoreNames: new Set(),
64
+ extensionFragment: true,
65
+ });
66
+ if (validation.manifest === undefined) {
67
+ const source = validation.issues[0];
68
+ return {
69
+ issue: issue(fragmentPath, source.message, source.received, source.expected, regenerate(fragmentPath), source.path),
70
+ };
71
+ }
72
+ const conflict = validation.manifest.roots.find((root) => ownChildren.includes(root.name));
73
+ if (conflict !== undefined) {
74
+ return {
75
+ issue: issue(fragmentPath, `contributed command "${conflict.name}" conflicts with a child the extensible branch already owns`, conflict.name, 'a name not already owned by the plugin branch', `Rename "${conflict.name}" in the repository CLI tree, then regenerate ${fragmentPath}.`, conflict.name),
76
+ };
77
+ }
78
+ return { fragment: { roots: validation.manifest.roots, executable: executableResult.executable } };
79
+ }
80
+ function nearestFragment(branch, startDir) {
81
+ for (const scopeRoot of projectScopeRoots(startDir, null)) {
82
+ const projectRoot = dirname(scopeRoot);
83
+ const fragmentPath = join(scopeRoot, 'commands', `${branch}.json`);
84
+ if (existsSync(fragmentPath))
85
+ return { fragmentPath, projectRoot };
86
+ }
87
+ return undefined;
88
+ }
89
+ function markLeaves(node, leaves) {
90
+ if (node.kind === 'leaf') {
91
+ leaves.add(node);
92
+ return;
93
+ }
94
+ node.children.forEach((child) => markLeaves(child, leaves));
95
+ }
96
+ /** Attach the nearest repository-owned fragment to each marked plugin branch.
97
+ * Discovery reads only; execution remains in the leaf adapter. */
98
+ export function applyCommandExtensions(contributions, startDir = process.cwd()) {
99
+ const validations = [];
100
+ const attached = contributions.map((contribution) => {
101
+ if (contribution.node.extensible !== true)
102
+ return contribution;
103
+ const branch = contribution.node.name;
104
+ const located = nearestFragment(branch, startDir);
105
+ if (located === undefined)
106
+ return contribution;
107
+ const result = validateFragment(located.fragmentPath, located.projectRoot, branch, contribution.node.children.map((child) => child.name));
108
+ if (result.issue !== undefined) {
109
+ validations.push({ branch, fragmentPath: located.fragmentPath, projectRoot: located.projectRoot, issue: result.issue });
110
+ return {
111
+ ...contribution,
112
+ extensionIssue: { fragment: located.fragmentPath, message: result.issue.message },
113
+ };
114
+ }
115
+ const fragment = result.fragment;
116
+ validations.push({ branch, fragmentPath: located.fragmentPath, projectRoot: located.projectRoot });
117
+ const leaves = new WeakSet();
118
+ fragment.roots.forEach((root) => markLeaves(root, leaves));
119
+ return {
120
+ ...contribution,
121
+ node: { ...contribution.node, children: [...contribution.node.children, ...fragment.roots] },
122
+ adaptLeaf: (leaf, path) => {
123
+ if (!leaves.has(leaf))
124
+ return contribution.adaptLeaf(leaf, path);
125
+ const commandPath = [...path];
126
+ return {
127
+ outputKind: 'object',
128
+ run: async (input) => executeExternalLeaf({
129
+ extension: { branch, root: located.projectRoot },
130
+ executable: fragment.executable,
131
+ commandPath,
132
+ output: leaf.output,
133
+ }, input),
134
+ };
135
+ },
136
+ };
137
+ });
138
+ return { contributions: attached, validations };
139
+ }
140
+ /** Doctor's only directory scan: fragments with no matching effective marked branch are otherwise inert by design. */
141
+ export function orphanCommandExtensionValidations(contributions, startDir = process.cwd()) {
142
+ const scopeRoot = projectScopeRoots(startDir, null)[0];
143
+ if (scopeRoot === undefined)
144
+ return [];
145
+ const commandsDir = join(scopeRoot, 'commands');
146
+ let names;
147
+ try {
148
+ names = readdirSync(commandsDir);
149
+ }
150
+ catch {
151
+ return [];
152
+ }
153
+ const extensible = new Set(contributions.filter((contribution) => contribution.node.extensible === true).map((contribution) => contribution.node.name));
154
+ return names.filter((name) => name.endsWith('.json')).flatMap((name) => {
155
+ const branch = name.slice(0, -'.json'.length);
156
+ if (extensible.has(branch))
157
+ return [];
158
+ const fragmentPath = join(commandsDir, name);
159
+ return [{
160
+ branch,
161
+ fragmentPath,
162
+ projectRoot: dirname(scopeRoot),
163
+ issue: issue(fragmentPath, `fragment names branch "${branch}" but no effective plugin branch is extensible`, branch, 'the name of an effective extensible plugin branch', `Mark the plugin branch extensible or rename/remove ${fragmentPath}.`),
164
+ }];
165
+ });
166
+ }
@@ -1,14 +1,27 @@
1
1
  import type { InstalledPlugin } from '../../../types.js';
2
2
  import type { Field } from '../../help.js';
3
- export interface ExternalLeafSpec {
3
+ export type ExternalLeafSpec = {
4
4
  plugin: InstalledPlugin;
5
+ extension?: never;
5
6
  /** Absolute, resolved executable path. */
6
7
  executable: string;
7
8
  /** Full canonical command path, e.g. ['app', 'show']. */
8
9
  commandPath: string[];
9
10
  /** Declared leaf output fields — validated against the result before render. */
10
11
  output: Field[];
11
- }
12
+ } | {
13
+ plugin?: never;
14
+ extension: {
15
+ branch: string;
16
+ root: string;
17
+ };
18
+ /** Absolute, resolved executable path. */
19
+ executable: string;
20
+ /** Full canonical command path, e.g. ['app', 'show']. */
21
+ commandPath: string[];
22
+ /** Declared leaf output fields — validated against the result before render. */
23
+ output: Field[];
24
+ };
12
25
  /** Validates a result object against declared output field descriptors.
13
26
  * Returns null if valid; otherwise returns a { field, receivedType } error.
14
27
  * Transport-neutral: used by exec and HTTP transport adapters. */
@@ -52,12 +52,14 @@ export function executeExternalLeaf(spec, input) {
52
52
  input,
53
53
  context: {
54
54
  cwd: process.cwd(),
55
- plugin: {
56
- name: spec.plugin.name,
57
- version: spec.plugin.version ?? spec.plugin.manifest.version ?? null,
58
- scope: spec.plugin.scope,
59
- root: spec.plugin.root,
60
- },
55
+ ...(spec.plugin !== undefined
56
+ ? { plugin: {
57
+ name: spec.plugin.name,
58
+ version: spec.plugin.version ?? spec.plugin.manifest.version ?? null,
59
+ scope: spec.plugin.scope,
60
+ root: spec.plugin.root,
61
+ } }
62
+ : { extension: spec.extension }),
61
63
  },
62
64
  };
63
65
  const res = spawnSync(spec.executable, ['--crtr-command-protocol', '1'], {
@@ -207,5 +209,7 @@ function protocolError(spec, received, expected) {
207
209
  return new CrtrError('plugin_protocol_error', `${label(spec)}: ${received}. Expected ${expected}.`, ExitCode.GENERAL, { received, next: NEXT });
208
210
  }
209
211
  function label(spec) {
210
- return `plugin "${spec.plugin.name}" command \`${spec.commandPath.join(' ')}\``;
212
+ return spec.plugin !== undefined
213
+ ? `plugin "${spec.plugin.name}" command \`${spec.commandPath.join(' ')}\``
214
+ : `extension branch "${spec.extension.branch}" command \`${spec.commandPath.join(' ')}\``;
211
215
  }