my-durable 0.0.6 → 0.0.8

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.
Files changed (45) hide show
  1. package/README.md +6 -0
  2. package/apps/dpi-backend/src/config.ts +10 -3
  3. package/apps/dpi-backend/src/daemon.ts +422 -0
  4. package/apps/dpi-backend/src/main.ts +148 -7
  5. package/apps/dpi-backend/src/pair-page.ts +111 -0
  6. package/apps/dpi-backend/src/pairing-files.ts +39 -9
  7. package/apps/dpi-backend/src/pairing.ts +368 -59
  8. package/apps/dpi-backend/src/routes.ts +14 -1
  9. package/apps/dpi-backend/src/store.ts +1 -0
  10. package/apps/dpi-backend/src/tunnel.ts +294 -55
  11. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-Cg3vey95.css +1 -0
  12. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CoYMgU-2.js +77 -0
  13. package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +2 -2
  14. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +17 -4
  15. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +29 -0
  16. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +107 -18
  17. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +1 -1
  18. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceHandoff.vue +132 -0
  19. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +2 -2
  20. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +36 -6
  21. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +107 -5
  22. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +3 -1
  23. package/apps/dpi-playground/apps/dpi-playground-hub/src/admin-auth.ts +19 -17
  24. package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +1 -0
  25. package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +4 -0
  26. package/apps/dpi-playground/apps/dpi-playground-hub/src/hub-identity.ts +98 -0
  27. package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +31 -3
  28. package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +172 -25
  29. package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +24 -0
  30. package/apps/dpi-sdk/src/api.ts +2 -0
  31. package/apps/dpi-sdk/src/node/private-file.ts +140 -0
  32. package/apps/dpi-sdk/src/node/registry-migration.ts +9 -0
  33. package/apps/dpi-sdk/src/node/registry.ts +6 -0
  34. package/apps/dpi-sdk/src/protocol/pairing.ts +137 -16
  35. package/apps/dpi-sdk/src/protocol/tunnel.ts +162 -38
  36. package/apps/dpi-tui/src/app.ts +889 -187
  37. package/apps/dpi-tui/src/hub-feed.ts +71 -0
  38. package/apps/dpi-tui/src/hub-mux.ts +168 -0
  39. package/apps/dpi-tui/src/main.ts +5 -1
  40. package/apps/dpi-tui/src/theme.ts +14 -0
  41. package/apps/dpi-tui/src/views.ts +311 -0
  42. package/bin/dpi.mjs +8 -3
  43. package/package.json +2 -2
  44. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index--aoZ5Tj0.css +0 -1
  45. 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
+ }
@@ -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 app = new App(device, client, info, registry);
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 device-side daemon
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
- Device-side daemon (pairs to a hub, serves one machine's agents).
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
@@ -149,6 +151,9 @@ if (command === "update" || command === "upgrade") {
149
151
  } else {
150
152
  // The entries parse process.argv.slice(2) themselves: hand them their own args.
151
153
  process.argv = [process.argv[0], process.argv[1], ...commandRest];
154
+ // The device daemon respawns and installs services through this launcher:
155
+ // hand it the absolute path before the module loads.
156
+ process.env.DPI_BIN = fileURLToPath(import.meta.url);
152
157
  const { register } = await loadTsx();
153
158
  register();
154
159
  await import(COMMANDS[command]);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "my-durable",
3
- "version": "0.0.6",
4
- "description": "Durable Pi — the durable agent network: hub web UI, device backend daemon, TUI admin and the shared SDK, packaged as one runnable monorepo install",
3
+ "version": "0.0.8",
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",