my-durable 0.0.10 → 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.
@@ -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-CoYMgU-2.js"></script>
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
- return { ok: false, error: "origin: HTTPS origin required" };
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" };
@@ -28,6 +28,7 @@ import type { DeviceRow, NetworkRegistry } from "../../dpi-sdk/src/node/registry
28
28
  import { COMMANDS, runCommand } from "./commands.ts";
29
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
- private rows(): Array<{ sessionId?: string; deviceId?: string; label: string }> {
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
- const rows = this.rows().filter((r) => r.sessionId);
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
- // Agent view keeps pi muscle memory: any other printable char opens the
512
- // message line seeded with itself (herdr requires "m"; typing talks here).
513
- else if (this.view === "agent" && /^[^\x00-\x1f]$/.test(data)) {
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
 
@@ -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
- if (this.busy()) void this.interrupt();
616
- } else this.setView("agent");
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
- this.cursor = Math.max(0, Math.min(this.cursor + delta, rows.length - 1));
629
- const row = rows[this.cursor];
630
- if (row?.sessionId) this.selectedId = row.sessionId;
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
- if (this.hub && ++this.hubTick >= 125) {
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) {
@@ -323,7 +323,7 @@ function chooseModel(app: App, current?: string) {
323
323
  return new Promise<(typeof app.info.models)[number] | undefined>((done) => {
324
324
  const models = app.info.models;
325
325
  const picker = new Picker(
326
- `Model (${app.device.id})`,
326
+ `Model (${app.device.name})`,
327
327
  models.map((m) => ({ value: `${m.provider}/${m.id}`, label: m.name, description: `${m.tier} │ ${m.provider}` })),
328
328
  (item) => {
329
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
- lines.push(
125
- `${c.muted("~/")}${c.text(row.label)}${c.muted("/")} ${c.muted(`· ${group.length} agent${group.length === 1 ? "" : "s"} · ${busy} busy`)}`,
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);
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 { spawn } = await import("node:child_process");
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 current = pkg.version;
94
- const checkOnly = commandRest.includes("--check") || commandRest.includes("-c");
95
-
96
- let latest;
97
- try {
98
- const res = await fetch("https://registry.npmjs.org/my-durable/latest", {
99
- headers: { accept: "application/json" },
100
- });
101
- if (!res.ok) throw new Error(`registry responded ${res.status}`);
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}`);
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
- child.on("exit", (code) => {
153
- if (code !== 0) {
154
- process.stderr.write(`dpi update: npm exited ${code}; still on ${current}.\n`);
155
- process.exit(code ?? 1);
156
- }
157
- process.stdout.write(`dpi updated ${current} -> ${latest}.\n`);
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);
@@ -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> };
package/bin/update.mjs ADDED
@@ -0,0 +1,198 @@
1
+ // `dpi update` — self-update with the two Windows hazards handled explicitly:
2
+ //
3
+ // 1. EBUSY: the running daemon (and a running TUI) hold files inside
4
+ // `…\npm\node_modules\my-durable\bin`, so npm cannot rename the package
5
+ // folder while they live. The updater therefore stops the device daemon
6
+ // first and starts it again afterwards — a background daemon must not be
7
+ // the reason an update fails, and it must not silently stay down either.
8
+ // 2. EBADENGINE: our dependencies need Node >= 22.19.0. npm only WARNS, then
9
+ // installs something that can fail at runtime. The updater refuses up front
10
+ // with an actionable message instead.
11
+ //
12
+ // Everything here is dependency-injected so it can be tested without npm, a
13
+ // network, or a real daemon.
14
+ import { spawn } from "node:child_process";
15
+
16
+ /** Numeric triple + prerelease flag. A prerelease counts as older. */
17
+ export function rank(version) {
18
+ const [core, ...pre] = String(version).trim().split("-");
19
+ const nums = core.split(".").map((n) => Number.parseInt(n, 10) || 0);
20
+ return { nums: [nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0], pre: pre.length > 0 };
21
+ }
22
+
23
+ /** -1 | 0 | 1 — semver-ish ordering, enough for "is there a newer release". */
24
+ export function compareVersions(a, b) {
25
+ const x = rank(a);
26
+ const y = rank(b);
27
+ for (let i = 0; i < 3; i += 1) {
28
+ if (x.nums[i] !== y.nums[i]) return x.nums[i] < y.nums[i] ? -1 : 1;
29
+ }
30
+ if (x.pre !== y.pre) return x.pre ? -1 : 1;
31
+ return 0;
32
+ }
33
+
34
+ /**
35
+ * Does `current` satisfy `required` when required looks like ">=22.19.0" /
36
+ * ">=22" / "22.x"? Only the forms our own package.json can carry are supported;
37
+ * an unknown form is treated as satisfied so a future syntax never blocks an
38
+ * update for no reason.
39
+ */
40
+ export function satisfiesEngine(current, required) {
41
+ const m = /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(String(required).trim());
42
+ if (!m) return true;
43
+ const want = [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)];
44
+ const have = rank(current).nums;
45
+ for (let i = 0; i < 3; i += 1) {
46
+ if (have[i] !== want[i]) return have[i] > want[i];
47
+ }
48
+ return true;
49
+ }
50
+
51
+ /** npm on Windows reports negative errno values as huge unsigned ints. */
52
+ export function normalizeExitCode(code) {
53
+ if (typeof code !== "number") return 1;
54
+ return code > 2 ** 31 ? code - 2 ** 32 : code;
55
+ }
56
+
57
+ /** A failed install that means "a process still holds the package folder". */
58
+ export function isBusyFailure(text) {
59
+ return /EBUSY|EPERM|resource busy or locked/i.test(String(text));
60
+ }
61
+
62
+ /**
63
+ * Run the update. Deps:
64
+ * argv — process argv (for --check)
65
+ * pkg — this package's package.json contents
66
+ * platform — process.platform
67
+ * nodeVersion — process.versions.node
68
+ * fetchLatest — async () => version string
69
+ * daemon — { running(): Promise<boolean>, stop(): Promise<void>, start(): Promise<void> }
70
+ * runNpm — (version) => Promise<{ code: number; output: string }>
71
+ * print / printErr
72
+ */
73
+ export async function runUpdate(deps) {
74
+ const { pkg, print, printErr } = deps;
75
+ const current = pkg.version;
76
+ const checkOnly = deps.argv.includes("--check") || deps.argv.includes("-c");
77
+
78
+ let latest;
79
+ try {
80
+ latest = await deps.fetchLatest();
81
+ } catch (error) {
82
+ printErr(`dpi update: cannot reach the npm registry (${error.message})`);
83
+ return 1;
84
+ }
85
+
86
+ const order = compareVersions(current, latest);
87
+ if (order >= 0) {
88
+ print(`dpi ${current} is up to date (latest ${latest}).`);
89
+ return 0;
90
+ }
91
+ print(`dpi ${current} -> ${latest} available.`);
92
+ if (checkOnly) {
93
+ print("Run `dpi update` to install it.");
94
+ return 0;
95
+ }
96
+
97
+ // Refuse before touching npm: a warned-about engine is a broken install later.
98
+ const required = pkg.engines?.node;
99
+ if (required && !satisfiesEngine(deps.nodeVersion, required)) {
100
+ printErr(
101
+ `dpi update: this release needs Node ${required}, you have ${deps.nodeVersion}.`,
102
+ );
103
+ printErr(" Upgrade Node first (nvm install --lts / winget upgrade OpenJS.NodeJS.LTS), then retry.");
104
+ return 1;
105
+ }
106
+
107
+ // Windows: the daemon (and a TUI) hold files inside the package folder, so npm
108
+ // cannot rename it. Stop the daemon, install, then bring it back.
109
+ const wasRunning = await deps.daemon.running();
110
+ if (wasRunning) {
111
+ print("stopping the device daemon so npm can replace the package…");
112
+ await deps.daemon.stop();
113
+ }
114
+
115
+ let result = await deps.runNpm(latest);
116
+
117
+ // A stale TUI can still hold the folder; retry once with the reason spelled out.
118
+ if (result.code !== 0 && isBusyFailure(result.output)) {
119
+ printErr("dpi update: the package folder is still in use — close any running `dpi tui` window.");
120
+ printErr(" Retrying once…");
121
+ result = await deps.runNpm(latest);
122
+ }
123
+
124
+ if (result.code !== 0) {
125
+ printErr(`dpi update: npm failed (exit ${normalizeExitCode(result.code)}); still on ${current}.`);
126
+ if (isBusyFailure(result.output))
127
+ printErr(" Close every dpi window (tui + device) and run `dpi update` again.");
128
+ if (wasRunning) await deps.daemon.start();
129
+ return 1;
130
+ }
131
+
132
+ print(`dpi updated ${current} -> ${latest}.`);
133
+ if (wasRunning) {
134
+ print("restarting the device daemon…");
135
+ await deps.daemon.start();
136
+ }
137
+ return 0;
138
+ }
139
+
140
+ // ------------------------------------------------------------------ real deps
141
+
142
+ /** Spawn npm correctly on every platform (npm is npm.cmd on Windows). */
143
+ export function spawnNpm(args) {
144
+ return new Promise((resolve) => {
145
+ const onWindows = process.platform === "win32";
146
+ const child = spawn(onWindows ? "npm.cmd" : "npm", args, {
147
+ stdio: ["ignore", "pipe", "pipe"],
148
+ shell: onWindows,
149
+ });
150
+ let output = "";
151
+ child.stdout?.on("data", (x) => {
152
+ output += x;
153
+ process.stdout.write(x);
154
+ });
155
+ child.stderr?.on("data", (x) => {
156
+ output += x;
157
+ process.stderr.write(x);
158
+ });
159
+ child.on("error", (error) => resolve({ code: 1, output: `${output}${error.message}` }));
160
+ child.on("exit", (code) => resolve({ code: code ?? 1, output }));
161
+ });
162
+ }
163
+
164
+ export async function fetchLatestVersion() {
165
+ const res = await fetch("https://registry.npmjs.org/my-durable/latest", {
166
+ headers: { accept: "application/json" },
167
+ });
168
+ if (!res.ok) throw new Error(`registry responded ${res.status}`);
169
+ const version = (await res.json()).version;
170
+ // This string reaches a command line (shell on Windows): a malformed value
171
+ // must stop the update instead of running.
172
+ if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/.test(String(version)))
173
+ throw new Error(`registry returned a malformed version: ${version}`);
174
+ return version;
175
+ }
176
+
177
+ /** Daemon control through the same CLI, so update needs no TS import. */
178
+ export function daemonControl(dpiPath, port) {
179
+ const call = (args) =>
180
+ new Promise((resolve) => {
181
+ const child = spawn(process.execPath, [dpiPath, ...args], { stdio: "ignore" });
182
+ child.on("error", () => resolve(1));
183
+ child.on("exit", (code) => resolve(code ?? 1));
184
+ });
185
+ const running = async () => {
186
+ try {
187
+ const res = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(800) });
188
+ return res.ok;
189
+ } catch {
190
+ return false;
191
+ }
192
+ };
193
+ return {
194
+ running,
195
+ stop: () => call(["device", "stop"]),
196
+ start: () => call(["device", "start"]),
197
+ };
198
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "my-durable",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
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": {
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "homepage": "https://github.com/baphuongna/my-durable#readme",
14
14
  "engines": {
15
- "node": ">=22.0.0"
15
+ "node": ">=22.19.0"
16
16
  },
17
17
  "bin": {
18
18
  "dpi": "bin/dpi.mjs"
@@ -141,6 +141,11 @@ const SUITES = {
141
141
  cmd: ["node", "tests/newsession-ui.mjs"],
142
142
  title: "real PTY session creation on a keyless device + empty-state layout",
143
143
  },
144
+ "ui:tui-keymap": {
145
+ cwd: "apps/dpi-tui",
146
+ cmd: ["node", "tests/keymap-ui.mjs"],
147
+ title: "real PTY keymap: every advertised key drives a real backend",
148
+ },
144
149
  "ui:tui-welcome": {
145
150
  cwd: "apps/dpi-tui",
146
151
  cmd: ["node", "tests/welcome-ui.mjs"],
@@ -165,6 +170,7 @@ const PHASE_SUITES = {
165
170
  "ui:pairing",
166
171
  "ui:tui",
167
172
  "ui:tui-session",
173
+ "ui:tui-keymap",
168
174
  "ui:tui-welcome",
169
175
  ],
170
176
  };