@jameslovespancakes/pi-plus 1.0.0 → 1.0.2

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 (41) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +190 -190
  3. package/config/pi-plus.example.json +60 -60
  4. package/config/skills/model-routing/SKILL.md +86 -86
  5. package/images/pi-plus.svg +10 -10
  6. package/images/usage_demo.png +0 -0
  7. package/package.json +67 -67
  8. package/server/board-server.mjs +641 -641
  9. package/server/package.json +17 -17
  10. package/src/core/accounts/registry.ts +93 -93
  11. package/src/core/anthropic/client-identity.ts +241 -241
  12. package/src/core/catalog/quality.ts +314 -314
  13. package/src/core/config.ts +169 -169
  14. package/src/core/env.ts +58 -58
  15. package/src/core/exec/process.ts +146 -146
  16. package/src/core/exec/ssh-config.ts +157 -157
  17. package/src/core/policy/policy.ts +183 -183
  18. package/src/core/quota/pool.ts +64 -64
  19. package/src/core/quota/usage-source.ts +289 -289
  20. package/src/core/store.ts +43 -43
  21. package/src/domains/agents/board-setup.ts +409 -409
  22. package/src/domains/agents/index.ts +462 -462
  23. package/src/domains/models/catalog-tool.ts +361 -361
  24. package/src/domains/models/index.ts +14 -14
  25. package/src/domains/models/policy-gate.ts +169 -169
  26. package/src/domains/models/provider-picker.ts +207 -207
  27. package/src/domains/remote/config-path.ts +41 -41
  28. package/src/domains/remote/index.ts +866 -866
  29. package/src/domains/remote/setup.ts +425 -425
  30. package/src/domains/setup/index.ts +220 -220
  31. package/src/domains/subscriptions/accounts.ts +242 -242
  32. package/src/domains/subscriptions/footer.ts +182 -182
  33. package/src/domains/subscriptions/index.ts +42 -42
  34. package/src/domains/subscriptions/provider.ts +219 -219
  35. package/src/domains/subscriptions/providers/anthropic.ts +149 -149
  36. package/src/domains/subscriptions/providers/codex.ts +148 -148
  37. package/src/domains/subscriptions/routing.ts +72 -72
  38. package/src/services/usage-service.ts +186 -186
  39. package/src/ui/format.ts +73 -73
  40. package/src/ui/usage-bars.ts +154 -154
  41. package/src/vendor/anthropic.ts +109 -109
@@ -1,425 +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
- }
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
+ }