@yuriteixeira/pi-muxr 0.1.0

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 (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -0
  3. package/dist/cli/dashboard-component.d.ts +41 -0
  4. package/dist/cli/dashboard-component.js +236 -0
  5. package/dist/cli/dashboard-component.js.map +1 -0
  6. package/dist/cli/dashboard-icons.d.ts +10 -0
  7. package/dist/cli/dashboard-icons.js +40 -0
  8. package/dist/cli/dashboard-icons.js.map +1 -0
  9. package/dist/cli/dashboard-layout.d.ts +20 -0
  10. package/dist/cli/dashboard-layout.js +50 -0
  11. package/dist/cli/dashboard-layout.js.map +1 -0
  12. package/dist/cli/dashboard-theme.d.ts +43 -0
  13. package/dist/cli/dashboard-theme.js +56 -0
  14. package/dist/cli/dashboard-theme.js.map +1 -0
  15. package/dist/cli/dashboard.d.ts +4 -0
  16. package/dist/cli/dashboard.js +186 -0
  17. package/dist/cli/dashboard.js.map +1 -0
  18. package/dist/cli/format.d.ts +4 -0
  19. package/dist/cli/format.js +35 -0
  20. package/dist/cli/format.js.map +1 -0
  21. package/dist/cli/index.d.ts +2 -0
  22. package/dist/cli/index.js +74 -0
  23. package/dist/cli/index.js.map +1 -0
  24. package/dist/cli/rows.d.ts +2 -0
  25. package/dist/cli/rows.js +27 -0
  26. package/dist/cli/rows.js.map +1 -0
  27. package/dist/config/config.d.ts +3 -0
  28. package/dist/config/config.js +37 -0
  29. package/dist/config/config.js.map +1 -0
  30. package/dist/config/paths.d.ts +5 -0
  31. package/dist/config/paths.js +22 -0
  32. package/dist/config/paths.js.map +1 -0
  33. package/dist/domain/sorting.d.ts +3 -0
  34. package/dist/domain/sorting.js +28 -0
  35. package/dist/domain/sorting.js.map +1 -0
  36. package/dist/domain/status.d.ts +60 -0
  37. package/dist/domain/status.js +17 -0
  38. package/dist/domain/status.js.map +1 -0
  39. package/dist/extension/index.d.ts +1 -0
  40. package/dist/extension/index.js +2 -0
  41. package/dist/extension/index.js.map +1 -0
  42. package/dist/extension/pi-muxr.d.ts +7 -0
  43. package/dist/extension/pi-muxr.js +262 -0
  44. package/dist/extension/pi-muxr.js.map +1 -0
  45. package/dist/index.d.ts +11 -0
  46. package/dist/index.js +12 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/notifications/desktop.d.ts +2 -0
  49. package/dist/notifications/desktop.js +29 -0
  50. package/dist/notifications/desktop.js.map +1 -0
  51. package/dist/state/dashboard-presence.d.ts +6 -0
  52. package/dist/state/dashboard-presence.js +23 -0
  53. package/dist/state/dashboard-presence.js.map +1 -0
  54. package/dist/state/database.d.ts +3 -0
  55. package/dist/state/database.js +12 -0
  56. package/dist/state/database.js.map +1 -0
  57. package/dist/state/read-statuses.d.ts +3 -0
  58. package/dist/state/read-statuses.js +5 -0
  59. package/dist/state/read-statuses.js.map +1 -0
  60. package/dist/state/rows.d.ts +23 -0
  61. package/dist/state/rows.js +23 -0
  62. package/dist/state/rows.js.map +1 -0
  63. package/dist/state/schema.d.ts +1 -0
  64. package/dist/state/schema.js +31 -0
  65. package/dist/state/schema.js.map +1 -0
  66. package/dist/state/write-status.d.ts +10 -0
  67. package/dist/state/write-status.js +59 -0
  68. package/dist/state/write-status.js.map +1 -0
  69. package/dist/tmux/focus.d.ts +3 -0
  70. package/dist/tmux/focus.js +19 -0
  71. package/dist/tmux/focus.js.map +1 -0
  72. package/dist/tmux/list-panes.d.ts +5 -0
  73. package/dist/tmux/list-panes.js +21 -0
  74. package/dist/tmux/list-panes.js.map +1 -0
  75. package/dist/tmux/sidebar.d.ts +16 -0
  76. package/dist/tmux/sidebar.js +171 -0
  77. package/dist/tmux/sidebar.js.map +1 -0
  78. package/dist/web/assets.d.ts +5 -0
  79. package/dist/web/assets.js +19 -0
  80. package/dist/web/assets.js.map +1 -0
  81. package/dist/web/base16-theme.d.ts +23 -0
  82. package/dist/web/base16-theme.js +68 -0
  83. package/dist/web/base16-theme.js.map +1 -0
  84. package/dist/web/notifications.d.ts +7 -0
  85. package/dist/web/notifications.js +47 -0
  86. package/dist/web/notifications.js.map +1 -0
  87. package/dist/web/page.d.ts +1 -0
  88. package/dist/web/page.js +155 -0
  89. package/dist/web/page.js.map +1 -0
  90. package/dist/web/protocol.d.ts +25 -0
  91. package/dist/web/protocol.js +2 -0
  92. package/dist/web/protocol.js.map +1 -0
  93. package/dist/web/server.d.ts +5 -0
  94. package/dist/web/server.js +51 -0
  95. package/dist/web/server.js.map +1 -0
  96. package/dist/web/terminal-gateway.d.ts +2 -0
  97. package/dist/web/terminal-gateway.js +110 -0
  98. package/dist/web/terminal-gateway.js.map +1 -0
  99. package/dist/web/tmux-session.d.ts +4 -0
  100. package/dist/web/tmux-session.js +50 -0
  101. package/dist/web/tmux-session.js.map +1 -0
  102. package/docs/ARCHITECTURE.md +287 -0
  103. package/docs/BUILDING.md +78 -0
  104. package/docs/CONTRIBUTING.md +32 -0
  105. package/docs/resources/fullscreen.png +0 -0
  106. package/docs/resources/sidebar.png +0 -0
  107. package/docs/resources/web.png +0 -0
  108. package/examples/config.json +13 -0
  109. package/package.json +76 -0
@@ -0,0 +1,25 @@
1
+ import type { DashboardRow } from "../domain/status.js";
2
+ export type ClientMessage = {
3
+ type: "input";
4
+ data: string;
5
+ } | {
6
+ type: "resize";
7
+ cols: number;
8
+ rows: number;
9
+ };
10
+ export type ServerMessage = {
11
+ type: "output";
12
+ data: string;
13
+ } | {
14
+ type: "exit";
15
+ code?: number;
16
+ } | {
17
+ type: "error";
18
+ message: string;
19
+ } | {
20
+ type: "notification";
21
+ title: string;
22
+ body: string;
23
+ row: NotificationRow;
24
+ };
25
+ export type NotificationRow = Pick<DashboardRow, "id" | "state" | "severity" | "summary" | "cwd" | "tmuxSession" | "tmuxWindowIndex" | "paneId" | "lastEventAt">;
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=protocol.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.js","sourceRoot":"","sources":["../../src/web/protocol.ts"],"names":[],"mappings":""}
@@ -0,0 +1,5 @@
1
+ export interface WebServerOptions {
2
+ host?: string;
3
+ port?: number;
4
+ }
5
+ export declare function runWebServer(options?: WebServerOptions): void;
@@ -0,0 +1,51 @@
1
+ import http from "node:http";
2
+ import { WEB_PAGE } from "./page.js";
3
+ import { readVendorAsset } from "./assets.js";
4
+ import { getBase16TerminalTheme } from "./base16-theme.js";
5
+ export function runWebServer(options = {}) {
6
+ void startWebServer(options);
7
+ }
8
+ async function startWebServer(options) {
9
+ const host = options.host ?? process.env.HOST ?? "127.0.0.1";
10
+ const port = options.port ?? Number(process.env.PORT ?? 3042);
11
+ const server = http.createServer(handleRequest);
12
+ const { attachTerminalGateway } = await import("./terminal-gateway.js");
13
+ attachTerminalGateway(server);
14
+ server.listen(port, host, () => {
15
+ console.log(`pi-muxr web listening on http://${host}:${port}`);
16
+ });
17
+ }
18
+ function handleRequest(request, response) {
19
+ void routeRequest(request, response).catch((error) => {
20
+ send(response, 500, "application/json; charset=utf-8", JSON.stringify({ message: formatError(error) }));
21
+ });
22
+ }
23
+ async function routeRequest(request, response) {
24
+ const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
25
+ if (request.method !== "GET") {
26
+ send(response, 405, "text/plain; charset=utf-8", "Method not allowed");
27
+ return;
28
+ }
29
+ if (url.pathname === "/api/terminal-theme") {
30
+ send(response, 200, "application/json; charset=utf-8", JSON.stringify({ theme: await getBase16TerminalTheme() }));
31
+ return;
32
+ }
33
+ const asset = readVendorAsset(url.pathname);
34
+ if (asset) {
35
+ send(response, 200, asset.contentType, asset.body);
36
+ return;
37
+ }
38
+ if (url.pathname === "/" || url.pathname === "/index.html") {
39
+ send(response, 200, "text/html; charset=utf-8", WEB_PAGE);
40
+ return;
41
+ }
42
+ send(response, 404, "text/plain; charset=utf-8", "Not found");
43
+ }
44
+ function send(response, statusCode, contentType, body) {
45
+ response.writeHead(statusCode, { "content-type": contentType });
46
+ response.end(body);
47
+ }
48
+ function formatError(error) {
49
+ return error instanceof Error ? error.message : String(error);
50
+ }
51
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../../src/web/server.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AACrC,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAC;AAO3D,MAAM,UAAU,YAAY,CAAC,OAAO,GAAqB,EAAE;IACzD,KAAK,cAAc,CAAC,OAAO,CAAC,CAAC;AAC/B,CAAC;AAED,KAAK,UAAU,cAAc,CAAC,OAAyB;IACrD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,WAAW,CAAC;IAC7D,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC;IAC9D,MAAM,MAAM,GAAG,IAAI,CAAC,YAAY,CAAC,aAAa,CAAC,CAAC;IAChD,MAAM,EAAE,qBAAqB,EAAE,GAAG,MAAM,MAAM,CAAC,uBAAuB,CAAC,CAAC;IAExE,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAC9B,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE;QAC7B,OAAO,CAAC,GAAG,CAAC,mCAAmC,IAAI,IAAI,IAAI,EAAE,CAAC,CAAC;IACjE,CAAC,CAAC,CAAC;AACL,CAAC;AAED,SAAS,aAAa,CAAC,OAA6B,EAAE,QAA6B;IACjF,KAAK,YAAY,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;QAC5D,IAAI,CAAC,QAAQ,EAAE,GAAG,EAAE,iCAAiC,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,OAAO,EAAE,WAAW,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC;IAC1G,CAAC,CAAC,CAAC;AACL,CAAC;AAED,KAAK,UAAU,YAAY,CAAC,OAA6B,EAAE,QAA6B;IACtF,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,IAAI,GAAG,EAAE,UAAU,OAAO,CAAC,OAAO,CAAC,IAAI,IAAI,WAAW,EAAE,CAAC,CAAC;IACzF,IAAI,OAAO,CAAC,MAAM,KAAK,KAAK,EAAE,CAAC;QAC7B,IAAI,CAAC,QAAQ,EAAE,GAAG,EAAE,2BAA2B,EAAE,oBAAoB,CAAC,CAAC;QACvE,OAAO;IACT,CAAC;IAED,IAAI,GAAG,CAAC,QAAQ,KAAK,qBAAqB,EAAE,CAAC;QAC3C,IAAI,CAAC,QAAQ,EAAE,GAAG,EAAE,iCAAiC,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,MAAM,sBAAsB,EAAE,EAAE,CAAC,CAAC,CAAC;QAClH,OAAO;IACT,CAAC;IAED,MAAM,KAAK,GAAG,eAAe,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IAC5C,IAAI,KAAK,EAAE,CAAC;QACV,IAAI,CAAC,QAAQ,EAAE,GAAG,EAAE,KAAK,CAAC,WAAW,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;QACnD,OAAO;IACT,CAAC;IAED,IAAI,GAAG,CAAC,QAAQ,KAAK,GAAG,IAAI,GAAG,CAAC,QAAQ,KAAK,aAAa,EAAE,CAAC;QAC3D,IAAI,CAAC,QAAQ,EAAE,GAAG,EAAE,0BAA0B,EAAE,QAAQ,CAAC,CAAC;QAC1D,OAAO;IACT,CAAC;IAED,IAAI,CAAC,QAAQ,EAAE,GAAG,EAAE,2BAA2B,EAAE,WAAW,CAAC,CAAC;AAChE,CAAC;AAED,SAAS,IAAI,CAAC,QAA6B,EAAE,UAAkB,EAAE,WAAmB,EAAE,IAAqB;IACzG,QAAQ,CAAC,SAAS,CAAC,UAAU,EAAE,EAAE,cAAc,EAAE,WAAW,EAAE,CAAC,CAAC;IAChE,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;AACrB,CAAC;AAED,SAAS,WAAW,CAAC,KAAc;IACjC,OAAO,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAChE,CAAC"}
@@ -0,0 +1,2 @@
1
+ import type http from "node:http";
2
+ export declare function attachTerminalGateway(server: http.Server): void;
@@ -0,0 +1,110 @@
1
+ import { URL } from "node:url";
2
+ import { spawn } from "node-pty";
3
+ import { WebSocketServer } from "ws";
4
+ import { loadConfig } from "../config/config.js";
5
+ import { openDatabase } from "../state/database.js";
6
+ import { createNotificationMonitor } from "./notifications.js";
7
+ import { ensurePiMuxrSession, hideTmuxStatus, isValidSessionName, restoreTmuxStatus } from "./tmux-session.js";
8
+ export function attachTerminalGateway(server) {
9
+ const websocketServer = new WebSocketServer({ noServer: true });
10
+ server.on("upgrade", (request, socket, head) => {
11
+ const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
12
+ if (url.pathname !== "/ws/terminal") {
13
+ socket.destroy();
14
+ return;
15
+ }
16
+ websocketServer.handleUpgrade(request, socket, head, (websocket) => {
17
+ websocketServer.emit("connection", websocket, request, url);
18
+ });
19
+ });
20
+ websocketServer.on("connection", (websocket, _request, url) => {
21
+ void attachTerminal(websocket, url);
22
+ });
23
+ }
24
+ async function attachTerminal(websocket, url) {
25
+ const session = url.searchParams.get("session")?.trim() || "pi-muxr-web";
26
+ if (!isValidSessionName(session)) {
27
+ sendJson(websocket, { type: "error", message: "Invalid tmux session name." });
28
+ websocket.close();
29
+ return;
30
+ }
31
+ try {
32
+ await ensurePiMuxrSession(session);
33
+ await hideTmuxStatus(session);
34
+ }
35
+ catch (error) {
36
+ sendJson(websocket, { type: "error", message: formatError(error) });
37
+ websocket.close();
38
+ return;
39
+ }
40
+ const terminal = spawn("tmux", ["attach-session", "-t", session], {
41
+ name: "xterm-256color",
42
+ cols: 80,
43
+ rows: 24,
44
+ cwd: process.env.HOME,
45
+ env: process.env,
46
+ });
47
+ const config = loadConfig();
48
+ const db = openDatabase(config.databasePath);
49
+ const notifications = createNotificationMonitor(db, config);
50
+ const notificationTimer = setInterval(() => sendMessages(websocket, notifications.poll()), 1_000);
51
+ let closed = false;
52
+ const close = () => {
53
+ if (closed)
54
+ return;
55
+ closed = true;
56
+ clearInterval(notificationTimer);
57
+ terminal.kill();
58
+ restoreTmuxStatus(session);
59
+ db.close();
60
+ };
61
+ const release = () => {
62
+ if (closed)
63
+ return;
64
+ closed = true;
65
+ clearInterval(notificationTimer);
66
+ restoreTmuxStatus(session);
67
+ db.close();
68
+ };
69
+ terminal.onData((data) => sendJson(websocket, { type: "output", data }));
70
+ terminal.onExit(({ exitCode }) => {
71
+ sendJson(websocket, { type: "exit", code: exitCode });
72
+ websocket.close();
73
+ release();
74
+ });
75
+ websocket.on("message", (message) => handleClientMessage(terminal, websocket, message.toString()));
76
+ websocket.on("close", close);
77
+ websocket.on("error", close);
78
+ }
79
+ function handleClientMessage(terminal, websocket, data) {
80
+ try {
81
+ const message = JSON.parse(data);
82
+ if (message.type === "input" && typeof message.data === "string") {
83
+ terminal.write(message.data);
84
+ return;
85
+ }
86
+ if (message.type === "resize" && isValidSize(message.cols, message.rows)) {
87
+ terminal.resize(message.cols, message.rows);
88
+ return;
89
+ }
90
+ sendJson(websocket, { type: "error", message: "Unsupported terminal message." });
91
+ }
92
+ catch {
93
+ sendJson(websocket, { type: "error", message: "Invalid terminal message." });
94
+ }
95
+ }
96
+ function isValidSize(cols, rows) {
97
+ return Number.isInteger(cols) && Number.isInteger(rows) && cols > 0 && rows > 0;
98
+ }
99
+ function sendMessages(websocket, messages) {
100
+ for (const message of messages)
101
+ sendJson(websocket, message);
102
+ }
103
+ function sendJson(websocket, message) {
104
+ if (websocket.readyState === websocket.OPEN)
105
+ websocket.send(JSON.stringify(message));
106
+ }
107
+ function formatError(error) {
108
+ return error instanceof Error ? error.message : String(error);
109
+ }
110
+ //# sourceMappingURL=terminal-gateway.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"terminal-gateway.js","sourceRoot":"","sources":["../../src/web/terminal-gateway.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ export declare function isValidSessionName(name: string): boolean;
2
+ export declare function ensurePiMuxrSession(session: string): Promise<void>;
3
+ export declare function hideTmuxStatus(session: string): Promise<void>;
4
+ export declare function restoreTmuxStatus(session: string): void;
@@ -0,0 +1,50 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ const SESSION_NAME_PATTERN = /^[A-Za-z0-9_.:-]+$/;
5
+ const hiddenStatusSessions = new Map();
6
+ export function isValidSessionName(name) {
7
+ return SESSION_NAME_PATTERN.test(name);
8
+ }
9
+ export async function ensurePiMuxrSession(session) {
10
+ if (!isValidSessionName(session))
11
+ throw new Error("Invalid tmux session name.");
12
+ if (await hasSession(session))
13
+ return;
14
+ await execFileAsync("tmux", ["new-session", "-d", "-s", session, piMuxrCommand()]);
15
+ }
16
+ export async function hideTmuxStatus(session) {
17
+ const existing = hiddenStatusSessions.get(session);
18
+ if (existing) {
19
+ existing.clients += 1;
20
+ return;
21
+ }
22
+ const { stdout } = await execFileAsync("tmux", ["show-options", "-t", session, "-v", "status"]);
23
+ const previousStatus = stdout.trim() || "on";
24
+ await execFileAsync("tmux", ["set-option", "-t", session, "status", "off"]);
25
+ hiddenStatusSessions.set(session, { clients: 1, previousStatus });
26
+ }
27
+ export function restoreTmuxStatus(session) {
28
+ const existing = hiddenStatusSessions.get(session);
29
+ if (!existing)
30
+ return;
31
+ existing.clients -= 1;
32
+ if (existing.clients > 0)
33
+ return;
34
+ hiddenStatusSessions.delete(session);
35
+ execFile("tmux", ["set-option", "-t", session, "status", existing.previousStatus], () => { });
36
+ }
37
+ async function hasSession(session) {
38
+ try {
39
+ await execFileAsync("tmux", ["has-session", "-t", session]);
40
+ return true;
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ function piMuxrCommand() {
47
+ const entrypoint = JSON.stringify(process.argv[1] ?? "pi-muxr");
48
+ return `${JSON.stringify(process.execPath)} ${entrypoint}`;
49
+ }
50
+ //# sourceMappingURL=tmux-session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tmux-session.js","sourceRoot":"","sources":["../../src/web/tmux-session.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC9C,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAEtC,MAAM,aAAa,GAAG,SAAS,CAAC,QAAQ,CAAC,CAAC;AAC1C,MAAM,oBAAoB,GAAG,oBAAoB,CAAC;AAClD,MAAM,oBAAoB,GAAG,IAAI,GAAG,EAAuD,CAAC;AAE5F,MAAM,UAAU,kBAAkB,CAAC,IAAY;IAC7C,OAAO,oBAAoB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACzC,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,mBAAmB,CAAC,OAAe;IACvD,IAAI,CAAC,kBAAkB,CAAC,OAAO,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,4BAA4B,CAAC,CAAC;IAChF,IAAI,MAAM,UAAU,CAAC,OAAO,CAAC;QAAE,OAAO;IACtC,MAAM,aAAa,CAAC,MAAM,EAAE,CAAC,aAAa,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,aAAa,EAAE,CAAC,CAAC,CAAC;AACrF,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,OAAe;IAClD,MAAM,QAAQ,GAAG,oBAAoB,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IACnD,IAAI,QAAQ,EAAE,CAAC;QACb,QAAQ,CAAC,OAAO,IAAI,CAAC,CAAC;QACtB,OAAO;IACT,CAAC;IAED,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,aAAa,CAAC,MAAM,EAAE,CAAC,cAAc,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC;IAChG,MAAM,cAAc,GAAG,MAAM,CAAC,IAAI,EAAE,IAAI,IAAI,CAAC;IAC7C,MAAM,aAAa,CAAC,MAAM,EAAE,CAAC,YAAY,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,CAAC,CAAC,CAAC;IAC5E,oBAAoB,CAAC,GAAG,CAAC,OAAO,EAAE,EAAE,OAAO,EAAE,CAAC,EAAE,cAAc,EAAE,CAAC,CAAC;AACpE,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,OAAe;IAC/C,MAAM,QAAQ,GAAG,oBAAoB,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IACnD,IAAI,CAAC,QAAQ;QAAE,OAAO;IAEtB,QAAQ,CAAC,OAAO,IAAI,CAAC,CAAC;IACtB,IAAI,QAAQ,CAAC,OAAO,GAAG,CAAC;QAAE,OAAO;IAEjC,oBAAoB,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACrC,QAAQ,CAAC,MAAM,EAAE,CAAC,YAAY,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,CAAC,cAAc,CAAC,EAAE,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;AAC/F,CAAC;AAED,KAAK,UAAU,UAAU,CAAC,OAAe;IACvC,IAAI,CAAC;QACH,MAAM,aAAa,CAAC,MAAM,EAAE,CAAC,aAAa,EAAE,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC;QAC5D,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,SAAS,aAAa;IACpB,MAAM,UAAU,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,SAAS,CAAC,CAAC;IAChE,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,UAAU,EAAE,CAAC;AAC7D,CAAC"}
@@ -0,0 +1,287 @@
1
+ # `pi-muxr` architecture
2
+
3
+ ## Purpose
4
+
5
+ `pi-muxr` gives one view of Pi sessions that run in tmux. It answers three questions:
6
+
7
+ 1. Which sessions are active?
8
+ 2. Which sessions need attention?
9
+ 3. Which tmux pane owns each session?
10
+
11
+ The project does not read terminal output. A Pi extension receives Pi events and publishes structured state. The terminal and browser interfaces read that shared state.
12
+
13
+ ## System overview
14
+
15
+ ```text
16
+ Pi session
17
+ |
18
+ | Pi extension events
19
+ v
20
+ SQLite database: ~/.pi-muxr/pi-muxr.sqlite
21
+ |
22
+ +--> CLI dashboard
23
+ | |
24
+ | +--> terminal input and display
25
+ | +--> tmux pane focus
26
+ | +--> terminal bell
27
+ |
28
+ +--> Web terminal gateway
29
+ | |
30
+ | +--> browser xterm terminal
31
+ | +--> tmux session through node-pty
32
+ | +--> browser notifications
33
+ |
34
+ +--> Desktop notifications
35
+ ```
36
+
37
+ SQLite is the shared boundary between the Pi extension and the user interfaces. It allows the extension and one or more dashboard processes to read and update state without a separate service.
38
+
39
+ ## Source layout
40
+
41
+ ```text
42
+ src/
43
+ ├── domain/ State types and rules that do not depend on I/O
44
+ ├── extension/ Pi event integration and session state publishing
45
+ ├── state/ SQLite schema and state reads and writes
46
+ ├── tmux/ Pane discovery and pane focus
47
+ ├── cli/ Terminal entry point and dashboard rendering
48
+ ├── web/ HTTP server, browser assets, and terminal gateway
49
+ ├── config/ Configuration and path resolution
50
+ └── notifications/ Desktop notification integration
51
+ ```
52
+
53
+ The domain layer is kept independent from the terminal, web, tmux, and database layers. This makes state rules easier to test and keeps the user interfaces as clients of the same data model.
54
+
55
+ ## State model
56
+
57
+ Each Pi process owns one row in the `sessions` table.
58
+
59
+ Important fields include:
60
+
61
+ - `id`: Stable identifier for the Pi session and tmux pane.
62
+ - `pane_id`: tmux pane identifier such as `%12`.
63
+ - `tmux_session`, `tmux_window`, and `tmux_window_index`: Pane location.
64
+ - `pid`: Pi process ID.
65
+ - `cwd`: Project directory.
66
+ - `pi_session_file`: Pi session file when available.
67
+ - `model`: Selected model.
68
+ - `state`: Current Pi state.
69
+ - `severity`: `low`, `medium`, or `high`.
70
+ - `summary`: Short description of the latest event.
71
+ - `last_event_at`: Time of the latest meaningful event.
72
+ - `heartbeat_at`: Time of the latest process heartbeat.
73
+ - Read, dismissal, and notification markers.
74
+
75
+ The database also contains `dashboard_presence`. Dashboard processes update this table so the extension can avoid sending desktop notifications when a dashboard is already open.
76
+
77
+ ## Session states
78
+
79
+ The domain supports these states:
80
+
81
+ | State | Meaning |
82
+ | --- | --- |
83
+ | `ASK` | Pi is waiting for user input through `ask_user`. |
84
+ | `ERROR` | A turn or tool ended with an error. |
85
+ | `DONE` | A turn completed. |
86
+ | `QUEUED` | Work is waiting to start. |
87
+ | `RUN` | Pi is processing a turn or tool. |
88
+ | `IDLE` | The session is alive but not active. |
89
+ | `STALE` | Internal display classification for an expired heartbeat or a missing tmux pane. |
90
+
91
+ By default, `ASK`, `ERROR`, and `DONE` are actionable. The configuration can change the actionable states used by the dashboard, dashboard bell, dismissal actions, and browser notifications. Desktop notifications always use the default actionable states.
92
+
93
+ An event is unread when its `last_event_at` is newer than `read_until_event_at` and it has not been dismissed. Unread and actionable are separate properties. The dashboard can therefore show an unread marker for a state that is not actionable. An event is dismissed when `dismissed_until_event_at` is at least as recent as `last_event_at`. This means a new event becomes visible again after an older event was read or dismissed.
94
+
95
+ ## Pi extension flow
96
+
97
+ The extension is loaded by Pi and listens to Pi lifecycle events.
98
+
99
+ ```text
100
+ session_start
101
+ -> open database
102
+ -> create session ID
103
+ -> publish IDLE state
104
+ -> start heartbeat timer
105
+
106
+ agent_start / turn_start
107
+ -> publish RUN state
108
+
109
+ tool_call
110
+ -> publish ASK for ask_user
111
+ -> publish RUN for other tools
112
+
113
+ tool_result
114
+ -> retain error information
115
+ -> publish tool summary when needed
116
+
117
+ agent_end
118
+ -> publish ERROR when the turn failed
119
+ -> otherwise publish DONE
120
+
121
+ session_shutdown
122
+ -> stop heartbeat
123
+ -> remove session row
124
+ -> close database
125
+ ```
126
+
127
+ The extension also refreshes tmux information when it writes a state update. It uses `TMUX_PANE` and tmux pane discovery to associate a Pi process with its owning pane.
128
+
129
+ Summaries are truncated before storage. This keeps the database small and limits the amount of event data retained for display.
130
+
131
+ ## Heartbeats and stale sessions
132
+
133
+ The extension updates `heartbeat_at` on a timer. The dashboard does not trust clean shutdown alone because a process can be killed or a pane can disappear.
134
+
135
+ When dashboard rows are built:
136
+
137
+ - A session is stale when its heartbeat is older than `staleAfterMs`.
138
+ - A session is also stale when its recorded pane is missing.
139
+ - Stale rows are classified as `STALE`, marked as not actionable, and removed from the displayed rows.
140
+
141
+ This check is performed when rows are read. The database does not need a cleanup process for abandoned rows, but abandoned rows remain stored and are hidden from the interfaces.
142
+
143
+ ## CLI dashboard flow
144
+
145
+ The CLI has four modes:
146
+
147
+ - Interactive dashboard: `pi-muxr`
148
+ - Tmux sidebar toggle: `pi-muxr --sidebar [left|right]`
149
+ - Plain output: `pi-muxr --list`
150
+ - Browser server: `pi-muxr --web`
151
+
152
+ The interactive dashboard follows this flow:
153
+
154
+ ```text
155
+ load configuration
156
+ -> open SQLite database
157
+ -> record dashboard presence
158
+ -> read session rows
159
+ -> list tmux panes
160
+ -> join sessions with panes
161
+ -> mark and remove stale rows
162
+ -> sort active rows
163
+ -> render dashboard
164
+ ```
165
+
166
+ The dashboard refreshes once per second. It keeps the selected row, tracks events already seen during the current run, and emits a terminal bell for new unread actionable events.
167
+
168
+ User actions update SQLite before they affect the display:
169
+
170
+ - `Enter` marks the event as read and focuses its pane.
171
+ - `d` dismisses the selected event.
172
+ - `D` dismisses all read actionable events.
173
+ - `r` refreshes the rows.
174
+ - `q`, `Esc`, or `Ctrl+C` cleans up presence and exits.
175
+
176
+ The rendering layer uses `@earendil-works/pi-tui`. Dashboard orchestration, state updates, tmux commands, and rendering are separate responsibilities.
177
+
178
+ ## Tmux integration
179
+
180
+ The extension uses `tmux list-panes` to discover pane metadata. The dashboard joins this data with the session rows by pane ID.
181
+
182
+ When the user focuses a row, `src/tmux/focus.ts` selects the pane and switches to its tmux session and window. When the CLI runs outside tmux, it reports the commands needed to focus the pane instead of pretending that the focus succeeded.
183
+
184
+ The sidebar command splits every window in every tmux session and starts the same `pi-muxr` entrypoint in each new pane. The default side is right, and each pane uses at most 25 percent of the window width. A tmux pane option marks each pane. A global tmux option stores the selected side. Indexed `after-new-session` and `after-new-window` hooks add the sidebar to new sessions and windows without replacing other hooks. The next sidebar command finds the markers, closes all dashboard sidebar panes, and removes the option and hooks.
185
+
186
+ The browser gateway uses a separate tmux session, named `pi-muxr-web` by default. A query parameter can select another valid session name.
187
+
188
+ ## Browser interface
189
+
190
+ The web interface is a local HTTP server with a WebSocket terminal gateway.
191
+
192
+ ```text
193
+ Browser
194
+ | HTTP
195
+ v
196
+ Web server
197
+ | serves page, assets, and terminal theme
198
+ |
199
+ | WebSocket
200
+ v
201
+ Terminal gateway
202
+ | node-pty
203
+ v
204
+ tmux attach-session
205
+ ```
206
+
207
+ When a browser connects, the gateway:
208
+
209
+ 1. Validates the requested tmux session name.
210
+ 2. Creates the session when it does not exist.
211
+ 3. Temporarily hides the tmux status line.
212
+ 4. Starts `tmux attach-session` through `node-pty`.
213
+ 5. Forwards terminal output to the browser.
214
+ 6. Forwards keyboard input and valid resize messages to the terminal.
215
+ 7. Polls SQLite for unread actionable events and sends browser notification messages.
216
+ 8. Restores tmux status and closes resources when the connection ends.
217
+
218
+ The web interface is a terminal view, not a second dashboard implementation. It attaches to the tmux session that runs the normal CLI dashboard.
219
+
220
+ ## Notifications
221
+
222
+ The dashboard bell and browser notifications use the configured actionable states. Desktop notifications use the fixed default states: `ASK`, `ERROR`, and `DONE`.
223
+
224
+ Desktop notification flow:
225
+
226
+ ```text
227
+ extension state update
228
+ -> check default desktop notification state
229
+ -> check previous notification marker
230
+ -> check dashboard presence
231
+ -> send desktop notification
232
+ -> store last_notified_event_at
233
+ ```
234
+
235
+ The extension avoids repeated desktop notifications for the same state. When configured, a fresh dashboard presence suppresses desktop notifications because the user can already see the event.
236
+
237
+ The interactive CLI uses a separate in process event set for its terminal bell. The browser gateway polls notification state and sends browser messages for new unread actionable rows. Both paths use the configured actionable states.
238
+
239
+ ## Configuration boundary
240
+
241
+ Configuration is loaded from `~/.pi-muxr/config.json`. If the file does not exist, built in defaults are used.
242
+
243
+ Configuration controls:
244
+
245
+ - Database and state paths.
246
+ - Actionable states for the dashboard, dashboard bell, dismissal actions, and browser notifications.
247
+ - Desktop notification behavior for the fixed `ASK`, `ERROR`, and `DONE` states.
248
+ - Dashboard bell behavior.
249
+ - Whether dismissed rows are shown.
250
+ - Stale and heartbeat timing.
251
+ - Dashboard presence timing.
252
+
253
+ The CLI, extension, and web gateway all load the same configuration. They share state and timing rules, while desktop notifications retain their fixed default state list.
254
+
255
+ ## Design decisions
256
+
257
+ ### Structured events instead of screen scraping
258
+
259
+ Pi events provide clear meaning and stable summaries. Screen scraping would depend on terminal formatting and would not reliably identify questions, errors, or completed turns.
260
+
261
+ ### SQLite instead of per process files
262
+
263
+ SQLite supports concurrent reads and writes, preserves read and dismissal markers, and keeps the state model in one place.
264
+
265
+ ### tmux as the focus layer
266
+
267
+ Pi sessions already run in tmux. Storing the pane ID allows the dashboard to move from an event directly to the owning session without controlling Pi itself.
268
+
269
+ ### Shared domain rules
270
+
271
+ Actionable, unread, dismissed, and stale behavior is defined in the domain and used by every interface. This prevents the terminal and browser paths from making different decisions about the same event.
272
+
273
+ ### Best effort cleanup with display time checks
274
+
275
+ Clean shutdown removes rows, but stale detection remains necessary for crashes, closed panes, and killed processes.
276
+
277
+ ## Extension points
278
+
279
+ The design leaves room for future changes without changing the main data flow:
280
+
281
+ - Add new Pi event mappings in the extension.
282
+ - Add new states to the domain model and configuration.
283
+ - Add another client that reads the SQLite state.
284
+ - Add notification providers beside desktop and browser notifications.
285
+ - Add more tmux actions without changing session publishing.
286
+
287
+ A new interface should read the domain state and reuse the existing row rules. It should not create a second session protocol.
@@ -0,0 +1,78 @@
1
+ # Building `pi-muxr`
2
+
3
+ This guide explains how to build and run a local copy of `pi-muxr`.
4
+
5
+ ## Requirements
6
+
7
+ - Node.js 24 or later
8
+ - pnpm
9
+ - tmux for running the dashboard
10
+ - Pi for testing the extension
11
+
12
+ The CLI uses the built in `node:sqlite` module.
13
+
14
+ ## Install and build
15
+
16
+ ```bash
17
+ pnpm install
18
+ pnpm build
19
+ ```
20
+
21
+ The build output is written to `dist`.
22
+
23
+ For an automatic rebuild during development, run:
24
+
25
+ ```bash
26
+ pnpm start
27
+ ```
28
+
29
+ ## Check the code
30
+
31
+ ```bash
32
+ pnpm typecheck
33
+ pnpm test
34
+ ```
35
+
36
+ ## Link the CLI
37
+
38
+ To run the local CLI from any directory:
39
+
40
+ ```bash
41
+ pnpm link --global
42
+ ```
43
+
44
+ The command points to `dist/cli/index.js`. Rebuild after source changes, or keep `pnpm start` running.
45
+
46
+ ## Load the Pi extension
47
+
48
+ Build the package, then link the complete extension directory:
49
+
50
+ ```bash
51
+ pnpm build
52
+ mkdir -p ~/.pi/agent/extensions
53
+ rm -f ~/.pi/agent/extensions/pi-muxr.js ~/.pi/agent/extensions/pi-muxr
54
+ ln -sfn "$PWD/dist/extension" ~/.pi/agent/extensions/pi-muxr
55
+ ```
56
+
57
+ The directory link is required because the built extension imports sibling modules from `dist`.
58
+
59
+ If Pi is already running, use `/reload` after creating or changing the link.
60
+
61
+ The extension stores state in:
62
+
63
+ ```text
64
+ ~/.pi-muxr/pi-muxr.sqlite
65
+ ```
66
+
67
+ It removes its row during a clean shutdown. Wrappers around Pi are supported when they load the extension.
68
+
69
+ ## Run the CLI
70
+
71
+ ```bash
72
+ pi-muxr --help
73
+ pi-muxr --list
74
+ pi-muxr
75
+ pi-muxr --web
76
+ ```
77
+
78
+ See the main [README](../README.md) for user configuration, key bindings, and usage details.