my-durable 0.0.8 → 0.0.10
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-tui/src/app.ts +3 -3
- package/apps/dpi-tui/src/commands.ts +5 -0
- package/apps/dpi-tui/src/pickers.ts +85 -21
- package/apps/dpi-tui/src/views.ts +26 -1
- package/apps/dpi-tui/src/welcome.ts +8 -2
- package/bin/dpi.mjs +13 -1
- package/package.json +1 -1
- package/scripts/verify.mjs +24 -1
|
@@ -35,6 +35,9 @@ const store = await Store.open({
|
|
|
35
35
|
const app = express();
|
|
36
36
|
app.disable("x-powered-by");
|
|
37
37
|
app.get("/healthz", (_req, res) => res.json({ ok: true }));
|
|
38
|
+
// The base URL is what people type first; a JSON 404 reads like a broken
|
|
39
|
+
// daemon. Land them on the one real page (unpaired: enroll; paired: status).
|
|
40
|
+
app.get("/", (_req, res) => res.redirect("/pair"));
|
|
38
41
|
const pairing = new DevicePairing(store, config.home, config.name, process.env.DPI_HUB_URL, {
|
|
39
42
|
// Forced hub change (P-5): once the new pairing is persisted the OLD
|
|
40
43
|
// connector is stopped — never two live tunnels for one device, and the old
|
package/apps/dpi-tui/src/app.ts
CHANGED
|
@@ -26,7 +26,7 @@ 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
31
|
import { fetchHubNetwork } from "./hub-feed.ts";
|
|
32
32
|
import { HubMux } from "./hub-mux.ts";
|
|
@@ -587,7 +587,7 @@ export class App {
|
|
|
587
587
|
} else if (input.kind === "kill") {
|
|
588
588
|
if (value.trim().toLowerCase().startsWith("y")) void this.command("reset");
|
|
589
589
|
else this.notify("reset cancelled");
|
|
590
|
-
} else if (input.kind === "new") void this
|
|
590
|
+
} else if (input.kind === "new") void newSession(this, value);
|
|
591
591
|
return true;
|
|
592
592
|
}
|
|
593
593
|
if (data === "\x7f" || matchesKey(data, "backspace")) (input.value = input.value.slice(0, -1)), (this.suggIndex = 0);
|
|
@@ -855,7 +855,7 @@ export class App {
|
|
|
855
855
|
}).slice(0, h);
|
|
856
856
|
if (!wide) return table;
|
|
857
857
|
const selected = this.sessions.find((s) => s.id === (this.selectedId ?? this.sessionId));
|
|
858
|
-
const panel = renderHerdPanel(panelW, h, selected, this.contextLeft());
|
|
858
|
+
const panel = renderHerdPanel(panelW, h, selected, this.contextLeft(), this.device.name);
|
|
859
859
|
return joinPanel(table, panel, tableW, w, h);
|
|
860
860
|
}
|
|
861
861
|
|
|
@@ -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);
|
|
@@ -142,6 +142,16 @@ export function renderHerd(
|
|
|
142
142
|
if (showAge) line += ` ${fit(c.muted(ageOf(s.createdAt)), cols.age)}`;
|
|
143
143
|
lines.push(atCursor && !selected ? c.inverse(fit(line, w)) : fit(line, w));
|
|
144
144
|
}
|
|
145
|
+
if (!rows.length && !opts.filter) {
|
|
146
|
+
// herdr never shows a bare header over nothing: the empty state points at
|
|
147
|
+
// the next move instead of a wall of blank rows.
|
|
148
|
+
lines.push(
|
|
149
|
+
"",
|
|
150
|
+
` ${c.muted("no sessions yet on this device")}`,
|
|
151
|
+
"",
|
|
152
|
+
` ${c.accent("n")} ${c.muted("new session")} ${c.accent(":")} ${c.muted("palette")} ${c.accent("N")} ${c.muted("network view")}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
145
155
|
if (opts.filter) lines.push(c.warn(`filter: ${opts.filter}`));
|
|
146
156
|
// herdr frames pad EVERY line to the exact width — the caller paints rows as-is.
|
|
147
157
|
return lines.map((l) => fit(l, w));
|
|
@@ -273,8 +283,23 @@ export function renderHerdPanel(
|
|
|
273
283
|
h: number,
|
|
274
284
|
s: SessionSummary | undefined,
|
|
275
285
|
ctxLeft?: number,
|
|
286
|
+
device?: string,
|
|
276
287
|
): string[] {
|
|
277
|
-
|
|
288
|
+
// No session selected: a blank panel next to the divider is what made the
|
|
289
|
+
// wide layout look broken — herdr's panel always carries a device header.
|
|
290
|
+
if (!s) {
|
|
291
|
+
const lines = [
|
|
292
|
+
c.muted(" OVERVIEW"),
|
|
293
|
+
` ${c.bold(device ?? "device")}`,
|
|
294
|
+
` ${c.muted("no session selected")}`,
|
|
295
|
+
"",
|
|
296
|
+
` ${c.muted("n")} ${c.text("new session")}`,
|
|
297
|
+
` ${c.muted("↵")} ${c.text("open the cursor row")}`,
|
|
298
|
+
` ${c.muted("j/k")} ${c.text("move")}`,
|
|
299
|
+
` ${c.muted(":")} ${c.text("palette")}`,
|
|
300
|
+
];
|
|
301
|
+
return lines.slice(0, h).map((l) => fit(l, w));
|
|
302
|
+
}
|
|
278
303
|
const agents = s.agents.map((a) =>
|
|
279
304
|
a.kind === "fork" ? `⑂ ${a.name}` : a.kind === "subagent" ? `↳ ${a.name}` : `● ${a.name}`,
|
|
280
305
|
);
|
|
@@ -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
|
@@ -100,6 +100,10 @@ if (command === "update" || command === "upgrade") {
|
|
|
100
100
|
});
|
|
101
101
|
if (!res.ok) throw new Error(`registry responded ${res.status}`);
|
|
102
102
|
latest = (await res.json()).version;
|
|
103
|
+
// This string is later interpolated into a command line (shell on Windows):
|
|
104
|
+
// anything that is not a plain semver must stop the update, not run.
|
|
105
|
+
if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/.test(String(latest)))
|
|
106
|
+
throw new Error(`registry returned a malformed version: ${latest}`);
|
|
103
107
|
} catch (error) {
|
|
104
108
|
process.stderr.write(`dpi update: cannot reach the npm registry (${error.message})\n`);
|
|
105
109
|
process.exit(1);
|
|
@@ -132,7 +136,15 @@ if (command === "update" || command === "upgrade") {
|
|
|
132
136
|
process.exit(0);
|
|
133
137
|
}
|
|
134
138
|
|
|
135
|
-
|
|
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
|
+
});
|
|
136
148
|
child.on("error", (error) => {
|
|
137
149
|
process.stderr.write(`dpi update: cannot run npm (${error.message})\n`);
|
|
138
150
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "my-durable",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"description": "Durable Pi \u2014 the durable agent network: hub web UI, device backend daemon, TUI admin and the shared SDK, packaged as one runnable monorepo install",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/scripts/verify.mjs
CHANGED
|
@@ -136,6 +136,16 @@ const SUITES = {
|
|
|
136
136
|
cmd: ["node", "tests/pairing-ui.mjs"],
|
|
137
137
|
title: "real PTY pairing lifecycle and 40/80/200-column resize",
|
|
138
138
|
},
|
|
139
|
+
"ui:tui-session": {
|
|
140
|
+
cwd: "apps/dpi-tui",
|
|
141
|
+
cmd: ["node", "tests/newsession-ui.mjs"],
|
|
142
|
+
title: "real PTY session creation on a keyless device + empty-state layout",
|
|
143
|
+
},
|
|
144
|
+
"ui:tui-welcome": {
|
|
145
|
+
cwd: "apps/dpi-tui",
|
|
146
|
+
cmd: ["node", "tests/welcome-ui.mjs"],
|
|
147
|
+
title: "real PTY welcome screen, detect-local and quit",
|
|
148
|
+
},
|
|
139
149
|
};
|
|
140
150
|
|
|
141
151
|
const PHASE_GATES = ["test:verification", "check:sdk", "check:backend", "contract:backend", "build:playground", "check:tui"];
|
|
@@ -143,7 +153,20 @@ const PHASE_SUITES = {
|
|
|
143
153
|
P0: ["sdk"],
|
|
144
154
|
P1: ["sdk", "playground"],
|
|
145
155
|
P2: ["sdk", "backend", "playground", "tui"],
|
|
146
|
-
ALL: [
|
|
156
|
+
ALL: [
|
|
157
|
+
"sdk",
|
|
158
|
+
"backend",
|
|
159
|
+
"timing:backend",
|
|
160
|
+
"playground",
|
|
161
|
+
"tui",
|
|
162
|
+
"lint:playground",
|
|
163
|
+
"lint:core",
|
|
164
|
+
"ui:network",
|
|
165
|
+
"ui:pairing",
|
|
166
|
+
"ui:tui",
|
|
167
|
+
"ui:tui-session",
|
|
168
|
+
"ui:tui-welcome",
|
|
169
|
+
],
|
|
147
170
|
};
|
|
148
171
|
|
|
149
172
|
function run(name, suite) {
|