my-durable 0.0.7 → 0.0.9
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/README.md +6 -0
- package/apps/dpi-backend/src/config.ts +10 -3
- package/apps/dpi-backend/src/daemon.ts +422 -0
- package/apps/dpi-backend/src/main.ts +148 -7
- package/apps/dpi-backend/src/pair-page.ts +111 -0
- package/apps/dpi-backend/src/pairing-files.ts +29 -3
- package/apps/dpi-backend/src/pairing.ts +368 -59
- package/apps/dpi-backend/src/routes.ts +14 -1
- package/apps/dpi-backend/src/store.ts +1 -0
- package/apps/dpi-backend/src/tunnel.ts +294 -55
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Cg3vey95.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CoYMgU-2.js +77 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +17 -4
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +29 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +107 -18
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +1 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceHandoff.vue +132 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +36 -6
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +107 -5
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +3 -1
- package/apps/dpi-playground/apps/dpi-playground-hub/src/admin-auth.ts +19 -17
- package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +1 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +4 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/hub-identity.ts +98 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +31 -3
- package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +172 -25
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +24 -0
- package/apps/dpi-sdk/src/api.ts +2 -0
- package/apps/dpi-sdk/src/node/private-file.ts +140 -0
- package/apps/dpi-sdk/src/node/registry.ts +6 -0
- package/apps/dpi-sdk/src/protocol/pairing.ts +137 -16
- package/apps/dpi-sdk/src/protocol/tunnel.ts +162 -38
- package/apps/dpi-tui/src/app.ts +889 -187
- package/apps/dpi-tui/src/hub-feed.ts +71 -0
- package/apps/dpi-tui/src/hub-mux.ts +168 -0
- package/apps/dpi-tui/src/main.ts +5 -1
- package/apps/dpi-tui/src/theme.ts +14 -0
- package/apps/dpi-tui/src/views.ts +311 -0
- package/bin/dpi.mjs +21 -4
- package/package.json +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index--aoZ5Tj0.css +0 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Dq0CZYr7.js +0 -77
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-host display feed: the hub's /api/network is the same source of truth
|
|
3
|
+
* the web UI renders. The TUI polls it (no local registry sqlite needed — the
|
|
4
|
+
* TUI's dpiHome() default differs from the hub's, which starved the network
|
|
5
|
+
* view to a single adhoc row) and maps NetworkDevice rows into the DeviceRow
|
|
6
|
+
* shape the network view already renders: devices with live status dots and
|
|
7
|
+
* their session trees, exactly like the web sidebar.
|
|
8
|
+
*
|
|
9
|
+
* Attaching still needs a reachable endpoint: LAN rows carry url+token and
|
|
10
|
+
* switch directly; tunnel rows have url null and honestly say so (full tunnel
|
|
11
|
+
* attach rides the hub's /api/stream mux — a later step, same as the web).
|
|
12
|
+
*/
|
|
13
|
+
import type { DeviceRow } from "../../dpi-sdk/src/node/registry.ts";
|
|
14
|
+
|
|
15
|
+
/** The subset of the hub's NetworkDevice DTO the feed maps into DeviceRow. */
|
|
16
|
+
export interface HubNetworkDevice {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
kind: string;
|
|
20
|
+
transport: string;
|
|
21
|
+
url: string | null;
|
|
22
|
+
source: string;
|
|
23
|
+
deviceUid?: string | null;
|
|
24
|
+
status?: string;
|
|
25
|
+
error?: string | null;
|
|
26
|
+
lastSeenAt?: number;
|
|
27
|
+
info?: unknown;
|
|
28
|
+
tree?: { sessions?: { id: string; name: string }[] } | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function toDeviceRows(devices: HubNetworkDevice[]): DeviceRow[] {
|
|
32
|
+
return devices.map(
|
|
33
|
+
(d): DeviceRow => ({
|
|
34
|
+
id: d.id,
|
|
35
|
+
name: d.name,
|
|
36
|
+
kind: "url",
|
|
37
|
+
transport: d.transport === "tunnel" ? "tunnel" : "lan",
|
|
38
|
+
url: d.url ?? null,
|
|
39
|
+
token: null,
|
|
40
|
+
source: d.source === "config" ? "config" : "ui",
|
|
41
|
+
position: 0,
|
|
42
|
+
deviceUid: d.deviceUid ?? null,
|
|
43
|
+
lastStatus: (d.status ?? "offline") as DeviceRow["lastStatus"],
|
|
44
|
+
lastError: d.error ?? null,
|
|
45
|
+
lastSeenAt: d.lastSeenAt ?? null,
|
|
46
|
+
lastInfo: (d.info ?? null) as DeviceRow["lastInfo"],
|
|
47
|
+
lastTree: (d.tree ?? null) as DeviceRow["lastTree"],
|
|
48
|
+
createdAt: 0,
|
|
49
|
+
updatedAt: 0,
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Fetch the hub network once; returns null on any failure (caller keeps the last good rows). */
|
|
55
|
+
export async function fetchHubNetwork(
|
|
56
|
+
url: string,
|
|
57
|
+
token: string,
|
|
58
|
+
signal?: AbortSignal,
|
|
59
|
+
): Promise<DeviceRow[] | null> {
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch(new URL("api/network", url), {
|
|
62
|
+
headers: { authorization: `Bearer ${token}` },
|
|
63
|
+
signal,
|
|
64
|
+
});
|
|
65
|
+
if (!res.ok) return null;
|
|
66
|
+
const body = (await res.json()) as { devices?: HubNetworkDevice[] };
|
|
67
|
+
return toDeviceRows(body.devices ?? []);
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node client for the hub's /api/stream mux — the same transport the web FE
|
|
3
|
+
* rides. One SSE connection; subscription keys (<device>/agent/<s>/<a> etc.)
|
|
4
|
+
* open one authenticated upstream per key on the HUB, so every subscribe
|
|
5
|
+
* restarts from the device's snapshot, exactly like a direct device stream.
|
|
6
|
+
*
|
|
7
|
+
* This is what lets the TUI ATTACH to tunnel devices: REST flows through the
|
|
8
|
+
* hub proxy (DpiClient baseUrl = <hub>/api/devices/<id>), events flow here.
|
|
9
|
+
*/
|
|
10
|
+
import { readSse } from "../../dpi-sdk/src/sse.js";
|
|
11
|
+
import { Backoff, type BackoffPolicy } from "../../dpi-sdk/src/protocol/backoff.js";
|
|
12
|
+
import type { Frame, MuxFrame } from "../../dpi-sdk/src/api.ts";
|
|
13
|
+
|
|
14
|
+
export type MuxState = "connecting" | "live" | "offline";
|
|
15
|
+
|
|
16
|
+
const RECONNECT: BackoffPolicy = { initS: 1, capS: 30 };
|
|
17
|
+
|
|
18
|
+
interface Subscriber {
|
|
19
|
+
push: (frame: Frame) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class HubMux {
|
|
23
|
+
private streamId = "";
|
|
24
|
+
private wanted = new Set<string>();
|
|
25
|
+
private listeners = new Map<string, Set<Subscriber>>();
|
|
26
|
+
private source?: AbortController;
|
|
27
|
+
private state: MuxState = "connecting";
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
private readonly hub: { url: string; token: string },
|
|
31
|
+
private readonly onState?: (state: MuxState) => void,
|
|
32
|
+
) {}
|
|
33
|
+
|
|
34
|
+
get live() {
|
|
35
|
+
return this.state === "live";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private setState(state: MuxState) {
|
|
39
|
+
if (state === this.state) return;
|
|
40
|
+
this.state = state;
|
|
41
|
+
this.onState?.(state);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private auth(): Record<string, string> {
|
|
45
|
+
return { authorization: `Bearer ${this.hub.token}` };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private started(): Promise<void> {
|
|
49
|
+
if (this.source) return Promise.resolve();
|
|
50
|
+
void this.run();
|
|
51
|
+
return Promise.resolve();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private async run() {
|
|
55
|
+
const ctl = (this.source = new AbortController());
|
|
56
|
+
const backoff = new Backoff(RECONNECT);
|
|
57
|
+
while (!ctl.signal.aborted) {
|
|
58
|
+
this.setState("connecting");
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch(new URL("api/stream", this.hub.url), {
|
|
61
|
+
headers: { ...this.auth(), accept: "text/event-stream" },
|
|
62
|
+
signal: ctl.signal,
|
|
63
|
+
});
|
|
64
|
+
if (!res.ok || !res.body) throw Error(`hub stream ${res.status}`);
|
|
65
|
+
for await (const message of readSse(res.body)) {
|
|
66
|
+
const mux = JSON.parse(message.data) as MuxFrame;
|
|
67
|
+
if (mux.sub === "hello" && mux.frame.type === "stream") {
|
|
68
|
+
this.streamId = mux.frame.streamId;
|
|
69
|
+
this.setState("live");
|
|
70
|
+
backoff.reset();
|
|
71
|
+
// Reconnects reopen fresh upstreams: resubscribe everything.
|
|
72
|
+
await this.put([...this.wanted]);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (mux.sub === "hello" || mux.sub === "network") continue;
|
|
76
|
+
if (mux.frame.type === "stream" || mux.frame.type === "network") continue;
|
|
77
|
+
for (const sub of this.listeners.get(mux.sub) ?? []) sub.push(mux.frame);
|
|
78
|
+
}
|
|
79
|
+
if (ctl.signal.aborted) return;
|
|
80
|
+
this.setState("offline");
|
|
81
|
+
} catch {
|
|
82
|
+
if (ctl.signal.aborted) return;
|
|
83
|
+
this.setState("offline");
|
|
84
|
+
}
|
|
85
|
+
await new Promise((resolve) => setTimeout(resolve, backoff.ms));
|
|
86
|
+
backoff.advance();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private async put(subs: string[]) {
|
|
91
|
+
const streamId = this.streamId;
|
|
92
|
+
// An EMPTY list is meaningful: it closes every upstream the hub holds for
|
|
93
|
+
// this stream (that is how leaving a session frees the device reader).
|
|
94
|
+
if (!streamId) return;
|
|
95
|
+
try {
|
|
96
|
+
await fetch(new URL(`api/stream/${streamId}`, this.hub.url), {
|
|
97
|
+
method: "PUT",
|
|
98
|
+
headers: { ...this.auth(), "content-type": "application/json" },
|
|
99
|
+
body: JSON.stringify({ subs }),
|
|
100
|
+
signal: AbortSignal.timeout(10_000),
|
|
101
|
+
});
|
|
102
|
+
} catch {
|
|
103
|
+
// The SSE loop reconnects and resubscribes; a lost PUT is not fatal.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Frames for one subscription key. Subscribing on the hub opens a fresh
|
|
109
|
+
* upstream whose first frame is the device hello — the same contract as a
|
|
110
|
+
* direct device stream, so `follow()` works unchanged.
|
|
111
|
+
*/
|
|
112
|
+
async *events(key: string, signal: AbortSignal): AsyncGenerator<Frame> {
|
|
113
|
+
await this.started();
|
|
114
|
+
const queue: Frame[] = [];
|
|
115
|
+
let wake: () => void = () => {};
|
|
116
|
+
const sub: Subscriber = {
|
|
117
|
+
push: (frame) => {
|
|
118
|
+
queue.push(frame);
|
|
119
|
+
wake();
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
const set = this.listeners.get(key) ?? new Set();
|
|
123
|
+
set.add(sub);
|
|
124
|
+
this.listeners.set(key, set);
|
|
125
|
+
const known = this.wanted.has(key);
|
|
126
|
+
this.wanted.add(key);
|
|
127
|
+
if (!known) void this.put([...this.wanted]);
|
|
128
|
+
// Cleanup runs on abort AND on consumer return, and is idempotent: a
|
|
129
|
+
// consumer that simply stops reading (no next() call) must still release
|
|
130
|
+
// the hub upstream, or the device keeps a reader forever.
|
|
131
|
+
let released = false;
|
|
132
|
+
const release = () => {
|
|
133
|
+
if (released) return;
|
|
134
|
+
released = true;
|
|
135
|
+
signal.removeEventListener("abort", onAbort);
|
|
136
|
+
set.delete(sub);
|
|
137
|
+
if (set.size) return;
|
|
138
|
+
this.listeners.delete(key);
|
|
139
|
+
this.wanted.delete(key);
|
|
140
|
+
void this.put([...this.wanted]);
|
|
141
|
+
};
|
|
142
|
+
function onAbort() {
|
|
143
|
+
release();
|
|
144
|
+
wake();
|
|
145
|
+
}
|
|
146
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
147
|
+
try {
|
|
148
|
+
while (!signal.aborted) {
|
|
149
|
+
while (queue.length && !signal.aborted) yield queue.shift()!;
|
|
150
|
+
if (signal.aborted) break;
|
|
151
|
+
await new Promise<void>((resolve) => {
|
|
152
|
+
wake = resolve;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
} finally {
|
|
156
|
+
release();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
stop() {
|
|
161
|
+
this.source?.abort();
|
|
162
|
+
this.source = undefined;
|
|
163
|
+
this.streamId = "";
|
|
164
|
+
this.listeners.clear();
|
|
165
|
+
this.wanted.clear();
|
|
166
|
+
this.setState("offline");
|
|
167
|
+
}
|
|
168
|
+
}
|
package/apps/dpi-tui/src/main.ts
CHANGED
|
@@ -48,6 +48,8 @@ const { values, positionals } = parseArgs({
|
|
|
48
48
|
model: { type: "string" },
|
|
49
49
|
pair: { type: "string" },
|
|
50
50
|
hub: { type: "string" },
|
|
51
|
+
"hub-url": { type: "string" },
|
|
52
|
+
"hub-token": { type: "string" },
|
|
51
53
|
force: { type: "boolean" },
|
|
52
54
|
help: { type: "boolean", short: "h" },
|
|
53
55
|
},
|
|
@@ -84,7 +86,9 @@ async function start(device: DeviceRef, client: DpiClient, info: DeviceInfo, opt
|
|
|
84
86
|
session = created.id;
|
|
85
87
|
}
|
|
86
88
|
|
|
87
|
-
const
|
|
89
|
+
const hubUrl = values["hub-url"] ?? process.env.DPI_HUB_URL;
|
|
90
|
+
const hubToken = values["hub-token"] ?? process.env.DPI_HUB_TOKEN;
|
|
91
|
+
const app = new App(device, client, info, registry, hubUrl && hubToken ? { url: hubUrl, token: hubToken } : undefined);
|
|
88
92
|
|
|
89
93
|
// Bootstrap local pairing (--pair CODE --hub ORIGIN [--force]): runs the same
|
|
90
94
|
// controller as /pair, against the LOCAL backend, before the UI opens. A bare
|
|
@@ -27,6 +27,20 @@ export const c = {
|
|
|
27
27
|
underline: wrap("4", "24"),
|
|
28
28
|
strike: wrap("9", "29"),
|
|
29
29
|
userBg: (s: string) => `\x1b[48;2;42;32;33m${s}\x1b[49m`,
|
|
30
|
+
inverse: wrap("7", "27"),
|
|
31
|
+
accentBg: (s: string) => `\x1b[48;2;255;137;151;38;2;26;20;21m${s}\x1b[49;39m`,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// herdr-style chrome tokens (apps/tui/src/theme.ts of the reference), mapped onto
|
|
35
|
+
// this project's brand palette. STATE_* drives every agent/session glyph row.
|
|
36
|
+
export const box = { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│" } as const;
|
|
37
|
+
|
|
38
|
+
export type RowState = "busy" | "idle" | "error";
|
|
39
|
+
export const STATE_GLYPH: Record<RowState, string> = { busy: "●", idle: "·", error: "✗" };
|
|
40
|
+
export const STATE_COLOR: Record<RowState, (s: string) => string> = {
|
|
41
|
+
busy: c.accent,
|
|
42
|
+
idle: c.muted,
|
|
43
|
+
error: c.err,
|
|
30
44
|
};
|
|
31
45
|
|
|
32
46
|
export const markdownTheme: MarkdownTheme = {
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// herdr-style dashboard views (reference: herdr-0.4.0 apps/tui/src/app.ts),
|
|
2
|
+
// fed from THIS project's real data: SessionSummary[], DeviceRow[] and the
|
|
3
|
+
// App chrome. Pure string builders — no TTY, no timers — so every line is
|
|
4
|
+
// unit-testable exactly like the reference's frame(w, h).
|
|
5
|
+
import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
6
|
+
import type { SessionSummary } from "../../dpi-sdk/src/api.ts";
|
|
7
|
+
import type { DeviceRow } from "../../dpi-sdk/src/node/registry.ts";
|
|
8
|
+
import { c, STATE_COLOR, STATE_GLYPH, type RowState } from "./theme.ts";
|
|
9
|
+
|
|
10
|
+
export type View = "herd" | "network" | "agent" | "log";
|
|
11
|
+
|
|
12
|
+
export const VIEW_TABS: { id: View; label: string }[] = [
|
|
13
|
+
{ id: "herd", label: "herd" },
|
|
14
|
+
{ id: "network", label: "network" },
|
|
15
|
+
{ id: "agent", label: "agent" },
|
|
16
|
+
{ id: "log", label: "log" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const rep = (ch: string, n: number) => ch.repeat(Math.max(0, n));
|
|
20
|
+
|
|
21
|
+
/** A one-line component whose text is computed at render time, with the real width. */
|
|
22
|
+
export class DynamicLine implements Component {
|
|
23
|
+
constructor(public fn: (width: number) => string) {}
|
|
24
|
+
render(width: number) {
|
|
25
|
+
return [truncateToWidth(this.fn(width), width)];
|
|
26
|
+
}
|
|
27
|
+
invalidate() {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A multi-line component whose body is computed at render time, with the real width. */
|
|
31
|
+
export class DynamicBlock implements Component {
|
|
32
|
+
constructor(public fn: (width: number) => string[]) {}
|
|
33
|
+
render(width: number) {
|
|
34
|
+
return this.fn(width);
|
|
35
|
+
}
|
|
36
|
+
invalidate() {}
|
|
37
|
+
}
|
|
38
|
+
const padTo = (s: string, w: number) => s + rep(" ", Math.max(0, w - visibleWidth(s)));
|
|
39
|
+
const fit = (s: string, w: number) =>
|
|
40
|
+
padTo(truncateToWidth(s, Math.max(0, w), "…"), Math.max(0, w));
|
|
41
|
+
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
|
|
42
|
+
const compact = (n: number) =>
|
|
43
|
+
Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(n);
|
|
44
|
+
const basename = (p: string) => p.replace(/\/+$/, "").split("/").pop() || p;
|
|
45
|
+
export const ageOf = (createdAt: number, now = Date.now()) => {
|
|
46
|
+
const s = Math.max(0, Math.floor((now - createdAt) / 1000));
|
|
47
|
+
if (s < 60) return `${s}s`;
|
|
48
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
49
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
|
50
|
+
return `${Math.floor(s / 86400)}d`;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const sessionState = (s: SessionSummary): RowState =>
|
|
54
|
+
s.error ? "error" : s.busy ? "busy" : "idle";
|
|
55
|
+
export const deviceState = (d: DeviceRow): RowState =>
|
|
56
|
+
d.lastStatus === "online"
|
|
57
|
+
? "busy"
|
|
58
|
+
: d.lastStatus === "checking"
|
|
59
|
+
? "idle"
|
|
60
|
+
: d.lastStatus === "offline"
|
|
61
|
+
? "idle"
|
|
62
|
+
: "error";
|
|
63
|
+
|
|
64
|
+
export interface HerdRow {
|
|
65
|
+
kind: "group" | "session";
|
|
66
|
+
label: string;
|
|
67
|
+
sessionId?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Group sessions by working directory (herdr groups by repo) and apply the / filter. */
|
|
71
|
+
export function herdRows(sessions: SessionSummary[], filter = ""): HerdRow[] {
|
|
72
|
+
const needle = filter.trim().toLowerCase();
|
|
73
|
+
const kept = needle
|
|
74
|
+
? sessions.filter((s) =>
|
|
75
|
+
[s.name, s.cwd, s.model, s.busy ? "busy" : "idle"].some((v) =>
|
|
76
|
+
v.toLowerCase().includes(needle),
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
: sessions;
|
|
80
|
+
const out: HerdRow[] = [];
|
|
81
|
+
const groups = new Map<string, SessionSummary[]>();
|
|
82
|
+
for (const s of kept) {
|
|
83
|
+
const key = basename(s.cwd);
|
|
84
|
+
const list = groups.get(key) ?? [];
|
|
85
|
+
list.push(s);
|
|
86
|
+
groups.set(key, list);
|
|
87
|
+
}
|
|
88
|
+
for (const [dir, list] of groups) {
|
|
89
|
+
out.push({ kind: "group", label: dir });
|
|
90
|
+
for (const s of list) out.push({ kind: "session", label: s.name, sessionId: s.id });
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The herd table: NAME · STATE · MODEL · AGENTS · AGE, grouped by directory. */
|
|
96
|
+
export function renderHerd(
|
|
97
|
+
w: number,
|
|
98
|
+
rows: HerdRow[],
|
|
99
|
+
sessions: SessionSummary[],
|
|
100
|
+
opts: { selectedId?: string; cursor?: number; filter?: string } = {},
|
|
101
|
+
): string[] {
|
|
102
|
+
const showAge = w >= 72;
|
|
103
|
+
const showModel = w >= 84;
|
|
104
|
+
const cols = {
|
|
105
|
+
name: clamp(w - (showModel ? 44 : 34) - (showAge ? 8 : 0), 12, 30),
|
|
106
|
+
state: 9,
|
|
107
|
+
model: 18,
|
|
108
|
+
agents: 7,
|
|
109
|
+
age: 6,
|
|
110
|
+
};
|
|
111
|
+
const lines: string[] = [
|
|
112
|
+
c.muted(
|
|
113
|
+
` ${fit("NAME", cols.name)} ${fit("STATE", cols.state)}` +
|
|
114
|
+
(showModel ? ` ${fit("MODEL", cols.model)}` : "") +
|
|
115
|
+
` ${fit("AGENTS", cols.agents)}` +
|
|
116
|
+
(showAge ? ` ${fit("AGE", cols.age)}` : ""),
|
|
117
|
+
),
|
|
118
|
+
];
|
|
119
|
+
const cursorRow = rows[opts.cursor ?? 0];
|
|
120
|
+
for (const row of rows) {
|
|
121
|
+
if (row.kind === "group") {
|
|
122
|
+
const group = sessions.filter((s) => basename(s.cwd) === row.label);
|
|
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
|
+
);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const s = sessions.find((x) => x.id === row.sessionId);
|
|
130
|
+
if (!s) continue;
|
|
131
|
+
const selected = s.id === opts.selectedId;
|
|
132
|
+
const atCursor = cursorRow?.sessionId === s.id;
|
|
133
|
+
const marker = selected ? c.accent("▌ ") : " ";
|
|
134
|
+
const state = `${STATE_COLOR[sessionState(s)](STATE_GLYPH[sessionState(s)])} ${c.muted(fit(sessionState(s), 7))}`;
|
|
135
|
+
const forks = s.agents.filter((a) => a.kind === "fork").length;
|
|
136
|
+
const subs = s.agents.filter((a) => a.kind === "subagent").length;
|
|
137
|
+
const agents = `${s.agents.length}${forks ? `⑂${forks}` : ""}${subs ? `↳${subs}` : ""}`;
|
|
138
|
+
let line =
|
|
139
|
+
`${marker}${fit(selected ? c.bold(s.name) : s.name, cols.name)} ${fit(state, cols.state)}` +
|
|
140
|
+
(showModel ? ` ${fit(c.muted(basename(s.model) || s.model), cols.model)}` : "") +
|
|
141
|
+
` ${fit(c.muted(agents), cols.agents)}`;
|
|
142
|
+
if (showAge) line += ` ${fit(c.muted(ageOf(s.createdAt)), cols.age)}`;
|
|
143
|
+
lines.push(atCursor && !selected ? c.inverse(fit(line, w)) : fit(line, w));
|
|
144
|
+
}
|
|
145
|
+
if (opts.filter) lines.push(c.warn(`filter: ${opts.filter}`));
|
|
146
|
+
// herdr frames pad EVERY line to the exact width — the caller paints rows as-is.
|
|
147
|
+
return lines.map((l) => fit(l, w));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface NetRow {
|
|
151
|
+
kind: "device" | "session";
|
|
152
|
+
label: string;
|
|
153
|
+
deviceId?: string;
|
|
154
|
+
sessionId?: string;
|
|
155
|
+
current?: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The network tree: every registry device, its sessions underneath (herdr: device › repo › agent). */
|
|
159
|
+
export function networkRows(devices: DeviceRow[], currentDeviceId: string, filter = ""): NetRow[] {
|
|
160
|
+
const needle = filter.trim().toLowerCase();
|
|
161
|
+
const out: NetRow[] = [];
|
|
162
|
+
for (const d of devices) {
|
|
163
|
+
if (needle && !`${d.name} ${d.id} ${d.kind} ${d.transport}`.toLowerCase().includes(needle))
|
|
164
|
+
continue;
|
|
165
|
+
out.push({
|
|
166
|
+
kind: "device",
|
|
167
|
+
label: d.name || d.id,
|
|
168
|
+
deviceId: d.id,
|
|
169
|
+
current: d.id === currentDeviceId,
|
|
170
|
+
});
|
|
171
|
+
for (const s of d.lastTree?.sessions ?? [])
|
|
172
|
+
out.push({ kind: "session", label: s.name, deviceId: d.id, sessionId: s.id });
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function renderNetwork(
|
|
178
|
+
w: number,
|
|
179
|
+
rows: NetRow[],
|
|
180
|
+
devices: DeviceRow[],
|
|
181
|
+
opts: { cursor?: number; currentSessionId?: string; filter?: string } = {},
|
|
182
|
+
): string[] {
|
|
183
|
+
const lines: string[] = [];
|
|
184
|
+
for (const [index, row] of rows.entries()) {
|
|
185
|
+
const atCursor = index === (opts.cursor ?? 0);
|
|
186
|
+
let label: string;
|
|
187
|
+
if (row.kind === "device") {
|
|
188
|
+
const d = devices.find((x) => x.id === row.deviceId)!;
|
|
189
|
+
const dot =
|
|
190
|
+
d.lastStatus === "online"
|
|
191
|
+
? c.ok("●")
|
|
192
|
+
: d.lastStatus === "checking"
|
|
193
|
+
? c.warn("◌")
|
|
194
|
+
: c.muted("○");
|
|
195
|
+
const seen = d.lastSeenAt ? ageOf(d.lastSeenAt) : "never";
|
|
196
|
+
label =
|
|
197
|
+
`${dot} ${c.bold(row.label)}${row.current ? c.accent(" ▌") : ""} ${c.muted(`[${d.kind}]`)} ` +
|
|
198
|
+
`${c.muted(d.transport === "tunnel" ? "tunnel" : "lan")} ${c.muted(d.lastStatus === "online" ? seen : `${d.lastStatus} · ${seen}`)}`;
|
|
199
|
+
} else {
|
|
200
|
+
const selected = row.sessionId === opts.currentSessionId;
|
|
201
|
+
const marker = selected ? c.accent("▌") : " ";
|
|
202
|
+
const session = devices
|
|
203
|
+
.find((d) => d.id === row.deviceId)
|
|
204
|
+
?.lastTree?.sessions.find((s) => s.id === row.sessionId);
|
|
205
|
+
const glyph = session?.busy ? c.accent("●") : c.muted("·");
|
|
206
|
+
label = ` ${c.muted("└─")} ${marker} ${glyph} ${fit(row.label, Math.max(8, w - 30))}`;
|
|
207
|
+
}
|
|
208
|
+
lines.push(atCursor ? c.inverse(fit(label, w)) : fit(label, w));
|
|
209
|
+
}
|
|
210
|
+
if (opts.filter) lines.push(c.warn(`filter: ${opts.filter}`));
|
|
211
|
+
return lines.map((l) => fit(l, w));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The one-line viewbar under the header: active tab inverse + a right-aligned hint. */
|
|
215
|
+
export function renderViewbar(w: number, view: View, right: string): string {
|
|
216
|
+
const tabs = VIEW_TABS.map((t) =>
|
|
217
|
+
t.id === view ? c.inverse(c.bold(` ${t.label} `)) : c.muted(` ${t.label} `),
|
|
218
|
+
).join(c.muted("│"));
|
|
219
|
+
const gap = Math.max(1, w - visibleWidth(tabs) - visibleWidth(right) - 1);
|
|
220
|
+
return fit(`${tabs}${rep(" ", gap)}${c.muted(right)}`, w);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The inverse status bar: mode chip + per-view keymap + right context. */
|
|
224
|
+
export function renderStatusbar(
|
|
225
|
+
w: number,
|
|
226
|
+
opts: { view: View; device: string; ctxLeft?: number; background?: number; mode?: "NORMAL" | "INSERT" },
|
|
227
|
+
): string {
|
|
228
|
+
const insert = opts.mode === "INSERT";
|
|
229
|
+
const chip = insert ? c.warn(" INSERT ") : c.accentBg(c.bold(" NORMAL "));
|
|
230
|
+
const keys = insert
|
|
231
|
+
? ["tab complete", "↑ history", "enter send", "esc cancel"]
|
|
232
|
+
: opts.view === "agent"
|
|
233
|
+
? ["g herd", "N network", "L log", "[ ] tabs", "m message"]
|
|
234
|
+
: ["j/k move", "↵ open", "1-9 jump", "g/N/L views", "esc agent"];
|
|
235
|
+
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))}`;
|
|
236
|
+
const left = `${chip} ${keys.map((k) => c.muted(k)).join(c.muted(" • "))}`;
|
|
237
|
+
const gap = Math.max(1, w - visibleWidth(left) - visibleWidth(right));
|
|
238
|
+
return c.inverse(padTo(fit(`${left}${rep(" ", gap)}${right}`, w), w));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The top header line: brand + device + counters + connection, right-aligned hints. */
|
|
242
|
+
export function renderHeader(
|
|
243
|
+
w: number,
|
|
244
|
+
opts: {
|
|
245
|
+
device: string;
|
|
246
|
+
sessions: number;
|
|
247
|
+
devicesOnline?: number;
|
|
248
|
+
devicesTotal?: number;
|
|
249
|
+
conn: string;
|
|
250
|
+
connOk: boolean;
|
|
251
|
+
},
|
|
252
|
+
): string {
|
|
253
|
+
const left = [
|
|
254
|
+
`${c.accent("▲")} ${c.bold("dpi")}`,
|
|
255
|
+
c.muted("tui"),
|
|
256
|
+
c.muted("│"),
|
|
257
|
+
`${opts.connOk ? c.ok("●") : c.err("○")} ${c.text(opts.conn)}`,
|
|
258
|
+
c.muted(`${opts.sessions} session${opts.sessions === 1 ? "" : "s"}`),
|
|
259
|
+
opts.devicesTotal !== undefined
|
|
260
|
+
? c.muted(`${opts.devicesOnline ?? 0}/${opts.devicesTotal} devices`)
|
|
261
|
+
: "",
|
|
262
|
+
]
|
|
263
|
+
.filter(Boolean)
|
|
264
|
+
.join(" ");
|
|
265
|
+
const right = c.muted("? help : palette q quit");
|
|
266
|
+
const gap = Math.max(1, w - visibleWidth(left) - visibleWidth(right));
|
|
267
|
+
return fit(`${left}${rep(" ", gap)}${right}`, w);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The herd overview panel (wide terminals): selected session at a glance. */
|
|
271
|
+
export function renderHerdPanel(
|
|
272
|
+
w: number,
|
|
273
|
+
h: number,
|
|
274
|
+
s: SessionSummary | undefined,
|
|
275
|
+
ctxLeft?: number,
|
|
276
|
+
): string[] {
|
|
277
|
+
if (!s) return Array.from({ length: Math.max(0, h) }, () => rep(" ", w));
|
|
278
|
+
const agents = s.agents.map((a) =>
|
|
279
|
+
a.kind === "fork" ? `⑂ ${a.name}` : a.kind === "subagent" ? `↳ ${a.name}` : `● ${a.name}`,
|
|
280
|
+
);
|
|
281
|
+
const lines = [
|
|
282
|
+
c.muted(" OVERVIEW"),
|
|
283
|
+
` ${c.bold(s.name)}`,
|
|
284
|
+
` ${c.muted(basename(s.cwd))} ${c.muted("·")} ${c.text(basename(s.model) || s.model)}`,
|
|
285
|
+
"",
|
|
286
|
+
` ${c.muted("state")} ${STATE_COLOR[sessionState(s)](sessionState(s))}`,
|
|
287
|
+
` ${c.muted("agents")} ${agents.length ? agents[0] : "—"}`,
|
|
288
|
+
...agents.slice(1, 8).map((a) => ` ${c.muted(a)}`),
|
|
289
|
+
"",
|
|
290
|
+
` ${c.muted("ctx")} ${ctxLeft === undefined ? c.muted("—") : c.text(`${ctxLeft}% left`)}`,
|
|
291
|
+
` ${c.muted("age")} ${c.muted(ageOf(s.createdAt))}`,
|
|
292
|
+
` ${c.muted("tokens")} ${c.muted(compact(s.agents.reduce((n, a) => n + a.background, 0)) + " bg")}`,
|
|
293
|
+
];
|
|
294
|
+
return lines.slice(0, h).map((l) => fit(l, w));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Join table + panel columns herdr-style: `table │ panel` — tableW is the table column's exact width. */
|
|
298
|
+
export function joinPanel(
|
|
299
|
+
table: string[],
|
|
300
|
+
panel: string[],
|
|
301
|
+
tableW: number,
|
|
302
|
+
w: number,
|
|
303
|
+
h: number,
|
|
304
|
+
): string[] {
|
|
305
|
+
const out: string[] = [];
|
|
306
|
+
for (let i = 0; i < h; i += 1)
|
|
307
|
+
out.push(
|
|
308
|
+
`${padTo(table[i] ?? "", tableW)}${c.muted("│")}${panel[i] ?? rep(" ", Math.max(0, w - tableW - 1))}`,
|
|
309
|
+
);
|
|
310
|
+
return out;
|
|
311
|
+
}
|
package/bin/dpi.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// `dpi` — one command for the Durable Pi stack.
|
|
3
3
|
// dpi hub [--port N] [--host H] [--public-host H] web UI + device tunnel server
|
|
4
|
-
// dpi device
|
|
4
|
+
// dpi device [start|stop|status|install|uninstall] device-side daemon
|
|
5
5
|
// dpi tui terminal admin client
|
|
6
6
|
// Every subcommand runs the TypeScript entry through tsx, so no build step is needed.
|
|
7
7
|
import { access } from "node:fs/promises";
|
|
@@ -19,8 +19,10 @@ const HELP = `dpi — Durable Pi (bare "dpi" opens the TUI)
|
|
|
19
19
|
Usage:
|
|
20
20
|
dpi hub [--port 4781] [--host 127.0.0.1] [--public-host <host>]
|
|
21
21
|
Web UI + device tunnel server (devices dial this).
|
|
22
|
-
dpi device
|
|
23
|
-
|
|
22
|
+
dpi device run the device daemon (foreground)
|
|
23
|
+
dpi device start | stop | status background daemon without a terminal
|
|
24
|
+
dpi device install | uninstall OS service (systemd/launchd); Windows: no
|
|
25
|
+
autostart — run "dpi device start" after login
|
|
24
26
|
dpi tui
|
|
25
27
|
Terminal admin client.
|
|
26
28
|
dpi help | dpi --help | dpi -h
|
|
@@ -98,6 +100,10 @@ if (command === "update" || command === "upgrade") {
|
|
|
98
100
|
});
|
|
99
101
|
if (!res.ok) throw new Error(`registry responded ${res.status}`);
|
|
100
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}`);
|
|
101
107
|
} catch (error) {
|
|
102
108
|
process.stderr.write(`dpi update: cannot reach the npm registry (${error.message})\n`);
|
|
103
109
|
process.exit(1);
|
|
@@ -130,7 +136,15 @@ if (command === "update" || command === "upgrade") {
|
|
|
130
136
|
process.exit(0);
|
|
131
137
|
}
|
|
132
138
|
|
|
133
|
-
|
|
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
|
+
});
|
|
134
148
|
child.on("error", (error) => {
|
|
135
149
|
process.stderr.write(`dpi update: cannot run npm (${error.message})\n`);
|
|
136
150
|
process.exit(1);
|
|
@@ -149,6 +163,9 @@ if (command === "update" || command === "upgrade") {
|
|
|
149
163
|
} else {
|
|
150
164
|
// The entries parse process.argv.slice(2) themselves: hand them their own args.
|
|
151
165
|
process.argv = [process.argv[0], process.argv[1], ...commandRest];
|
|
166
|
+
// The device daemon respawns and installs services through this launcher:
|
|
167
|
+
// hand it the absolute path before the module loads.
|
|
168
|
+
process.env.DPI_BIN = fileURLToPath(import.meta.url);
|
|
152
169
|
const { register } = await loadTsx();
|
|
153
170
|
register();
|
|
154
171
|
await import(COMMANDS[command]);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "my-durable",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Durable Pi
|
|
3
|
+
"version": "0.0.9",
|
|
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": {
|
|
7
7
|
"type": "git",
|