@aiti-ai/pi-web-bridge 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.
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@aiti-ai/pi-web-bridge",
3
+ "version": "0.1.0",
4
+ "description": "Bridge a pi TUI session to the browser with /web — live view + bidirectional IO against pi-web (aiti-agent). A pi package.",
5
+ "keywords": [
6
+ "pi",
7
+ "pi-package",
8
+ "pi-extension",
9
+ "pi-coding-agent",
10
+ "pi-web",
11
+ "tui",
12
+ "bridge",
13
+ "multi-agent"
14
+ ],
15
+ "license": "MIT",
16
+ "type": "module",
17
+ "scripts": {
18
+ "sync-src": "node tools/sync-src.mjs",
19
+ "prepack": "node tools/sync-src.mjs",
20
+ "test": "node --test"
21
+ },
22
+ "files": [
23
+ "src",
24
+ "dist",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "exports": {
29
+ ".": "./src/pi-web-bridge.ts"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "peerDependencies": {
35
+ "@earendil-works/pi-coding-agent": "*"
36
+ },
37
+ "pi": {
38
+ "extensions": [
39
+ "./src/pi-web-bridge.ts",
40
+ "./dist/aiti-devtools-pi.js"
41
+ ]
42
+ },
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/aiti-ai/aiti-ai.git",
46
+ "directory": "pi-plugins/pi-web-bridge"
47
+ },
48
+ "homepage": "https://github.com/aiti-ai/aiti-ai#readme",
49
+ "bugs": {
50
+ "url": "https://github.com/aiti-ai/aiti-ai/issues"
51
+ },
52
+ "contentHash": "693441d9546328b9"
53
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Bridge registry — maps live TUI session files to their pi-web bridge sockets.
3
+ *
4
+ * Written by the pi TUI /web bridge extension (pi-web-bridge.ts) when a bridge
5
+ * starts; cleaned up on session_shutdown. Read by pi-web core when opening a
6
+ * session: a registry hit means "this session is owned by a running TUI with a
7
+ * bridge — attach to the socket instead of opening the file locally"
8
+ * (single-writer rule; prevents two runtimes appending to one .jsonl).
9
+ *
10
+ * Cross-platform: socket is a Unix domain socket path OR a Windows named pipe
11
+ * (\\.\pipe\NAME) — node:net treats both as ConnectOpts paths.
12
+ *
13
+ * File: ~/.pi/agent/bridge-registry.json
14
+ * {
15
+ * "/abs/path/session.jsonl": { socket, pid, startedAt },
16
+ * }
17
+ */
18
+
19
+ import { existsSync } from "node:fs";
20
+ import { readFile, writeFile, rename, rm } from "node:fs/promises";
21
+ import { createConnection } from "node:net";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+
25
+ export interface BridgeEntry {
26
+ /** Unix socket path or Windows named pipe. */
27
+ socket: string;
28
+ /** Owning pi process — used for cheap staleness checks. */
29
+ pid: number;
30
+ startedAt: number;
31
+ }
32
+
33
+ type Registry = Record<string, BridgeEntry>;
34
+
35
+ function registryPath(): string {
36
+ return process.env.PI_BRIDGE_REGISTRY || join(homedir(), ".pi", "agent", "bridge-registry.json");
37
+ }
38
+
39
+ function isPidAlive(pid: number): boolean {
40
+ try {
41
+ process.kill(pid, 0);
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /** Read the whole registry, dropping entries whose owning pid is gone. */
49
+ export async function readBridgeRegistry(): Promise<Registry> {
50
+ const file = registryPath();
51
+ if (!existsSync(file)) return {};
52
+ try {
53
+ const raw = JSON.parse(await readFile(file, "utf8")) as Registry;
54
+ const out: Registry = {};
55
+ for (const [k, v] of Object.entries(raw)) {
56
+ if (v?.pid && isPidAlive(v.pid)) out[k] = v;
57
+ else await removeBridgeEntry(k).catch(() => {}); // self-clean stale
58
+ }
59
+ return out;
60
+ } catch {
61
+ return {};
62
+ }
63
+ }
64
+
65
+ export async function writeBridgeEntry(sessionFile: string, entry: BridgeEntry): Promise<void> {
66
+ const reg = await readBridgeRegistry();
67
+ reg[sessionFile] = entry;
68
+ await atomicWriteJson(registryPath(), reg);
69
+ }
70
+
71
+ /**
72
+ * Atomic replace-write (tmp + rename): a non-atomic direct write lets concurrent
73
+ * readers (dev/prod pi-web instances + several TUI processes all sharing this registry)
74
+ * see a half-written JSON — readBridgeRegistry would then parse-fail to an empty table,
75
+ * and the next read-modify-write would wipe every entry (all attaches break).
76
+ */
77
+ async function atomicWriteJson(file: string, reg: Registry): Promise<void> {
78
+ const tmp = `${file}.${process.pid}.tmp`;
79
+ await writeFile(tmp, JSON.stringify(reg, null, 2), "utf8");
80
+ await rename(tmp, file);
81
+ }
82
+
83
+ export async function removeBridgeEntry(sessionFile: string): Promise<void> {
84
+ const file = registryPath();
85
+ if (!existsSync(file)) return;
86
+ try {
87
+ const reg = JSON.parse(await readFile(file, "utf8")) as Registry;
88
+ delete reg[sessionFile];
89
+ await atomicWriteJson(file, reg);
90
+ } catch { /* best-effort */ }
91
+ }
92
+
93
+ /** Registry claims ownership but the bridge is unreachable — callers must error out, never fall back to a local open (single-writer rule). */
94
+ export class BridgeOccupiedError extends Error {
95
+ readonly sessionFile: string;
96
+ constructor(sessionFile: string, detail: string) {
97
+ super(`Session is owned by another pi process (bridge unreachable: ${detail}). Refusing to open locally.`);
98
+ this.name = "BridgeOccupiedError";
99
+ this.sessionFile = sessionFile;
100
+ }
101
+ }
102
+
103
+ /** Socket/pipeline name for the TUI bridge (cross-platform). */
104
+ export function bridgeSocketName(pid: number): string {
105
+ return process.platform === "win32"
106
+ ? `\\\\.\\pipe\\pi-tui-bridge-${pid}`
107
+ : `/tmp/pi-tui-bridge-${pid}`;
108
+ }
109
+
110
+ /** Actively probe socket connectability (authoritative liveness — pids get reused, processes can hang). */
111
+ function probeSocketAlive(socketPath: string, timeoutMs = 1000): Promise<boolean> {
112
+ return new Promise((resolve) => {
113
+ const s = createConnection({ path: socketPath });
114
+ let settled = false;
115
+ const done = (ok: boolean) => {
116
+ if (settled) return;
117
+ settled = true;
118
+ s.destroy();
119
+ resolve(ok);
120
+ };
121
+ s.once("connect", () => done(true));
122
+ s.once("error", () => done(false));
123
+ setTimeout(() => done(false), timeoutMs);
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Find a live bridge for a session file. Returns the socket name ONLY when
129
+ * the owning process is alive AND the socket accepts connections — anything
130
+ * else is treated as stale (self-cleaned) so the session opens locally.
131
+ *
132
+ * Liveness layers:
133
+ * 1. pid check (cheap, read time) — filters obvious deaths
134
+ * 2. active socket probe (authoritative) — catches pid reuse & hung processes
135
+ */
136
+ export async function findBridgeSocketForSession(sessionFile: string): Promise<string | undefined> {
137
+ const reg = await readBridgeRegistry();
138
+ const entry = reg[sessionFile];
139
+ if (!entry) return undefined;
140
+ // Double-check (readBridgeRegistry only checked the pid): an unconnectable socket = dead bridge
141
+ if (!(await probeSocketAlive(entry.socket))) {
142
+ await removeBridgeEntry(sessionFile).catch(() => {});
143
+ return undefined;
144
+ }
145
+ return entry.socket;
146
+ }
147
+
148
+ /** Live bridge entry (with pid), reused by ownsFile/createSession. */
149
+ export async function findLiveBridgeEntry(
150
+ sessionFile: string,
151
+ ): Promise<{ socket: string; pid: number } | undefined> {
152
+ const socket = await findBridgeSocketForSession(sessionFile);
153
+ if (!socket) return undefined;
154
+ const reg = await readBridgeRegistry();
155
+ return reg[sessionFile] ? { socket, pid: reg[sessionFile].pid } : undefined;
156
+ }
157
+
158
+ /** TUI bridge startup: register (socket name derived from pid, cross-platform). */
159
+ export async function registerBridge(sessionFile: string): Promise<void> {
160
+ await writeBridgeEntry(sessionFile, {
161
+ socket: bridgeSocketName(process.pid),
162
+ pid: process.pid,
163
+ startedAt: Date.now(),
164
+ });
165
+ }
166
+
167
+ /** TUI bridge shutdown: unregister. */
168
+ export async function unregisterBridge(sessionFile: string): Promise<void> {
169
+ await removeBridgeEntry(sessionFile);
170
+ }