@north-light/crouter 0.3.183 → 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.
- package/dist/build-root.js +10 -3
- package/dist/builtin-memory/internal/plugins.md +1 -1
- package/dist/clients/attach/input/capabilities.js +3 -0
- package/dist/clients/attach/slash/dispatch.js +12 -0
- package/dist/clients/attach/viewer.js +191 -191
- package/dist/commands/pkg/browse/actions.d.ts +7 -0
- package/dist/commands/pkg/browse/actions.js +199 -0
- package/dist/commands/pkg/browse/catalog.d.ts +9 -0
- package/dist/commands/pkg/browse/catalog.js +466 -0
- package/dist/commands/pkg/browse/command-view.d.ts +3 -0
- package/dist/commands/pkg/browse/command-view.js +191 -0
- package/dist/commands/pkg/browse/detail-panel.d.ts +66 -0
- package/dist/commands/pkg/browse/detail-panel.js +402 -0
- package/dist/commands/pkg/browse/doc-view.d.ts +3 -0
- package/dist/commands/pkg/browse/doc-view.js +81 -0
- package/dist/commands/pkg/browse/list-panel.d.ts +28 -0
- package/dist/commands/pkg/browse/list-panel.js +168 -0
- package/dist/commands/pkg/browse/model.d.ts +230 -0
- package/dist/commands/pkg/browse/model.js +36 -0
- package/dist/commands/pkg/browse/shell.d.ts +93 -0
- package/dist/commands/pkg/browse/shell.js +640 -0
- package/dist/commands/pkg/browse.d.ts +2 -0
- package/dist/commands/pkg/browse.js +27 -0
- package/dist/commands/pkg/market-manage.js +13 -4
- package/dist/commands/pkg/plugin-manage.js +8 -3
- package/dist/commands/pkg.js +2 -1
- package/dist/commands/sys/doctor.js +6 -4
- package/dist/commands/sys/panels/broker-limits-panel.d.ts +1 -1
- package/dist/commands/sys/panels/broker-limits-panel.js +1 -1
- package/dist/commands/sys/panels/dependencies-panel.d.ts +1 -1
- package/dist/commands/sys/panels/dependencies-panel.js +1 -1
- package/dist/commands/sys/panels/keybindings-panel.js +1 -1
- package/dist/commands/sys/panels/models-panel.d.ts +1 -1
- package/dist/commands/sys/panels/models-panel.js +1 -1
- package/dist/commands/sys/panels/plugins-panel.d.ts +1 -1
- package/dist/commands/sys/panels/plugins-panel.js +1 -1
- package/dist/commands/sys/panels/prefs-panel.d.ts +1 -1
- package/dist/commands/sys/panels/prefs-panel.js +1 -1
- package/dist/commands/sys/panels/provider-panel.d.ts +1 -1
- package/dist/commands/sys/panels/provider-panel.js +1 -1
- package/dist/commands/sys/settings-shell.js +1 -1
- package/dist/commands/sys/setup-settings-runner.d.ts +7 -18
- package/dist/commands/sys/setup-settings-runner.js +3 -52
- package/dist/commands/sys/setup-wizard.js +1 -1
- package/dist/core/__tests__/serial/command-plugins.test.js +21 -40
- package/dist/core/canvas/nav-model.d.ts +1 -8
- package/dist/core/canvas/nav-model.js +30 -59
- package/dist/core/canvas/nav-render.d.ts +2 -2
- package/dist/core/canvas/nav-render.js +8 -6
- package/dist/core/command-manifests/registry.d.ts +10 -3
- package/dist/core/command-manifests/registry.js +36 -22
- package/dist/core/command-plugins/compose.js +1 -1
- package/dist/core/command-plugins/discovery.js +15 -7
- package/dist/core/keybindings/catalog.d.ts +1 -1
- package/dist/core/keybindings/catalog.js +1 -0
- package/dist/core/resolver.d.ts +2 -2
- package/dist/core/runtime/tmux-bindings.js +2 -0
- package/dist/core/tui/page-host.d.ts +25 -0
- package/dist/core/tui/page-host.js +65 -0
- package/dist/daemon/__tests__/subtree-filter-freshness.test.d.ts +1 -0
- package/dist/daemon/__tests__/subtree-filter-freshness.test.js +63 -0
- package/dist/pi-extensions/canvas-bash-valve.js +45 -1
- package/dist/types.d.ts +4 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- /package/dist/{commands/sys/panels → core/tui}/panel.d.ts +0 -0
- /package/dist/{commands/sys/panels → core/tui}/panel.js +0 -0
|
@@ -15,74 +15,45 @@ import { activeFaultForDisplay } from './render.js';
|
|
|
15
15
|
import { readFault } from '../runtime/fault.js';
|
|
16
16
|
import { faultSummary } from './status-glyph.js';
|
|
17
17
|
import { statusRank } from './node-order.js';
|
|
18
|
-
import {
|
|
18
|
+
import { nodeGlyph, navLabel, shortId, tokensCell, activityCell, cycleBadge, askBadge, liveBelowBadge, truncate, fillBar, fillWidth, isAttached, BOLD, DIM, RESET, REVERSE, BG_ATTACHED, } from './nav-render.js';
|
|
19
19
|
// Re-export the pure render/telemetry half so existing nav-model importers
|
|
20
20
|
// (chrome/canvas-panels, overlays/graph, resume) keep resolving these here.
|
|
21
21
|
export { PI_MAX_WIDGET_LINES, VIEWPORT_FALLBACK_ROWS, ESC, RESET, BOLD, DIM, REVERSE, BG_ATTACHED, GREEN, RED, YELLOW, CYAN, GRAY, nodeGlyph, visibleWidth, truncate, fillWidth, fillBar, readTelemetry, fmtTokens, tokensCell, activityCell, cycleBadge, navLabel, shortId, askBadge, liveBelowBadge, isAttached, hasBrokerViewers, fetchAsksMap, graphWidgetBudget, GRAPH_HINT, } from './nav-render.js';
|
|
22
22
|
// ---------------------------------------------------------------------------
|
|
23
|
-
//
|
|
23
|
+
// Read primitives — UNCACHED, deliberately
|
|
24
24
|
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// That redundancy is the GRAPH view's open/scroll lag.
|
|
25
|
+
// These once memoized into module-global Maps that a `beginFrame()` cleared at
|
|
26
|
+
// the top of each TUI render. That cache is gone. Nothing in this module is
|
|
27
|
+
// frame-scoped any more: the GRAPH overlay reads the canvas through crtrd's
|
|
28
|
+
// remote CanvasSource (clients/attach/overlays/graph.ts) and never imports this
|
|
29
|
+
// file, while the one live consumer left — crtrd's `GET /v1/nodes?under=` via
|
|
30
|
+
// `subtreeIds` — is a daemon that runs for HOURS between restarts.
|
|
32
31
|
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
32
|
+
// A process-lifetime memo there is not "at worst one frame stale", it is
|
|
33
|
+
// permanent: the first `?under=X` request that lands before X has spawned any
|
|
34
|
+
// children pinned `[]` under X and every later request returned it. Correctness
|
|
35
|
+
// on the daemon outranks a render amortization no caller asks for, so each read
|
|
36
|
+
// goes straight to the canvas db. The only frame cache still in play is
|
|
37
|
+
// nav-render's telemetry memo, whose TUI callers clear it themselves via
|
|
38
|
+
// `beginFrameTelem()`.
|
|
39
39
|
// ---------------------------------------------------------------------------
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const frameMgrs = new Map();
|
|
43
|
-
export function beginFrame() {
|
|
44
|
-
frameNodes.clear();
|
|
45
|
-
frameSubs.clear();
|
|
46
|
-
frameMgrs.clear();
|
|
47
|
-
beginFrameTelem();
|
|
48
|
-
}
|
|
49
|
-
/** getNode memoized for the current frame (meta.json read + sqlite row). */
|
|
50
|
-
export function cNode(id) {
|
|
51
|
-
if (frameNodes.has(id))
|
|
52
|
-
return frameNodes.get(id) ?? null;
|
|
53
|
-
const v = getNode(id);
|
|
54
|
-
frameNodes.set(id, v);
|
|
55
|
-
return v;
|
|
56
|
-
}
|
|
57
|
-
/** subscriptionsOf (a node's reports) memoized for the current frame. */
|
|
58
|
-
export function cSubscriptions(id) {
|
|
59
|
-
const hit = frameSubs.get(id);
|
|
60
|
-
if (hit !== undefined)
|
|
61
|
-
return hit;
|
|
62
|
-
let v;
|
|
40
|
+
/** subscriptionsOf (a node's reports); an unreadable row reads as no edges. */
|
|
41
|
+
function subsOf(id) {
|
|
63
42
|
try {
|
|
64
|
-
|
|
43
|
+
return subscriptionsOf(id);
|
|
65
44
|
}
|
|
66
45
|
catch {
|
|
67
|
-
|
|
46
|
+
return [];
|
|
68
47
|
}
|
|
69
|
-
frameSubs.set(id, v);
|
|
70
|
-
return v;
|
|
71
48
|
}
|
|
72
|
-
/** subscribersOf (a node's managers)
|
|
73
|
-
|
|
74
|
-
const hit = frameMgrs.get(id);
|
|
75
|
-
if (hit !== undefined)
|
|
76
|
-
return hit;
|
|
77
|
-
let v;
|
|
49
|
+
/** subscribersOf (a node's managers); an unreadable row reads as no edges. */
|
|
50
|
+
function mgrsOf(id) {
|
|
78
51
|
try {
|
|
79
|
-
|
|
52
|
+
return subscribersOf(id);
|
|
80
53
|
}
|
|
81
54
|
catch {
|
|
82
|
-
|
|
55
|
+
return [];
|
|
83
56
|
}
|
|
84
|
-
frameMgrs.set(id, v);
|
|
85
|
-
return v;
|
|
86
57
|
}
|
|
87
58
|
// ---------------------------------------------------------------------------
|
|
88
59
|
// Attachment — is a human currently WATCHING a node? A separate axis from
|
|
@@ -100,7 +71,7 @@ export function cSubscribers(id) {
|
|
|
100
71
|
// ---------------------------------------------------------------------------
|
|
101
72
|
/** First manager (by created) — the UP step for the ancestry spine. */
|
|
102
73
|
export function managerOf(id) {
|
|
103
|
-
return
|
|
74
|
+
return mgrsOf(id)[0]?.node_id;
|
|
104
75
|
}
|
|
105
76
|
/** A kind:'human' node is a control-plane ASK (a deck on the human's
|
|
106
77
|
* screen), NOT a pi conversation — it has no session, so focusing/reviving it
|
|
@@ -110,17 +81,17 @@ export function managerOf(id) {
|
|
|
110
81
|
* own. Drop it from every navigable list (the tree, BASE reports, child counts,
|
|
111
82
|
* subtree expansion) so it can never be selected. */
|
|
112
83
|
export function isHumanAsk(id) {
|
|
113
|
-
return
|
|
84
|
+
return getNode(id)?.kind === 'human';
|
|
114
85
|
}
|
|
115
86
|
/** A node's direct children that are navigable conversations — human-ask nodes
|
|
116
87
|
* dropped. The one place the nav chrome enumerates children. */
|
|
117
88
|
export function convoChildIds(id) {
|
|
118
|
-
return
|
|
89
|
+
return subsOf(id).map((s) => s.node_id).filter((cid) => !isHumanAsk(cid));
|
|
119
90
|
}
|
|
120
91
|
/** Live reports (active|idle) of a node — the DOWN set in BASE. */
|
|
121
92
|
export function liveReports(id) {
|
|
122
93
|
return convoChildIds(id).filter((cid) => {
|
|
123
|
-
const st =
|
|
94
|
+
const st = getNode(cid)?.status;
|
|
124
95
|
return st === 'active' || st === 'idle';
|
|
125
96
|
});
|
|
126
97
|
}
|
|
@@ -175,7 +146,7 @@ export function childBadge(node) {
|
|
|
175
146
|
* the tree and when stepping into a subtree (`l`). Array.sort is stable, so
|
|
176
147
|
* equal-status siblings keep their creation order. */
|
|
177
148
|
export function sortedChildIds(id) {
|
|
178
|
-
return convoChildIds(id).sort((a, b) => statusRank(
|
|
149
|
+
return convoChildIds(id).sort((a, b) => statusRank(getNode(a)?.status) - statusRank(getNode(b)?.status));
|
|
179
150
|
}
|
|
180
151
|
// ---------------------------------------------------------------------------
|
|
181
152
|
// GRAPH model — flatten the local graph fold-aware. Rebuilt every render (cheap
|
|
@@ -189,7 +160,7 @@ export function computeSubtreeActivity(root, self) {
|
|
|
189
160
|
// Returns the count of active nodes in subtree(id) INCLUDING id, and whether
|
|
190
161
|
// that subtree is worth revealing (holds an active node or self).
|
|
191
162
|
const visit = (id) => {
|
|
192
|
-
const selfActive =
|
|
163
|
+
const selfActive = getNode(id)?.status === 'active';
|
|
193
164
|
if (seen.has(id))
|
|
194
165
|
return { active: selfActive ? 1 : 0, reveal: selfActive || id === self };
|
|
195
166
|
seen.add(id);
|
|
@@ -254,7 +225,7 @@ export function renderGraphRow(r, isCursor, focused, activeBelow, asks) {
|
|
|
254
225
|
const line = `${r.branch} ${DIM}↺ ${shortId(r.id)}${RESET}`;
|
|
255
226
|
return wrap(line, false);
|
|
256
227
|
}
|
|
257
|
-
const node =
|
|
228
|
+
const node = getNode(r.id);
|
|
258
229
|
const dot = nodeGlyph(node);
|
|
259
230
|
const rawName = navLabel(node, r.id);
|
|
260
231
|
const name = r.isSelf ? `${BOLD}${rawName}${RESET}` : rawName;
|
|
@@ -50,8 +50,8 @@ export declare function fillWidth(): number;
|
|
|
50
50
|
* bar; the visible width is padded out to `width`; the line closes with a real
|
|
51
51
|
* RESET so the style never bleeds into the editor below. */
|
|
52
52
|
export declare function fillBar(content: string, width: number, open: string): string;
|
|
53
|
-
/** Clear the per-frame telemetry cache.
|
|
54
|
-
*
|
|
53
|
+
/** Clear the per-frame telemetry cache. Every render pass calls this first, so
|
|
54
|
+
* one keystroke re-reads each node's telemetry exactly once. */
|
|
55
55
|
export declare function beginFrameTelem(): void;
|
|
56
56
|
export interface Telemetry {
|
|
57
57
|
/** Live context-window size (pi's getContextUsage) — the figure pi's own footer
|
|
@@ -130,14 +130,16 @@ export function fillBar(content, width, open) {
|
|
|
130
130
|
return `${open}${reasserted}${' '.repeat(pad)}${RESET}`;
|
|
131
131
|
}
|
|
132
132
|
// ---------------------------------------------------------------------------
|
|
133
|
-
// Per-frame telemetry cache.
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
// worst be one frame old.
|
|
133
|
+
// Per-frame telemetry cache. Its callers are TUI render passes ONLY (the GRAPH
|
|
134
|
+
// overlay and the canvas panels), each of which calls beginFrameTelem() at the
|
|
135
|
+
// top of the pass, so token/activity cells read fresh once per frame instead of
|
|
136
|
+
// once per row and a stale entry can at worst be one frame old. Never memoize a
|
|
137
|
+
// canvas read for a caller that is not a render pass: crtrd lives for hours, and
|
|
138
|
+
// a memo it never clears is permanent, not per-frame.
|
|
137
139
|
// ---------------------------------------------------------------------------
|
|
138
140
|
const frameTelem = new Map();
|
|
139
|
-
/** Clear the per-frame telemetry cache.
|
|
140
|
-
*
|
|
141
|
+
/** Clear the per-frame telemetry cache. Every render pass calls this first, so
|
|
142
|
+
* one keystroke re-reads each node's telemetry exactly once. */
|
|
141
143
|
export function beginFrameTelem() {
|
|
142
144
|
frameTelem.clear();
|
|
143
145
|
}
|
|
@@ -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.
|
|
38
|
-
*
|
|
39
|
-
* results are deterministically ordered by contributor
|
|
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
|
|
6
|
-
// 2.
|
|
7
|
-
// 3. Records
|
|
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.
|
|
17
|
-
*
|
|
18
|
-
* results are deterministically ordered by contributor
|
|
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.
|
|
23
|
-
//
|
|
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.
|
|
58
|
-
//
|
|
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
|
-
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
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
|
|
96
|
+
message: `top-level command "${contrib.node.name}" is claimed by ${claimantNames} — mounted as "${qualifiedName}"`,
|
|
84
97
|
received: claimantNames,
|
|
85
|
-
expected: '
|
|
86
|
-
next: `
|
|
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;
|
|
@@ -3,7 +3,7 @@ import { resolve, sep } from 'node:path';
|
|
|
3
3
|
import { listInstalledPlugins, listInstalledPluginsInRoot } from '../resolver.js';
|
|
4
4
|
import { projectScopeRoots } from '../scope.js';
|
|
5
5
|
import { validateCommandManifest } from '../command-manifests/manifest.js';
|
|
6
|
-
import { resolveCommandRegistry } from '../command-manifests/registry.js';
|
|
6
|
+
import { qualifiedTopLevelCommandName, resolveCommandRegistry } from '../command-manifests/registry.js';
|
|
7
7
|
import { adaptPluginContributions } from './compose.js';
|
|
8
8
|
import { validateHttpPluginTransport } from './endpoint.js';
|
|
9
9
|
export function effectiveCommandPlugins(startDir = process.cwd(), profileId) {
|
|
@@ -139,18 +139,26 @@ export function validateEffectiveCommandPlugins(reservedNames, startDir = proces
|
|
|
139
139
|
const validations = effectiveCommandPlugins(startDir, profileId).map((plugin) => validatePluginCommands(plugin, reservedNames, coreCommandPaths));
|
|
140
140
|
const claims = new Map();
|
|
141
141
|
for (const validation of validations)
|
|
142
|
-
for (const contribution of validation.contributions)
|
|
143
|
-
|
|
142
|
+
for (const contribution of validation.contributions) {
|
|
143
|
+
const claimants = claims.get(contribution.node.name);
|
|
144
|
+
if (claimants === undefined)
|
|
145
|
+
claims.set(contribution.node.name, [contribution]);
|
|
146
|
+
else
|
|
147
|
+
claimants.push(contribution);
|
|
148
|
+
}
|
|
144
149
|
for (const [name, claimants] of claims)
|
|
145
150
|
if (claimants.length > 1)
|
|
146
|
-
for (const
|
|
147
|
-
|
|
148
|
-
|
|
151
|
+
for (const claimant of claimants) {
|
|
152
|
+
const { plugin } = claimant;
|
|
153
|
+
const qualifiedName = qualifiedTopLevelCommandName({ name: plugin.name, sourceMarketplace: plugin.sourceMarketplace }, name);
|
|
154
|
+
const validation = validations.find((candidate) => candidate.plugin.root === plugin.root);
|
|
155
|
+
validation.contributions = validation.contributions.map((contribution) => contribution === claimant ? { ...contribution, node: { ...contribution.node, name: qualifiedName } } : contribution);
|
|
156
|
+
validation.issues.push({ code: 'command_collision', plugin: plugin.name, path: name, message: `top-level command "${name}" is claimed by ${claimants.map((entry) => entry.plugin.name).join(', ')} — mounted as "${qualifiedName}"`, received: claimants.map((entry) => entry.plugin.name).join(', '), expected: 'an origin-qualified top-level command for each claimant', next: `Invoke "${qualifiedName}" to use this plugin command.` });
|
|
149
157
|
}
|
|
150
158
|
return validations;
|
|
151
159
|
}
|
|
152
160
|
function contributorIssue(plugin, issue) {
|
|
153
|
-
return { ...issue, contributor: { name: plugin.name, scope: plugin.scope } };
|
|
161
|
+
return { ...issue, contributor: { name: plugin.name, scope: plugin.scope, sourceMarketplace: plugin.sourceMarketplace } };
|
|
154
162
|
}
|
|
155
163
|
export function discoverCommandContributions(reservedNames, startDir = process.cwd(), profileId) {
|
|
156
164
|
const validations = validateEffectiveCommandPlugins(reservedNames, startDir, profileId);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { BindingDefinition, SafetyRequirement } from './types.js';
|
|
2
2
|
import type { AttachBindingId, AttachMenuBindingId } from './attach-control.js';
|
|
3
|
-
type TmuxMenuAction = 'open' | 'attach' | 'show' | 'view-settings' | 'fold-tools' | 'metadata' | 'promote' | 'resume' | 'fork' | 'demote' | 'detach' | 'close-subtree' | 'background-bash' | 'file-review' | 'pin' | 'reload-viewer' | 'recap' | 'context' | 'providers' | 'graph' | 'focus-manager' | 'copy-id' | 'issues';
|
|
3
|
+
type TmuxMenuAction = 'open' | 'attach' | 'show' | 'view-settings' | 'fold-tools' | 'metadata' | 'promote' | 'resume' | 'fork' | 'demote' | 'detach' | 'close-subtree' | 'background-bash' | 'file-review' | 'pin' | 'reload-viewer' | 'recap' | 'context' | 'providers' | 'plugins' | 'graph' | 'focus-manager' | 'copy-id' | 'issues';
|
|
4
4
|
type TmuxMenuEntryAction = Exclude<TmuxMenuAction, 'open' | 'attach'> | `issue.${'idea' | 'bug'}`;
|
|
5
5
|
type GraphAction = 'down' | 'up' | 'first' | 'last' | 'collapse-or-manager' | 'expand-or-child' | 'focus' | 'revive' | 'focus-manager' | 'close-subtree' | 'dismiss';
|
|
6
6
|
type BrowseAction = 'quit' | 'down' | 'up' | 'first' | 'last' | 'expand-or-child' | 'collapse-or-parent' | 'toggle-fold' | 'tab-next' | 'tab-previous' | `tab.${1 | 2 | 3 | 4 | 5}` | 'close-subtree' | 'confirm-close' | 'sort' | 'scope' | 'profile' | 'residents' | 'preview' | 'search' | 'revive-now' | 'focus' | 'focus-background';
|
|
@@ -53,6 +53,7 @@ export const BINDING_CATALOG = Object.freeze([
|
|
|
53
53
|
...menuEntry('metadata', 'Show metadata', 'Show the focused node’s metadata dossier.', ['m'], ['tmux.menu.show']),
|
|
54
54
|
...menuEntry('context', 'Browse context', 'Browse the focused node context.', ['c'], ['tmux.menu.show']),
|
|
55
55
|
...menuEntry('providers', 'Open settings', 'Open the crouter settings panel.', [','], ['tmux.menu']),
|
|
56
|
+
...menuEntry('plugins', 'Browse plugins', 'Open the crouter plugin browser.', ['k'], ['tmux.menu']),
|
|
56
57
|
...menuEntry('graph', 'Canvas graph', 'Open the canvas graph.', ['g'], ['tmux.menu']),
|
|
57
58
|
...menuEntry('focus-manager', 'Go to manager', 'Focus this node’s manager.', ['h'], ['tmux.menu']),
|
|
58
59
|
...menuEntry('copy-id', 'Copy node id', 'Copy the focused node id to the system clipboard.', ['y'], ['tmux.menu']),
|
package/dist/core/resolver.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface ParsedSkillQualifier {
|
|
|
8
8
|
segments: string[];
|
|
9
9
|
}
|
|
10
10
|
export declare function parseSkillQualifier(raw: string): ParsedSkillQualifier;
|
|
11
|
-
export declare function listInstalledMarketplaces(scope: Scope): InstalledMarketplace[];
|
|
11
|
+
export declare function listInstalledMarketplaces(scope: Exclude<Scope, 'builtin'>): InstalledMarketplace[];
|
|
12
12
|
export declare function listAllMarketplaces(): InstalledMarketplace[];
|
|
13
|
-
export declare function findMarketplaceByName(name: string, scope?: Scope): InstalledMarketplace | null;
|
|
13
|
+
export declare function findMarketplaceByName(name: string, scope?: Exclude<Scope, 'builtin'>): InstalledMarketplace | null;
|
|
14
14
|
export declare function scopeRootsLabel(): string;
|
|
@@ -33,6 +33,7 @@ const LEAF_MENU_ACTIONS = {
|
|
|
33
33
|
// The settings page lays out a grouped rail beside a content panel; the
|
|
34
34
|
// default dialog-sized popup clipped both the rail and the models matrix.
|
|
35
35
|
'crtr.tmux.menu.providers': { description: 'settings', action: { kind: 'popup', run: 'sys settings', size: { w: '90%', h: '85%' } } },
|
|
36
|
+
'crtr.tmux.menu.plugins': { description: 'plugins', action: { kind: 'popup', run: 'pkg browse', size: { w: '90%', h: '85%' } } },
|
|
36
37
|
'crtr.tmux.menu.graph': { description: 'canvas graph', action: { kind: 'keys', keys: '/graph' } },
|
|
37
38
|
'crtr.tmux.menu.focus-manager': { description: 'go to manager', action: { kind: 'command', run: 'surface node focus {manager}' } },
|
|
38
39
|
'crtr.tmux.menu.copy-id': { description: 'copy node id', action: { kind: 'command', run: 'surface node id --pane {pane}' } },
|
|
@@ -301,6 +302,7 @@ function menuItems(bindings, entries) {
|
|
|
301
302
|
]));
|
|
302
303
|
addGroup('System', boundItems([
|
|
303
304
|
'crtr.tmux.menu.providers',
|
|
305
|
+
'crtr.tmux.menu.plugins',
|
|
304
306
|
'crtr.tmux.menu.issues',
|
|
305
307
|
]));
|
|
306
308
|
const prefixGestures = bindings.gestures('crtr.tmux.menu.open');
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ProcessTerminal, TUI, type Component, type Focusable } from "@earendil-works/pi-tui";
|
|
2
|
+
import { readRawScopeConfig } from "../config.js";
|
|
3
|
+
import { type BindingId, type BindingResolution } from "../keybindings/index.js";
|
|
4
|
+
/** A full-screen page: a focusable TUI component that cleans up after itself. */
|
|
5
|
+
export type TuiPageSurface = Component & Focusable & {
|
|
6
|
+
dispose(): void;
|
|
7
|
+
};
|
|
8
|
+
export interface TuiPageContext {
|
|
9
|
+
tui: TUI;
|
|
10
|
+
terminal: ProcessTerminal;
|
|
11
|
+
bindings: BindingResolution<BindingId>;
|
|
12
|
+
rawUserConfig: ReturnType<typeof readRawScopeConfig>;
|
|
13
|
+
}
|
|
14
|
+
export interface TuiPageControl<Result> {
|
|
15
|
+
/** Close the page with a result. */
|
|
16
|
+
submit(result: Result): void;
|
|
17
|
+
/** Close the page with no result. */
|
|
18
|
+
cancel(): void;
|
|
19
|
+
}
|
|
20
|
+
/** Mount one page as the TUI's only child and resolve when it closes.
|
|
21
|
+
*
|
|
22
|
+
* `interceptCancel` is a process-level escape hatch for surfaces that must be
|
|
23
|
+
* abandonable while a nested component holds the keyboard; it runs before the
|
|
24
|
+
* page sees the input and closes the page when it returns true. */
|
|
25
|
+
export declare function runTuiPage<Result>(createSurface: (context: TuiPageContext, control: TuiPageControl<Result>) => TuiPageSurface, interceptCancel?: (data: string, bindings: BindingResolution<BindingId>) => boolean): Promise<Result | undefined>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The process-level terminal host for crouter's full-screen TUI pages —
|
|
2
|
+
// `crtr sys settings`, the setup wizard, and `crtr pkg browse`.
|
|
3
|
+
//
|
|
4
|
+
// It owns exactly the concerns every page shares: theme, resolved keybindings,
|
|
5
|
+
// the raw user config those pages edit, the ProcessTerminal/TUI pair, and a
|
|
6
|
+
// restore path that survives an exception, a signal, and process exit. Anything
|
|
7
|
+
// a single page needs beyond that — pi's model runtime, a canvas client — the
|
|
8
|
+
// page's own runner builds and closes over before calling in here.
|
|
9
|
+
import { ProcessTerminal, TUI, } from "@earendil-works/pi-tui";
|
|
10
|
+
import { applyTheme } from "../../clients/attach/config.js";
|
|
11
|
+
import { readRawScopeConfig } from "../config.js";
|
|
12
|
+
import { resolveKeybindings, } from "../keybindings/index.js";
|
|
13
|
+
/** Mount one page as the TUI's only child and resolve when it closes.
|
|
14
|
+
*
|
|
15
|
+
* `interceptCancel` is a process-level escape hatch for surfaces that must be
|
|
16
|
+
* abandonable while a nested component holds the keyboard; it runs before the
|
|
17
|
+
* page sees the input and closes the page when it returns true. */
|
|
18
|
+
export async function runTuiPage(createSurface, interceptCancel) {
|
|
19
|
+
applyTheme({ cwd: process.cwd() });
|
|
20
|
+
const rawUserConfig = readRawScopeConfig("user");
|
|
21
|
+
const bindings = resolveKeybindings(rawUserConfig?.keybindings);
|
|
22
|
+
const terminal = new ProcessTerminal();
|
|
23
|
+
const tui = new TUI(terminal, true);
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
let surface;
|
|
26
|
+
let restored = false;
|
|
27
|
+
const restore = () => {
|
|
28
|
+
if (restored)
|
|
29
|
+
return;
|
|
30
|
+
restored = true;
|
|
31
|
+
surface?.dispose();
|
|
32
|
+
try {
|
|
33
|
+
tui.stop();
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
/* terminal restore */
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const finish = (result) => {
|
|
40
|
+
restore();
|
|
41
|
+
resolve(result);
|
|
42
|
+
};
|
|
43
|
+
try {
|
|
44
|
+
surface = createSurface({ tui, terminal, bindings, rawUserConfig }, { submit: finish, cancel: () => finish(undefined) });
|
|
45
|
+
tui.addChild(surface);
|
|
46
|
+
tui.setFocus(surface);
|
|
47
|
+
process.once("exit", restore);
|
|
48
|
+
for (const signal of ["SIGTERM", "SIGHUP", "SIGINT"])
|
|
49
|
+
process.once(signal, () => finish(undefined));
|
|
50
|
+
if (interceptCancel !== undefined) {
|
|
51
|
+
tui.addInputListener((data) => {
|
|
52
|
+
if (!interceptCancel(data, bindings))
|
|
53
|
+
return undefined;
|
|
54
|
+
finish(undefined);
|
|
55
|
+
return { consume: true };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
tui.start();
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
restore();
|
|
62
|
+
reject(error);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Regression: crtrd served a PERMANENTLY stale `GET /v1/nodes?under=<id>`.
|
|
2
|
+
//
|
|
3
|
+
// `handleList`'s subtree scope came from nav-model's `subtreeIds`, whose reads
|
|
4
|
+
// memoized into module-global Maps that only a `beginFrame()` cleared — and
|
|
5
|
+
// `beginFrame()` had no call sites anywhere. In a TUI that re-renders, a memo is
|
|
6
|
+
// one frame stale; in crtrd, which lives for hours, it is forever. The first
|
|
7
|
+
// `?under=X` request that arrived BEFORE X had spawned children pinned `[]`
|
|
8
|
+
// under X, and every later request returned that `[]` — Northlight's onboarding
|
|
9
|
+
// progress bar sat at 0/100 for the life of the daemon while the unfiltered list
|
|
10
|
+
// showed all 23 children with correct parents.
|
|
11
|
+
//
|
|
12
|
+
// This drives the real route handler against a real canvas db in exactly that
|
|
13
|
+
// order: list the subtree while it is genuinely empty, THEN spawn children, then
|
|
14
|
+
// list again. Nothing here would have failed with a hand-checked subtree walk —
|
|
15
|
+
// only the request-order sequence catches it.
|
|
16
|
+
import { test, before, after, beforeEach } from 'node:test';
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { createNode, subscribe } from '../../core/canvas/canvas.js';
|
|
22
|
+
import { closeDb } from '../../core/canvas/db.js';
|
|
23
|
+
import { nodeRoutes } from '../api/handlers/nodes.js';
|
|
24
|
+
let home;
|
|
25
|
+
function node(id, parent) {
|
|
26
|
+
return {
|
|
27
|
+
node_id: id, name: id, created: new Date().toISOString(), cwd: '/tmp/work',
|
|
28
|
+
kind: 'general', mode: 'base', lifecycle: 'terminal', status: 'active',
|
|
29
|
+
...(parent !== undefined ? { parent } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** GET /v1/nodes?under=<id> through the registered route, as crtrd serves it. */
|
|
33
|
+
async function listUnder(id) {
|
|
34
|
+
const route = nodeRoutes.find((r) => r.method === 'GET' && r.pattern === '/v1/nodes');
|
|
35
|
+
const ctx = {
|
|
36
|
+
method: 'GET', path: '/v1/nodes', params: {},
|
|
37
|
+
query: new URLSearchParams({ under: id }), body: undefined,
|
|
38
|
+
};
|
|
39
|
+
const res = (await route.handler(ctx));
|
|
40
|
+
assert.equal(res.status, 200);
|
|
41
|
+
return res.body.map((r) => r.node_id);
|
|
42
|
+
}
|
|
43
|
+
before(() => { home = mkdtempSync(join(tmpdir(), 'crtr-subtree-')); process.env['CRTR_HOME'] = home; });
|
|
44
|
+
beforeEach(() => { closeDb(); rmSync(home, { recursive: true, force: true }); });
|
|
45
|
+
after(() => { closeDb(); rmSync(home, { recursive: true, force: true }); delete process.env['CRTR_HOME']; });
|
|
46
|
+
test('a subtree query answered before any child exists does not poison later queries on the same daemon process', async () => {
|
|
47
|
+
const coordinator = 'coordinator';
|
|
48
|
+
createNode(node(coordinator));
|
|
49
|
+
// The poisoning request: the gateway polls within a second of the coordinator
|
|
50
|
+
// being created, before it has spawned anything.
|
|
51
|
+
assert.deepEqual(await listUnder(coordinator), [], 'an empty subtree is genuinely empty');
|
|
52
|
+
// The coordinator now spawns its children (parent subscribes_to child).
|
|
53
|
+
createNode(node('research-gmail', coordinator));
|
|
54
|
+
createNode(node('research-misc', coordinator));
|
|
55
|
+
subscribe(coordinator, 'research-gmail');
|
|
56
|
+
subscribe(coordinator, 'research-misc');
|
|
57
|
+
assert.deepEqual((await listUnder(coordinator)).sort(), ['research-gmail', 'research-misc'], 'the same long-lived process must see children spawned after its first subtree query');
|
|
58
|
+
// A grandchild spawned later still surfaces — the walk is re-read at every
|
|
59
|
+
// level, not just the root's direct edges.
|
|
60
|
+
createNode(node('writer-connectivity', 'research-gmail'));
|
|
61
|
+
subscribe('research-gmail', 'writer-connectivity');
|
|
62
|
+
assert.deepEqual((await listUnder(coordinator)).sort(), ['research-gmail', 'research-misc', 'writer-connectivity'], 'a grandchild spawned after an earlier query surfaces too');
|
|
63
|
+
});
|