pipane 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 (35) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +44 -0
  3. package/bin/pipane.js +22 -0
  4. package/dist/client/assets/_node-stub_node_crypto-C_7epg3G.js +1 -0
  5. package/dist/client/assets/_node-stub_node_fs-B-VOzeXW.js +1 -0
  6. package/dist/client/assets/_node-stub_node_http-CROXaVGJ.js +1 -0
  7. package/dist/client/assets/_node-stub_node_os-TTKJha15.js +1 -0
  8. package/dist/client/assets/_node-stub_node_path-DJCJiKv7.js +1 -0
  9. package/dist/client/assets/_node-stub_stream-DDexIdn_.js +1 -0
  10. package/dist/client/assets/index-CQP8RzjO.js +45 -0
  11. package/dist/client/assets/index-CVr_JVrA.js +131 -0
  12. package/dist/client/assets/index-DQxrqu4K.js +4412 -0
  13. package/dist/client/assets/index-DuJn-Nwv.js +3 -0
  14. package/dist/client/assets/index-Pj8zaBYJ.js +1 -0
  15. package/dist/client/assets/index-yL1A-vgM.css +1 -0
  16. package/dist/client/assets/pdf.worker.min-Cpi8b8z3.mjs +28 -0
  17. package/dist/client/favicon.png +0 -0
  18. package/dist/client/index.html +118 -0
  19. package/dist/server/server/attached-session.js +209 -0
  20. package/dist/server/server/load-trace-store.js +48 -0
  21. package/dist/server/server/local-settings.js +376 -0
  22. package/dist/server/server/pi-launch.js +10 -0
  23. package/dist/server/server/pi-runtime.js +32 -0
  24. package/dist/server/server/process-pool.js +254 -0
  25. package/dist/server/server/rest-api.js +289 -0
  26. package/dist/server/server/server.js +355 -0
  27. package/dist/server/server/session-cwd.js +33 -0
  28. package/dist/server/server/session-index.js +231 -0
  29. package/dist/server/server/session-jsonl.js +260 -0
  30. package/dist/server/server/session-lifecycle.js +155 -0
  31. package/dist/server/server/ws-handler.js +808 -0
  32. package/dist/server/shared/jsonl-sync.js +141 -0
  33. package/extensions/canvas.ts +57 -0
  34. package/package.json +82 -0
  35. package/patches/@mariozechner+pi-web-ui+0.55.3.patch +3279 -0
@@ -0,0 +1,289 @@
1
+ /**
2
+ * REST API endpoints for pipane.
3
+ *
4
+ * Stateless handlers that read session data from JSONL files on disk.
5
+ */
6
+ import { existsSync, readFileSync, readdirSync, watchFile } from "node:fs";
7
+ import { unlink } from "node:fs/promises";
8
+ import path from "node:path";
9
+ import { buildSessionContext, parseSessionEntries } from "@mariozechner/pi-coding-agent";
10
+ import { SessionIndex } from "./session-index.js";
11
+ import { LocalSettingsStore } from "./local-settings.js";
12
+ let localSettingsStore;
13
+ let sessionIndex;
14
+ let localSettingsWatcherStarted = false;
15
+ function startLocalSettingsWatcher(onLocalSettingsReloaded) {
16
+ if (localSettingsWatcherStarted)
17
+ return;
18
+ localSettingsWatcherStarted = true;
19
+ let debounceTimer = null;
20
+ watchFile(localSettingsStore.path, { interval: 500 }, () => {
21
+ if (debounceTimer)
22
+ clearTimeout(debounceTimer);
23
+ debounceTimer = setTimeout(async () => {
24
+ const changed = localSettingsStore.reloadFromDiskIfValid();
25
+ if (!changed)
26
+ return;
27
+ await sessionIndex.invalidateAll();
28
+ onLocalSettingsReloaded?.();
29
+ }, 150);
30
+ });
31
+ }
32
+ async function readJsonBody(req) {
33
+ const chunks = [];
34
+ for await (const chunk of req)
35
+ chunks.push(chunk);
36
+ const raw = Buffer.concat(chunks).toString();
37
+ return JSON.parse(raw || "{}");
38
+ }
39
+ export function registerRestApi(app, options = {}) {
40
+ const traceStore = options.traceStore;
41
+ localSettingsStore = options.localSettingsStore ?? new LocalSettingsStore();
42
+ sessionIndex = new SessionIndex({
43
+ cwdDisplayFormatter: (cwd) => localSettingsStore.formatCwdTitle(cwd),
44
+ });
45
+ startLocalSettingsWatcher(options.onLocalSettingsReloaded);
46
+ app.post("/api/debug/load-trace/event", async (req, res) => {
47
+ try {
48
+ if (!traceStore) {
49
+ res.status(404).json({ error: "Tracing disabled" });
50
+ return;
51
+ }
52
+ const chunks = [];
53
+ for await (const chunk of req)
54
+ chunks.push(chunk);
55
+ const body = JSON.parse(Buffer.concat(chunks).toString() || "{}");
56
+ const traceId = String(body.traceId || req.headers["x-pi-trace-id"] || "");
57
+ if (!traceId) {
58
+ res.status(400).json({ error: "Missing traceId" });
59
+ return;
60
+ }
61
+ traceStore.record(traceId, {
62
+ ts: new Date().toISOString(),
63
+ source: "frontend",
64
+ kind: body.durationMs != null ? "span" : "instant",
65
+ name: String(body.name || "frontend_event"),
66
+ durationMs: typeof body.durationMs === "number" ? body.durationMs : undefined,
67
+ attrs: body.attrs && typeof body.attrs === "object" ? body.attrs : undefined,
68
+ });
69
+ res.json({ ok: true });
70
+ }
71
+ catch (err) {
72
+ res.status(500).json({ error: err.message });
73
+ }
74
+ });
75
+ app.get("/api/debug/load-trace/latest", (_req, res) => {
76
+ if (!traceStore) {
77
+ res.status(404).json({ error: "Tracing disabled" });
78
+ return;
79
+ }
80
+ res.json({ traces: traceStore.getLatest() });
81
+ });
82
+ app.get("/api/debug/load-trace/:traceId", (req, res) => {
83
+ if (!traceStore) {
84
+ res.status(404).json({ error: "Tracing disabled" });
85
+ return;
86
+ }
87
+ const trace = traceStore.get(req.params.traceId);
88
+ if (!trace) {
89
+ res.status(404).json({ error: "Trace not found" });
90
+ return;
91
+ }
92
+ res.json(trace);
93
+ });
94
+ app.get("/api/sessions", async (_req, res) => {
95
+ try {
96
+ res.json(await sessionIndex.listSessions());
97
+ }
98
+ catch (err) {
99
+ res.status(500).json({ error: err.message });
100
+ }
101
+ });
102
+ app.get("/api/settings/local", (_req, res) => {
103
+ try {
104
+ res.json(localSettingsStore.read());
105
+ }
106
+ catch (err) {
107
+ res.status(500).json({ error: err.message });
108
+ }
109
+ });
110
+ app.post("/api/settings/local/validate", async (req, res) => {
111
+ try {
112
+ const body = await readJsonBody(req);
113
+ if (typeof body.content !== "string") {
114
+ res.status(400).json({ error: "Missing 'content' string" });
115
+ return;
116
+ }
117
+ res.json(localSettingsStore.validate(body.content));
118
+ }
119
+ catch (err) {
120
+ res.status(500).json({ error: err.message });
121
+ }
122
+ });
123
+ app.patch("/api/settings/local", async (req, res) => {
124
+ try {
125
+ const body = await readJsonBody(req);
126
+ if (!body || typeof body !== "object") {
127
+ res.status(400).json({ error: "Request body must be a JSON object" });
128
+ return;
129
+ }
130
+ const result = localSettingsStore.patch(body);
131
+ if (!result.valid) {
132
+ res.status(400).json(result);
133
+ return;
134
+ }
135
+ await sessionIndex.invalidateAll();
136
+ options.onLocalSettingsReloaded?.();
137
+ res.json(result);
138
+ }
139
+ catch (err) {
140
+ res.status(500).json({ error: err.message });
141
+ }
142
+ });
143
+ app.put("/api/settings/local", async (req, res) => {
144
+ try {
145
+ const body = await readJsonBody(req);
146
+ if (typeof body.content !== "string") {
147
+ res.status(400).json({ error: "Missing 'content' string" });
148
+ return;
149
+ }
150
+ const result = localSettingsStore.save(body.content);
151
+ if (!result.valid) {
152
+ res.status(400).json(result);
153
+ return;
154
+ }
155
+ await sessionIndex.invalidateAll();
156
+ options.onLocalSettingsReloaded?.();
157
+ res.json(result);
158
+ }
159
+ catch (err) {
160
+ res.status(500).json({ error: err.message });
161
+ }
162
+ });
163
+ app.delete("/api/sessions", async (req, res) => {
164
+ try {
165
+ const chunks = [];
166
+ for await (const chunk of req)
167
+ chunks.push(chunk);
168
+ const body = JSON.parse(Buffer.concat(chunks).toString());
169
+ const { path: sessionPath } = body;
170
+ if (!sessionPath || typeof sessionPath !== "string") {
171
+ res.status(400).json({ error: "Missing session path" });
172
+ return;
173
+ }
174
+ if (!sessionPath.endsWith(".jsonl") || !existsSync(sessionPath)) {
175
+ res.status(404).json({ error: "Session not found" });
176
+ return;
177
+ }
178
+ await unlink(sessionPath);
179
+ res.json({ success: true });
180
+ }
181
+ catch (err) {
182
+ res.status(500).json({ error: err.message });
183
+ }
184
+ });
185
+ app.get("/api/sessions/messages", (req, res) => {
186
+ try {
187
+ const sessionPath = req.query.path;
188
+ if (!sessionPath || !sessionPath.endsWith(".jsonl")) {
189
+ res.status(400).json({ error: "Missing or invalid session path" });
190
+ return;
191
+ }
192
+ if (!existsSync(sessionPath)) {
193
+ res.status(404).json({ error: "Session file not found" });
194
+ return;
195
+ }
196
+ const content = readFileSync(sessionPath, "utf8");
197
+ const entries = parseSessionEntries(content);
198
+ const context = buildSessionContext(entries);
199
+ res.json({
200
+ messages: context.messages,
201
+ model: context.model,
202
+ thinkingLevel: context.thinkingLevel,
203
+ });
204
+ }
205
+ catch (err) {
206
+ res.status(500).json({ error: err.message });
207
+ }
208
+ });
209
+ app.get("/api/sessions/fork-messages", (req, res) => {
210
+ try {
211
+ const sessionPath = req.query.path;
212
+ if (!sessionPath || !sessionPath.endsWith(".jsonl")) {
213
+ res.status(400).json({ error: "Missing or invalid session path" });
214
+ return;
215
+ }
216
+ if (!existsSync(sessionPath)) {
217
+ res.status(404).json({ error: "Session file not found" });
218
+ return;
219
+ }
220
+ const content = readFileSync(sessionPath, "utf8");
221
+ const entries = parseSessionEntries(content);
222
+ const messages = [];
223
+ for (const entry of entries) {
224
+ if (entry.type !== "message")
225
+ continue;
226
+ const msg = entry.message;
227
+ if (!msg || msg.role !== "user")
228
+ continue;
229
+ let text = "";
230
+ if (typeof msg.content === "string") {
231
+ text = msg.content;
232
+ }
233
+ else if (Array.isArray(msg.content)) {
234
+ text = msg.content
235
+ .filter((c) => c.type === "text")
236
+ .map((c) => c.text)
237
+ .join("");
238
+ }
239
+ if (text && entry.id) {
240
+ messages.push({ entryId: entry.id, text });
241
+ }
242
+ }
243
+ res.json({ messages });
244
+ }
245
+ catch (err) {
246
+ res.status(500).json({ error: err.message });
247
+ }
248
+ });
249
+ app.get("/api/sessions/raw", (req, res) => {
250
+ try {
251
+ const sessionPath = req.query.path;
252
+ if (!sessionPath || !sessionPath.endsWith(".jsonl")) {
253
+ res.status(400).json({ error: "Missing or invalid session path" });
254
+ return;
255
+ }
256
+ if (!existsSync(sessionPath)) {
257
+ res.status(404).json({ error: "Session file not found" });
258
+ return;
259
+ }
260
+ const content = readFileSync(sessionPath, "utf8");
261
+ res.type("text/plain").send(content);
262
+ }
263
+ catch (err) {
264
+ res.status(500).json({ error: err.message });
265
+ }
266
+ });
267
+ app.get("/api/browse", (req, res) => {
268
+ try {
269
+ const requestedPath = req.query.path || process.env.HOME || "/";
270
+ const resolved = path.resolve(requestedPath.replace(/^~/, process.env.HOME || "/"));
271
+ if (!existsSync(resolved)) {
272
+ res.status(404).json({ error: "Path not found" });
273
+ return;
274
+ }
275
+ const entries = readdirSync(resolved, { withFileTypes: true });
276
+ const dirs = entries
277
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
278
+ .map((e) => ({
279
+ name: e.name,
280
+ path: path.join(resolved, e.name),
281
+ }))
282
+ .sort((a, b) => a.name.localeCompare(b.name));
283
+ res.json({ path: resolved, dirs });
284
+ }
285
+ catch (err) {
286
+ res.status(500).json({ error: err.message });
287
+ }
288
+ });
289
+ }
@@ -0,0 +1,355 @@
1
+ /**
2
+ * pipane backend server.
3
+ *
4
+ * Architecture: sessions are either "detached" (read from JSONL on disk)
5
+ * or "attached" (a pi RPC process is running a turn for them).
6
+ *
7
+ * A CWD-aware pool of pi RPC processes is maintained. When a user sends
8
+ * a message, a process matching the session's project directory is acquired,
9
+ * switched to that session, and runs one turn. After the turn completes,
10
+ * the process is released back to the pool.
11
+ *
12
+ * Session lifecycle is managed by SessionLifecycle (single source of truth
13
+ * for session→process mappings and status). Process spawning and pooling
14
+ * is handled by ProcessPool. The WsHandler routes WebSocket commands to
15
+ * these modules.
16
+ */
17
+ import express from "express";
18
+ import { createServer } from "node:http";
19
+ import { fileURLToPath } from "node:url";
20
+ import path from "node:path";
21
+ import { hostname } from "node:os";
22
+ import { randomBytes } from "node:crypto";
23
+ import { existsSync, watch } from "node:fs";
24
+ import { WebSocketServer, WebSocket } from "ws";
25
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
26
+ import { resolvePiLaunch } from "./pi-launch.js";
27
+ import { checkCommandAvailable, makePiNotFoundMessage } from "./pi-runtime.js";
28
+ import { registerRestApi } from "./rest-api.js";
29
+ import { SessionLifecycle } from "./session-lifecycle.js";
30
+ import { ProcessPool } from "./process-pool.js";
31
+ import { WsHandler } from "./ws-handler.js";
32
+ import { LoadTraceStore } from "./load-trace-store.js";
33
+ import { LocalSettingsStore } from "./local-settings.js";
34
+ const DEFAULT_PORT = process.env.NODE_ENV === "production" ? "8222" : "18111";
35
+ const PORT = parseInt(process.env.PORT || DEFAULT_PORT, 10);
36
+ const PI_CWD = process.env.PI_CWD || process.cwd();
37
+ // Quiet mode: only show URLs unless --verbose or PIPANE_VERBOSE=1
38
+ const VERBOSE = process.argv.includes("--verbose") || process.env.PIPANE_VERBOSE === "1";
39
+ if (!VERBOSE) {
40
+ const origLog = console.log;
41
+ const origError = console.error;
42
+ const origWarn = console.warn;
43
+ // Suppress all console output; we'll use _log for the few lines we want
44
+ console.log = () => { };
45
+ console.error = () => { };
46
+ console.warn = () => { };
47
+ globalThis._pipaneLog = origLog;
48
+ }
49
+ else {
50
+ globalThis._pipaneLog = console.log;
51
+ }
52
+ /** Always prints, even in quiet mode */
53
+ function log(...args) {
54
+ globalThis._pipaneLog(...args);
55
+ }
56
+ const PI_CLI = process.env.PI_CLI;
57
+ const PI_LAUNCH = resolvePiLaunch(PI_CLI);
58
+ const PI_AVAILABLE = checkCommandAvailable(PI_LAUNCH.command);
59
+ const PI_MAX_PROCESSES = parseInt(process.env.PI_MAX_PROCESSES || "24", 10);
60
+ const PI_PREWARM_COUNT = parseInt(process.env.PI_PREWARM_COUNT || "2", 10);
61
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
62
+ const AUTH_COOKIE_NAME = "pipane_auth";
63
+ const AUTH_TOKEN = process.env.PIPANE_AUTH_TOKEN || randomBytes(24).toString("base64url");
64
+ const PUBLIC_HOSTNAME = process.env.PI_PUBLIC_HOSTNAME || hostname();
65
+ const AUTH_URL = `http://${PUBLIC_HOSTNAME}:${PORT}/auth?token=${encodeURIComponent(AUTH_TOKEN)}`;
66
+ function parseCookies(header) {
67
+ const out = {};
68
+ if (!header)
69
+ return out;
70
+ for (const part of header.split(";")) {
71
+ const idx = part.indexOf("=");
72
+ if (idx <= 0)
73
+ continue;
74
+ const k = part.slice(0, idx).trim();
75
+ const v = part.slice(idx + 1).trim();
76
+ out[k] = decodeURIComponent(v);
77
+ }
78
+ return out;
79
+ }
80
+ function isLocalAddress(addr) {
81
+ if (!addr)
82
+ return false;
83
+ return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
84
+ }
85
+ function isLocalRequest(req) {
86
+ if (process.env.PIPANE_DISABLE_LOCAL_BYPASS === "1")
87
+ return false;
88
+ return isLocalAddress(req.socket.remoteAddress);
89
+ }
90
+ function setAuthCookie(res) {
91
+ const secure = process.env.PIPANE_SECURE_COOKIE === "1" ? "; Secure" : "";
92
+ const maxAgeSeconds = 60 * 60 * 24 * 30;
93
+ res.setHeader("Set-Cookie", `${AUTH_COOKIE_NAME}=${encodeURIComponent(AUTH_TOKEN)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}${secure}`);
94
+ }
95
+ function isAuthorizedRequest(req) {
96
+ if (isLocalRequest(req))
97
+ return true;
98
+ const cookies = parseCookies(req.headers.cookie);
99
+ return cookies[AUTH_COOKIE_NAME] === AUTH_TOKEN;
100
+ }
101
+ // ============================================================================
102
+ // Express + HTTP server
103
+ // ============================================================================
104
+ const app = express();
105
+ const server = createServer(app);
106
+ const wss = new WebSocketServer({ server, path: "/ws" });
107
+ // ── WebSocket keep-alive via ping/pong ────────────────────────────────
108
+ // Ping all connected clients every 30s. If a client doesn't respond with
109
+ // a pong within the interval, the connection is considered dead and terminated.
110
+ // This prevents silent disconnects (e.g. from network changes, sleep, etc.)
111
+ // from leaving zombie connections on the server side, and ensures the client's
112
+ // onclose handler fires so auto-reconnect kicks in.
113
+ const WS_PING_INTERVAL = 30_000;
114
+ const wsAliveMap = new WeakMap();
115
+ wss.on("connection", (ws) => {
116
+ wsAliveMap.set(ws, true);
117
+ ws.on("pong", () => { wsAliveMap.set(ws, true); });
118
+ });
119
+ const pingInterval = setInterval(() => {
120
+ for (const ws of wss.clients) {
121
+ if (wsAliveMap.get(ws) === false) {
122
+ // Didn't respond to last ping — terminate
123
+ ws.terminate();
124
+ continue;
125
+ }
126
+ wsAliveMap.set(ws, false);
127
+ ws.ping();
128
+ }
129
+ }, WS_PING_INTERVAL);
130
+ wss.on("close", () => { clearInterval(pingInterval); });
131
+ app.get("/auth", (req, res) => {
132
+ const token = typeof req.query.token === "string" ? req.query.token : undefined;
133
+ if (isLocalRequest(req) || token === AUTH_TOKEN) {
134
+ setAuthCookie(res);
135
+ res.redirect("/");
136
+ return;
137
+ }
138
+ res.status(401).type("html").send("<h3>Unauthorized</h3><p>Invalid auth token.</p>");
139
+ });
140
+ app.use((req, res, next) => {
141
+ if (isAuthorizedRequest(req)) {
142
+ if (isLocalRequest(req)) {
143
+ setAuthCookie(res);
144
+ }
145
+ next();
146
+ return;
147
+ }
148
+ res.status(401).type("html").send("<h3>Unauthorized</h3><p>Open the one-time auth URL shown in the pipane terminal.</p>");
149
+ });
150
+ const traceStore = new LoadTraceStore();
151
+ const localSettingsStore = new LocalSettingsStore();
152
+ app.use((req, res, next) => {
153
+ const traceId = typeof req.headers["x-pi-trace-id"] === "string" ? req.headers["x-pi-trace-id"] : "";
154
+ if (!traceId) {
155
+ next();
156
+ return;
157
+ }
158
+ const start = performance.now();
159
+ res.on("finish", () => {
160
+ traceStore.record(traceId, {
161
+ ts: new Date().toISOString(),
162
+ source: "backend",
163
+ kind: "span",
164
+ name: `http ${req.method} ${req.path}`,
165
+ durationMs: Number((performance.now() - start).toFixed(2)),
166
+ attrs: {
167
+ statusCode: res.statusCode,
168
+ },
169
+ });
170
+ });
171
+ next();
172
+ });
173
+ // Serve static files in production
174
+ const clientDist = path.resolve(__dirname, "../../client");
175
+ app.use(express.static(clientDist));
176
+ // Register REST endpoints
177
+ registerRestApi(app, {
178
+ traceStore,
179
+ localSettingsStore,
180
+ onLocalSettingsReloaded: () => {
181
+ wss.clients.forEach((client) => {
182
+ if (client.readyState === WebSocket.OPEN) {
183
+ client.send(JSON.stringify({
184
+ type: "sessions_changed",
185
+ file: "__local_settings__",
186
+ }));
187
+ }
188
+ });
189
+ },
190
+ });
191
+ // ============================================================================
192
+ // Core modules
193
+ // ============================================================================
194
+ const lifecycle = new SessionLifecycle();
195
+ // Resolve canvas extension path relative to project root
196
+ const canvasExtension = path.resolve(__dirname, "../../../extensions/canvas.ts");
197
+ const pool = new ProcessPool({
198
+ command: PI_LAUNCH.command,
199
+ baseArgs: () => {
200
+ const args = [...PI_LAUNCH.baseArgs, "--mode", "rpc"];
201
+ if (localSettingsStore.canvasEnabled) {
202
+ args.push("-e", canvasExtension);
203
+ }
204
+ return args;
205
+ },
206
+ }, {
207
+ maxProcesses: PI_MAX_PROCESSES,
208
+ prewarmCount: PI_PREWARM_COUNT,
209
+ onProcessExit: (proc) => {
210
+ // If the process was attached to a session, handle the crash
211
+ const sessionPath = lifecycle.getAttachedSessionForProcess(proc);
212
+ if (sessionPath) {
213
+ console.log(`[pool] pi#${proc.id} crashed while attached to ${path.basename(sessionPath)} — marking done`);
214
+ lifecycle.crash(sessionPath);
215
+ }
216
+ // Replenish the pool for the default cwd
217
+ if (PI_AVAILABLE) {
218
+ pool.prewarm(PI_CWD);
219
+ }
220
+ },
221
+ });
222
+ const wsHandler = new WsHandler({
223
+ lifecycle,
224
+ pool,
225
+ defaultCwd: PI_CWD,
226
+ piLaunch: PI_LAUNCH,
227
+ ensurePool: () => {
228
+ if (wsHandler.isPiAvailable) {
229
+ pool.prewarm(PI_CWD);
230
+ }
231
+ },
232
+ isRequestAuthorized: (req) => isAuthorizedRequest(req),
233
+ traceStore,
234
+ });
235
+ // Register WS handler
236
+ wsHandler.register(wss);
237
+ // ============================================================================
238
+ // Debug endpoints
239
+ // ============================================================================
240
+ app.get("/api/debug/pool", (_req, res) => {
241
+ try {
242
+ res.json(wsHandler.getDebugState());
243
+ }
244
+ catch (err) {
245
+ res.status(500).json({ error: err.message });
246
+ }
247
+ });
248
+ app.get("/debug/pool", (_req, res) => {
249
+ res.type("html").send(`<!doctype html>
250
+ <html>
251
+ <head>
252
+ <meta charset="utf-8" />
253
+ <title>pipane pool debug</title>
254
+ <style>
255
+ body { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; margin: 16px; }
256
+ table { border-collapse: collapse; width: 100%; margin-top: 12px; }
257
+ th, td { border: 1px solid #ddd; padding: 6px 8px; font-size: 12px; text-align: left; }
258
+ th { background: #f6f6f6; }
259
+ .ok { color: #0a7d22; }
260
+ .bad { color: #b42318; }
261
+ </style>
262
+ </head>
263
+ <body>
264
+ <h3>pipane pool debug</h3>
265
+ <div id="meta">loading…</div>
266
+ <table>
267
+ <thead>
268
+ <tr>
269
+ <th>proc</th><th>pid</th><th>alive</th><th>cwd</th><th>busy</th><th>attachedSession</th><th>pendingRequests</th><th>exitCode</th>
270
+ </tr>
271
+ </thead>
272
+ <tbody id="rows"></tbody>
273
+ </table>
274
+ <pre id="raw"></pre>
275
+ <script>
276
+ async function tick(){
277
+ const r = await fetch('/api/debug/pool');
278
+ const d = await r.json();
279
+ document.getElementById('meta').textContent =
280
+ 'now=' + d.now + ' total=' + d.totalProcesses + ' attached=' + d.attachedSessionCount + ' wsOpen=' + d.connectedWsOpen;
281
+ const rows = (d.processes || []).map(p =>
282
+ '<tr><td>' + p.id + '</td><td>' + (p.pid ?? '') + '</td><td class="' + (p.alive ? 'ok':'bad') + '">' + p.alive + '</td><td>' + (p.cwd || '') + '</td><td>' + p.busy + '</td><td>' + (p.attachedSession ?? '') + '</td><td>' + p.pendingRequests + '</td><td>' + (p.exitCode ?? '') + '</td></tr>'
283
+ ).join('');
284
+ document.getElementById('rows').innerHTML = rows;
285
+ document.getElementById('raw').textContent = JSON.stringify({ sessionStatuses: d.sessionStatuses }, null, 2);
286
+ }
287
+ setInterval(tick, 1000); tick();
288
+ </script>
289
+ </body>
290
+ </html>`);
291
+ });
292
+ // ============================================================================
293
+ // Sessions Directory Watcher
294
+ // ============================================================================
295
+ const SESSIONS_DIR = path.join(getAgentDir(), "sessions");
296
+ function startSessionsWatcher() {
297
+ if (!existsSync(SESSIONS_DIR)) {
298
+ console.log(`Sessions dir does not exist yet: ${SESSIONS_DIR}`);
299
+ return null;
300
+ }
301
+ let debounceTimer = null;
302
+ let lastChangedFile = null;
303
+ const watcher = watch(SESSIONS_DIR, { recursive: true }, (_event, filename) => {
304
+ if (!filename || !filename.endsWith(".jsonl"))
305
+ return;
306
+ lastChangedFile = filename;
307
+ if (debounceTimer)
308
+ clearTimeout(debounceTimer);
309
+ debounceTimer = setTimeout(() => {
310
+ const fullPath = path.join(SESSIONS_DIR, lastChangedFile);
311
+ // Notify the message cache — it will re-read from disk if changed
312
+ // and push session_messages to the subscribed client if needed.
313
+ wsHandler.notifySessionFileChanged(fullPath);
314
+ // Also notify all WS clients about the file change (for sidebar refresh)
315
+ wss.clients.forEach((client) => {
316
+ if (client.readyState === WebSocket.OPEN) {
317
+ client.send(JSON.stringify({
318
+ type: "sessions_changed",
319
+ file: fullPath,
320
+ }));
321
+ }
322
+ });
323
+ }, 300);
324
+ });
325
+ console.log(`Watching sessions directory: ${SESSIONS_DIR}`);
326
+ return watcher;
327
+ }
328
+ startSessionsWatcher();
329
+ // ============================================================================
330
+ // Startup
331
+ // ============================================================================
332
+ if (PI_AVAILABLE) {
333
+ console.log(`[pool] Pre-warming process pool for ${PI_CWD}...`);
334
+ pool.prewarm(PI_CWD);
335
+ }
336
+ else {
337
+ console.log(`[pi] ${makePiNotFoundMessage(PI_LAUNCH.command)}`);
338
+ }
339
+ server.listen(PORT, () => {
340
+ log("");
341
+ log(" _ ");
342
+ log(" _ __ (_)_ __ __ _ _ __ ___ ");
343
+ log(" | '_ \\| | '_ \\ / _` | '_ \\ / _ \\");
344
+ log(" | |_) | | |_) | (_| | | | | __/");
345
+ log(" | .__/|_| .__/ \\__,_|_| |_|\\___|");
346
+ log(" |_| |_| ");
347
+ log("");
348
+ log(` Local: http://localhost:${PORT}`);
349
+ log(` Remote: ${AUTH_URL}`);
350
+ if (!process.env.PIPANE_AUTH_TOKEN) {
351
+ log(`\n Auth token is random and changes on restart.`);
352
+ log(` Set PIPANE_AUTH_TOKEN to use a fixed token.`);
353
+ }
354
+ log("");
355
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Resolve the cwd for a session by reading the JSONL header.
3
+ * Caches results since a session's cwd never changes.
4
+ */
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ const cwdCache = new Map();
7
+ /**
8
+ * Read the cwd from a session JSONL file's header.
9
+ * Returns the cwd or undefined if the file doesn't exist or has no valid header.
10
+ */
11
+ export function getSessionCwd(sessionPath) {
12
+ const cached = cwdCache.get(sessionPath);
13
+ if (cached !== undefined)
14
+ return cached;
15
+ try {
16
+ if (!existsSync(sessionPath))
17
+ return undefined;
18
+ const content = readFileSync(sessionPath, "utf8");
19
+ const firstNewline = content.indexOf("\n");
20
+ const firstLine = firstNewline === -1 ? content : content.slice(0, firstNewline);
21
+ if (!firstLine.trim())
22
+ return undefined;
23
+ const header = JSON.parse(firstLine);
24
+ if (header.type === "session" && typeof header.cwd === "string") {
25
+ cwdCache.set(sessionPath, header.cwd);
26
+ return header.cwd;
27
+ }
28
+ }
29
+ catch {
30
+ // Ignore parse errors
31
+ }
32
+ return undefined;
33
+ }