@jameslovespancakes/pi-plus 1.0.0

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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +190 -0
  3. package/config/pi-plus.example.json +60 -0
  4. package/config/skills/model-routing/SKILL.md +86 -0
  5. package/images/board_demo.png +0 -0
  6. package/images/pi-plus.svg +10 -0
  7. package/images/pi-plus_demo.png +0 -0
  8. package/images/provider_demo.png +0 -0
  9. package/images/remote_demo.png +0 -0
  10. package/images/usage_demo.png +0 -0
  11. package/package.json +67 -0
  12. package/server/board-server.mjs +641 -0
  13. package/server/package.json +17 -0
  14. package/src/core/accounts/registry.ts +93 -0
  15. package/src/core/anthropic/client-identity.ts +241 -0
  16. package/src/core/anthropic/models.ts +69 -0
  17. package/src/core/anthropic/oauth.ts +208 -0
  18. package/src/core/anthropic/quota.ts +253 -0
  19. package/src/core/anthropic/routing.ts +168 -0
  20. package/src/core/anthropic/store.ts +225 -0
  21. package/src/core/anthropic/vendor/README.md +36 -0
  22. package/src/core/anthropic/vendor/xxhash-wasm.LICENSE.md +25 -0
  23. package/src/core/anthropic/vendor/xxhash-wasm.js +2 -0
  24. package/src/core/anthropic/xxhash64.ts +33 -0
  25. package/src/core/catalog/quality.ts +314 -0
  26. package/src/core/codex/oauth.ts +129 -0
  27. package/src/core/codex/quota.ts +88 -0
  28. package/src/core/codex/store.ts +97 -0
  29. package/src/core/config.ts +169 -0
  30. package/src/core/env.ts +58 -0
  31. package/src/core/exec/process.ts +146 -0
  32. package/src/core/exec/ssh-config.ts +157 -0
  33. package/src/core/oauth/pkce.ts +88 -0
  34. package/src/core/policy/policy.ts +183 -0
  35. package/src/core/quota/pool.ts +64 -0
  36. package/src/core/quota/usage-source.ts +289 -0
  37. package/src/core/store.ts +43 -0
  38. package/src/domains/agents/board-setup.ts +409 -0
  39. package/src/domains/agents/index.ts +462 -0
  40. package/src/domains/models/catalog-tool.ts +361 -0
  41. package/src/domains/models/index.ts +14 -0
  42. package/src/domains/models/policy-gate.ts +169 -0
  43. package/src/domains/models/provider-picker.ts +208 -0
  44. package/src/domains/remote/config-path.ts +41 -0
  45. package/src/domains/remote/index.ts +866 -0
  46. package/src/domains/remote/setup.ts +425 -0
  47. package/src/domains/setup/index.ts +220 -0
  48. package/src/domains/subscriptions/accounts-picker.ts +178 -0
  49. package/src/domains/subscriptions/accounts.ts +242 -0
  50. package/src/domains/subscriptions/footer.ts +182 -0
  51. package/src/domains/subscriptions/index.ts +42 -0
  52. package/src/domains/subscriptions/provider.ts +219 -0
  53. package/src/domains/subscriptions/providers/anthropic.ts +149 -0
  54. package/src/domains/subscriptions/providers/codex.ts +148 -0
  55. package/src/domains/subscriptions/routing.ts +72 -0
  56. package/src/services/usage-service.ts +186 -0
  57. package/src/ui/format.ts +73 -0
  58. package/src/ui/usage-bars.ts +154 -0
  59. package/src/vendor/anthropic.ts +109 -0
@@ -0,0 +1,425 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { chmodSync, mkdirSync, readFileSync, statSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { agentPath } from "../../core/store.ts";
6
+ import { runProcess, runSshCommand } from "../../core/exec/process.ts";
7
+ import { parseTarget, readSshHosts, type SshHost } from "../../core/exec/ssh-config.ts";
8
+ import { readRemote, writeRemoteWorkers, type RemoteWorkerRecord } from "./config-path.ts";
9
+
10
+ /**
11
+ * `/remote` is the management surface for remote test workers.
12
+ *
13
+ * /remote setup hub: toggle, add, rename, remove (bare /remote is the same)
14
+ * /remote add jump straight to the add wizard
15
+ * /remote remove jump straight to removal
16
+ *
17
+ * Hosts are merged from ~/.ssh/config and the `remote` section of pi-plus.json.
18
+ *
19
+ * Privacy: host aliases routinely name internal infrastructure. Everything here
20
+ * stays in the UI layer and is never returned from a tool, so the list is not
21
+ * sent to a model provider.
22
+ */
23
+
24
+ const KEY_DIR = "remote-keys";
25
+
26
+ type StoredWorker = RemoteWorkerRecord;
27
+
28
+ function loadWorkers(): { workers: StoredWorker[] } {
29
+ return { workers: readRemote().workers };
30
+ }
31
+
32
+ function saveWorkers(file: { workers: StoredWorker[] }): boolean {
33
+ return writeRemoteWorkers(file.workers);
34
+ }
35
+
36
+ interface Row {
37
+ name: string;
38
+ ssh: string;
39
+ enabled: boolean;
40
+ origin: "ssh config" | "pi-plus" | "both";
41
+ detail: string;
42
+ stored?: StoredWorker;
43
+ sshHost?: SshHost;
44
+ }
45
+
46
+ function buildRows(): Row[] {
47
+ const stored = loadWorkers().workers;
48
+ const storedByName = new Map(stored.map((worker) => [worker.name, worker]));
49
+ const claimedSsh = new Set(stored.map((worker) => worker.ssh));
50
+ const rows: Row[] = [];
51
+
52
+ let hosts: SshHost[] = [];
53
+ try {
54
+ hosts = readSshHosts();
55
+ } catch { /* no ssh config is fine */ }
56
+
57
+ for (const host of hosts) {
58
+ const existing = storedByName.get(host.alias);
59
+ // A renamed worker still points at this alias; don't list it twice.
60
+ if (!existing && claimedSsh.has(host.alias)) continue;
61
+ const target = host.user && host.hostName ? `${host.user}@${host.hostName}` : host.hostName ?? host.alias;
62
+ rows.push({
63
+ name: host.alias,
64
+ ssh: existing?.ssh ?? host.alias,
65
+ enabled: existing ? existing.enabled !== false : false,
66
+ origin: existing ? "both" : "ssh config",
67
+ detail: target + (host.port ? `:${host.port}` : ""),
68
+ stored: existing,
69
+ sshHost: host,
70
+ });
71
+ }
72
+
73
+ for (const worker of stored) {
74
+ if (rows.some((row) => row.name === worker.name)) continue;
75
+ rows.push({
76
+ name: worker.name,
77
+ ssh: worker.ssh,
78
+ enabled: worker.enabled !== false,
79
+ origin: "pi-plus",
80
+ detail: worker.ssh + (worker.port ? `:${worker.port}` : ""),
81
+ stored: worker,
82
+ });
83
+ }
84
+
85
+ return rows.sort((a, b) => Number(b.enabled) - Number(a.enabled) || a.name.localeCompare(b.name));
86
+ }
87
+
88
+ function renderRow(row: Row, width: number): string {
89
+ return `[${row.enabled ? "✓" : " "}] ${row.name.padEnd(width)} ${row.detail.padEnd(28)} ${row.origin}`;
90
+ }
91
+
92
+ function setEnabled(row: Row, enabled: boolean): void {
93
+ const file = loadWorkers();
94
+ const existing = file.workers.find((worker) => worker.name === row.name);
95
+ if (existing) {
96
+ existing.enabled = enabled;
97
+ } else {
98
+ file.workers.push({
99
+ name: row.name,
100
+ ssh: row.ssh,
101
+ root: "~/remote_tests",
102
+ nice: 10,
103
+ tags: [],
104
+ enabled,
105
+ ...(row.sshHost?.port ? { port: row.sshHost.port } : {}),
106
+ });
107
+ }
108
+ saveWorkers(file);
109
+ }
110
+
111
+ /** Valid worker names keep the remote directory layout predictable. */
112
+ function validateName(name: string, taken: string[]): string | undefined {
113
+ if (!name) return "Name cannot be empty.";
114
+ if (!/^[A-Za-z0-9._-]+$/.test(name)) return "Use letters, numbers, dot, dash or underscore only.";
115
+ if (name.length > 64) return "Name is too long.";
116
+ if (taken.includes(name)) return `“${name}” is already used.`;
117
+ return undefined;
118
+ }
119
+
120
+ async function probe(ssh: string, extraArgs: string[]): Promise<{ ok: boolean; detail: string }> {
121
+ try {
122
+ const result = await runSshCommand(
123
+ ssh,
124
+ "command -v tar >/dev/null && command -v bash >/dev/null && uname -s",
125
+ { timeoutSeconds: 15 },
126
+ extraArgs,
127
+ );
128
+ if (result.code === 0) return { ok: true, detail: result.stdout.trim() || "ok" };
129
+ return { ok: false, detail: result.stderr.trim().split("\n")[0] || `exit ${result.code}` };
130
+ } catch (error) {
131
+ return { ok: false, detail: error instanceof Error ? error.message : String(error) };
132
+ }
133
+ }
134
+
135
+ function keyPath(label: string): string {
136
+ return join(agentPath(KEY_DIR), `pi-plus_${label.replace(/[^A-Za-z0-9._-]/g, "_")}`);
137
+ }
138
+
139
+ /** Generates a dedicated ed25519 key. The private half never leaves disk. */
140
+ async function generateKey(label: string): Promise<{ path: string; publicKey: string }> {
141
+ mkdirSync(agentPath(KEY_DIR), { recursive: true });
142
+ const path = keyPath(label);
143
+
144
+ const result = await runProcess(
145
+ "ssh-keygen",
146
+ ["-t", "ed25519", "-N", "", "-C", `pi-plus@${label}`, "-f", path],
147
+ { timeoutSeconds: 30 },
148
+ );
149
+ if (result.code !== 0 && !result.stderr.includes("already exists")) {
150
+ throw new Error(result.stderr.trim() || `ssh-keygen exited ${result.code}`);
151
+ }
152
+
153
+ try {
154
+ chmodSync(path, 0o600);
155
+ } catch { /* best effort on Windows */ }
156
+
157
+ return { path, publicKey: readFileSync(`${path}.pub`, "utf8").trim() };
158
+ }
159
+
160
+ function addWorker(name: string, ssh: string, extras: Partial<StoredWorker>): void {
161
+ const file = loadWorkers();
162
+ const entry: StoredWorker = { name, ssh, root: "~/remote_tests", nice: 10, tags: [], enabled: true, ...extras };
163
+ const index = file.workers.findIndex((worker) => worker.name === name);
164
+ if (index >= 0) file.workers[index] = entry;
165
+ else file.workers.push(entry);
166
+ saveWorkers(file);
167
+ }
168
+
169
+ async function addServer(ctx: any): Promise<void> {
170
+ const route = await ctx.ui.select("Add a server", [
171
+ "I can already SSH to it (fastest)",
172
+ "I have a private key file (point at the path)",
173
+ "I have nothing yet (generate a key)",
174
+ ]);
175
+ if (!route) return;
176
+
177
+ const raw = await ctx.ui.input("Host", "gpu-box or deploy@203.0.113.9:22");
178
+ if (!raw) return;
179
+ const target = parseTarget(raw);
180
+ if (!target) {
181
+ ctx.ui.notify(`“${raw}” is not a valid host or user@host:port.`, "error");
182
+ return;
183
+ }
184
+
185
+ const ssh = target.user ? `${target.user}@${target.host}` : target.host;
186
+ const taken = loadWorkers().workers.map((worker) => worker.name);
187
+ let name = target.host.replace(/[^A-Za-z0-9._-]/g, "-");
188
+ if (taken.includes(name)) {
189
+ const chosen = await ctx.ui.input(`Name (“${name}” is taken)`, name);
190
+ if (!chosen) return;
191
+ const problem = validateName(chosen.trim(), taken);
192
+ if (problem) {
193
+ ctx.ui.notify(problem, "error");
194
+ return;
195
+ }
196
+ name = chosen.trim();
197
+ }
198
+ const portArgs = target.port ? ["-p", String(target.port)] : [];
199
+ const port = target.port ? { port: target.port } : {};
200
+
201
+ if (route.startsWith("I can already")) {
202
+ ctx.ui.notify(`Testing ${ssh}…`, "info");
203
+ const result = await probe(ssh, portArgs);
204
+ if (!result.ok) {
205
+ ctx.ui.notify(
206
+ `Could not connect: ${result.detail}\n\n`
207
+ + "If it needs a password, pick “I have nothing yet” instead. pi cannot answer password prompts.",
208
+ "error",
209
+ );
210
+ return;
211
+ }
212
+ addWorker(name, ssh, port);
213
+ ctx.ui.notify(`${name} added and enabled (${result.detail}).`, "info");
214
+ return;
215
+ }
216
+
217
+ if (route.startsWith("I have a private key")) {
218
+ const given = await ctx.ui.input("Key file", "~/.ssh/id_ed25519");
219
+ if (!given) return;
220
+ const expanded = given.startsWith("~/") ? resolve(homedir(), given.slice(2)) : resolve(given);
221
+
222
+ // Existence is checked via stat, never by reading: the private key must not
223
+ // enter this process's memory, where it could reach a log or crash dump.
224
+ try {
225
+ if (!statSync(expanded).isFile()) throw new Error("not a file");
226
+ } catch {
227
+ ctx.ui.notify(`No key file at ${given}.`, "error");
228
+ return;
229
+ }
230
+
231
+ const result = await probe(ssh, ["-i", expanded, "-o", "IdentitiesOnly=yes", ...portArgs]);
232
+ if (!result.ok) {
233
+ ctx.ui.notify(`Could not connect with that key: ${result.detail}`, "error");
234
+ return;
235
+ }
236
+ addWorker(name, ssh, { identityFile: given, ...port });
237
+ ctx.ui.notify(`${name} added and enabled (${result.detail}).`, "info");
238
+ return;
239
+ }
240
+
241
+ let generated: { path: string; publicKey: string };
242
+ try {
243
+ generated = await generateKey(name);
244
+ } catch (error) {
245
+ ctx.ui.notify(`Could not generate a key: ${error instanceof Error ? error.message : String(error)}`, "error");
246
+ return;
247
+ }
248
+
249
+ const display = generated.path.replace(homedir(), "~");
250
+ const instructions = [
251
+ "A dedicated key was generated for this server.",
252
+ "",
253
+ "Run ONE of these in your own terminal. pi cannot answer password prompts:",
254
+ "",
255
+ ` ssh-copy-id -i ${display} ${ssh}`,
256
+ "",
257
+ "…or paste this line into ~/.ssh/authorized_keys on the server:",
258
+ "",
259
+ ` ${generated.publicKey}`,
260
+ ].join("\n");
261
+
262
+ for (;;) {
263
+ ctx.ui.notify(instructions, "info");
264
+ const choice = await ctx.ui.select("Install the key on the server, then:", [
265
+ "Finished",
266
+ "Show instructions again",
267
+ "Cancel",
268
+ ]);
269
+ if (!choice || choice === "Cancel") return;
270
+ if (choice === "Show instructions again") continue;
271
+
272
+ const result = await probe(ssh, ["-i", generated.path, "-o", "IdentitiesOnly=yes", ...portArgs]);
273
+ if (result.ok) {
274
+ addWorker(name, ssh, { identityFile: display, ...port });
275
+ ctx.ui.notify(`${name} verified and enabled (${result.detail}).`, "info");
276
+ return;
277
+ }
278
+ ctx.ui.notify(`Still cannot connect: ${result.detail}`, "warning");
279
+ }
280
+ }
281
+
282
+ async function renameWorker(ctx: any): Promise<void> {
283
+ const rows = buildRows();
284
+ if (rows.length === 0) {
285
+ ctx.ui.notify("No workers to rename.", "info");
286
+ return;
287
+ }
288
+
289
+ const width = Math.max(4, ...rows.map((row) => row.name.length));
290
+ const labels = rows.map((row) => renderRow(row, width));
291
+ const choice = await ctx.ui.select("Rename which worker?", labels);
292
+ if (!choice) return;
293
+
294
+ const row = rows[labels.indexOf(choice)];
295
+ if (!row) return;
296
+
297
+ const next = await ctx.ui.input(`New name for “${row.name}”`, row.name);
298
+ if (!next) return;
299
+ const trimmed = next.trim();
300
+ if (trimmed === row.name) return;
301
+
302
+ const file = loadWorkers();
303
+ const problem = validateName(trimmed, file.workers.map((worker) => worker.name));
304
+ if (problem) {
305
+ ctx.ui.notify(problem, "error");
306
+ return;
307
+ }
308
+
309
+ const existing = file.workers.find((worker) => worker.name === row.name);
310
+ if (existing) {
311
+ existing.name = trimmed;
312
+ } else {
313
+ // Renaming a host that was only ever in ssh config materialises it here,
314
+ // keeping `ssh` pointed at the original alias.
315
+ file.workers.push({
316
+ name: trimmed,
317
+ ssh: row.ssh,
318
+ root: "~/remote_tests",
319
+ nice: 10,
320
+ tags: [],
321
+ enabled: row.enabled,
322
+ ...(row.sshHost?.port ? { port: row.sshHost.port } : {}),
323
+ });
324
+ }
325
+ saveWorkers(file);
326
+ ctx.ui.notify(`Renamed ${row.name} → ${trimmed} (still connects to ${row.ssh}).`, "info");
327
+ }
328
+
329
+ async function removeWorker(ctx: any): Promise<void> {
330
+ const stored = loadWorkers().workers;
331
+ if (stored.length === 0) {
332
+ ctx.ui.notify("Nothing to remove, no workers are configured.", "info");
333
+ return;
334
+ }
335
+
336
+ const labels = stored.map((worker) => `${worker.name.padEnd(16)} ${worker.ssh}`);
337
+ const choice = await ctx.ui.select("Remove which worker?", labels);
338
+ if (!choice) return;
339
+ const worker = stored[labels.indexOf(choice)];
340
+ if (!worker) return;
341
+
342
+ const ok = await ctx.ui.confirm(
343
+ `Remove ${worker.name}?`,
344
+ "This only edits pi-plus.json. Nothing on the server or in ~/.ssh/config is touched.",
345
+ );
346
+ if (!ok) return;
347
+
348
+ const file = loadWorkers();
349
+ file.workers = file.workers.filter((candidate) => candidate.name !== worker.name);
350
+ saveWorkers(file);
351
+ ctx.ui.notify(`Removed ${worker.name}.`, "info");
352
+ }
353
+
354
+ async function hub(ctx: any): Promise<void> {
355
+ const ADD = "+ Add a server…";
356
+ const RENAME = "✎ Rename…";
357
+ const REMOVE = "− Remove…";
358
+
359
+ for (;;) {
360
+ const rows = buildRows();
361
+ const width = Math.max(4, ...rows.map((row) => row.name.length), 4);
362
+ const labels = rows.map((row) => renderRow(row, width));
363
+ const actions = rows.length > 0 ? [ADD, RENAME, REMOVE] : [ADD];
364
+
365
+ const choice = await ctx.ui.select(
366
+ rows.length === 0 ? "No remote workers yet, add one" : "Remote workers",
367
+ [...labels, ...actions],
368
+ );
369
+ if (!choice) return;
370
+
371
+ if (choice === ADD) {
372
+ await addServer(ctx);
373
+ continue;
374
+ }
375
+ if (choice === RENAME) {
376
+ await renameWorker(ctx);
377
+ continue;
378
+ }
379
+ if (choice === REMOVE) {
380
+ await removeWorker(ctx);
381
+ continue;
382
+ }
383
+
384
+ const row = rows[labels.indexOf(choice)];
385
+ if (row) setEnabled(row, !row.enabled);
386
+ }
387
+ }
388
+
389
+ export function registerRemoteSetup(pi: ExtensionAPI): void {
390
+ pi.registerCommand("remote", {
391
+ description: "Manage remote test workers (setup | add | remove | rename)",
392
+ getArgumentCompletions: (prefix) =>
393
+ ["setup", "add", "remove", "rename"]
394
+ .filter((option) => option.startsWith(prefix))
395
+ .map((option) => ({ value: option, label: option })),
396
+ handler: async (args, ctx) => {
397
+ const action = args.trim().toLowerCase();
398
+
399
+ // Every interactive path needs ui.select, so headless sessions get the
400
+ // plain summary instead.
401
+ if (!ctx.hasUI) {
402
+ const rows = buildRows();
403
+ ctx.ui.notify(
404
+ rows.length === 0
405
+ ? "No hosts found. Run /remote add to configure one."
406
+ : rows.map((row) => `${row.enabled ? "[on] " : "[off]"} ${row.name}: ${row.detail}`).join("\n"),
407
+ "info",
408
+ );
409
+ return;
410
+ }
411
+
412
+ if (action === "add") return addServer(ctx);
413
+ if (action === "remove") return removeWorker(ctx);
414
+ if (action === "rename") return renameWorker(ctx);
415
+
416
+ // `setup` is the documented entry point; a bare /remote is the same hub.
417
+ if (action && action !== "setup") {
418
+ ctx.ui.notify(`Unknown action “${action}”. Use: /remote [setup|add|remove|rename]`, "warning");
419
+ return;
420
+ }
421
+
422
+ return hub(ctx);
423
+ },
424
+ });
425
+ }
@@ -0,0 +1,220 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { existsSync } from "node:fs";
3
+ import { env } from "../../core/env.ts";
4
+ import { agentPath, readJson } from "../../core/store.ts";
5
+ import { configPath, readConfig } from "../../core/config.ts";
6
+ import { accountProviders } from "../../core/accounts/registry.ts";
7
+ import { readSshHosts } from "../../core/exec/ssh-config.ts";
8
+
9
+ /**
10
+ * `/pi-plus` is the status modal for the pack; `/pi-plus help` explains it.
11
+ *
12
+ * The modal lists every feature with its live state. Selecting an unconfigured
13
+ * one runs its setup command; selecting a ready one opens its hub. `help` hands
14
+ * the detected state to the model so the explanation is specific to this
15
+ * machine rather than a static README dump.
16
+ *
17
+ * Only `core/` is read, so this stays inside the layering rules: it never
18
+ * reaches into another domain.
19
+ */
20
+
21
+ interface Feature {
22
+ name: string;
23
+ ready: boolean;
24
+ detail: string;
25
+ commands: string[];
26
+ /** Command that configures this feature, when it is not ready. */
27
+ setup?: string;
28
+ /** Command that opens this feature once it is ready. */
29
+ open: string;
30
+ }
31
+
32
+ async function inspect(ctx: any): Promise<Feature[]> {
33
+ const config = readConfig();
34
+ const features: Feature[] = [];
35
+
36
+ /* subscriptions */
37
+ const providers = accountProviders();
38
+ const accountSummary: string[] = [];
39
+ let anySignedIn = false;
40
+ for (const provider of providers) {
41
+ // pi holds the primary credential itself; the adapter only knows about the
42
+ // extra pooled accounts. Being signed in at all is what makes this usable.
43
+ let primary = false;
44
+ try {
45
+ primary = !!(await ctx.modelRegistry.getProviderAuth(provider.id))?.auth?.apiKey;
46
+ } catch { /* provider not configured */ }
47
+
48
+ try {
49
+ const accounts = await provider.list();
50
+ const routing = provider.routing ? await provider.routing.get() : "n/a";
51
+ const pooled = accounts.length + (primary ? 1 : 0);
52
+ anySignedIn ||= primary || accounts.length > 0;
53
+ accountSummary.push(
54
+ primary || accounts.length > 0
55
+ ? `${provider.id}: ${pooled} account(s), routing=${routing}`
56
+ : `${provider.id}: not signed in`,
57
+ );
58
+ } catch {
59
+ accountSummary.push(`${provider.id}: unreadable`);
60
+ }
61
+ }
62
+ features.push({
63
+ name: "Subscriptions",
64
+ ready: anySignedIn,
65
+ detail: accountSummary.join("; ") || "no account providers registered",
66
+ commands: ["/account", "/account <provider> add", "/routing standard|optimal", "/usage"],
67
+ setup: anySignedIn ? undefined : "/account anthropic add",
68
+ open: "/account",
69
+ });
70
+
71
+ /* benchmarks */
72
+ const hasKey = !!env("ARTIFICIAL_ANALYSIS_API_KEY");
73
+ const cache = readJson<{ records?: Record<string, unknown>; checkedAt?: number; fetchedAt?: number }>(
74
+ agentPath("model-quality.json"),
75
+ {},
76
+ );
77
+ const records = Object.keys(cache.records ?? {}).length;
78
+ // Caches written before the store rewrite carry `fetchedAt`.
79
+ const refreshedAt = cache.checkedAt ?? cache.fetchedAt;
80
+ const ageHours = refreshedAt ? Math.round((Date.now() - refreshedAt) / 3.6e6) : undefined;
81
+ features.push({
82
+ name: "Model Information",
83
+ ready: hasKey && records > 0,
84
+ detail: hasKey
85
+ ? `${records} models cached${ageHours !== undefined ? `, refreshed ${ageHours}h ago` : ""}`
86
+ : "no Artificial Analysis API key",
87
+ commands: ["/models", "/model-info <id>", "/model-info refresh", "list_models (tool)"],
88
+ setup: hasKey ? undefined : "/model-info setup",
89
+ open: "/models",
90
+ });
91
+
92
+ /* spend policy */
93
+ features.push({
94
+ name: "Providers",
95
+ ready: config.policy.requireApproval.length > 0,
96
+ detail: `${config.policy.requireApproval.length} gated pattern(s), ${config.policy.autoApprove.length} auto-approved`,
97
+ commands: ["/provider", "/provider list", "/provider approve <name>"],
98
+ open: "/provider",
99
+ });
100
+
101
+ /* agent board */
102
+ const boardUrl = env("AGENT_BOARD_URL");
103
+ const boardReady = !!boardUrl && !!env("AGENT_BOARD_TOKEN");
104
+ // A board configured before /board setup existed has no recorded mode; it is
105
+ // externally managed by definition.
106
+ const mode = env("AGENT_BOARD_MODE") ?? (boardReady ? "external" : "none");
107
+ features.push({
108
+ name: "Agent Board",
109
+ ready: boardReady,
110
+ detail: boardUrl ? `${mode} at ${boardUrl}` : "no board configured",
111
+ commands: ["/board", "/board setup", "/board restart", "/board clear", "agent_board (tool)"],
112
+ setup: boardReady ? undefined : "/board setup",
113
+ open: "/board status",
114
+ });
115
+
116
+ /* remote workers */
117
+ const workers = config.remote.workers ?? [];
118
+ const enabled = workers.filter((worker) => (worker as { enabled?: boolean }).enabled !== false);
119
+ let sshHosts = 0;
120
+ try {
121
+ sshHosts = readSshHosts().length;
122
+ } catch { /* no ssh config */ }
123
+ features.push({
124
+ name: "Remote Workers",
125
+ ready: enabled.length > 0,
126
+ detail: enabled.length > 0
127
+ ? `${enabled.length} enabled of ${workers.length} configured`
128
+ : `none enabled${sshHosts > 0 ? ` (${sshHosts} host(s) available in ~/.ssh/config)` : ""}`,
129
+ commands: ["/remote setup", "/remote add", "remote_test (tool)", "remote_status (tool)"],
130
+ setup: enabled.length > 0 ? undefined : "/remote setup",
131
+ open: "/remote setup",
132
+ });
133
+
134
+ return features;
135
+ }
136
+
137
+ /** Unconfigured features sort first: the work to do is what you see first. */
138
+ function ordered(features: Feature[]): Feature[] {
139
+ return [...features].sort((a, b) => Number(a.ready) - Number(b.ready));
140
+ }
141
+
142
+ function renderRow(feature: Feature, width: number): string {
143
+ return `[${feature.ready ? "✓" : " "}] ${feature.name.padEnd(width)} ${feature.detail}`;
144
+ }
145
+
146
+ function buildBrief(features: Feature[]): string {
147
+ const lines = features.map((feature) => {
148
+ const parts = [
149
+ `- ${feature.name}: ${feature.ready ? "READY" : "NOT SET UP"}`,
150
+ ` state: ${feature.detail}`,
151
+ ` commands: ${feature.commands.join(", ")}`,
152
+ ];
153
+ if (feature.setup) parts.push(` to enable: ${feature.setup}`);
154
+ return parts.join("\n");
155
+ });
156
+
157
+ const pending = features.filter((feature) => !feature.ready);
158
+
159
+ return [
160
+ "The user just ran /pi-plus. Give them a short, friendly orientation to the pi-plus extension pack.",
161
+ "",
162
+ "Detected state on this machine:",
163
+ "",
164
+ ...lines,
165
+ "",
166
+ `Config file: ${configPath()}${existsSync(configPath()) ? "" : " (not created yet)"}`,
167
+ "",
168
+ "Write the reply yourself, in chat. Requirements:",
169
+ "1. One short sentence on what pi-plus is: four capabilities in one package.",
170
+ "2. A compact list of the capabilities, each with one line on what it does and the command to try. Mark which are already working.",
171
+ pending.length > 0
172
+ ? `3. Then a short 'Set these up next' section covering ONLY the ones marked NOT SET UP (${pending.map((f) => f.name).join(", ")}), each with the single command to run and one line on what it will ask for.`
173
+ : "3. Note that everything is already configured, and suggest one or two commands worth trying.",
174
+ "4. Keep it under ~250 words. No preamble, no headings deeper than one level, no invented features.",
175
+ "5. Do not call any tools. Just answer.",
176
+ ].join("\n");
177
+ }
178
+
179
+ export default function setupGuide(pi: ExtensionAPI) {
180
+ pi.registerCommand("pi-plus", {
181
+ description: "Status for every pi-plus feature, or `help` for an explanation",
182
+ getArgumentCompletions: (prefix) =>
183
+ "help".startsWith(prefix) ? [{ value: "help", label: "help: explain the extension and what is missing" }] : [],
184
+ handler: async (args, ctx) => {
185
+ const features = await inspect(ctx);
186
+
187
+ // `/pi-plus help` asks the model to explain the pack and what is missing.
188
+ if (args.trim().toLowerCase() === "help") {
189
+ await pi.sendUserMessage(buildBrief(features));
190
+ return;
191
+ }
192
+
193
+ // Everything else is the status modal. ui.select needs a TTY, so headless
194
+ // sessions fall back to the same information as plain text.
195
+ const rows = ordered(features);
196
+ if (!ctx.hasUI) {
197
+ ctx.ui.notify(
198
+ rows
199
+ .map((feature) => `${feature.ready ? "[ready]" : "[setup]"} ${feature.name}\n ${feature.detail}`)
200
+ .join("\n"),
201
+ "info",
202
+ );
203
+ return;
204
+ }
205
+
206
+ const width = Math.max(...rows.map((feature) => feature.name.length));
207
+ const labels = rows.map((feature) => renderRow(feature, width));
208
+ const choice = await ctx.ui.select("pi-plus", labels);
209
+ if (!choice) return;
210
+
211
+ const picked = rows[labels.indexOf(choice)];
212
+ if (!picked) return;
213
+
214
+ // Dispatch through the command pipeline rather than importing another
215
+ // domain, which would break the no-cross-domain-imports rule.
216
+ const command = picked.ready ? picked.open : (picked.setup ?? picked.open);
217
+ await pi.sendUserMessage(command, { expandPromptTemplates: true });
218
+ },
219
+ });
220
+ }