@crouton-kit/crouter 0.3.78 → 0.3.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-root.d.ts +12 -4
- package/dist/build-root.js +25 -6
- package/dist/builtin-memory/crouter-development/plugins.md +82 -5
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1228 -12
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +3 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -787
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +8 -3
- package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
- package/dist/clients/attach/__tests__/mermaid-render.test.js +9 -1
- package/dist/clients/attach/attach-cmd.d.ts +9 -1
- package/dist/clients/attach/attach-cmd.js +849 -803
- package/dist/clients/attach/auth-pickers.d.ts +0 -12
- package/dist/clients/attach/auth-pickers.js +64 -15
- package/dist/clients/attach/graph-overlay.d.ts +12 -2
- package/dist/clients/attach/graph-overlay.js +83 -33
- package/dist/commands/human/queue.js +3 -4
- package/dist/commands/pkg/plugin-inspect.js +22 -1
- package/dist/commands/pkg/plugin-manage.js +31 -9
- package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
- package/dist/commands/sys/doctor.js +42 -4
- package/dist/commands/sys/setup-core.d.ts +37 -0
- package/dist/commands/sys/setup-core.js +138 -1
- package/dist/commands/sys/setup.d.ts +88 -0
- package/dist/commands/sys/setup.js +915 -171
- package/dist/commands/view-pick.d.ts +4 -0
- package/dist/commands/view-pick.js +17 -7
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
- package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
- package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
- package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
- package/dist/core/__tests__/command-plugins.test.js +444 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
- package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
- package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
- package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
- package/dist/core/canvas/browse/app.d.ts +6 -0
- package/dist/core/canvas/browse/app.js +71 -41
- package/dist/core/command-plugins/adapter.d.ts +15 -0
- package/dist/core/command-plugins/adapter.js +145 -0
- package/dist/core/command-plugins/compose.d.ts +5 -0
- package/dist/core/command-plugins/compose.js +56 -0
- package/dist/core/command-plugins/discovery.d.ts +104 -0
- package/dist/core/command-plugins/discovery.js +565 -0
- package/dist/core/config.d.ts +2 -5
- package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
- package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
- package/dist/core/keybindings/catalog.d.ts +3 -3
- package/dist/core/keybindings/catalog.js +2 -1
- package/dist/core/profiles/select.d.ts +6 -0
- package/dist/core/profiles/select.js +86 -52
- package/dist/core/provider-management.d.ts +12 -0
- package/dist/core/provider-management.js +24 -0
- package/dist/core/runtime/broker.js +7 -6
- package/dist/core/runtime/pi-vendored.d.ts +8 -0
- package/dist/core/runtime/pi-vendored.js +14 -0
- package/dist/core/runtime/recap.d.ts +1 -1
- package/dist/core/runtime/recap.js +50 -25
- package/dist/core/runtime/session-list-cache.d.ts +10 -0
- package/dist/core/runtime/session-list-cache.js +94 -26
- package/dist/core/runtime/spawn.js +1 -13
- package/dist/core/runtime/tmux.js +2 -1
- package/dist/core/scope.js +27 -4
- package/dist/core/subscription-state.d.ts +90 -0
- package/dist/core/subscription-state.js +762 -0
- package/dist/daemon/crtrd.js +253 -12
- package/dist/pi-extensions/canvas-recap.js +43 -17
- package/dist/types.d.ts +6 -13
- package/dist/types.js +0 -3
- package/package.json +7 -3
|
@@ -1,17 +1,5 @@
|
|
|
1
1
|
import type { TUI } from '@earendil-works/pi-tui';
|
|
2
2
|
import type { Picker, PickerControls } from './pickers.js';
|
|
3
|
-
/** A compact auth summary for a provider — used by non-picker surfaces (e.g.
|
|
4
|
-
* the `crtr sys setup` login tab) to show which providers are already
|
|
5
|
-
* configured without mounting the full selector. */
|
|
6
|
-
export interface ProviderAuthSummary {
|
|
7
|
-
id: string;
|
|
8
|
-
name: string;
|
|
9
|
-
configured: boolean;
|
|
10
|
-
}
|
|
11
|
-
/** Summarize which providers have a stored credential in `agentDir`'s auth.json.
|
|
12
|
-
* Mirrors the OAuth selector's provider list; `configured` is true when the
|
|
13
|
-
* provider has an explicit stored credential (OAuth token or API key). */
|
|
14
|
-
export declare function summarizeProviderAuth(agentDir: string): ProviderAuthSummary[];
|
|
15
3
|
/** `/login` — show the provider selector, then run pi's native login flow in
|
|
16
4
|
* the viewer. On success, notifies the broker to reload auth via reload_auth.
|
|
17
5
|
* Returns a Picker (OAuthSelectorComponent as the overlay). */
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { execFile } from 'node:child_process';
|
|
12
12
|
import { AuthStorage, ModelRegistry, OAuthSelectorComponent, LoginDialogComponent, } from '@earendil-works/pi-coding-agent';
|
|
13
|
+
import { loginAnthropic, loginOpenAICodex } from '@earendil-works/pi-ai/oauth';
|
|
14
|
+
import { ANTHROPIC_PROVIDER_ID, commitManagedLogin, isManagedProvider, readSubscriptionPool, removeManagedProvider, } from '../../core/subscription-state.js';
|
|
13
15
|
/** Build an AuthStorage + ModelRegistry from the broker's agentDir (or pi's
|
|
14
16
|
* default). Both point at the SAME auth.json the broker uses — the viewer and
|
|
15
17
|
* broker share the filesystem (tmux-local invariant). */
|
|
@@ -47,17 +49,6 @@ function buildProviderList(authStorage, registry) {
|
|
|
47
49
|
}
|
|
48
50
|
return providers;
|
|
49
51
|
}
|
|
50
|
-
/** Summarize which providers have a stored credential in `agentDir`'s auth.json.
|
|
51
|
-
* Mirrors the OAuth selector's provider list; `configured` is true when the
|
|
52
|
-
* provider has an explicit stored credential (OAuth token or API key). */
|
|
53
|
-
export function summarizeProviderAuth(agentDir) {
|
|
54
|
-
const { authStorage, registry } = buildLocalAuth(agentDir);
|
|
55
|
-
return buildProviderList(authStorage, registry).map((p) => ({
|
|
56
|
-
id: p.id,
|
|
57
|
-
name: p.name,
|
|
58
|
-
configured: authStorage.get(p.id) !== undefined,
|
|
59
|
-
}));
|
|
60
|
-
}
|
|
61
52
|
/** Open the browser with a URL (best-effort; platform-specific). */
|
|
62
53
|
function openBrowser(url) {
|
|
63
54
|
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
@@ -112,6 +103,52 @@ export function buildLoginPicker(tui, agentDir, onAuthReloaded, notify, controls
|
|
|
112
103
|
// inline under the editor (same slot as the model picker), not as a centered
|
|
113
104
|
// overlay. `replace` focuses the dialog; `close()` (via finish) unmounts it.
|
|
114
105
|
replace(dialog, dialog);
|
|
106
|
+
if (isManagedProvider(providerId)) {
|
|
107
|
+
// Managed provider (anthropic / openai-codex): drive pi's OAuth flow ourselves so we hold
|
|
108
|
+
// the credential, then persist it through the coordinated writer (commitManagedLogin ->
|
|
109
|
+
// commitDefaultIdentity for the default slot) rather than authStorage.login(). That keeps
|
|
110
|
+
// auth.json + the rotation pool identity in lockstep and runs the account-uniqueness gate
|
|
111
|
+
// the raw path skipped. /login here manages only the DEFAULT slot (label === providerId).
|
|
112
|
+
const runLogin = providerId === ANTHROPIC_PROVIDER_ID ? loginAnthropic : loginOpenAICodex;
|
|
113
|
+
// Snapshot the default slot's rate-limit timestamp BEFORE OAuth begins: a strictly newer
|
|
114
|
+
// concurrent 429 recorded during the login must survive a same-account re-login.
|
|
115
|
+
const defaultLastRateLimitedAt = readSubscriptionPool(providerId).find((entry) => entry.label === providerId)?.lastRateLimitedAt ?? 0;
|
|
116
|
+
runLogin({
|
|
117
|
+
onAuth: (info) => {
|
|
118
|
+
dialog.showAuth(info.url, info.instructions);
|
|
119
|
+
openBrowser(info.url);
|
|
120
|
+
},
|
|
121
|
+
onPrompt: (prompt) => dialog.showPrompt(prompt.message, prompt.placeholder),
|
|
122
|
+
onProgress: (message) => {
|
|
123
|
+
dialog.showProgress(message);
|
|
124
|
+
},
|
|
125
|
+
onManualCodeInput: () => dialog.showManualInput('Paste the code or URL:'),
|
|
126
|
+
})
|
|
127
|
+
.then((credential) => {
|
|
128
|
+
if (settled)
|
|
129
|
+
return;
|
|
130
|
+
const accountId = typeof credential.accountId === 'string' && credential.accountId.trim()
|
|
131
|
+
? credential.accountId.trim()
|
|
132
|
+
: undefined;
|
|
133
|
+
commitManagedLogin(providerId, providerId, {
|
|
134
|
+
refresh: credential.refresh,
|
|
135
|
+
access: credential.access,
|
|
136
|
+
expires: credential.expires,
|
|
137
|
+
accountId,
|
|
138
|
+
defaultLastRateLimitedAt,
|
|
139
|
+
});
|
|
140
|
+
notify(`Logged in to ${provider.name}`);
|
|
141
|
+
onAuthReloaded();
|
|
142
|
+
finish();
|
|
143
|
+
})
|
|
144
|
+
.catch((err) => {
|
|
145
|
+
if (err?.name !== 'AbortError') {
|
|
146
|
+
notify(`Login error: ${String(err?.message ?? err)}`);
|
|
147
|
+
}
|
|
148
|
+
finish();
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
115
152
|
if (provider.authType === 'oauth') {
|
|
116
153
|
const isKnownOAuth = authStorage.getOAuthProviders().some((p) => p.id === providerId);
|
|
117
154
|
if (!isKnownOAuth) {
|
|
@@ -195,10 +232,22 @@ export function buildLogoutPicker(agentDir, onAuthReloaded, notify, close) {
|
|
|
195
232
|
const getAuthStatus = (id) => authStorage.getAuthStatus(id);
|
|
196
233
|
const onSelect = (providerId) => {
|
|
197
234
|
const provider = providers.find((p) => p.id === providerId);
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
235
|
+
try {
|
|
236
|
+
// Managed providers tear down the WHOLE provider (every rotation account + the auth.json
|
|
237
|
+
// default record) through the coordinated service, never a raw authStorage.logout() that
|
|
238
|
+
// would strip only auth.json and orphan the pool.
|
|
239
|
+
if (isManagedProvider(providerId))
|
|
240
|
+
removeManagedProvider(providerId);
|
|
241
|
+
else
|
|
242
|
+
authStorage.logout(providerId);
|
|
243
|
+
close();
|
|
244
|
+
notify(`Logged out of ${provider?.name ?? providerId}`);
|
|
245
|
+
onAuthReloaded();
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
close();
|
|
249
|
+
notify(`Logout error: ${error instanceof Error ? error.message : String(error)}`);
|
|
250
|
+
}
|
|
202
251
|
};
|
|
203
252
|
const component = new OAuthSelectorComponent('logout', authStorage, providers, onSelect, () => close(), getAuthStatus);
|
|
204
253
|
return { component, focus: component };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Component, type TUI } from '@earendil-works/pi-tui';
|
|
2
|
+
import { type BindingResolution, type BindingId } from '../../core/keybindings/index.js';
|
|
2
3
|
import { type CanvasSource } from '../../core/canvas/source.js';
|
|
3
4
|
import type { AttachPalette } from './config-load.js';
|
|
4
5
|
export declare class GraphOverlay implements Component {
|
|
@@ -6,6 +7,7 @@ export declare class GraphOverlay implements Component {
|
|
|
6
7
|
private readonly self;
|
|
7
8
|
private readonly getAsks;
|
|
8
9
|
private readonly palette;
|
|
10
|
+
private bindings;
|
|
9
11
|
private readonly source;
|
|
10
12
|
private handle;
|
|
11
13
|
/** Manual fold OVERRIDES (h collapses → userCollapsed, l expands → userExpanded);
|
|
@@ -17,7 +19,7 @@ export declare class GraphOverlay implements Component {
|
|
|
17
19
|
private cursorId;
|
|
18
20
|
/** Viewport scroll offset (row index of the top visible tree row). */
|
|
19
21
|
private scrollTop;
|
|
20
|
-
/** Transient
|
|
22
|
+
/** Transient close confirmation gate. */
|
|
21
23
|
private pendingConfirm;
|
|
22
24
|
private snapshot;
|
|
23
25
|
private snapshotSeq;
|
|
@@ -26,7 +28,15 @@ export declare class GraphOverlay implements Component {
|
|
|
26
28
|
* default and makes Enter/m/x no-op instead of shelling local focus/kill
|
|
27
29
|
* commands against a remote graph row. See the file header. */
|
|
28
30
|
private readonly remote;
|
|
29
|
-
constructor(tui: TUI, self: string, getAsks: () => Record<string, number>, palette: AttachPalette, source?: CanvasSource);
|
|
31
|
+
constructor(tui: TUI, self: string, getAsks: () => Record<string, number>, palette: AttachPalette, bindings: BindingResolution<BindingId>, source?: CanvasSource);
|
|
32
|
+
setBindings(bindings: BindingResolution<BindingId>): void;
|
|
33
|
+
private matches;
|
|
34
|
+
private hint;
|
|
35
|
+
/** Compose footer hints in priority order, dropping WHOLE entries that would
|
|
36
|
+
* overflow `budget` visible columns rather than letting the border mid-clip
|
|
37
|
+
* the tail — so the prioritized head (dismiss + remapped local actions) stays
|
|
38
|
+
* discoverable even in a narrow overlay. */
|
|
39
|
+
private fitHints;
|
|
30
40
|
isOpen(): boolean;
|
|
31
41
|
open(): void;
|
|
32
42
|
close(): void;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// graph-overlay.ts — the
|
|
1
|
+
// graph-overlay.ts — the configurable GRAPH navigator overlay for `crtr surface attach`.
|
|
2
2
|
//
|
|
3
3
|
// Native reimplementation of canvas-nav.ts's GRAPH modal as a pi-tui OVERLAY
|
|
4
4
|
// (the viewer has no pi extension host). `tui.showOverlay(this, …)` mounts this
|
|
@@ -6,12 +6,9 @@
|
|
|
6
6
|
// CAPTURES keyboard focus (mirrors extension-dialogs.ts);
|
|
7
7
|
// `OverlayHandle.hide()` tears it down and restores focus to the editor. While
|
|
8
8
|
// shown, every key routes to handleInput() — a fold-aware NERDTree of the
|
|
9
|
-
// active CanvasSource subscription graph
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// `e` is a tmux-menu prefixBind, not a GRAPH key, so it is swallowed (per
|
|
13
|
-
// canvas-nav). Model/render uses the CanvasSource snapshot built for this
|
|
14
|
-
// overlay; keys are decoded with pi-tui's canonical matchesKey (kitty/CSI-u aware).
|
|
9
|
+
// active CanvasSource subscription graph. Actions and footer hints resolve from
|
|
10
|
+
// the crouter binding catalog; input is decoded with pi-tui's canonical matcher
|
|
11
|
+
// (kitty/CSI-u aware).
|
|
15
12
|
//
|
|
16
13
|
// Enter/m/x SHELL `crtr` out-of-process (node focus / node lifecycle close) — the viewer
|
|
17
14
|
// itself never spawns pi or opens a session (the §0 one-writer invariant). It
|
|
@@ -30,6 +27,7 @@
|
|
|
30
27
|
// graph structure + node metadata ONLY.
|
|
31
28
|
import { execFile } from 'node:child_process';
|
|
32
29
|
import { matchesKey, truncateToWidth } from '@earendil-works/pi-tui';
|
|
30
|
+
import { formatBinding, matchesPiTuiInput, } from '../../core/keybindings/index.js';
|
|
33
31
|
import { fullName } from '../../core/canvas/index.js';
|
|
34
32
|
import { activeFaultForDisplay } from '../../core/canvas/render.js';
|
|
35
33
|
import { readFault } from '../../core/runtime/fault.js';
|
|
@@ -43,18 +41,34 @@ import { beginFrame, navLabel, nodeGlyph, tokensCell, cycleBadge, askBadge, acti
|
|
|
43
41
|
// clipped. margin keeps it off the screen edges.
|
|
44
42
|
const HEIGHT_PCT = 0.72;
|
|
45
43
|
const OVERLAY_OPTIONS = { anchor: 'center', width: '72%', minWidth: 48, maxHeight: '72%', margin: 1 };
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
44
|
+
// The footer is width-budgeted (fitHints drops WHOLE trailing entries that
|
|
45
|
+
// won't fit rather than mid-clipping the tail). List the keys a user most needs
|
|
46
|
+
// to discover first — dismiss (the only way out) then the remapped local
|
|
47
|
+
// actions — so they survive at the narrowest overlay width; routine navigation
|
|
48
|
+
// trails and is the first to be dropped.
|
|
49
|
+
const GRAPH_PRIMARY_LOCAL_HINTS = [
|
|
50
|
+
['crtr.graph.dismiss', 'dismiss'],
|
|
51
|
+
['crtr.graph.focus', 'focus'],
|
|
52
|
+
['crtr.graph.close-subtree', 'close'],
|
|
53
|
+
['crtr.graph.focus-manager', 'manager'],
|
|
54
|
+
];
|
|
55
|
+
const GRAPH_PRIMARY_REMOTE_HINTS = [
|
|
56
|
+
['crtr.graph.dismiss', 'dismiss'],
|
|
57
|
+
];
|
|
58
|
+
const GRAPH_NAV_HINTS = [
|
|
59
|
+
['crtr.graph.down', 'down'],
|
|
60
|
+
['crtr.graph.up', 'up'],
|
|
61
|
+
['crtr.graph.first', 'first'],
|
|
62
|
+
['crtr.graph.last', 'last'],
|
|
63
|
+
['crtr.graph.collapse-or-manager', 'fold/manager'],
|
|
64
|
+
['crtr.graph.expand-or-child', 'expand/child'],
|
|
65
|
+
];
|
|
53
66
|
export class GraphOverlay {
|
|
54
67
|
tui;
|
|
55
68
|
self;
|
|
56
69
|
getAsks;
|
|
57
70
|
palette;
|
|
71
|
+
bindings;
|
|
58
72
|
source;
|
|
59
73
|
handle;
|
|
60
74
|
/** Manual fold OVERRIDES (h collapses → userCollapsed, l expands → userExpanded);
|
|
@@ -66,7 +80,7 @@ export class GraphOverlay {
|
|
|
66
80
|
cursorId;
|
|
67
81
|
/** Viewport scroll offset (row index of the top visible tree row). */
|
|
68
82
|
scrollTop = 0;
|
|
69
|
-
/** Transient
|
|
83
|
+
/** Transient close confirmation gate. */
|
|
70
84
|
pendingConfirm;
|
|
71
85
|
snapshot;
|
|
72
86
|
snapshotSeq = 0;
|
|
@@ -75,14 +89,43 @@ export class GraphOverlay {
|
|
|
75
89
|
* default and makes Enter/m/x no-op instead of shelling local focus/kill
|
|
76
90
|
* commands against a remote graph row. See the file header. */
|
|
77
91
|
remote;
|
|
78
|
-
constructor(tui, self, getAsks, palette, source = localCanvasSource) {
|
|
92
|
+
constructor(tui, self, getAsks, palette, bindings, source = localCanvasSource) {
|
|
79
93
|
this.tui = tui;
|
|
80
94
|
this.self = self;
|
|
81
95
|
this.getAsks = getAsks;
|
|
82
96
|
this.palette = palette;
|
|
97
|
+
this.bindings = bindings;
|
|
83
98
|
this.source = source;
|
|
84
99
|
this.remote = source instanceof RemoteCanvasSource;
|
|
85
100
|
}
|
|
101
|
+
setBindings(bindings) {
|
|
102
|
+
this.bindings = bindings;
|
|
103
|
+
if (this.handle !== undefined)
|
|
104
|
+
this.tui.requestRender();
|
|
105
|
+
}
|
|
106
|
+
matches(id, data) {
|
|
107
|
+
return matchesPiTuiInput(this.bindings, id, data, (input, gesture) => matchesKey(input, gesture));
|
|
108
|
+
}
|
|
109
|
+
hint(entries) {
|
|
110
|
+
return entries.flatMap(([id, label]) => this.bindings.gestures(id).length === 0 ? [] : [`${formatBinding(this.bindings, id)} ${label}`]);
|
|
111
|
+
}
|
|
112
|
+
/** Compose footer hints in priority order, dropping WHOLE entries that would
|
|
113
|
+
* overflow `budget` visible columns rather than letting the border mid-clip
|
|
114
|
+
* the tail — so the prioritized head (dismiss + remapped local actions) stays
|
|
115
|
+
* discoverable even in a narrow overlay. */
|
|
116
|
+
fitHints(entries, budget) {
|
|
117
|
+
const SEP = 3; // visible width of ' · '
|
|
118
|
+
const out = [];
|
|
119
|
+
let used = 0;
|
|
120
|
+
for (const piece of this.hint(entries)) {
|
|
121
|
+
const add = (out.length === 0 ? 0 : SEP) + visibleWidth(piece);
|
|
122
|
+
if (used + add > budget)
|
|
123
|
+
break;
|
|
124
|
+
out.push(piece);
|
|
125
|
+
used += add;
|
|
126
|
+
}
|
|
127
|
+
return out.join(' · ');
|
|
128
|
+
}
|
|
86
129
|
isOpen() {
|
|
87
130
|
return this.handle !== undefined;
|
|
88
131
|
}
|
|
@@ -341,8 +384,14 @@ export class GraphOverlay {
|
|
|
341
384
|
? `${BOLD}⌗ canvas graph${RESET} ${DIM}(${cursorIdx + 1}/${rows.length})${RESET}`
|
|
342
385
|
: `${BOLD}⌗ canvas graph${RESET} ${DIM}(loading)${RESET}`;
|
|
343
386
|
const hint = this.pendingConfirm !== undefined
|
|
344
|
-
? `${YELLOW}${this.pendingConfirm.label} ${BOLD}
|
|
345
|
-
|
|
387
|
+
? `${YELLOW}${this.pendingConfirm.label} ${BOLD}${this.hint([
|
|
388
|
+
['crtr.graph.confirm.accept', 'accept'],
|
|
389
|
+
['crtr.graph.confirm.cancel', 'cancel'],
|
|
390
|
+
]).join(' · ')}${RESET}`
|
|
391
|
+
: `${DIM}${this.fitHints([
|
|
392
|
+
...(this.remote ? GRAPH_PRIMARY_REMOTE_HINTS : GRAPH_PRIMARY_LOCAL_HINTS),
|
|
393
|
+
...GRAPH_NAV_HINTS,
|
|
394
|
+
], Math.max(1, width - 5))}${RESET}`; // budget matches borderRow's span
|
|
346
395
|
// Frame the panel: title in the top border, hint in the bottom border, tree
|
|
347
396
|
// rows boxed with themed side bars (truncate/pad to the inner width so the
|
|
348
397
|
// right edge stays flush and opaque).
|
|
@@ -385,18 +434,19 @@ export class GraphOverlay {
|
|
|
385
434
|
dispatch(data) {
|
|
386
435
|
// y/n confirm gate takes precedence over everything.
|
|
387
436
|
if (this.pendingConfirm !== undefined) {
|
|
388
|
-
if (
|
|
437
|
+
if (this.matches('crtr.graph.confirm.accept', data)) {
|
|
389
438
|
const act = this.pendingConfirm.action;
|
|
390
439
|
this.pendingConfirm = undefined;
|
|
391
440
|
act();
|
|
392
441
|
}
|
|
393
442
|
else {
|
|
394
|
-
|
|
443
|
+
// Preserve GRAPH's modal fallback: any non-accept key cancels close.
|
|
444
|
+
this.pendingConfirm = undefined;
|
|
395
445
|
}
|
|
396
446
|
this.tui.requestRender();
|
|
397
447
|
return;
|
|
398
448
|
}
|
|
399
|
-
if (
|
|
449
|
+
if (this.matches('crtr.graph.dismiss', data)) {
|
|
400
450
|
this.close();
|
|
401
451
|
return;
|
|
402
452
|
}
|
|
@@ -427,29 +477,29 @@ export class GraphOverlay {
|
|
|
427
477
|
}
|
|
428
478
|
return idx;
|
|
429
479
|
};
|
|
430
|
-
if (
|
|
480
|
+
if (this.matches('crtr.graph.down', data)) {
|
|
431
481
|
idx = step(idx, 1);
|
|
432
482
|
this.cursorId = rows[idx]?.id ?? this.cursorId;
|
|
433
483
|
this.tui.requestRender();
|
|
434
484
|
return;
|
|
435
485
|
}
|
|
436
|
-
if (
|
|
486
|
+
if (this.matches('crtr.graph.up', data)) {
|
|
437
487
|
idx = step(idx, -1);
|
|
438
488
|
this.cursorId = rows[idx]?.id ?? this.cursorId;
|
|
439
489
|
this.tui.requestRender();
|
|
440
490
|
return;
|
|
441
491
|
}
|
|
442
|
-
if (
|
|
492
|
+
if (this.matches('crtr.graph.first', data)) {
|
|
443
493
|
this.cursorId = rows[edge(1)]?.id ?? this.cursorId;
|
|
444
494
|
this.tui.requestRender();
|
|
445
495
|
return;
|
|
446
496
|
}
|
|
447
|
-
if (
|
|
497
|
+
if (this.matches('crtr.graph.last', data)) {
|
|
448
498
|
this.cursorId = rows[edge(-1)]?.id ?? this.cursorId;
|
|
449
499
|
this.tui.requestRender();
|
|
450
500
|
return;
|
|
451
501
|
}
|
|
452
|
-
if (
|
|
502
|
+
if (this.matches('crtr.graph.collapse-or-manager', data)) {
|
|
453
503
|
if (cur !== undefined && cur.hasKids && !cur.collapsed) {
|
|
454
504
|
this.userCollapsed.add(cur.id);
|
|
455
505
|
this.userExpanded.delete(cur.id);
|
|
@@ -463,7 +513,7 @@ export class GraphOverlay {
|
|
|
463
513
|
}
|
|
464
514
|
return;
|
|
465
515
|
}
|
|
466
|
-
if (
|
|
516
|
+
if (this.matches('crtr.graph.expand-or-child', data)) {
|
|
467
517
|
if (cur !== undefined && cur.collapsed && cur.hasKids) {
|
|
468
518
|
this.userExpanded.add(cur.id);
|
|
469
519
|
this.userCollapsed.delete(cur.id);
|
|
@@ -477,12 +527,12 @@ export class GraphOverlay {
|
|
|
477
527
|
}
|
|
478
528
|
return;
|
|
479
529
|
}
|
|
480
|
-
//
|
|
530
|
+
// Focus/manager/close actions drive local lifecycle/focus (`crtr node focus` / `crtr node
|
|
481
531
|
// lifecycle close`) against `this.cursorId` — a REMOTE graph row's id has no
|
|
482
532
|
// relationship to this machine's canvas.db (or, worse, collides with one
|
|
483
533
|
// that does), so all three no-op in remote mode instead of shelling a local
|
|
484
|
-
// mutation command against a remote id.
|
|
485
|
-
if (
|
|
534
|
+
// mutation command against a remote id. Their hints are omitted remotely.
|
|
535
|
+
if (this.matches('crtr.graph.focus', data)) {
|
|
486
536
|
if (this.remote)
|
|
487
537
|
return;
|
|
488
538
|
if (this.cursorId !== undefined)
|
|
@@ -490,7 +540,7 @@ export class GraphOverlay {
|
|
|
490
540
|
this.close();
|
|
491
541
|
return;
|
|
492
542
|
}
|
|
493
|
-
if (
|
|
543
|
+
if (this.matches('crtr.graph.focus-manager', data)) {
|
|
494
544
|
if (this.remote)
|
|
495
545
|
return;
|
|
496
546
|
const mgr = snapshot?.managerOf.get(this.self);
|
|
@@ -500,7 +550,7 @@ export class GraphOverlay {
|
|
|
500
550
|
}
|
|
501
551
|
return;
|
|
502
552
|
}
|
|
503
|
-
if (
|
|
553
|
+
if (this.matches('crtr.graph.close-subtree', data)) {
|
|
504
554
|
if (this.remote)
|
|
505
555
|
return;
|
|
506
556
|
const target = this.cursorId ?? this.self;
|
|
@@ -510,7 +560,7 @@ export class GraphOverlay {
|
|
|
510
560
|
this.tui.requestRender();
|
|
511
561
|
return;
|
|
512
562
|
}
|
|
513
|
-
// Modal: swallow everything else
|
|
563
|
+
// Modal: swallow everything else so a stray
|
|
514
564
|
// key never reaches the editor underneath.
|
|
515
565
|
}
|
|
516
566
|
// -------------------------------------------------------------------------
|
|
@@ -9,7 +9,7 @@ import { appendInbox } from '../../core/feed/inbox.js';
|
|
|
9
9
|
import { closeSync, existsSync, linkSync, openSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
10
10
|
import { basename, join } from 'node:path';
|
|
11
11
|
import { spawnSync } from 'node:child_process';
|
|
12
|
-
import {
|
|
12
|
+
import { openInbox, scanInbox, parseDeck, deckPath, responsePath, progressPath, isResolved, atomicWriteJson, ask, launchReview, readJson, display, } from '@crouton-kit/humanloop';
|
|
13
13
|
import { killPane } from './shared.js';
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
// stranded-answer healing
|
|
@@ -158,8 +158,8 @@ export const humanInbox = defineLeaf({
|
|
|
158
158
|
],
|
|
159
159
|
},
|
|
160
160
|
run: async () => {
|
|
161
|
-
await
|
|
162
|
-
// humanloop's
|
|
161
|
+
await openInbox({ roots: [interactionsRoot(process.cwd())] });
|
|
162
|
+
// humanloop's openInbox() writes response.json but never calls pushFinal — so a
|
|
163
163
|
// deck drained here would strand (no answer-back, leaked bridge node). Heal
|
|
164
164
|
// every resolved-but-live interaction: deliver its answer to the asker + reap.
|
|
165
165
|
const delivered = await healStrandedInteractions(process.cwd());
|
|
@@ -766,7 +766,6 @@ export const humanRun = defineLeaf({
|
|
|
766
766
|
try {
|
|
767
767
|
const res = await launchReview(rc.file, {
|
|
768
768
|
output: rc.output,
|
|
769
|
-
noTmux: true,
|
|
770
769
|
});
|
|
771
770
|
await pushFinal(rc.job_id, JSON.stringify(res));
|
|
772
771
|
}
|
|
@@ -3,6 +3,8 @@ import { notFound } from '../../core/errors.js';
|
|
|
3
3
|
import { paginate } from '../../core/pagination.js';
|
|
4
4
|
import { listInstalledPlugins, findPluginByName, } from '../../core/resolver.js';
|
|
5
5
|
import { listAllMemoryDocs } from '../../core/memory-resolver.js';
|
|
6
|
+
import { validatePluginCommands, validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
|
|
7
|
+
import { SUBTREE_NAMES } from '../../build-root.js';
|
|
6
8
|
import { resolveScopeArg, projectScopeRoot } from '../../core/scope.js';
|
|
7
9
|
export const pluginList = defineLeaf({
|
|
8
10
|
name: 'list',
|
|
@@ -89,9 +91,10 @@ export const pluginShow = defineLeaf({
|
|
|
89
91
|
{ name: 'enabled', type: 'boolean', required: true, constraint: 'Whether the plugin is active.' },
|
|
90
92
|
{ name: 'manifest', type: 'object', required: true, constraint: 'Full plugin.json contents.' },
|
|
91
93
|
{ name: 'docs', type: 'object[]', required: true, constraint: 'Each: {name, path}. Memory docs provided by the plugin (its `<pluginName>/` memory subtree).' },
|
|
94
|
+
{ name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest (manifest.commands). {manifestPath: string, executable: string | null (null when path/exec-bit validation failed), mounts: string[] (accepted top-level command names), issues: object[] ({code, path?, message, received, expected, next})}. Validated statically — the plugin binary is never executed.' },
|
|
92
95
|
],
|
|
93
96
|
outputKind: 'object',
|
|
94
|
-
effects: ['None. Read-only.'],
|
|
97
|
+
effects: ['None. Read-only. Command manifests are validated statically; the plugin executable is never run.'],
|
|
95
98
|
},
|
|
96
99
|
run: async (input) => {
|
|
97
100
|
const name = input['name'];
|
|
@@ -114,6 +117,23 @@ export const pluginShow = defineLeaf({
|
|
|
114
117
|
}
|
|
115
118
|
const prefix = `${found.name}/`;
|
|
116
119
|
const docs = listAllMemoryDocs().filter((d) => d.name.startsWith(prefix));
|
|
120
|
+
let commands;
|
|
121
|
+
if (typeof found.manifest.commands === 'string' && found.manifest.commands.length > 0) {
|
|
122
|
+
const reserved = new Set(SUBTREE_NAMES);
|
|
123
|
+
// Validate against the whole effective set so cross-plugin top-level
|
|
124
|
+
// name collisions show up in this plugin's issues (a collided command is
|
|
125
|
+
// NOT mounted). A copy outside the effective set (disabled, or shadowed
|
|
126
|
+
// by a higher-precedence copy) cannot collide, so the per-plugin
|
|
127
|
+
// validator is exact for it.
|
|
128
|
+
const v = validateEffectiveCommandPlugins(reserved).find((x) => x.plugin.root === found.root) ??
|
|
129
|
+
validatePluginCommands(found, reserved);
|
|
130
|
+
commands = {
|
|
131
|
+
manifestPath: v.manifestPath,
|
|
132
|
+
executable: v.executable ?? null,
|
|
133
|
+
mounts: v.contributions.map((c) => c.node.name),
|
|
134
|
+
issues: v.issues,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
117
137
|
return {
|
|
118
138
|
name: found.name,
|
|
119
139
|
scope: found.scope,
|
|
@@ -124,6 +144,7 @@ export const pluginShow = defineLeaf({
|
|
|
124
144
|
name: d.name,
|
|
125
145
|
path: d.path,
|
|
126
146
|
})),
|
|
147
|
+
...(commands !== undefined ? { commands } : {}),
|
|
127
148
|
};
|
|
128
149
|
},
|
|
129
150
|
});
|
|
@@ -8,7 +8,25 @@ import { updateConfig, updateState, ensureScopeInitialized } from '../../core/co
|
|
|
8
8
|
import { pathExists, ensureDir, removePath, nowIso, linkOrCopy, isSymlink } from '../../core/fs-utils.js';
|
|
9
9
|
import { clone, pull, deriveNameFromUrl, currentSha, isGitRepo } from '../../core/git.js';
|
|
10
10
|
import { readMarketplaceManifest, readPluginManifest } from '../../core/manifest.js';
|
|
11
|
+
import { validatePluginCommands, validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
|
|
12
|
+
import { SUBTREE_NAMES } from '../../build-root.js';
|
|
11
13
|
import { isGitUrl, setPluginEnabled, resolveInstallScope } from './shared.js';
|
|
14
|
+
/** Validate a just-installed/updated plugin's command manifest, if it declares
|
|
15
|
+
* one. Returns undefined for plugins without a `commands` pointer. Never
|
|
16
|
+
* executes the plugin binary — validation is fully static. */
|
|
17
|
+
function commandReport(name, scope) {
|
|
18
|
+
const plugin = findPluginByName(name, scope);
|
|
19
|
+
if (plugin === null || typeof plugin.manifest.commands !== 'string' || plugin.manifest.commands.length === 0) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const reserved = new Set(SUBTREE_NAMES);
|
|
23
|
+
// Report from the effective set so a cross-plugin top-level collision is
|
|
24
|
+
// visible at install time (a collided command is NOT mounted). A copy
|
|
25
|
+
// outside the effective set (disabled, or shadowed) cannot collide.
|
|
26
|
+
const v = validateEffectiveCommandPlugins(reserved).find((x) => x.plugin.root === plugin.root) ??
|
|
27
|
+
validatePluginCommands(plugin, reserved);
|
|
28
|
+
return { mounts: v.contributions.map((c) => c.node.name), issues: v.issues };
|
|
29
|
+
}
|
|
12
30
|
function classifyInstallRef(raw) {
|
|
13
31
|
if (raw.startsWith('./') || raw.startsWith('../') || raw.startsWith('/')) {
|
|
14
32
|
return { kind: 'local', value: raw };
|
|
@@ -203,10 +221,12 @@ function updateOnePlugin(plugin, marketplaceCache, opts) {
|
|
|
203
221
|
}
|
|
204
222
|
const manifest = readPluginManifest(plugin.root);
|
|
205
223
|
updatePluginVersion(plugin.scope, plugin.name, marketplaceVersion ?? manifest?.version);
|
|
224
|
+
const commands = commandReport(plugin.name, plugin.scope);
|
|
206
225
|
return {
|
|
207
226
|
name: plugin.name,
|
|
208
227
|
updated: contentUpdate.updated,
|
|
209
228
|
sha: contentUpdate.sha,
|
|
229
|
+
...(commands !== undefined ? { commands } : {}),
|
|
210
230
|
};
|
|
211
231
|
}
|
|
212
232
|
export const pluginInstall = defineLeaf({
|
|
@@ -225,9 +245,13 @@ export const pluginInstall = defineLeaf({
|
|
|
225
245
|
{ name: 'name', type: 'string', required: true, constraint: 'Plugin name as installed.' },
|
|
226
246
|
{ name: 'scope', type: 'string', required: true, constraint: 'Scope the plugin was installed into.' },
|
|
227
247
|
{ name: 'path', type: 'string', required: true, constraint: 'Absolute path to the installed plugin directory.' },
|
|
248
|
+
{ name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest. {mounts: string[] (accepted top-level command names now live for the next invocation), issues: object[] (typed validation issues that rejected a contribution — {code, path?, message, received, expected, next})}. Validated statically; the plugin binary is never executed.' },
|
|
228
249
|
],
|
|
229
250
|
outputKind: 'object',
|
|
230
|
-
effects: [
|
|
251
|
+
effects: [
|
|
252
|
+
'Clones, links, or copies the plugin into the scope plugins directory. Registers the plugin in config.json with enabled=true. Marketplace installs refresh the source marketplace before resolving the plugin entry.',
|
|
253
|
+
'If the plugin declares a command manifest, its commands go live on the next crtr invocation. A command leaf runs the plugin\'s own executable as trusted local code with your authority — only when you explicitly invoke that leaf, never on install, help, or discovery.',
|
|
254
|
+
],
|
|
231
255
|
},
|
|
232
256
|
run: async (input) => {
|
|
233
257
|
const rawRef = input['installRef'];
|
|
@@ -235,13 +259,11 @@ export const pluginInstall = defineLeaf({
|
|
|
235
259
|
const gitRef = input['ref'];
|
|
236
260
|
const scope = resolveInstallScope(scopeInput);
|
|
237
261
|
const classified = classifyInstallRef(rawRef);
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
return installFromGit(classified.value, gitRef, scope);
|
|
262
|
+
const installed = classified.kind === 'marketplace' ? installFromMarketplace(classified.value, scope)
|
|
263
|
+
: classified.kind === 'local' ? installFromLocal(classified.value, scope)
|
|
264
|
+
: installFromGit(classified.value, gitRef, scope);
|
|
265
|
+
const commands = commandReport(installed.name, installed.scope);
|
|
266
|
+
return commands !== undefined ? { ...installed, commands } : installed;
|
|
245
267
|
},
|
|
246
268
|
});
|
|
247
269
|
export const pluginRemove = defineLeaf({
|
|
@@ -351,7 +373,7 @@ export const pluginUpdate = defineLeaf({
|
|
|
351
373
|
{ kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Plugin name to update. Omit to update all installed plugins.' },
|
|
352
374
|
],
|
|
353
375
|
output: [
|
|
354
|
-
{ name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, updated, sha}.' },
|
|
376
|
+
{ name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, updated, sha, commands?}. commands is present only for plugins declaring a command manifest: {mounts: string[] (accepted top-level command names), issues: object[] ({code, path?, message, received, expected, next})} — validated statically, never executing the plugin binary.' },
|
|
355
377
|
],
|
|
356
378
|
outputKind: 'object',
|
|
357
379
|
effects: ['Runs git pull in plugin or marketplace directories when they are git checkouts. Updates plugin version and last_updated state.'],
|