@north-light/crouter 0.3.184 → 0.3.185

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 (61) hide show
  1. package/dist/build-root.js +10 -3
  2. package/dist/builtin-memory/internal/plugins.md +1 -1
  3. package/dist/clients/attach/input/capabilities.js +3 -0
  4. package/dist/clients/attach/slash/dispatch.js +12 -0
  5. package/dist/clients/attach/viewer.js +191 -191
  6. package/dist/commands/pkg/browse/actions.d.ts +7 -0
  7. package/dist/commands/pkg/browse/actions.js +199 -0
  8. package/dist/commands/pkg/browse/catalog.d.ts +9 -0
  9. package/dist/commands/pkg/browse/catalog.js +466 -0
  10. package/dist/commands/pkg/browse/command-view.d.ts +3 -0
  11. package/dist/commands/pkg/browse/command-view.js +191 -0
  12. package/dist/commands/pkg/browse/detail-panel.d.ts +66 -0
  13. package/dist/commands/pkg/browse/detail-panel.js +402 -0
  14. package/dist/commands/pkg/browse/doc-view.d.ts +3 -0
  15. package/dist/commands/pkg/browse/doc-view.js +81 -0
  16. package/dist/commands/pkg/browse/list-panel.d.ts +28 -0
  17. package/dist/commands/pkg/browse/list-panel.js +168 -0
  18. package/dist/commands/pkg/browse/model.d.ts +230 -0
  19. package/dist/commands/pkg/browse/model.js +36 -0
  20. package/dist/commands/pkg/browse/shell.d.ts +93 -0
  21. package/dist/commands/pkg/browse/shell.js +640 -0
  22. package/dist/commands/pkg/browse.d.ts +2 -0
  23. package/dist/commands/pkg/browse.js +27 -0
  24. package/dist/commands/pkg/market-manage.js +13 -4
  25. package/dist/commands/pkg/plugin-manage.js +8 -3
  26. package/dist/commands/pkg.js +2 -1
  27. package/dist/commands/sys/doctor.js +6 -4
  28. package/dist/commands/sys/panels/broker-limits-panel.d.ts +1 -1
  29. package/dist/commands/sys/panels/broker-limits-panel.js +1 -1
  30. package/dist/commands/sys/panels/dependencies-panel.d.ts +1 -1
  31. package/dist/commands/sys/panels/dependencies-panel.js +1 -1
  32. package/dist/commands/sys/panels/keybindings-panel.js +1 -1
  33. package/dist/commands/sys/panels/models-panel.d.ts +1 -1
  34. package/dist/commands/sys/panels/models-panel.js +1 -1
  35. package/dist/commands/sys/panels/plugins-panel.d.ts +1 -1
  36. package/dist/commands/sys/panels/plugins-panel.js +1 -1
  37. package/dist/commands/sys/panels/prefs-panel.d.ts +1 -1
  38. package/dist/commands/sys/panels/prefs-panel.js +1 -1
  39. package/dist/commands/sys/panels/provider-panel.d.ts +1 -1
  40. package/dist/commands/sys/panels/provider-panel.js +1 -1
  41. package/dist/commands/sys/settings-shell.js +1 -1
  42. package/dist/commands/sys/setup-settings-runner.d.ts +7 -18
  43. package/dist/commands/sys/setup-settings-runner.js +3 -52
  44. package/dist/commands/sys/setup-wizard.js +1 -1
  45. package/dist/core/__tests__/serial/command-plugins.test.js +21 -40
  46. package/dist/core/command-manifests/registry.d.ts +10 -3
  47. package/dist/core/command-manifests/registry.js +36 -22
  48. package/dist/core/command-plugins/compose.js +1 -1
  49. package/dist/core/command-plugins/discovery.js +15 -7
  50. package/dist/core/keybindings/catalog.d.ts +1 -1
  51. package/dist/core/keybindings/catalog.js +1 -0
  52. package/dist/core/resolver.d.ts +2 -2
  53. package/dist/core/runtime/tmux-bindings.js +2 -0
  54. package/dist/core/tui/page-host.d.ts +25 -0
  55. package/dist/core/tui/page-host.js +65 -0
  56. package/dist/pi-extensions/canvas-bash-valve.js +45 -1
  57. package/dist/types.d.ts +4 -1
  58. package/package.json +1 -1
  59. package/runtime.lock.json +2 -2
  60. /package/dist/{commands/sys/panels → core/tui}/panel.d.ts +0 -0
  61. /package/dist/{commands/sys/panels → core/tui}/panel.js +0 -0
@@ -155,6 +155,7 @@ export const marketUpdate = defineLeaf({
155
155
  summary: 'git pull one or all registered marketplaces',
156
156
  params: [
157
157
  { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Marketplace name to update. Omit to update all registered marketplaces.' },
158
+ { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Narrows resolution.' },
158
159
  ],
159
160
  output: [
160
161
  { name: 'updated', type: 'object[]', required: true, constraint: 'One entry per marketplace processed: {name, updated, sha}.' },
@@ -164,6 +165,8 @@ export const marketUpdate = defineLeaf({
164
165
  },
165
166
  run: async (input) => {
166
167
  const name = input['name'];
168
+ const scopeInput = input['scope'];
169
+ const scope = scopeInput === undefined ? undefined : resolveScopeArg(scopeInput);
167
170
  function pullIfGit(root) {
168
171
  const shaBefore = currentSha(root);
169
172
  if (!isGitRepo(root))
@@ -189,7 +192,7 @@ export const marketUpdate = defineLeaf({
189
192
  }
190
193
  s.marketplaces[target.name].last_updated = nowIso();
191
194
  });
192
- const sourcedPlugins = listAllPlugins().filter((plugin) => plugin.scope !== 'builtin' && plugin.sourceMarketplace === target.name);
195
+ const sourcedPlugins = listAllPlugins().filter((plugin) => plugin.scope === target.scope && plugin.sourceMarketplace === target.name);
193
196
  for (const plugin of sourcedPlugins) {
194
197
  if (isSymlink(plugin.root)) {
195
198
  // Marketplace pull above updates symlinked relative-source plugins.
@@ -217,13 +220,19 @@ export const marketUpdate = defineLeaf({
217
220
  return results;
218
221
  }
219
222
  if (name !== undefined) {
220
- const found = findMarketplaceByName(name);
223
+ const found = scope === undefined || scope === 'all' || scope === 'builtin'
224
+ ? findMarketplaceByName(name)
225
+ : findMarketplaceByName(name, scope);
221
226
  if (!found)
222
- throw notFound(`marketplace not found: ${name}`);
227
+ throw notFound(`marketplace not found: ${name}${scope === undefined || scope === 'all' || scope === 'builtin' ? '' : ` (scope: ${scope})`}`);
223
228
  const results = await doUpdate([{ name: found.name, scope: found.scope, root: found.root }]);
224
229
  return { updated: results };
225
230
  }
226
- const targets = listAllMarketplaces().map((m) => ({ name: m.name, scope: m.scope, root: m.root }));
231
+ const targets = (scope === undefined || scope === 'all'
232
+ ? listAllMarketplaces()
233
+ : scope === 'builtin'
234
+ ? []
235
+ : listAllMarketplaces().filter((marketplace) => marketplace.scope === scope)).map((m) => ({ name: m.name, scope: m.scope, root: m.root }));
227
236
  const results = await doUpdate(targets);
228
237
  return { updated: results };
229
238
  },
@@ -549,6 +549,7 @@ export const pluginUpdate = defineLeaf({
549
549
  summary: 'refresh one or all installed plugins',
550
550
  params: [
551
551
  { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Plugin name to update. Omit to update all installed plugins.' },
552
+ { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Narrows resolution.' },
552
553
  ],
553
554
  output: [
554
555
  { name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, transport?, updated, sha?, version?, docs?, commands?, error?}. Archive plugins report transport=http and replace their complete directory after an authenticated refetch; version is the archive content identifier and docs is the written memory-document count. sha is present for git updates. A bulk update reports and skips a failed archive plugin. commands is present for validated command plugins: {mounts: string[], issues: object[]}.' },
@@ -558,15 +559,19 @@ export const pluginUpdate = defineLeaf({
558
559
  },
559
560
  run: async (input) => {
560
561
  const name = input['name'];
562
+ const scopeInput = input['scope'];
563
+ const scope = scopeInput === undefined ? undefined : resolveScopeArg(scopeInput);
561
564
  const marketplaceCache = new Map();
562
565
  if (name !== undefined) {
563
- const found = findPluginByName(name);
566
+ const found = scope === undefined || scope === 'all' || scope === 'builtin'
567
+ ? findPluginByName(name)
568
+ : findPluginByName(name, scope);
564
569
  if (found === null) {
565
- throw notFound(`plugin not found: ${name}`);
570
+ throw notFound(`plugin not found: ${name}${scope === undefined || scope === 'all' || scope === 'builtin' ? '' : ` (scope: ${scope})`}`);
566
571
  }
567
572
  return { updated: [await updateOnePlugin(found, marketplaceCache, { strict: true })] };
568
573
  }
569
- const targets = listAllPlugins().filter((p) => p.scope !== 'builtin');
574
+ const targets = listAllPlugins().filter((plugin) => plugin.scope !== 'builtin' && (scope === undefined || scope === 'all' || plugin.scope === scope));
570
575
  const results = [];
571
576
  for (const plugin of targets) {
572
577
  try {
@@ -1,5 +1,6 @@
1
1
  // `crtr pkg` subtree: plugin lifecycle and marketplace source management.
2
2
  import { defineBranch } from '../core/command.js';
3
+ import { pkgBrowseLeaf } from './pkg/browse.js';
3
4
  import { pluginBranch } from './pkg/plugin.js';
4
5
  import { marketBranch } from './pkg/market.js';
5
6
  export function registerPkg() {
@@ -14,6 +15,6 @@ export function registerPkg() {
14
15
  name: 'pkg',
15
16
  summary: 'manage plugins and marketplace sources',
16
17
  },
17
- children: [pluginBranch, marketBranch],
18
+ children: [pkgBrowseLeaf, pluginBranch, marketBranch],
18
19
  });
19
20
  }
@@ -114,18 +114,20 @@ async function runCommandPluginChecks(scopes) {
114
114
  const inScope = new Set(scopes);
115
115
  const reserved = new Set(SUBTREE_NAMES);
116
116
  // Validate the whole effective set together so cross-plugin top-level name
117
- // collisions surface here — a collided command is NOT mounted, and every
118
- // claimant must fail, not pass.
117
+ // collisions surface here. A collision no longer costs anyone their command:
118
+ // each claimant mounts under an origin-qualified name, so it is reported as a
119
+ // healthy mount rather than a failure demanding the user remove a plugin.
119
120
  for (const v of validateEffectiveCommandPlugins(reserved, undefined, undefined, await coreCommandPaths())) {
120
121
  const plugin = v.plugin;
121
122
  if (!inScope.has(plugin.scope))
122
123
  continue;
123
- if (v.issues.length === 0) {
124
+ const blocking = v.issues.filter((issue) => issue.code !== 'command_collision');
125
+ if (blocking.length === 0) {
124
126
  const mounts = v.contributions.map((c) => c.node.name).join(', ');
125
127
  results.push(pass(plugin.scope, `plugin:${plugin.name}:commands`, mounts.length > 0 ? `contributes top-level command(s): ${mounts}` : `command manifest valid`));
126
128
  continue;
127
129
  }
128
- v.issues.forEach((issue, i) => {
130
+ blocking.forEach((issue, i) => {
129
131
  const remediation = {
130
132
  kind: 'human_action',
131
133
  description: `${issue.next} Inspect with \`pkg plugin show ${plugin.name}\`; then update (\`pkg plugin update --name ${plugin.name}\`) or remove (\`pkg plugin remove ${plugin.name}\`) the plugin.`,
@@ -1,5 +1,5 @@
1
1
  import { Input } from "@earendil-works/pi-tui";
2
- import { type SetupPanel } from "./panel.js";
2
+ import { type SetupPanel } from "../../../core/tui/panel.js";
3
3
  export declare class BrokerLimitsPanel implements SetupPanel {
4
4
  private readonly onChange;
5
5
  readonly warningInput: Input;
@@ -1,7 +1,7 @@
1
1
  import { Input, truncateToWidth } from "@earendil-works/pi-tui";
2
2
  import { readConfig, updateRawConfigAtomically } from "../../../core/config.js";
3
3
  import { BROKER_LIMIT_OVERRIDE_PRECEDENCE } from "../setup.js";
4
- import { padAnsi, theme, wrapText } from "./panel.js";
4
+ import { padAnsi, theme, wrapText } from "../../../core/tui/panel.js";
5
5
  function positive(value) {
6
6
  return /^\d+$/.test(value) &&
7
7
  Number.isSafeInteger(Number(value)) &&
@@ -1,5 +1,5 @@
1
1
  import type { SetupSystemInstallPlan } from "../setup-core.js";
2
- import { type SetupPanel } from "./panel.js";
2
+ import { type SetupPanel } from "../../../core/tui/panel.js";
3
3
  export declare class DependenciesPanel implements SetupPanel {
4
4
  private readonly plans;
5
5
  private readonly onChange;
@@ -1,5 +1,5 @@
1
1
  import { truncateToWidth } from "@earendil-works/pi-tui";
2
- import { theme } from "./panel.js";
2
+ import { theme } from "../../../core/tui/panel.js";
3
3
  function toolLabel(tool) {
4
4
  return tool === "nvim" ? "nvim" : tool === "nerd-font" ? "Nerd Font" : "tmux";
5
5
  }
@@ -2,7 +2,7 @@ import { Input, matchesKey, truncateToWidth, visibleWidth, } from "@earendil-wor
2
2
  import { BINDING_CATALOG, formatBinding, formatGesture, isAttachPaneBinding, matchesPiTuiInput, persistUserKeybindings, KeybindingConcurrentEditError, KeybindingValidationError, } from "../../../core/keybindings/index.js";
3
3
  import { installTmuxBindings, occupiedTmuxRootGestures, } from "../../../core/runtime/tmux-chrome.js";
4
4
  import { SetupKeybindingsDraft } from "../setup-core.js";
5
- import { padAnsi, theme } from "./panel.js";
5
+ import { padAnsi, theme } from "../../../core/tui/panel.js";
6
6
  /** Collapse explicitly-paired catalog definitions into one setup action row.
7
7
  * Pair membership and field roles come only from catalog metadata; neither
8
8
  * rendering nor installation guesses from binding ID names. */
@@ -1,7 +1,7 @@
1
1
  import { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent";
2
2
  import { type TUI } from "@earendil-works/pi-tui";
3
3
  import type { BindingId } from "../../../core/keybindings/index.js";
4
- import { type SetupPanel } from "./panel.js";
4
+ import { type SetupPanel } from "../../../core/tui/panel.js";
5
5
  export declare class ModelsPanel implements SetupPanel {
6
6
  private readonly tui;
7
7
  private readonly runtime;
@@ -6,7 +6,7 @@ import { readRawConfigAtRoot, persistDefaultKindModel, persistModelLadders, read
6
6
  import { findProjectScopeRoots, userScopeRoot } from "../../../core/scope.js";
7
7
  import { loadProfileManifest, profileRoot, } from "../../../core/profiles/manifest.js";
8
8
  import { configuredLadderProviders, inferPortableModelSelection, MODEL_PROVIDER_KEYS, MODEL_STRENGTHS, THINKING_SUFFIXES, } from "../../../core/runtime/model-selection.js";
9
- import { fitTail, padAnsi, renderRows, theme, } from "./panel.js";
9
+ import { fitTail, padAnsi, renderRows, theme, } from "../../../core/tui/panel.js";
10
10
  const MODELS_CHROME_LINES = 3;
11
11
  const RUNTIME_PROVIDER_ID = {
12
12
  anthropic: ANTHROPIC_PROVIDER_ID,
@@ -1,5 +1,5 @@
1
1
  import type { SetupPackageId, SetupPackageManifestEntry } from "../setup-core.js";
2
- import { type SetupPanel } from "./panel.js";
2
+ import { type SetupPanel } from "../../../core/tui/panel.js";
3
3
  export declare class PluginsPanel implements SetupPanel {
4
4
  private readonly manifest;
5
5
  private readonly installedPackageIds;
@@ -1,5 +1,5 @@
1
1
  import { truncateToWidth } from "@earendil-works/pi-tui";
2
- import { theme, wrapText } from "./panel.js";
2
+ import { theme, wrapText } from "../../../core/tui/panel.js";
3
3
  export class PluginsPanel {
4
4
  manifest;
5
5
  installedPackageIds;
@@ -1,5 +1,5 @@
1
1
  import { type UserSettingGroup } from "../../../core/user-settings.js";
2
- import { type SetupPanel } from "./panel.js";
2
+ import { type SetupPanel } from "../../../core/tui/panel.js";
3
3
  /**
4
4
  * One destination's worth of user-scope preference rows. The old single
5
5
  * "Settings" tab listed every row flat with a permanent description under each,
@@ -1,7 +1,7 @@
1
1
  import { truncateToWidth } from "@earendil-works/pi-tui";
2
2
  import { readConfig, updateConfig } from "../../../core/config.js";
3
3
  import { userSettingsInGroup, } from "../../../core/user-settings.js";
4
- import { padAnsi, theme, visibleRange, wrapText, } from "./panel.js";
4
+ import { padAnsi, theme, visibleRange, wrapText, } from "../../../core/tui/panel.js";
5
5
  const LABEL_WIDTH = 26;
6
6
  /**
7
7
  * One destination's worth of user-scope preference rows. The old single
@@ -1,6 +1,6 @@
1
1
  import { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent";
2
2
  import { type TUI } from "@earendil-works/pi-tui";
3
- import { type SetupPanel } from "./panel.js";
3
+ import { type SetupPanel } from "../../../core/tui/panel.js";
4
4
  type AuthStatus = ReturnType<ModelRegistry["getProviderAuthStatus"]>;
5
5
  type ProviderAuthType = "oauth" | "api_key";
6
6
  type ProviderRow = {
@@ -5,7 +5,7 @@ import { dialogAuthInteraction } from "../../../core/auth-interaction.js";
5
5
  import { isManagedProvider, promoteSubscription, readSubscriptionPool, renameSubscription, } from "../../../core/subscription-state.js";
6
6
  import { removeProviderRow } from "../../../core/provider-management.js";
7
7
  import { cliClient } from "../../api-client.js";
8
- import { fitTail, theme, visibleRange, } from "./panel.js";
8
+ import { fitTail, theme, visibleRange, } from "../../../core/tui/panel.js";
9
9
  /** Curated setup-catalog view over pi's OAuth providers + the model registry. */
10
10
  export function buildProviderCatalog(runtime, registry) {
11
11
  const oauth = runtime
@@ -10,7 +10,7 @@ import { runSetupSettingsProcess } from "./setup-settings-runner.js";
10
10
  import { BrokerLimitsPanel } from "./panels/broker-limits-panel.js";
11
11
  import { KeybindingsPanel } from "./panels/keybindings-panel.js";
12
12
  import { ModelsPanel } from "./panels/models-panel.js";
13
- import { PAGE_CHROME_ROWS, columns, padAnsi, pageFrame, theme, } from "./panels/panel.js";
13
+ import { PAGE_CHROME_ROWS, columns, padAnsi, pageFrame, theme, } from "../../core/tui/panel.js";
14
14
  import { PrefsPanel } from "./panels/prefs-panel.js";
15
15
  import { ProviderPanel } from "./panels/provider-panel.js";
16
16
  /** Rail column width, including its badge gutter. */
@@ -1,24 +1,13 @@
1
1
  import { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent";
2
- import { ProcessTerminal, TUI, type Component, type Focusable } from "@earendil-works/pi-tui";
3
- import { readRawScopeConfig } from "../../core/config.js";
4
- import { type BindingId, type BindingResolution } from "../../core/keybindings/index.js";
5
- type SetupSettingsSurface = Component & Focusable & {
6
- dispose(): void;
7
- };
8
- type SetupSettingsProcessContext = {
9
- tui: TUI;
10
- terminal: ProcessTerminal;
2
+ import type { BindingId, BindingResolution } from "../../core/keybindings/index.js";
3
+ import { type TuiPageContext, type TuiPageControl, type TuiPageSurface } from "../../core/tui/page-host.js";
4
+ /** Setup and settings both edit pi's model configuration, so their host adds
5
+ * the model runtime to the shared page context. Every other page concern —
6
+ * theme, keybindings, terminal lifecycle — belongs to `runTuiPage`. */
7
+ type SetupSettingsProcessContext = TuiPageContext & {
11
8
  agentDir: string;
12
9
  runtime: ModelRuntime;
13
10
  registry: ModelRegistry;
14
- bindings: BindingResolution<BindingId>;
15
- rawUserConfig: ReturnType<typeof readRawScopeConfig>;
16
- };
17
- type SetupSettingsProcessControl<Result> = {
18
- submit(result: Result): void;
19
- cancel(): void;
20
11
  };
21
- /** The process-level terminal host shared by setup and settings. Navigation,
22
- * submission, and panel policy stay in their concrete surface factories. */
23
- export declare function runSetupSettingsProcess<Result>(createSurface: (context: SetupSettingsProcessContext, control: SetupSettingsProcessControl<Result>) => SetupSettingsSurface, interceptCancel?: (data: string, bindings: BindingResolution<BindingId>) => boolean): Promise<Result | undefined>;
12
+ export declare function runSetupSettingsProcess<Result>(createSurface: (context: SetupSettingsProcessContext, control: TuiPageControl<Result>) => TuiPageSurface, interceptCancel?: (data: string, bindings: BindingResolution<BindingId>) => boolean): Promise<Result | undefined>;
24
13
  export {};
@@ -1,63 +1,14 @@
1
1
  import { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent";
2
- import { ProcessTerminal, TUI, } from "@earendil-works/pi-tui";
3
2
  import { join } from "node:path";
4
- import { applyTheme, defaultAgentDir } from "../../clients/attach/config.js";
5
- import { readRawScopeConfig } from "../../core/config.js";
6
- import { resolveKeybindings, } from "../../core/keybindings/index.js";
3
+ import { defaultAgentDir } from "../../clients/attach/config.js";
7
4
  import { PoolCredentialStore } from "../../core/pool-credential-store.js";
8
- /** The process-level terminal host shared by setup and settings. Navigation,
9
- * submission, and panel policy stay in their concrete surface factories. */
5
+ import { runTuiPage, } from "../../core/tui/page-host.js";
10
6
  export async function runSetupSettingsProcess(createSurface, interceptCancel) {
11
- applyTheme({ cwd: process.cwd() });
12
7
  const agentDir = defaultAgentDir();
13
8
  const runtime = await ModelRuntime.create({
14
9
  credentials: new PoolCredentialStore(join(agentDir, "auth.json")),
15
10
  modelsPath: join(agentDir, "models.json"),
16
11
  });
17
12
  const registry = new ModelRegistry(runtime);
18
- const rawUserConfig = readRawScopeConfig("user");
19
- const bindings = resolveKeybindings(rawUserConfig?.keybindings);
20
- const terminal = new ProcessTerminal();
21
- const tui = new TUI(terminal, true);
22
- return new Promise((resolve, reject) => {
23
- let surface;
24
- let restored = false;
25
- const restore = () => {
26
- if (restored)
27
- return;
28
- restored = true;
29
- surface?.dispose();
30
- try {
31
- tui.stop();
32
- }
33
- catch {
34
- /* terminal restore */
35
- }
36
- };
37
- const finish = (result) => {
38
- restore();
39
- resolve(result);
40
- };
41
- try {
42
- surface = createSurface({ tui, terminal, agentDir, runtime, registry, bindings, rawUserConfig }, { submit: finish, cancel: () => finish(undefined) });
43
- tui.addChild(surface);
44
- tui.setFocus(surface);
45
- process.once("exit", restore);
46
- for (const signal of ["SIGTERM", "SIGHUP", "SIGINT"])
47
- process.once(signal, () => finish(undefined));
48
- if (interceptCancel !== undefined) {
49
- tui.addInputListener((data) => {
50
- if (!interceptCancel(data, bindings))
51
- return undefined;
52
- finish(undefined);
53
- return { consume: true };
54
- });
55
- }
56
- tui.start();
57
- }
58
- catch (error) {
59
- restore();
60
- reject(error);
61
- }
62
- });
13
+ return runTuiPage((context, control) => createSurface({ ...context, agentDir, runtime, registry }, control), interceptCancel);
63
14
  }
@@ -10,7 +10,7 @@ import { formatGesture, matchesPiTuiInput, } from "../../core/keybindings/index.
10
10
  import { buildSetupInitialState, executeSetupSubmission, formatSetupExecutionSummary, packagePlanAction, reconcileInboxFrontDoor, renderSetupStaticInstructions, } from "./setup-core.js";
11
11
  import { runSetupSettingsProcess } from "./setup-settings-runner.js";
12
12
  import { DependenciesPanel } from "./panels/dependencies-panel.js";
13
- import { PAGE_CHROME_ROWS, pageFrame, theme, } from "./panels/panel.js";
13
+ import { PAGE_CHROME_ROWS, pageFrame, theme, } from "../../core/tui/panel.js";
14
14
  import { PluginsPanel } from "./panels/plugins-panel.js";
15
15
  import { ProviderPanel } from "./panels/provider-panel.js";
16
16
  const STEPS = ["Providers", "Plugins", "Dependencies"];
@@ -10,7 +10,8 @@
10
10
  // real BranchDef/LeafDef whose root/branch/leaf help carries the plugin's
11
11
  // representations, schema, effects, and stable order.
12
12
  // 2. Core collision — a fixture claiming a SUBTREE_NAMES name is skipped +
13
- // recorded as a command_collision issue.
13
+ // recorded as a command_collision issue; plugin collisions keep both
14
+ // commands under origin-qualified names.
14
15
  // 3. Scope/shadowing — a project copy shadows a user copy of the same plugin
15
16
  // name; a disabled project copy suppresses the user copy.
16
17
  // 4. Protocol integration (table-driven, one fixture executable) — request
@@ -39,10 +40,6 @@ import { CrtrError } from '../../errors.js';
39
40
  import { resetScopeCache } from '../../scope.js';
40
41
  import { listInstalledPluginsInRoot } from '../../resolver.js';
41
42
  import { pluginShow } from '../../../commands/pkg/plugin-inspect.js';
42
- import { sysDoctorLeaf } from '../../../commands/sys/doctor.js';
43
- import { createApiServer } from '../../../daemon/api/server.js';
44
- import { apiSocketPath, crtrHome } from '../../canvas/paths.js';
45
- import { closeDb } from '../../canvas/db.js';
46
43
  // The set of reserved core names discovery must fail closed against. We don't
47
44
  // import the real SUBTREE_NAMES (build-root.ts pulls the whole tree); a small
48
45
  // explicit set is enough for the collision contract and keeps the test cheap.
@@ -156,8 +153,12 @@ function installPlugin(scopeRoot, name, opts = {}) {
156
153
  const execPath = join(root, 'bin', 'cmd.js');
157
154
  writeFileSync(execPath, opts.exec ?? FIXTURE_EXEC);
158
155
  chmodSync(execPath, opts.execBit === false ? 0o644 : 0o755);
159
- if (opts.enabled === false) {
160
- writeFileSync(join(scopeRoot, 'config.json'), JSON.stringify({ plugins: { [name]: { enabled: false } } }));
156
+ const config = {
157
+ ...(opts.enabled === false ? { enabled: false } : {}),
158
+ ...(opts.sourceMarketplace !== undefined ? { source_marketplace: opts.sourceMarketplace } : {}),
159
+ };
160
+ if (Object.keys(config).length > 0) {
161
+ writeFileSync(join(scopeRoot, 'config.json'), JSON.stringify({ plugins: { [name]: config } }));
161
162
  }
162
163
  return root;
163
164
  }
@@ -599,8 +600,8 @@ describe('plugin help addenda', () => {
599
600
  });
600
601
  // ---------------------------------------------------------------------------
601
602
  // Lifecycle surfaces — the validator's verdict must reach the caller WITHOUT
602
- // executing the plugin binary, and a cross-plugin top-level collision must not
603
- // read as a pass on any claimant.
603
+ // executing the plugin binary, and a cross-plugin top-level collision must
604
+ // preserve every claimant under its origin-qualified command name.
604
605
  // ---------------------------------------------------------------------------
605
606
  describe('lifecycle surfaces report validation without executing the plugin', () => {
606
607
  // Records the fact it ran. No validation surface may ever spawn it.
@@ -639,45 +640,25 @@ process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: true, result: { ap
639
640
  assert.equal(commands.mounts.length, 0, 'a rejected manifest contributes no mounts');
640
641
  assertNotExecuted();
641
642
  });
642
- test('a cross-plugin top-level collision drops the command and fails BOTH claimants on every surface', async () => {
643
- // Two plugins, each valid in isolation, both claiming `app`.
643
+ test('a cross-plugin top-level collision keeps both commands under their origin-qualified names', async () => {
644
+ // Two plugins, each valid in isolation, both claiming `app`. One is direct;
645
+ // the other records its marketplace as the command origin.
644
646
  installPlugin(userRoot, 'p-one', { exec: SENTINEL_EXEC });
645
- installPlugin(userRoot, 'p-two', { exec: SENTINEL_EXEC });
647
+ installPlugin(userRoot, 'p-two', { exec: SENTINEL_EXEC, sourceMarketplace: 'fixture-market' });
646
648
  resetScopeCache();
647
649
  const snapshot = discoverCommandContributions(new Set(['sys']), emptyStart);
648
- assert.equal(snapshot.contributions.length, 0, 'a collided top-level command must not be mounted by ANY claimant');
649
- assert.deepEqual(snapshot.issues.filter((i) => i.code === 'command_collision').map((i) => i.plugin).sort(), ['p-one', 'p-two'], 'both claimants are flagged');
650
- for (const name of ['p-one', 'p-two']) {
650
+ assert.deepEqual(snapshot.contributions.map((contribution) => contribution.node.name).sort(), ['fixture-market:app', 'p-one:app']);
651
+ assert.deepEqual(snapshot.issues.filter((i) => i.code === 'command_collision').map((i) => i.plugin).sort(), ['p-one', 'p-two'], 'both claimants report their resolved mount');
652
+ for (const [name, mount] of [['p-one', 'p-one:app'], ['p-two', 'fixture-market:app']]) {
651
653
  const out = await pluginShow.run({ name });
652
654
  const commands = out['commands'];
653
- assert.deepEqual(commands.mounts, [], `${name} must not list the collided command as an accepted mount`);
655
+ assert.deepEqual(commands.mounts, [mount], `${name} lists its qualified command as an accepted mount`);
654
656
  const issue = commands.issues.find((i) => i.code === 'command_collision');
655
657
  assert.ok(issue, `${name} show must carry the command_collision issue`);
656
658
  assert.deepEqual(issue.received.split(', ').sort(), ['p-one', 'p-two'], 'the issue names both claimants');
657
- }
658
- // `sys doctor` scans live nodes through the daemon API, so it needs a
659
- // reachable daemon answering with an empty roster (no node is ever created
660
- // here) rather than trying to autostart a subprocess.
661
- mkdirSync(crtrHome(), { recursive: true });
662
- const server = createApiServer();
663
- try {
664
- for (let i = 0; i < 200 && !existsSync(apiSocketPath()); i++)
665
- await new Promise((r) => setTimeout(r, 25));
666
- assert.equal(existsSync(apiSocketPath()), true, 'api socket never bound');
667
- const out = await sysDoctorLeaf.run({ scope: 'user', fix: false, remote: false });
668
- const checks = out['checks'];
669
- for (const name of ['p-one', 'p-two']) {
670
- assert.equal(checks.find((k) => k.name === `plugin:${name}:commands` && k.status === 'pass'), undefined, `${name} must not PASS while its command is dropped by a collision`);
671
- const failCheck = checks.find((k) => k.name.startsWith(`plugin:${name}:commands`) && k.status === 'fail' && /command_collision/.test(k.message));
672
- assert.ok(failCheck, `${name} must fail with command_collision, got ${JSON.stringify(checks.map((k) => k.name + ':' + k.status))}`);
673
- assert.match(failCheck.message, /claimed by multiple plugins/);
674
- assert.equal(failCheck.remediation?.kind, 'human_action', 'command remediation is never auto-applied');
675
- }
676
- assert.equal(out['ok'], false);
677
- }
678
- finally {
679
- await server.close();
680
- closeDb();
659
+ assert.match(issue.message, new RegExp(`mounted as "${mount}"`));
660
+ assert.equal(issue.expected, 'an origin-qualified top-level command for each claimant');
661
+ assert.equal(issue.next, `Invoke "${mount}" to use this plugin command.`);
681
662
  }
682
663
  assertNotExecuted();
683
664
  });
@@ -4,7 +4,13 @@ import type { DeclLeafBase, DeclBranch, CommandManifestIssue } from './schema.js
4
4
  export type CommandContributorRef = {
5
5
  name: string;
6
6
  scope: 'user' | 'project';
7
+ sourceMarketplace?: string;
7
8
  };
9
+ /** The stable source name used to qualify a colliding plugin command. */
10
+ export declare function commandContributorOrigin(contributor: Pick<CommandContributorRef, 'name' | 'sourceMarketplace'>): string;
11
+ /** The top-level name assigned to a plugin command that collides with another
12
+ * plugin command. */
13
+ export declare function qualifiedTopLevelCommandName(contributor: Pick<CommandContributorRef, 'name' | 'sourceMarketplace'>, commandName: string): string;
8
14
  /** Adapter callback to turn a declared leaf into a crtr LeafDef. Takes the
9
15
  * leaf definition and its resolved command path, returns the outputKind and
10
16
  * run function to be used by the tree composer. */
@@ -34,7 +40,8 @@ export interface CommandRegistrySnapshot {
34
40
  issues: ContributorIssue[];
35
41
  }
36
42
  /** Resolve a flat list of candidate contributions into a collision-resolved
37
- * snapshot. Every contribution at a collided path is dropped (all claimants),
38
- * an issue is recorded on each claimant, core names take precedence, and
39
- * results are deterministically ordered by contributor name. */
43
+ * snapshot. Plugin claimants at a collided path are qualified by origin and
44
+ * kept; core names take precedence and are dropped. An issue is recorded on
45
+ * every claimant, and results are deterministically ordered by contributor
46
+ * name. */
40
47
  export declare function resolveCommandRegistry(candidates: readonly CommandContribution[], reservedCoreNames: ReadonlySet<string>): CommandRegistrySnapshot;
@@ -2,26 +2,35 @@
2
2
  // contributor attribution. This module takes raw candidates (from plugins,
3
3
  // HTTP-transport plugins, or any external source) and runs one unified pass that:
4
4
  //
5
- // 1. Detects collisions by resolved path (shortest collision wins).
6
- // 2. Drops all claimants at each collided path.
7
- // 3. Records issues on every claimant at each collision.
8
- // 4. Gives core absolute precedence.
5
+ // 1. Detects top-level collisions between plugins.
6
+ // 2. Keeps every plugin claimant by mounting it at <origin>:<command>.
7
+ // 3. Records an informational issue on every qualified claimant.
8
+ // 4. Gives core absolute precedence and drops core-name claimants.
9
9
  // 5. Returns a deterministically ordered, collision-resolved snapshot.
10
10
  //
11
11
  // The registry itself is transport-neutral: it knows nothing about plugin
12
12
  // executables or HTTP invokers. Each contribution carries its own adapter
13
13
  // callback, which the tree composer invokes to turn a leaf into a real crtr
14
14
  // LeafDef.
15
+ /** The stable source name used to qualify a colliding plugin command. */
16
+ export function commandContributorOrigin(contributor) {
17
+ return contributor.sourceMarketplace ?? contributor.name;
18
+ }
19
+ /** The top-level name assigned to a plugin command that collides with another
20
+ * plugin command. */
21
+ export function qualifiedTopLevelCommandName(contributor, commandName) {
22
+ return `${commandContributorOrigin(contributor)}:${commandName}`;
23
+ }
15
24
  /** Resolve a flat list of candidate contributions into a collision-resolved
16
- * snapshot. Every contribution at a collided path is dropped (all claimants),
17
- * an issue is recorded on each claimant, core names take precedence, and
18
- * results are deterministically ordered by contributor name. */
25
+ * snapshot. Plugin claimants at a collided path are qualified by origin and
26
+ * kept; core names take precedence and are dropped. An issue is recorded on
27
+ * every claimant, and results are deterministically ordered by contributor
28
+ * name. */
19
29
  export function resolveCommandRegistry(candidates, reservedCoreNames) {
20
30
  const collisions = new Map();
21
31
  const contributionsByPath = new Map();
22
- // Phase 1: Detect collisions. Collect every top-level path (and implicitly,
23
- // every ancestor path that the top-level node reaches), detect duplicates.
24
- // A collision drops ALL claimants at that path.
32
+ // Phase 1: Detect collisions. A core collision drops the plugin claimant;
33
+ // a cross-plugin collision qualifies every claimant in phase 2.
25
34
  for (const contrib of candidates) {
26
35
  const { node, contributor } = contrib;
27
36
  const path = [node.name];
@@ -54,8 +63,8 @@ export function resolveCommandRegistry(candidates, reservedCoreNames) {
54
63
  contributionsByPath.set(pathKey, contrib);
55
64
  }
56
65
  }
57
- // Phase 2: Build the final snapshot. Include only non-collided contributions
58
- // and record issues on all collided claimants.
66
+ // Phase 2: Build the final snapshot. Core collisions are dropped; plugin
67
+ // collisions keep every claimant under its origin-qualified command name.
59
68
  const accepted = [];
60
69
  const issues = [];
61
70
  for (const contrib of candidates) {
@@ -63,11 +72,11 @@ export function resolveCommandRegistry(candidates, reservedCoreNames) {
63
72
  if (collisions.has(pathKey)) {
64
73
  const collision = collisions.get(pathKey);
65
74
  const claimantNames = collision.claimants.map((c) => c.contributor.name).join(', ');
66
- const claimantKinds = collision.claimants.map((c) => c.contributor.name).join(', ');
67
- // Record an issue on this claimant. A core collision has exactly one
68
- // claimant contesting a reserved built-in name, not "multiple contributors".
69
- issues.push(collision.isCoreCollision
70
- ? {
75
+ // A core collision has exactly one claimant contesting a reserved
76
+ // built-in name. Plugin collisions are informative: every claimant is
77
+ // retained under the source-qualified name reported here.
78
+ if (collision.isCoreCollision) {
79
+ issues.push({
71
80
  code: 'command_collision',
72
81
  contributor: contrib.contributor,
73
82
  path: contrib.node.name,
@@ -75,16 +84,21 @@ export function resolveCommandRegistry(candidates, reservedCoreNames) {
75
84
  received: claimantNames,
76
85
  expected: 'no top-level command name matching a reserved core command',
77
86
  next: `Rename "${contrib.node.name}" to a name that does not collide with core.`,
78
- }
79
- : {
87
+ });
88
+ }
89
+ else {
90
+ const qualifiedName = qualifiedTopLevelCommandName(contrib.contributor, contrib.node.name);
91
+ accepted.push({ ...contrib, node: { ...contrib.node, name: qualifiedName } });
92
+ issues.push({
80
93
  code: 'command_collision',
81
94
  contributor: contrib.contributor,
82
95
  path: contrib.node.name,
83
- message: `top-level command "${contrib.node.name}" is claimed by multiple contributors: ${claimantKinds}`,
96
+ message: `top-level command "${contrib.node.name}" is claimed by ${claimantNames} — mounted as "${qualifiedName}"`,
84
97
  received: claimantNames,
85
- expected: 'exactly one contributor per top-level command',
86
- next: `Remove or disable all but one of: ${claimantNames}.`,
98
+ expected: 'an origin-qualified top-level command for each claimant',
99
+ next: `Invoke "${qualifiedName}" to use this plugin command.`,
87
100
  });
101
+ }
88
102
  }
89
103
  else {
90
104
  // Not collided — accept it.
@@ -4,7 +4,7 @@ import { invokeHttpLeaf } from './transport/http-invoke.js';
4
4
  export function adaptPluginContributions(contributions) {
5
5
  return {
6
6
  contributions: contributions.map((contribution) => ({
7
- contributor: { name: contribution.plugin.name, scope: contribution.plugin.scope },
7
+ contributor: { name: contribution.plugin.name, scope: contribution.plugin.scope, sourceMarketplace: contribution.plugin.sourceMarketplace },
8
8
  node: contribution.node,
9
9
  adaptLeaf: (leaf, commandPath) => {
10
10
  const transport = contribution.transport;