my-durable 0.0.9 → 0.0.11
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/apps/dpi-backend/src/main.ts +3 -0
- package/apps/dpi-backend/src/pair-cli.ts +489 -0
- package/apps/dpi-backend/src/routes.ts +6 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/{index-CoYMgU-2.js → index-Ct_cPQy9.js} +1 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +1 -1
- package/apps/dpi-sdk/src/protocol/pairing.ts +6 -1
- package/apps/dpi-tui/src/app.ts +75 -19
- package/apps/dpi-tui/src/commands.ts +5 -0
- package/apps/dpi-tui/src/pickers.ts +86 -22
- package/apps/dpi-tui/src/views.ts +30 -4
- package/apps/dpi-tui/src/welcome.ts +8 -2
- package/bin/dpi.mjs +43 -66
- package/bin/update.d.mts +24 -0
- package/bin/update.mjs +198 -0
- package/package.json +2 -2
- package/scripts/verify.mjs +30 -1
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<meta name="referrer" content="no-referrer" />
|
|
8
8
|
<link rel="icon" type="image/svg+xml" href="/icons/durable-pi.svg" />
|
|
9
9
|
<title>Durable Pi | Network</title>
|
|
10
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
+
<script type="module" crossorigin src="/assets/index-Ct_cPQy9.js"></script>
|
|
11
11
|
<link rel="stylesheet" crossorigin href="/assets/index-Cg3vey95.css">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
@@ -383,7 +383,12 @@ export function parseHubOrigin(
|
|
|
383
383
|
return { ok: false, error: "origin: not a valid URL" };
|
|
384
384
|
}
|
|
385
385
|
if (url.protocol !== "https:") {
|
|
386
|
-
|
|
386
|
+
// Loopback dev hubs (one machine running both ends) speak plain HTTP. The
|
|
387
|
+
// same rule as the device's own origin check: only 127.0.0.1/[::1]/localhost
|
|
388
|
+
// qualify, so a hub another machine can reach is still HTTPS-only.
|
|
389
|
+
const loopback =
|
|
390
|
+
url.protocol === "http:" && /^(127\.0\.0\.1|\[::1\]|localhost)$/.test(url.hostname);
|
|
391
|
+
if (!loopback) return { ok: false, error: "origin: HTTPS origin required" };
|
|
387
392
|
}
|
|
388
393
|
if (url.username || url.password) {
|
|
389
394
|
return { ok: false, error: "origin: userinfo not allowed" };
|
package/apps/dpi-tui/src/app.ts
CHANGED
|
@@ -26,8 +26,9 @@ import {
|
|
|
26
26
|
} from "../../dpi-sdk/src/api.ts";
|
|
27
27
|
import type { DeviceRow, NetworkRegistry } from "../../dpi-sdk/src/node/registry.ts";
|
|
28
28
|
import { COMMANDS, runCommand } from "./commands.ts";
|
|
29
|
-
import { Prompt, SecretPrompt, pickModel } from "./pickers.ts";
|
|
29
|
+
import { Prompt, SecretPrompt, pickModel, newSession } from "./pickers.ts";
|
|
30
30
|
import { renderHeader, renderHerd, renderHerdPanel, renderNetwork, renderStatusbar, renderViewbar, herdRows, joinPanel, networkRows } from "./views.ts";
|
|
31
|
+
import { appendFileSync } from "node:fs";
|
|
31
32
|
import { fetchHubNetwork } from "./hub-feed.ts";
|
|
32
33
|
import { HubMux } from "./hub-mux.ts";
|
|
33
34
|
import { box, c, STATE_COLOR, STATE_GLYPH, type RowState } from "./theme.ts";
|
|
@@ -358,7 +359,13 @@ export class App {
|
|
|
358
359
|
];
|
|
359
360
|
}
|
|
360
361
|
|
|
361
|
-
|
|
362
|
+
/** Herd and network rows share a shape; `kind` tells a header from an entry. */
|
|
363
|
+
private rows(): Array<{
|
|
364
|
+
kind: "group" | "session" | "device";
|
|
365
|
+
sessionId?: string;
|
|
366
|
+
deviceId?: string;
|
|
367
|
+
label: string;
|
|
368
|
+
}> {
|
|
362
369
|
if (this.view === "herd") return herdRows(this.sessions, this.filter);
|
|
363
370
|
if (this.view === "network") return networkRows(this.devicesCache, this.device.id, this.filter);
|
|
364
371
|
return [];
|
|
@@ -368,6 +375,14 @@ export class App {
|
|
|
368
375
|
const rows = this.rows();
|
|
369
376
|
const row = rows[this.cursor] ?? rows.find((r) => r.sessionId);
|
|
370
377
|
if (row?.sessionId) return this.openSession(row.sessionId, row.deviceId);
|
|
378
|
+
// A group header opens its first session — pressing enter on a header used
|
|
379
|
+
// to do literally nothing.
|
|
380
|
+
if (row?.kind === "group") {
|
|
381
|
+
// The next session row after the header belongs to that group.
|
|
382
|
+
const at = rows.indexOf(row);
|
|
383
|
+
const target = rows.slice(at + 1).find((r) => r.kind !== "group");
|
|
384
|
+
if (target?.sessionId) return this.openSession(target.sessionId, target.deviceId);
|
|
385
|
+
}
|
|
371
386
|
if (row?.deviceId && this.view === "network") {
|
|
372
387
|
const target = this.devicesCache.find((d) => d.id === row.deviceId);
|
|
373
388
|
if (!target) return;
|
|
@@ -402,7 +417,11 @@ export class App {
|
|
|
402
417
|
|
|
403
418
|
/** 1-9 jump: the nth session row of the current view. */
|
|
404
419
|
private jump(n: number) {
|
|
405
|
-
|
|
420
|
+
// Digits address sessions, so they work from the agent view as well — that
|
|
421
|
+
// is the fastest way out of a session you no longer want.
|
|
422
|
+
const rows = (this.view === "herd" ? this.rows() : herdRows(this.sessions, this.filter)).filter(
|
|
423
|
+
(r): r is { kind: "session"; sessionId: string; deviceId?: string; label: string } => !!r.sessionId,
|
|
424
|
+
);
|
|
406
425
|
const nth = rows[n - 1];
|
|
407
426
|
if (nth?.sessionId) this.openSession(nth.sessionId, nth.deviceId);
|
|
408
427
|
else this.notify(`no session #${n} in this view`);
|
|
@@ -454,6 +473,17 @@ export class App {
|
|
|
454
473
|
// ------------------------------------------------------------------- input
|
|
455
474
|
|
|
456
475
|
handleInput(data: string): TuiInputListenerResult {
|
|
476
|
+
// DPI_TUI_KEYS=<file> traces every key the app receives — the fastest way to
|
|
477
|
+
// tell "the terminal never sent it" from "a branch swallowed it".
|
|
478
|
+
if (process.env.DPI_TUI_KEYS)
|
|
479
|
+
try {
|
|
480
|
+
appendFileSync(
|
|
481
|
+
process.env.DPI_TUI_KEYS,
|
|
482
|
+
`${JSON.stringify({ data, view: this.view, tab: this.tab, input: this.input?.kind ?? null, overlays: this.overlays.length })}\n`,
|
|
483
|
+
);
|
|
484
|
+
} catch {
|
|
485
|
+
/* tracing must never break input */
|
|
486
|
+
}
|
|
457
487
|
// pi-tui overlays (welcome screen, pickers, prompts) own the keyboard while
|
|
458
488
|
// open; only ctrl+c still closes them from here.
|
|
459
489
|
if (this.overlays.length) {
|
|
@@ -489,7 +519,7 @@ export class App {
|
|
|
489
519
|
else if (data === "/") {
|
|
490
520
|
this.beginInput("message", "follow-up ❯ ");
|
|
491
521
|
this.input!.value = "/";
|
|
492
|
-
} else if (data === "f") this.beginInput("filter", "
|
|
522
|
+
} else if (data === "f") this.beginInput("filter", "filter ❯ ");
|
|
493
523
|
else if (data === "n") this.beginInput("new", "new session › ");
|
|
494
524
|
else if (data === "x") this.beginInput("kill", `reset ${this.sessionSummary()?.name ?? "context"}? y/n`);
|
|
495
525
|
else if (data === "g") this.setView("herd");
|
|
@@ -508,12 +538,9 @@ export class App {
|
|
|
508
538
|
else if (data === "]" || matchesKey(data, "tab")) this.cycleTab(1);
|
|
509
539
|
else if (data === "[" || matchesKey(data, "shift+tab")) this.cycleTab(-1);
|
|
510
540
|
else if (/^[1-9]$/.test(data)) this.jump(Number(data));
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
|
|
514
|
-
this.beginInput("message", "follow-up ❯ ");
|
|
515
|
-
this.input!.value = data;
|
|
516
|
-
}
|
|
541
|
+
// No catch-all: a stray letter used to jump views or open the composer with
|
|
542
|
+
// itself as the first character. herdr and piggery both require an explicit
|
|
543
|
+
// key (m, / or enter), and so do we now.
|
|
517
544
|
return { consume: true };
|
|
518
545
|
}
|
|
519
546
|
|
|
@@ -587,7 +614,7 @@ export class App {
|
|
|
587
614
|
} else if (input.kind === "kill") {
|
|
588
615
|
if (value.trim().toLowerCase().startsWith("y")) void this.command("reset");
|
|
589
616
|
else this.notify("reset cancelled");
|
|
590
|
-
} else if (input.kind === "new") void this
|
|
617
|
+
} else if (input.kind === "new") void newSession(this, value);
|
|
591
618
|
return true;
|
|
592
619
|
}
|
|
593
620
|
if (data === "\x7f" || matchesKey(data, "backspace")) (input.value = input.value.slice(0, -1)), (this.suggIndex = 0);
|
|
@@ -607,15 +634,27 @@ export class App {
|
|
|
607
634
|
this.requestRender();
|
|
608
635
|
}
|
|
609
636
|
|
|
637
|
+
/**
|
|
638
|
+
* esc is BACK, the way every list/detail client behaves (piggery: "esc back").
|
|
639
|
+
* Interrupting a running turn is a destructive action and keeps its own key
|
|
640
|
+
* (i) — folding it into esc meant "leave this screen" did nothing at all while
|
|
641
|
+
* an agent was idle.
|
|
642
|
+
*/
|
|
610
643
|
private escape() {
|
|
611
644
|
if (this.filter) {
|
|
612
645
|
this.filter = "";
|
|
613
646
|
this.notify("filter cleared");
|
|
614
|
-
} else if (this.view === "agent") {
|
|
615
|
-
|
|
616
|
-
}
|
|
647
|
+
} else if (this.view === "agent" || this.view === "log") {
|
|
648
|
+
this.setView("herd");
|
|
649
|
+
}
|
|
617
650
|
}
|
|
618
651
|
|
|
652
|
+
/**
|
|
653
|
+
* Move the cursor. Group rows ("~/project/ · 3 agents") are headers, not
|
|
654
|
+
* entries: stepping onto one left the marker and the OVERVIEW panel frozen on
|
|
655
|
+
* the previous session, which reads as "navigation is dead". Skip them, the
|
|
656
|
+
* way piggery's list only ever has real rows under the cursor.
|
|
657
|
+
*/
|
|
619
658
|
private move(delta: number) {
|
|
620
659
|
if (this.view === "agent" && this.tab === "transcript") {
|
|
621
660
|
// Scroll the transcript instead of moving a cursor (herdr H04's lesson).
|
|
@@ -625,9 +664,24 @@ export class App {
|
|
|
625
664
|
}
|
|
626
665
|
const rows = this.rows();
|
|
627
666
|
if (!rows.length) return;
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
667
|
+
const selectable = (i: number) => !!rows[i] && rows[i].kind !== "group";
|
|
668
|
+
let next = this.cursor;
|
|
669
|
+
for (let step = 0; step < rows.length; step += 1) {
|
|
670
|
+
next += delta;
|
|
671
|
+
if (next < 0 || next >= rows.length) break;
|
|
672
|
+
if (selectable(next)) {
|
|
673
|
+
this.cursor = next;
|
|
674
|
+
this.selectedId = rows[next]!.sessionId ?? this.selectedId;
|
|
675
|
+
this.requestRender();
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
// Nothing selectable in that direction: still clamp to a valid row so the
|
|
680
|
+
// highlight never points past the list.
|
|
681
|
+
if (!selectable(this.cursor)) {
|
|
682
|
+
const first = rows.findIndex((_, i) => selectable(i));
|
|
683
|
+
if (first >= 0) this.cursor = first;
|
|
684
|
+
}
|
|
631
685
|
this.requestRender();
|
|
632
686
|
}
|
|
633
687
|
|
|
@@ -767,7 +821,9 @@ export class App {
|
|
|
767
821
|
|
|
768
822
|
tick() {
|
|
769
823
|
if (this.toastTicks > 0 && --this.toastTicks === 0) this.toast = "";
|
|
770
|
-
|
|
824
|
+
// Live network: refresh the hub feed every ~5 s (tick = 120 ms), so a device
|
|
825
|
+
// paired from the CLI shows up while the operator is looking at the list.
|
|
826
|
+
if (this.hub && ++this.hubTick >= 42) {
|
|
771
827
|
this.hubTick = 0;
|
|
772
828
|
void fetchHubNetwork(this.hub.url, this.hub.token).then((rows) => {
|
|
773
829
|
if (rows) {
|
|
@@ -855,7 +911,7 @@ export class App {
|
|
|
855
911
|
}).slice(0, h);
|
|
856
912
|
if (!wide) return table;
|
|
857
913
|
const selected = this.sessions.find((s) => s.id === (this.selectedId ?? this.sessionId));
|
|
858
|
-
const panel = renderHerdPanel(panelW, h, selected, this.contextLeft());
|
|
914
|
+
const panel = renderHerdPanel(panelW, h, selected, this.contextLeft(), this.device.name);
|
|
859
915
|
return joinPanel(table, panel, tableW, w, h);
|
|
860
916
|
}
|
|
861
917
|
|
|
@@ -61,6 +61,11 @@ export async function runCommand(app: App, text: string): Promise<boolean> {
|
|
|
61
61
|
pickDevice(app);
|
|
62
62
|
break;
|
|
63
63
|
case "new":
|
|
64
|
+
// "new agent" needs an open session; the n KEY (new session) is the other door.
|
|
65
|
+
if (!app.sessionId) {
|
|
66
|
+
app.notify("no session open — press n to create one");
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
64
69
|
await app.run(async () => {
|
|
65
70
|
const created = await app.client.session(app.sessionId!).createAgent(arg ? { name: arg } : {});
|
|
66
71
|
await app.attach(app.sessionId!, created.id);
|
|
@@ -25,35 +25,91 @@ class Title implements Component {
|
|
|
25
25
|
invalidate() {}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* A titled list with local filtering. Two deliberate departures from pi-tui's
|
|
30
|
+
* SelectList defaults, both found by driving the real picker:
|
|
31
|
+
* - it filters on `value.startsWith`, so typing "pro" found nothing in a
|
|
32
|
+
* directory named "project/"; we filter on the LABEL (substring), which is
|
|
33
|
+
* what the user reads;
|
|
34
|
+
* - its empty message is "No matching commands" — the wrong vocabulary for a
|
|
35
|
+
* directory or model list, so the theme names the filter instead.
|
|
36
|
+
* j/k move (as the app advertises) instead of being swallowed as filter text.
|
|
37
|
+
*/
|
|
29
38
|
export class Picker extends Box {
|
|
30
|
-
|
|
39
|
+
list: SelectList;
|
|
31
40
|
private filter = "";
|
|
32
41
|
private title: Title;
|
|
42
|
+
private readonly hintText: string;
|
|
33
43
|
constructor(
|
|
34
44
|
private label: string,
|
|
35
|
-
|
|
36
|
-
onPick: (item: SelectItem) => void,
|
|
37
|
-
onCancel: () => void,
|
|
38
|
-
hint = "
|
|
39
|
-
subtitle?: string,
|
|
45
|
+
private readonly allItems: SelectItem[],
|
|
46
|
+
private onPick: (item: SelectItem) => void,
|
|
47
|
+
private readonly onCancel: () => void,
|
|
48
|
+
hint = "↑↓/j/k move │ enter select │ type to filter │ esc close",
|
|
49
|
+
private readonly subtitle?: string,
|
|
40
50
|
) {
|
|
41
51
|
super(1, 0, panelBg);
|
|
52
|
+
this.hintText = hint;
|
|
42
53
|
this.title = new Title(c.bold(c.accent(label)));
|
|
43
|
-
this.list =
|
|
44
|
-
this.
|
|
45
|
-
|
|
54
|
+
this.list = this.makeList(this.allItems);
|
|
55
|
+
this.rebuild();
|
|
56
|
+
}
|
|
57
|
+
private makeList(items: SelectItem[]): SelectList {
|
|
58
|
+
const theme = {
|
|
59
|
+
...selectTheme,
|
|
60
|
+
// Name the filter: "no match for “pro”" beats a command-palette phrase.
|
|
61
|
+
noMatch: (s: string) => c.muted(this.filter ? ` no match for “${this.filter}”` : s),
|
|
62
|
+
};
|
|
63
|
+
const list = new SelectList(items, Math.max(1, Math.min(14, items.length)), theme);
|
|
64
|
+
list.onSelect = this.onPick;
|
|
65
|
+
list.onCancel = this.onCancel;
|
|
66
|
+
return list;
|
|
67
|
+
}
|
|
68
|
+
/** Rebuild typing the children in order — the filter changes the item set. */
|
|
69
|
+
private rebuild() {
|
|
70
|
+
this.clear();
|
|
46
71
|
this.addChild(this.title);
|
|
47
|
-
if (subtitle) this.addChild(new Title(subtitle));
|
|
72
|
+
if (this.subtitle) this.addChild(new Title(this.subtitle));
|
|
48
73
|
this.addChild(this.list);
|
|
49
|
-
this.addChild(new Title(c.muted(
|
|
74
|
+
this.addChild(new Title(c.muted(this.hintText)));
|
|
75
|
+
this.invalidate();
|
|
76
|
+
}
|
|
77
|
+
/** Every item this picker was built with (shortcut lookups need the whole set). */
|
|
78
|
+
items(): SelectItem[] {
|
|
79
|
+
return this.allItems;
|
|
80
|
+
}
|
|
81
|
+
/** Run the same path a click/enter takes. */
|
|
82
|
+
pick(item: SelectItem) {
|
|
83
|
+
this.onPick(item);
|
|
84
|
+
}
|
|
85
|
+
private applyFilter() {
|
|
86
|
+
const needle = this.filter.toLowerCase();
|
|
87
|
+
const items = needle
|
|
88
|
+
? this.allItems.filter((i) => `${i.label} ${i.description ?? ""}`.toLowerCase().includes(needle))
|
|
89
|
+
: this.allItems;
|
|
90
|
+
this.list = this.makeList(items);
|
|
91
|
+
this.title.text =
|
|
92
|
+
c.bold(c.accent(this.label)) + (this.filter ? c.muted(` /${this.filter}`) : "");
|
|
93
|
+
this.rebuild();
|
|
50
94
|
}
|
|
51
95
|
handleInput(data: string) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
96
|
+
// Navigation first: j/k are movement everywhere else in the app, so a list
|
|
97
|
+
// must not swallow them as filter characters.
|
|
98
|
+
// SelectList only knows arrows/enter/esc, so vim keys are translated to the
|
|
99
|
+
// arrow sequences it already understands.
|
|
100
|
+
if (data === "j") return this.list.handleInput("\x1b[B");
|
|
101
|
+
if (data === "k") return this.list.handleInput("\x1b[A");
|
|
102
|
+
if (matchesKey(data, "up") || matchesKey(data, "down") || matchesKey(data, "enter") || matchesKey(data, "escape"))
|
|
103
|
+
return this.list.handleInput(data);
|
|
104
|
+
if (matchesKey(data, "backspace")) {
|
|
105
|
+
this.filter = this.filter.slice(0, -1);
|
|
106
|
+
return this.applyFilter();
|
|
107
|
+
}
|
|
108
|
+
if (data.length === 1 && data >= " " && data !== "\x7f") {
|
|
109
|
+
this.filter += data;
|
|
110
|
+
return this.applyFilter();
|
|
111
|
+
}
|
|
112
|
+
this.list.handleInput(data);
|
|
57
113
|
}
|
|
58
114
|
}
|
|
59
115
|
|
|
@@ -216,17 +272,25 @@ export async function pickSession(app: App) {
|
|
|
216
272
|
);
|
|
217
273
|
}
|
|
218
274
|
|
|
219
|
-
export async function newSession(app: App) {
|
|
220
|
-
|
|
275
|
+
export async function newSession(app: App, presetName?: string) {
|
|
276
|
+
// The `n` input line already carries the typed name — never re-ask for it.
|
|
277
|
+
const name = presetName !== undefined ? presetName : await app.prompt("Session name", "");
|
|
221
278
|
if (name === undefined) return app.sessionId ? undefined : pickSession(app);
|
|
222
279
|
const cwd = await walkDirs(app, "");
|
|
223
280
|
if (!cwd) return app.sessionId ? undefined : pickSession(app);
|
|
224
|
-
|
|
281
|
+
// A device without a provider key (the Windows default) lists ZERO models —
|
|
282
|
+
// the picker would be empty and creation dead-ends. Fall back to the device's
|
|
283
|
+
// own default exactly like the web dialog's "(device default)" option.
|
|
284
|
+
const models = app.info.models;
|
|
285
|
+
const model = models.length
|
|
286
|
+
? await chooseModel(app, app.info.defaultModel)
|
|
287
|
+
: { provider: (app.info as { provider?: string }).provider ?? "openrouter", id: app.info.defaultModel, name: "(device default)" };
|
|
225
288
|
if (!model) return;
|
|
226
289
|
await app.run(async () => {
|
|
227
290
|
const s = await app.client.createSession({ name: name || "New session", cwd, model: { provider: model.provider, modelId: model.id } });
|
|
228
291
|
await app.attach(s.id, s.agents.find((a) => a.kind === "root")?.id ?? 1);
|
|
229
292
|
app.flash(`Session created in ${cwd}`);
|
|
293
|
+
if (!models.length) app.flash(`no model catalog on the device — using its default (${app.info.defaultModel})`);
|
|
230
294
|
});
|
|
231
295
|
}
|
|
232
296
|
|
|
@@ -241,7 +305,7 @@ function walkDirs(app: App, path: string): Promise<string | undefined> {
|
|
|
241
305
|
...listing.dirs.map((d) => ({ value: d.path, label: `${d.name}/` })),
|
|
242
306
|
];
|
|
243
307
|
app.overlay(
|
|
244
|
-
new Picker(`Working directory on ${app.device.
|
|
308
|
+
new Picker(`Working directory on ${app.device.name}`, items, (item) => {
|
|
245
309
|
app.closeOverlay();
|
|
246
310
|
if (item.value.startsWith("use:")) done(item.value.slice(4));
|
|
247
311
|
else void step(item.value);
|
|
@@ -259,7 +323,7 @@ function chooseModel(app: App, current?: string) {
|
|
|
259
323
|
return new Promise<(typeof app.info.models)[number] | undefined>((done) => {
|
|
260
324
|
const models = app.info.models;
|
|
261
325
|
const picker = new Picker(
|
|
262
|
-
`Model (${app.device.
|
|
326
|
+
`Model (${app.device.name})`,
|
|
263
327
|
models.map((m) => ({ value: `${m.provider}/${m.id}`, label: m.name, description: `${m.tier} │ ${m.provider}` })),
|
|
264
328
|
(item) => {
|
|
265
329
|
app.closeOverlay();
|
|
@@ -121,9 +121,10 @@ export function renderHerd(
|
|
|
121
121
|
if (row.kind === "group") {
|
|
122
122
|
const group = sessions.filter((s) => basename(s.cwd) === row.label);
|
|
123
123
|
const busy = group.filter((s) => s.busy).length;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
)
|
|
124
|
+
const header = `${c.muted("~/")}${c.text(row.label)}${c.muted("/")} ${c.muted(`· ${group.length} agent${group.length === 1 ? "" : "s"} · ${busy} busy`)}`;
|
|
125
|
+
// The cursor is drawn on headers too: a keypress must always be visible,
|
|
126
|
+
// even though j/k skips them (they are not selectable entries).
|
|
127
|
+
lines.push(cursorRow === row ? c.inverse(fit(header, w)) : fit(header, w));
|
|
127
128
|
continue;
|
|
128
129
|
}
|
|
129
130
|
const s = sessions.find((x) => x.id === row.sessionId);
|
|
@@ -142,6 +143,16 @@ export function renderHerd(
|
|
|
142
143
|
if (showAge) line += ` ${fit(c.muted(ageOf(s.createdAt)), cols.age)}`;
|
|
143
144
|
lines.push(atCursor && !selected ? c.inverse(fit(line, w)) : fit(line, w));
|
|
144
145
|
}
|
|
146
|
+
if (!rows.length && !opts.filter) {
|
|
147
|
+
// herdr never shows a bare header over nothing: the empty state points at
|
|
148
|
+
// the next move instead of a wall of blank rows.
|
|
149
|
+
lines.push(
|
|
150
|
+
"",
|
|
151
|
+
` ${c.muted("no sessions yet on this device")}`,
|
|
152
|
+
"",
|
|
153
|
+
` ${c.accent("n")} ${c.muted("new session")} ${c.accent(":")} ${c.muted("palette")} ${c.accent("N")} ${c.muted("network view")}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
145
156
|
if (opts.filter) lines.push(c.warn(`filter: ${opts.filter}`));
|
|
146
157
|
// herdr frames pad EVERY line to the exact width — the caller paints rows as-is.
|
|
147
158
|
return lines.map((l) => fit(l, w));
|
|
@@ -273,8 +284,23 @@ export function renderHerdPanel(
|
|
|
273
284
|
h: number,
|
|
274
285
|
s: SessionSummary | undefined,
|
|
275
286
|
ctxLeft?: number,
|
|
287
|
+
device?: string,
|
|
276
288
|
): string[] {
|
|
277
|
-
|
|
289
|
+
// No session selected: a blank panel next to the divider is what made the
|
|
290
|
+
// wide layout look broken — herdr's panel always carries a device header.
|
|
291
|
+
if (!s) {
|
|
292
|
+
const lines = [
|
|
293
|
+
c.muted(" OVERVIEW"),
|
|
294
|
+
` ${c.bold(device ?? "device")}`,
|
|
295
|
+
` ${c.muted("no session selected")}`,
|
|
296
|
+
"",
|
|
297
|
+
` ${c.muted("n")} ${c.text("new session")}`,
|
|
298
|
+
` ${c.muted("↵")} ${c.text("open the cursor row")}`,
|
|
299
|
+
` ${c.muted("j/k")} ${c.text("move")}`,
|
|
300
|
+
` ${c.muted(":")} ${c.text("palette")}`,
|
|
301
|
+
];
|
|
302
|
+
return lines.slice(0, h).map((l) => fit(l, w));
|
|
303
|
+
}
|
|
278
304
|
const agents = s.agents.map((a) =>
|
|
279
305
|
a.kind === "fork" ? `⑂ ${a.name}` : a.kind === "subagent" ? `↳ ${a.name}` : `● ${a.name}`,
|
|
280
306
|
);
|
|
@@ -194,8 +194,8 @@ export function validateDeviceInput(urlAnswer: string, tokenAnswer: string): { u
|
|
|
194
194
|
// ------------------------------------------------------------------ the welcome screen
|
|
195
195
|
|
|
196
196
|
export function welcomeItems(deviceCount: number, port: number): SelectItem[] {
|
|
197
|
-
//
|
|
198
|
-
//
|
|
197
|
+
// The bracket letter is a real shortcut (see WelcomePicker) — typing it runs
|
|
198
|
+
// the action instead of relying on filter order.
|
|
199
199
|
const items: SelectItem[] = [
|
|
200
200
|
{ value: "pair", label: "[p] Pair this machine", description: "code or pair URL from the hub wizard" },
|
|
201
201
|
{ value: "add-url", label: "[a] Add a device by URL", description: "http://host:port + its DPI_TOKEN" },
|
|
@@ -219,6 +219,12 @@ export class WelcomePicker extends Picker {
|
|
|
219
219
|
}
|
|
220
220
|
handleInput(data: string) {
|
|
221
221
|
if (data === "q" || matchesKey(data, "escape")) return this.cancel();
|
|
222
|
+
// Letters in the labels are shortcuts, not filter text: "[d] Detect" must
|
|
223
|
+
// DETECT regardless of which other label happens to contain a "d".
|
|
224
|
+
if (data.length === 1) {
|
|
225
|
+
const item = this.items().find((i) => i.label.startsWith(`[${data}]`));
|
|
226
|
+
if (item) return this.pick(item);
|
|
227
|
+
}
|
|
222
228
|
return super.handleInput(data);
|
|
223
229
|
}
|
|
224
230
|
}
|
package/bin/dpi.mjs
CHANGED
|
@@ -23,6 +23,10 @@ Usage:
|
|
|
23
23
|
dpi device start | stop | status background daemon without a terminal
|
|
24
24
|
dpi device install | uninstall OS service (systemd/launchd); Windows: no
|
|
25
25
|
autostart — run "dpi device start" after login
|
|
26
|
+
dpi pair <code|link> [--hub <origin>] pair THIS machine from the CLI (no browser)
|
|
27
|
+
dpi pair --hub <origin> this machine asks the hub itself
|
|
28
|
+
dpi pair offer|list|approve|revoke operator side, from the CLI (dpi pair --help)
|
|
29
|
+
dpi devices [--watch] [--hub <origin>] paired machines + live status (CLI, no web)
|
|
26
30
|
dpi tui
|
|
27
31
|
Terminal admin client.
|
|
28
32
|
dpi help | dpi --help | dpi -h
|
|
@@ -88,75 +92,48 @@ if (command === "version" || command === "--version" || command === "-v") {
|
|
|
88
92
|
|
|
89
93
|
if (command === "update" || command === "upgrade") {
|
|
90
94
|
const { readFileSync } = await import("node:fs");
|
|
91
|
-
const {
|
|
95
|
+
const { runUpdate, fetchLatestVersion, spawnNpm, daemonControl } = await import("./update.mjs");
|
|
92
96
|
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/.test(String(latest)))
|
|
106
|
-
throw new Error(`registry returned a malformed version: ${latest}`);
|
|
107
|
-
} catch (error) {
|
|
108
|
-
process.stderr.write(`dpi update: cannot reach the npm registry (${error.message})\n`);
|
|
109
|
-
process.exit(1);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Numeric triple compare; a prerelease tag on either side counts as older.
|
|
113
|
-
const rank = (v) => {
|
|
114
|
-
const [core, ...pre] = String(v).split("-");
|
|
115
|
-
const nums = core.split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
116
|
-
return { nums: [nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0], pre: pre.length > 0 };
|
|
117
|
-
};
|
|
118
|
-
const a = rank(current);
|
|
119
|
-
const b = rank(latest);
|
|
120
|
-
let order = 0;
|
|
121
|
-
for (let i = 0; i < 3; i += 1) {
|
|
122
|
-
if (a.nums[i] !== b.nums[i]) {
|
|
123
|
-
order = a.nums[i] < b.nums[i] ? -1 : 1;
|
|
124
|
-
break;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
if (order === 0 && a.pre !== b.pre) order = a.pre ? -1 : 1;
|
|
128
|
-
|
|
129
|
-
if (order >= 0) {
|
|
130
|
-
process.stdout.write(`dpi ${current} is up to date (latest ${latest}).\n`);
|
|
131
|
-
process.exit(0);
|
|
132
|
-
}
|
|
133
|
-
process.stdout.write(`dpi ${current} -> ${latest} available.\n`);
|
|
134
|
-
if (checkOnly) {
|
|
135
|
-
process.stdout.write("Run `dpi update` to install it.\n");
|
|
136
|
-
process.exit(0);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Windows: npm is npm.cmd and cmd.exe only executes batch files through a
|
|
140
|
-
// shell — a bare spawn("npm") dies with ENOENT (CreateProcess does no
|
|
141
|
-
// PATHEXT resolution). Args are literals plus the strictly-validated
|
|
142
|
-
// version above, so shell: true has no injection surface here.
|
|
143
|
-
const onWindows = process.platform === "win32";
|
|
144
|
-
const child = spawn(onWindows ? "npm.cmd" : "npm", ["install", "-g", `my-durable@${latest}`], {
|
|
145
|
-
stdio: "inherit",
|
|
146
|
-
shell: onWindows,
|
|
147
|
-
});
|
|
148
|
-
child.on("error", (error) => {
|
|
149
|
-
process.stderr.write(`dpi update: cannot run npm (${error.message})\n`);
|
|
150
|
-
process.exit(1);
|
|
97
|
+
const port = Number(process.env.DPI_PORT ?? "4780");
|
|
98
|
+
const code = await runUpdate({
|
|
99
|
+
argv: commandRest,
|
|
100
|
+
pkg,
|
|
101
|
+
nodeVersion: process.versions.node,
|
|
102
|
+
fetchLatest: fetchLatestVersion,
|
|
103
|
+
// Stop/start through this same launcher: the daemon must not block npm's
|
|
104
|
+
// rename on Windows, and it must come back afterwards.
|
|
105
|
+
daemon: daemonControl(fileURLToPath(import.meta.url), port),
|
|
106
|
+
runNpm: (version) => spawnNpm(["install", "-g", `my-durable@${version}`]),
|
|
107
|
+
print: (line) => process.stdout.write(`${line}\n`),
|
|
108
|
+
printErr: (line) => process.stderr.write(`${line}\n`),
|
|
151
109
|
});
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
110
|
+
process.exit(code);
|
|
111
|
+
} else if (command === "devices") {
|
|
112
|
+
// The CLI view of the network: same data the web sidebar and the TUI show.
|
|
113
|
+
process.argv = [process.argv[0], process.argv[1], ...commandRest];
|
|
114
|
+
const { register } = await loadTsx();
|
|
115
|
+
register();
|
|
116
|
+
const { listDevices } = await import("../apps/dpi-backend/src/pair-cli.ts");
|
|
117
|
+
const hubIdx = commandRest.indexOf("--hub");
|
|
118
|
+
process.exit(
|
|
119
|
+
await listDevices({}, hubIdx >= 0 ? commandRest[hubIdx + 1] : undefined, commandRest.includes("--watch")),
|
|
120
|
+
);
|
|
121
|
+
} else if (command === "pair") {
|
|
122
|
+
// Pairing is its own surface: it runs headless (no TUI, no browser) and both
|
|
123
|
+
// directions live in one command — this machine, and the operator's side.
|
|
124
|
+
if (commandRest.includes("--help") || commandRest.includes("-h")) {
|
|
125
|
+
process.argv = [process.argv[0], process.argv[1], ...commandRest];
|
|
126
|
+
const { register } = await loadTsx();
|
|
127
|
+
register();
|
|
128
|
+
const { PAIR_USAGE } = await import("../apps/dpi-backend/src/pair-cli.ts");
|
|
129
|
+
process.stdout.write(PAIR_USAGE);
|
|
158
130
|
process.exit(0);
|
|
159
|
-
}
|
|
131
|
+
}
|
|
132
|
+
process.argv = [process.argv[0], process.argv[1], ...commandRest];
|
|
133
|
+
const { register } = await loadTsx();
|
|
134
|
+
register();
|
|
135
|
+
const { runPairCommand } = await import("../apps/dpi-backend/src/pair-cli.ts");
|
|
136
|
+
process.exit(await runPairCommand(commandRest));
|
|
160
137
|
} else if (!Object.hasOwn(COMMANDS, command)) {
|
|
161
138
|
process.stderr.write(`dpi: unknown command '${command}'\n\n${HELP}`);
|
|
162
139
|
process.exit(2);
|
package/bin/update.d.mts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Types for bin/update.mjs — the updater is plain JS (it must run before any
|
|
2
|
+
// TS toolchain is available), so its public surface is declared here.
|
|
3
|
+
export interface UpdateDeps {
|
|
4
|
+
argv: string[];
|
|
5
|
+
pkg: { version: string; engines?: { node?: string } };
|
|
6
|
+
nodeVersion: string;
|
|
7
|
+
fetchLatest: () => Promise<string>;
|
|
8
|
+
daemon: { running: () => Promise<boolean>; stop: () => Promise<unknown>; start: () => Promise<unknown> };
|
|
9
|
+
runNpm: (version: string) => Promise<{ code: number; output: string }>;
|
|
10
|
+
print: (line: string) => void;
|
|
11
|
+
printErr: (line: string) => void;
|
|
12
|
+
}
|
|
13
|
+
export function rank(version: string): { nums: number[]; pre: boolean };
|
|
14
|
+
export function compareVersions(a: string, b: string): -1 | 0 | 1;
|
|
15
|
+
export function satisfiesEngine(current: string, required: string): boolean;
|
|
16
|
+
export function normalizeExitCode(code: number | undefined): number;
|
|
17
|
+
export function isBusyFailure(text: string): boolean;
|
|
18
|
+
export function runUpdate(deps: UpdateDeps): Promise<number>;
|
|
19
|
+
export function spawnNpm(args: string[]): Promise<{ code: number; output: string }>;
|
|
20
|
+
export function fetchLatestVersion(): Promise<string>;
|
|
21
|
+
export function daemonControl(
|
|
22
|
+
dpiPath: string,
|
|
23
|
+
port: number,
|
|
24
|
+
): { running: () => Promise<boolean>; stop: () => Promise<number>; start: () => Promise<number> };
|