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.
@@ -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
@@ -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.command("new");
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
- /** A titled SelectList; typing filters it. */
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
- readonly list: SelectList;
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
- items: SelectItem[],
36
- onPick: (item: SelectItem) => void,
37
- onCancel: () => void,
38
- hint = "↑↓ move │ enter select │ type to filter │ esc close",
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 = new SelectList(items, Math.max(1, Math.min(14, items.length)), selectTheme);
44
- this.list.onSelect = onPick;
45
- this.list.onCancel = onCancel;
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(hint)));
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
- if (matchesKey(data, "backspace")) this.filter = this.filter.slice(0, -1);
53
- else if (data.length === 1 && data >= " " && data !== "\x7f") this.filter += data;
54
- else return this.list.handleInput(data);
55
- this.list.setFilter(this.filter);
56
- this.title.text = c.bold(c.accent(this.label)) + (this.filter ? c.muted(` /${this.filter}`) : "");
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
- const name = await app.prompt("Session name", "");
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
- const model = await chooseModel(app, app.info.defaultModel);
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.id}`, items, (item) => {
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
- if (!s) return Array.from({ length: Math.max(0, h) }, () => rep(" ", w));
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
- // Values lead with the bracket letter, so typing it jumps to the action (the
198
- // Picker filters on the value); enter selects it.
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
- const child = spawn("npm", ["install", "-g", `my-durable@${latest}`], { stdio: "inherit" });
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.8",
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": {
@@ -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: ["sdk", "backend", "timing:backend", "playground", "tui", "lint:playground", "lint:core", "ui:network", "ui:pairing", "ui:tui"],
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) {