my-durable 0.0.14 → 0.0.16
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.
|
@@ -242,25 +242,37 @@ function pidFrom(paths: DaemonPaths): number | undefined {
|
|
|
242
242
|
|
|
243
243
|
// ---------------------------------------------------------------- commands
|
|
244
244
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const
|
|
245
|
+
/** The spawn `dpi device start` performs — exported so the TUI can bring the
|
|
246
|
+
* local backend up itself instead of telling the user to open another shell. */
|
|
247
|
+
export function startBackendDetached(
|
|
248
|
+
env: Record<string, string | undefined>,
|
|
249
|
+
rest: string[] = [],
|
|
250
|
+
): { ok: boolean; pid?: number; log: string; reason?: string } {
|
|
251
|
+
const paths = daemonPaths(env);
|
|
252
|
+
const dpi = env.DPI_BIN ?? (process.argv[1]?.endsWith(".mjs") ? process.argv[1] : "");
|
|
252
253
|
if (!dpi || !existsSync(dpi))
|
|
253
|
-
return
|
|
254
|
+
return { ok: false, log: paths.log, reason: "no dpi launcher found" };
|
|
254
255
|
mkdirSync(paths.home, { recursive: true, mode: 0o700 });
|
|
255
256
|
const fd = openSync(paths.log, "a");
|
|
256
257
|
const child = spawn(process.execPath, [dpi, "device", ...rest], {
|
|
257
258
|
detached: true,
|
|
258
259
|
stdio: ["ignore", fd, fd],
|
|
259
|
-
env: cleanChildEnv(
|
|
260
|
+
env: cleanChildEnv(env),
|
|
260
261
|
cwd: dirname(dpi),
|
|
261
262
|
});
|
|
262
263
|
child.unref();
|
|
263
264
|
closeSync(fd);
|
|
265
|
+
return { ok: true, pid: child.pid, log: paths.log };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function cmdStart(io: Io, rest: string[]): Promise<number> {
|
|
269
|
+
const paths = daemonPaths(io.env);
|
|
270
|
+
if ((await health(io))?.ok) {
|
|
271
|
+
io.print(`already running (pid ${pidFrom(paths) ?? "?"}) on port ${portOf(io)}`);
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
const started = startBackendDetached(io.env, rest);
|
|
275
|
+
if (!started.ok) return io.print(started.reason ?? "start failed"), 1;
|
|
264
276
|
io.print(`starting dpi device … (log: ${paths.log})`);
|
|
265
277
|
if (await waitHealthUp(io, 20_000)) {
|
|
266
278
|
io.print(`running on http://127.0.0.1:${portOf(io)} (pid ${pidFrom(paths) ?? "?"})`);
|
package/apps/dpi-tui/src/app.ts
CHANGED
|
@@ -551,6 +551,12 @@ export class App {
|
|
|
551
551
|
if (matchesKey(data, "escape")) {
|
|
552
552
|
this.input = null;
|
|
553
553
|
this.toast = "";
|
|
554
|
+
// esc aborts the filter line: a half-typed needle must not linger.
|
|
555
|
+
if (input.kind === "filter" && this.filter) {
|
|
556
|
+
this.filter = "";
|
|
557
|
+
this.cursor = 0;
|
|
558
|
+
this.notify("filter cleared");
|
|
559
|
+
}
|
|
554
560
|
return true;
|
|
555
561
|
}
|
|
556
562
|
// The slash suggestion list is a MENU while it is open: ↑/↓ steer it and
|
|
@@ -652,6 +658,13 @@ export class App {
|
|
|
652
658
|
const chunk = data.replace(/\r\n?/g, "\n");
|
|
653
659
|
if (chunk >= " " || [...chunk].some((ch) => ch >= " ") || chunk.includes("\n")) (input.value += chunk), (this.suggIndex = 0);
|
|
654
660
|
}
|
|
661
|
+
// The filter narrows the list LIVE, on every keystroke — enter merely
|
|
662
|
+
// closes the line. Waiting for enter made "f" feel dead for a beat.
|
|
663
|
+
if (input.kind === "filter") {
|
|
664
|
+
this.filter = input.value.trim();
|
|
665
|
+
if (!this.filter && this.cursor) this.cursor = 0;
|
|
666
|
+
this.requestRender();
|
|
667
|
+
}
|
|
655
668
|
return true;
|
|
656
669
|
}
|
|
657
670
|
|
|
@@ -717,8 +730,19 @@ export class App {
|
|
|
717
730
|
this.openCursor();
|
|
718
731
|
}
|
|
719
732
|
|
|
733
|
+
/**
|
|
734
|
+
* Tab/Shift+Tab cycle whatever tab strip is on screen — pi's exact
|
|
735
|
+
* semantics: the four VIEWS on herd/network/log, the agent's own tabs in
|
|
736
|
+
* the conversation. Before, "tab" outside the agent view did nothing at
|
|
737
|
+
* all, which on the outermost session list reads as "navigation is dead"
|
|
738
|
+
* (reported: "không điều hướng xem tab khác ngoài tab session đầu tiên").
|
|
739
|
+
*/
|
|
720
740
|
private cycleTab(delta: number) {
|
|
721
|
-
if (this.view !== "agent")
|
|
741
|
+
if (this.view !== "agent") {
|
|
742
|
+
const index = VIEW_TABS.findIndex((t) => t.id === this.view);
|
|
743
|
+
this.setView(VIEW_TABS[(index + delta + VIEW_TABS.length) % VIEW_TABS.length]!.id);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
722
746
|
const index = AGENT_TABS.findIndex((t) => t.id === this.tab);
|
|
723
747
|
this.tab = AGENT_TABS[(index + delta + AGENT_TABS.length) % AGENT_TABS.length]!.id;
|
|
724
748
|
this.scrollOffset = 0;
|
package/apps/dpi-tui/src/main.ts
CHANGED
|
@@ -112,6 +112,16 @@ async function start(device: DeviceRef, client: DpiClient, info: DeviceInfo, opt
|
|
|
112
112
|
if (opts.devicePicker) pickDevice(app);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/** Loopback = the device this very machine runs; the TUI may start it itself. */
|
|
116
|
+
function isLoopbackUrl(url: string): boolean {
|
|
117
|
+
try {
|
|
118
|
+
const h = new URL(url).hostname.toLowerCase();
|
|
119
|
+
return h === "127.0.0.1" || h === "localhost" || h === "[::1]" || h === "::1";
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
115
125
|
// The app opens first; connection is a state, not a precondition. With no explicit
|
|
116
126
|
// device/url/pair: one saved device opens it directly, several open on the first
|
|
117
127
|
// with the device picker on top, none open the welcome screen.
|
|
@@ -127,6 +137,19 @@ if (startup.kind === "welcome") {
|
|
|
127
137
|
const info = await client.device({ timeoutMs: 8000 });
|
|
128
138
|
await start(device, client, info, { devicePicker: startup.kind === "picker" });
|
|
129
139
|
} catch (e) {
|
|
140
|
+
// The device this machine itself runs is loopback: nobody else can start
|
|
141
|
+
// it, so we do — exactly what `dpi device start` would, without leaving
|
|
142
|
+
// the TUI. A remote (tunnel) device stays an offline notice.
|
|
143
|
+
let info2: Awaited<ReturnType<typeof client.device>> | undefined;
|
|
144
|
+
if (isLoopbackUrl(device.url)) {
|
|
145
|
+
const { ensureLocalBackend } = await import("./welcome.ts");
|
|
146
|
+
const port = Number(new URL(device.url).port) || 4780;
|
|
147
|
+
const r = await ensureLocalBackend({ port, token: device.token });
|
|
148
|
+
if (r.probe.ok) info2 = await client.device({ timeoutMs: 8000 });
|
|
149
|
+
}
|
|
150
|
+
if (info2) {
|
|
151
|
+
await start(device, client, info2, { devicePicker: startup.kind === "picker" });
|
|
152
|
+
} else {
|
|
130
153
|
// An auto-connected device that is unreachable falls back to a screen that says
|
|
131
154
|
// so and offers the picker + welcome actions — never back to usage text.
|
|
132
155
|
showWelcome(new App(WELCOME_DEVICE, new DpiClient(WELCOME_DEVICE.url, WELCOME_DEVICE.token), emptyDeviceInfo(), registry), {
|
|
@@ -135,6 +158,7 @@ if (startup.kind === "welcome") {
|
|
|
135
158
|
? `${device.id}: token rejected by ${device.url}`
|
|
136
159
|
: `${device.id} is offline at ${device.url} (${(e as Error).message})`,
|
|
137
160
|
});
|
|
161
|
+
}
|
|
138
162
|
}
|
|
139
163
|
} else if (url) {
|
|
140
164
|
const token = values.token ?? process.env.DPI_TOKEN ?? "";
|
|
@@ -242,7 +242,7 @@ export function renderStatusbar(
|
|
|
242
242
|
? ["tab complete", "↑ history", "enter send", "esc cancel"]
|
|
243
243
|
: opts.view === "agent"
|
|
244
244
|
? ["g herd", "N network", "L log", "[ ] tabs", "m message"]
|
|
245
|
-
: ["j/k move", "↵ open", "1-9 jump", "
|
|
245
|
+
: ["j/k move", "↵ open", "1-9 jump", "⇥ views", "esc agent"];
|
|
246
246
|
const right = `${c.muted("ctx")} ${opts.ctxLeft === undefined ? c.muted("—") : c.text(`${opts.ctxLeft}%`)}${opts.background ? c.muted(` · ${opts.background} bg`) : ""} ${c.muted("dev")} ${c.text(fit(opts.device, 18))}`;
|
|
247
247
|
const left = `${chip} ${keys.map((k) => c.muted(k)).join(c.muted(" • "))}`;
|
|
248
248
|
const gap = Math.max(1, w - visibleWidth(left) - visibleWidth(right));
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { existsSync, readFileSync } from "node:fs";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { matchesKey, type SelectItem } from "@earendil-works/pi-tui";
|
|
15
|
+
import { startBackendDetached } from "../../dpi-backend/src/daemon.ts";
|
|
15
16
|
import { DpiClient } from "../../dpi-sdk/src/client.ts";
|
|
16
17
|
import type { DeviceInfo } from "../../dpi-sdk/src/api.ts";
|
|
17
18
|
import { isDpiError } from "../../dpi-sdk/src/errors.ts";
|
|
@@ -156,7 +157,41 @@ export function describeLocalProbe(probe: LocalProbe, port: number): string {
|
|
|
156
157
|
if (probe.ok) return `local dpi-backend on 127.0.0.1:${port}: ${probe.info.name || probe.info.hostname || "unnamed device"}`;
|
|
157
158
|
if (probe.reason === "unauthorized")
|
|
158
159
|
return `the local dpi-backend on 127.0.0.1:${port} rejected the token — check DPI_TOKEN (env or ${join(dpiHome(), "backend.env")})`;
|
|
159
|
-
return `no local dpi-backend on 127.0.0.1:${port} — start
|
|
160
|
+
return `no local dpi-backend on 127.0.0.1:${port} — auto-start failed; run \`dpi device start\` and check ${join(dpiHome(), "device.log")}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export type EnsureStarter = () => { ok: boolean; log: string; pid?: number; reason?: string };
|
|
164
|
+
export type EnsureResult = { probe: LocalProbe; started: boolean; log?: string; reason?: string };
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Probe the local backend and, if nothing answers, START one the way
|
|
168
|
+
* `dpi device start` does (detached, log to <home>/device.log) and wait for it.
|
|
169
|
+
* herdr spawns its own server; we do the same for the loopback device. A token
|
|
170
|
+
* rejection is never auto-started away — that's a credential problem.
|
|
171
|
+
*/
|
|
172
|
+
export async function ensureLocalBackend(
|
|
173
|
+
opts: { port?: number; token?: string; fetchImpl?: typeof fetch; start?: EnsureStarter; waitMs?: number } = {},
|
|
174
|
+
): Promise<EnsureResult> {
|
|
175
|
+
const port = opts.port ?? applyBackendEnv().port;
|
|
176
|
+
const probeOf = async (): Promise<LocalProbe> => {
|
|
177
|
+
// a first boot WRITES backend.env, so the token can appear mid-wait
|
|
178
|
+
const token = opts.token ?? applyBackendEnv({ ...process.env, ...(opts.token ? { DPI_TOKEN: opts.token } : {}) }).token;
|
|
179
|
+
if (!token) return { ok: false, reason: "unreachable" };
|
|
180
|
+
return probeLocalBackend(localBackendUrl(port), token, opts.fetchImpl);
|
|
181
|
+
};
|
|
182
|
+
const first = await probeOf();
|
|
183
|
+
if (first.ok || first.reason === "unauthorized") return { probe: first, started: false };
|
|
184
|
+
const start: EnsureStarter = opts.start ?? (() => startBackendDetached(process.env));
|
|
185
|
+
const r = start();
|
|
186
|
+
if (!r.ok) return { probe: first, started: false, reason: r.reason, log: r.log };
|
|
187
|
+
const deadline = Date.now() + (opts.waitMs ?? 15_000);
|
|
188
|
+
let last: LocalProbe = first;
|
|
189
|
+
while (Date.now() < deadline) {
|
|
190
|
+
await new Promise((res) => setTimeout(res, 400));
|
|
191
|
+
last = await probeOf();
|
|
192
|
+
if (last.ok) return { probe: last, started: true, log: r.log, reason: `pid ${r.pid}` };
|
|
193
|
+
}
|
|
194
|
+
return { probe: last, started: true, log: r.log };
|
|
160
195
|
}
|
|
161
196
|
|
|
162
197
|
// ------------------------------------------------------------------ ids and device input
|
|
@@ -354,20 +389,22 @@ async function welcomeAddUrl(app: App) {
|
|
|
354
389
|
async function welcomeLocal(app: App) {
|
|
355
390
|
const local = applyBackendEnv();
|
|
356
391
|
app.flash(`Probing 127.0.0.1:${local.port}…`);
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
app.flash(describeLocalProbe(probe, local.port), true);
|
|
392
|
+
const ensured = await ensureLocalBackend({ port: local.port, token: local.token });
|
|
393
|
+
if (ensured.started && ensured.probe.ok) app.flash(`started the local dpi-backend (${ensured.reason})`);
|
|
394
|
+
if (!ensured.probe.ok) {
|
|
395
|
+
app.flash(describeLocalProbe(ensured.probe, local.port), true);
|
|
362
396
|
return showWelcome(app);
|
|
363
397
|
}
|
|
398
|
+
const probe = ensured.probe;
|
|
364
399
|
const url = localBackendUrl(local.port);
|
|
365
400
|
const id = suggestDeviceId(probe.info.hostname, probe.info.name);
|
|
366
401
|
stepPicker(
|
|
367
402
|
app,
|
|
368
403
|
`Local backend: ${probe.info.name || probe.info.hostname || url}`,
|
|
369
404
|
[{ value: "open", label: `Save as "${id}" and open`, description: `${url} │ token from DPI_TOKEN / backend.env` }],
|
|
370
|
-
|
|
405
|
+
// local.token was read BEFORE a first boot may have written backend.env —
|
|
406
|
+
// re-resolve, the file exists now.
|
|
407
|
+
() => void saveAndOpen(app, id, url, applyBackendEnv().token!, probe.info.name),
|
|
371
408
|
);
|
|
372
409
|
}
|
|
373
410
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "my-durable",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.16",
|
|
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
|
@@ -151,6 +151,11 @@ const SUITES = {
|
|
|
151
151
|
cmd: ["node", "tests/welcome-ui.mjs"],
|
|
152
152
|
title: "real PTY welcome screen, detect-local and quit",
|
|
153
153
|
},
|
|
154
|
+
"ui:tui-ensure": {
|
|
155
|
+
cwd: "apps/dpi-tui",
|
|
156
|
+
cmd: ["node", "tests/ensure-ui.mjs"],
|
|
157
|
+
title: "real PTY [d] auto-starts the local dpi-backend and opens it",
|
|
158
|
+
},
|
|
154
159
|
};
|
|
155
160
|
|
|
156
161
|
const PHASE_GATES = ["test:verification", "check:sdk", "check:backend", "contract:backend", "build:playground", "check:tui"];
|
|
@@ -172,6 +177,7 @@ const PHASE_SUITES = {
|
|
|
172
177
|
"ui:tui-session",
|
|
173
178
|
"ui:tui-keymap",
|
|
174
179
|
"ui:tui-welcome",
|
|
180
|
+
"ui:tui-ensure",
|
|
175
181
|
],
|
|
176
182
|
};
|
|
177
183
|
|