pipane 0.1.6 → 0.1.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.
- package/README.md +14 -3
- package/bin/pipane-rendezvous.js +21 -0
- package/bin/pipane.js +21 -1
- package/dist/build-info.json +5 -0
- package/dist/client/assets/__vite-browser-external-DtLbhLGx.js +1 -0
- package/dist/client/assets/app-runtime-Rw-O-AwW.js +1 -0
- package/dist/client/assets/backend-landing-page-B7SbyIXQ.js +1 -0
- package/dist/client/assets/index-C7_1ODks.js +2 -0
- package/dist/client/assets/index-DgI_gkjg.css +1 -0
- package/dist/client/assets/main-DEfSB8wO.js +2822 -0
- package/dist/client/assets/pairing-page-C0YByC2D.js +1 -0
- package/dist/client/assets/remote-backend-manager-DCSdS38m.js +1 -0
- package/dist/client/assets/rendezvous-trust-api-C2Zob5i4.js +4 -0
- package/dist/client/assets/webrtc-frame-transport-DGX5EO_A.js +1 -0
- package/dist/client/assets/ws-agent-adapter-DpkkZGPl.js +6 -0
- package/dist/client/index.html +2 -2
- package/dist/server/rendezvous/rendezvous-hub.js +426 -0
- package/dist/server/rendezvous/server.js +247 -0
- package/dist/server/rendezvous/trust-store.js +432 -0
- package/dist/server/server/auth-guard.js +61 -0
- package/dist/server/server/backend-connection-authorizer.js +29 -0
- package/dist/server/server/backend-identity.js +167 -0
- package/dist/server/server/backend-protocol-handler.js +132 -0
- package/dist/server/server/backend-trust-store.js +157 -0
- package/dist/server/server/backend-webrtc.js +245 -0
- package/dist/server/server/build-info.js +13 -0
- package/dist/server/server/conversation-file-access.js +97 -0
- package/dist/server/server/frame-connection.js +106 -0
- package/dist/server/server/frame-router.js +45 -0
- package/dist/server/server/ice-servers.js +24 -0
- package/dist/server/server/local-backend-api.js +389 -0
- package/dist/server/server/local-settings.js +17 -4
- package/dist/server/server/pi-rpc-protocol.js +407 -0
- package/dist/server/server/process-pool.js +27 -24
- package/dist/server/server/rendezvous-client.js +282 -0
- package/dist/server/server/rest-api.js +133 -176
- package/dist/server/server/server.js +167 -97
- package/dist/server/server/session-index.js +105 -28
- package/dist/server/server/session-jsonl.js +82 -5
- package/dist/server/server/session-path.js +145 -0
- package/dist/server/server/update-api.js +28 -0
- package/dist/server/server/update-check.js +33 -13
- package/dist/server/server/update-manager.js +233 -0
- package/dist/server/server/worktree-name.js +116 -31
- package/dist/server/server/ws-handler.js +267 -186
- package/dist/server/shared/backend-api.js +1 -0
- package/dist/server/shared/backend-protocol.js +164 -0
- package/dist/server/shared/data-channel-framing.js +137 -0
- package/dist/server/shared/node-trust-crypto.js +61 -0
- package/dist/server/shared/rendezvous-protocol.js +243 -0
- package/dist/server/shared/tool-runtime.js +1 -0
- package/dist/server/shared/trust-protocol.js +97 -0
- package/dist/server/shared/updates.js +4 -0
- package/dist/server/shared/ws-protocol.js +473 -1
- package/docs/protocol.md +129 -0
- package/package.json +21 -8
- package/dist/client/assets/index-Dl_wdLZH.css +0 -1
- package/dist/client/assets/index-hNqbnG06.js +0 -2482
|
@@ -20,8 +20,10 @@ import path from "node:path";
|
|
|
20
20
|
import { hostname } from "node:os";
|
|
21
21
|
import { randomBytes } from "node:crypto";
|
|
22
22
|
import { existsSync, readFileSync, watch } from "node:fs";
|
|
23
|
-
import { WebSocketServer
|
|
23
|
+
import { WebSocketServer } from "ws";
|
|
24
24
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import { WS_PROTOCOL_VERSION } from "../shared/ws-protocol.js";
|
|
26
|
+
import { BACKEND_PROTOCOL_VERSION } from "../shared/backend-protocol.js";
|
|
25
27
|
import { resolvePiLaunch } from "./pi-launch.js";
|
|
26
28
|
import { checkCommandAvailable, makePiNotFoundMessage } from "./pi-runtime.js";
|
|
27
29
|
import { registerRestApi } from "./rest-api.js";
|
|
@@ -29,8 +31,22 @@ import { SessionRegistry } from "./session-registry.js";
|
|
|
29
31
|
import { ProcessPool } from "./process-pool.js";
|
|
30
32
|
import { WsHandler } from "./ws-handler.js";
|
|
31
33
|
import { LocalSettingsStore } from "./local-settings.js";
|
|
32
|
-
import { fetchLatestVersion, compareSemver } from "./update-check.js";
|
|
33
34
|
import { resolveUsageExtensionPath } from "./bundled-extensions.js";
|
|
35
|
+
import { UpdateManager } from "./update-manager.js";
|
|
36
|
+
import { isDevelopmentCommit } from "./build-info.js";
|
|
37
|
+
import { registerUpdateApi } from "./update-api.js";
|
|
38
|
+
import { SessionPathGuard } from "./session-path.js";
|
|
39
|
+
import { AuthGuard } from "./auth-guard.js";
|
|
40
|
+
import { loadOrCreateBackendIdentity } from "./backend-identity.js";
|
|
41
|
+
import { BackendRendezvousClient } from "./rendezvous-client.js";
|
|
42
|
+
import { BackendWebRtcManager } from "./backend-webrtc.js";
|
|
43
|
+
import { BackendTrustStore } from "./backend-trust-store.js";
|
|
44
|
+
import { BackendConnectionAuthorizer } from "./backend-connection-authorizer.js";
|
|
45
|
+
import { DataChannelFrameConnection } from "./frame-connection.js";
|
|
46
|
+
import { routeFrameConnection } from "./frame-router.js";
|
|
47
|
+
import { BackendProtocolHandler } from "./backend-protocol-handler.js";
|
|
48
|
+
import { LocalBackendApi } from "./local-backend-api.js";
|
|
49
|
+
import qrcode from "qrcode-terminal";
|
|
34
50
|
const DEFAULT_PORT = process.env.NODE_ENV === "production" ? "8222" : "18111";
|
|
35
51
|
const REQUESTED_PORT = parseInt(process.env.PORT || DEFAULT_PORT, 10);
|
|
36
52
|
const INSTANCE_ID = process.env.PIPANE_INSTANCE_ID || null;
|
|
@@ -58,6 +74,9 @@ const PI_AVAILABLE = checkCommandAvailable(PI_LAUNCH.command);
|
|
|
58
74
|
const PI_MAX_PROCESSES = parseInt(process.env.PI_MAX_PROCESSES || "24", 10);
|
|
59
75
|
const PI_PREWARM_COUNT = parseInt(process.env.PI_PREWARM_COUNT || "2", 10);
|
|
60
76
|
const USAGE_EXTENSION_ENABLED = process.env.PIPANE_USAGE_EXTENSION !== "0";
|
|
77
|
+
const RENDEZVOUS_URL = process.env.PIPANE_RENDEZVOUS_URL;
|
|
78
|
+
let rendezvousPairingRuntime;
|
|
79
|
+
let registeredBackendId;
|
|
61
80
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
62
81
|
// Read own version from package.json
|
|
63
82
|
const PKG_JSON_PATH = path.resolve(__dirname, "../../../package.json");
|
|
@@ -70,44 +89,81 @@ const PKG_VERSION = (() => {
|
|
|
70
89
|
}
|
|
71
90
|
})();
|
|
72
91
|
const PKG_NAME = "pipane";
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
92
|
+
const IS_DEVELOPMENT_COMMIT = process.env.NODE_ENV !== "production"
|
|
93
|
+
|| isDevelopmentCommit(path.resolve(path.dirname(PKG_JSON_PATH), "dist/build-info.json"), PKG_VERSION);
|
|
94
|
+
async function startRendezvousRegistration() {
|
|
95
|
+
if (!RENDEZVOUS_URL)
|
|
96
|
+
return;
|
|
97
|
+
const identity = loadOrCreateBackendIdentity(process.env.PIPANE_BACKEND_IDENTITY_FILE);
|
|
98
|
+
const trustStore = new BackendTrustStore({ filePath: process.env.PIPANE_BACKEND_TRUST_FILE });
|
|
99
|
+
registeredBackendId = identity.backendId;
|
|
100
|
+
const client = new BackendRendezvousClient({
|
|
101
|
+
url: RENDEZVOUS_URL,
|
|
102
|
+
identity,
|
|
103
|
+
metadata: {
|
|
104
|
+
name: process.env.PIPANE_BACKEND_NAME || hostname(),
|
|
105
|
+
softwareVersion: PKG_VERSION,
|
|
106
|
+
protocolVersions: [WS_PROTOCOL_VERSION, BACKEND_PROTOCOL_VERSION],
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
client.onError((error) => {
|
|
110
|
+
console.warn("[rendezvous]", error instanceof Error ? error.message : error.message);
|
|
111
|
+
});
|
|
112
|
+
let peers;
|
|
113
|
+
client.onAuthorizationRevoked(({ accountId, deviceId }) => {
|
|
114
|
+
trustStore.applyRevocation(accountId, deviceId);
|
|
115
|
+
peers?.closeAuthorization(accountId, deviceId);
|
|
116
|
+
});
|
|
117
|
+
const backendId = await client.start();
|
|
118
|
+
const authorizer = new BackendConnectionAuthorizer(trustStore, client);
|
|
119
|
+
peers = new BackendWebRtcManager({
|
|
120
|
+
signaling: client,
|
|
121
|
+
identity,
|
|
122
|
+
ticketPublicKey: () => client.ticketPublicKey,
|
|
123
|
+
authorize: (context) => authorizer.authorize(context),
|
|
124
|
+
iceTransportPolicy: process.env.PIPANE_ICE_TRANSPORT_POLICY === "relay" ? "relay" : "all",
|
|
125
|
+
});
|
|
126
|
+
peers.onDataChannel(({ channel, deviceId }) => {
|
|
127
|
+
const routes = routeFrameConnection(new DataChannelFrameConnection(channel));
|
|
128
|
+
wsHandler.acceptAuthenticatedConnection(routes.application);
|
|
129
|
+
backendProtocolHandler.accept(routes.semantic, deviceId);
|
|
130
|
+
});
|
|
131
|
+
peers.onError((_connectionId, error) => console.warn("[webrtc]", error.message));
|
|
132
|
+
const appUrl = new URL(process.env.PIPANE_APP_URL || RENDEZVOUS_URL);
|
|
133
|
+
if (appUrl.protocol === "ws:")
|
|
134
|
+
appUrl.protocol = "http:";
|
|
135
|
+
if (appUrl.protocol === "wss:")
|
|
136
|
+
appUrl.protocol = "https:";
|
|
137
|
+
appUrl.pathname = `/backend/${backendId}`;
|
|
138
|
+
appUrl.search = "";
|
|
139
|
+
appUrl.hash = "";
|
|
140
|
+
log(` Backend: ${backendId}`);
|
|
141
|
+
log(` Web: ${appUrl.toString()}`);
|
|
142
|
+
const createPairingUrl = async () => {
|
|
143
|
+
const pairing = trustStore.createPairing();
|
|
144
|
+
await client.openPairing(pairing.pairId, pairing.expiresAt);
|
|
145
|
+
const pairingUrl = new URL(appUrl);
|
|
146
|
+
pairingUrl.pathname = `/pair/${encodeURIComponent(pairing.pairId)}`;
|
|
147
|
+
pairingUrl.hash = new URLSearchParams({ backend: backendId, secret: pairing.secret }).toString();
|
|
148
|
+
return pairingUrl.toString();
|
|
149
|
+
};
|
|
150
|
+
rendezvousPairingRuntime = { createPairingUrl };
|
|
151
|
+
for (const pairing of trustStore.listActivePairings()) {
|
|
152
|
+
void client.openPairing(pairing.pairId, pairing.expiresAt).catch((error) => console.warn("[pairing]", error.message));
|
|
153
|
+
}
|
|
154
|
+
if (!trustStore.ownerAccountId || process.argv.includes("--pair")) {
|
|
155
|
+
const pairingUrl = await createPairingUrl();
|
|
156
|
+
log(` Pair: ${pairingUrl}`);
|
|
157
|
+
qrcode.generate(pairingUrl, { small: true }, (code) => log(code));
|
|
87
158
|
}
|
|
88
|
-
return out;
|
|
89
|
-
}
|
|
90
|
-
function isLocalAddress(addr) {
|
|
91
|
-
if (!addr)
|
|
92
|
-
return false;
|
|
93
|
-
return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
|
|
94
|
-
}
|
|
95
|
-
function isLocalRequest(req) {
|
|
96
|
-
if (process.env.PIPANE_DISABLE_LOCAL_BYPASS === "1")
|
|
97
|
-
return false;
|
|
98
|
-
return isLocalAddress(req.socket.remoteAddress);
|
|
99
|
-
}
|
|
100
|
-
function setAuthCookie(res) {
|
|
101
|
-
const secure = process.env.PIPANE_SECURE_COOKIE === "1" ? "; Secure" : "";
|
|
102
|
-
const maxAgeSeconds = 60 * 60 * 24 * 30;
|
|
103
|
-
res.setHeader("Set-Cookie", `${AUTH_COOKIE_NAME}=${encodeURIComponent(AUTH_TOKEN)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}${secure}`);
|
|
104
|
-
}
|
|
105
|
-
function isAuthorizedRequest(req) {
|
|
106
|
-
if (isLocalRequest(req))
|
|
107
|
-
return true;
|
|
108
|
-
const cookies = parseCookies(req.headers.cookie);
|
|
109
|
-
return cookies[AUTH_COOKIE_NAME] === AUTH_TOKEN;
|
|
110
159
|
}
|
|
160
|
+
const AUTH_TOKEN = process.env.PIPANE_AUTH_TOKEN || randomBytes(24).toString("base64url");
|
|
161
|
+
const PUBLIC_HOSTNAME = process.env.PI_PUBLIC_HOSTNAME || hostname();
|
|
162
|
+
const authGuard = new AuthGuard({
|
|
163
|
+
token: AUTH_TOKEN,
|
|
164
|
+
disableLocalBypass: process.env.PIPANE_DISABLE_LOCAL_BYPASS === "1",
|
|
165
|
+
secureCookie: process.env.PIPANE_SECURE_COOKIE === "1",
|
|
166
|
+
});
|
|
111
167
|
// ============================================================================
|
|
112
168
|
// Express + HTTP server
|
|
113
169
|
// ============================================================================
|
|
@@ -138,56 +194,32 @@ const pingInterval = setInterval(() => {
|
|
|
138
194
|
}
|
|
139
195
|
}, WS_PING_INTERVAL);
|
|
140
196
|
wss.on("close", () => { clearInterval(pingInterval); });
|
|
141
|
-
|
|
142
|
-
const token = typeof req.query.token === "string" ? req.query.token : undefined;
|
|
143
|
-
if (isLocalRequest(req) || token === AUTH_TOKEN) {
|
|
144
|
-
setAuthCookie(res);
|
|
145
|
-
res.redirect("/");
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
res.status(401).type("html").send("<h3>Unauthorized</h3><p>Invalid auth token.</p>");
|
|
149
|
-
});
|
|
150
|
-
app.use((req, res, next) => {
|
|
151
|
-
if (isAuthorizedRequest(req)) {
|
|
152
|
-
if (isLocalRequest(req)) {
|
|
153
|
-
setAuthCookie(res);
|
|
154
|
-
}
|
|
155
|
-
next();
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
res.status(401).type("html").send("<h3>Unauthorized</h3><p>Open the one-time auth URL shown in the pipane terminal.</p>");
|
|
159
|
-
});
|
|
197
|
+
authGuard.register(app);
|
|
160
198
|
// A harness can provide a unique ID and verify that it reached the child it
|
|
161
199
|
// launched, rather than an unrelated process that happened to acquire a port.
|
|
162
200
|
app.get("/api/debug/health", (_req, res) => {
|
|
163
201
|
res.json({ ok: true, instanceId: INSTANCE_ID, pid: process.pid });
|
|
164
202
|
});
|
|
203
|
+
app.post("/api/pairing", async (_req, res) => {
|
|
204
|
+
res.setHeader("Cache-Control", "no-store");
|
|
205
|
+
if (!rendezvousPairingRuntime) {
|
|
206
|
+
res.status(503).json({ error: "Backend is not registered with rendezvous" });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
res.json({ url: await rendezvousPairingRuntime.createPairingUrl() });
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
|
|
214
|
+
}
|
|
215
|
+
});
|
|
165
216
|
const localSettingsStore = new LocalSettingsStore();
|
|
166
217
|
const registry = new SessionRegistry();
|
|
218
|
+
const SESSIONS_DIR = path.join(getAgentDir(), "sessions");
|
|
219
|
+
const sessionPaths = new SessionPathGuard(SESSIONS_DIR);
|
|
167
220
|
// Serve static files in production
|
|
168
221
|
const clientDist = path.resolve(__dirname, "../../client");
|
|
169
222
|
app.use(express.static(clientDist));
|
|
170
|
-
// Register REST endpoints
|
|
171
|
-
registerRestApi(app, {
|
|
172
|
-
localSettingsStore,
|
|
173
|
-
runSessionMutation: (sessionPath, operation, mutation) => {
|
|
174
|
-
const actor = registry.get(sessionPath);
|
|
175
|
-
return actor.enqueue(operation, async () => {
|
|
176
|
-
actor.assertAvailable(operation);
|
|
177
|
-
await mutation();
|
|
178
|
-
});
|
|
179
|
-
},
|
|
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
223
|
// ============================================================================
|
|
192
224
|
// Core modules
|
|
193
225
|
// ============================================================================
|
|
@@ -221,6 +253,7 @@ const pool = new ProcessPool({
|
|
|
221
253
|
const wsHandler = new WsHandler({
|
|
222
254
|
registry,
|
|
223
255
|
pool,
|
|
256
|
+
sessionPaths,
|
|
224
257
|
defaultCwd: PI_CWD,
|
|
225
258
|
piLaunch: PI_LAUNCH,
|
|
226
259
|
ensurePool: () => {
|
|
@@ -228,10 +261,39 @@ const wsHandler = new WsHandler({
|
|
|
228
261
|
pool.prewarm(PI_CWD);
|
|
229
262
|
}
|
|
230
263
|
},
|
|
231
|
-
isRequestAuthorized: (req) => isAuthorizedRequest(req),
|
|
264
|
+
isRequestAuthorized: (req) => authGuard.isAuthorizedRequest(req),
|
|
232
265
|
});
|
|
233
266
|
// Register WS handler
|
|
234
267
|
wsHandler.register(wss);
|
|
268
|
+
const updateManager = new UpdateManager({
|
|
269
|
+
pipaneVersion: PKG_VERSION,
|
|
270
|
+
pipanePackageName: PKG_NAME,
|
|
271
|
+
piLaunch: PI_LAUNCH,
|
|
272
|
+
skipPipaneCheck: IS_DEVELOPMENT_COMMIT,
|
|
273
|
+
cwd: PI_CWD,
|
|
274
|
+
onPiRuntimeChanged: async () => {
|
|
275
|
+
pool.decommissionAll();
|
|
276
|
+
if (wsHandler.isPiAvailable)
|
|
277
|
+
await pool.prewarm(PI_CWD);
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
registerUpdateApi(app, updateManager);
|
|
281
|
+
const backendApi = new LocalBackendApi({
|
|
282
|
+
localSettingsStore,
|
|
283
|
+
sessionPaths,
|
|
284
|
+
backendId: () => registeredBackendId,
|
|
285
|
+
updateManager,
|
|
286
|
+
runSessionMutation: (sessionPath, operation, mutation) => {
|
|
287
|
+
const actor = registry.get(sessionPath);
|
|
288
|
+
return actor.enqueue(operation, async () => {
|
|
289
|
+
actor.assertAvailable(operation);
|
|
290
|
+
await mutation();
|
|
291
|
+
});
|
|
292
|
+
},
|
|
293
|
+
onLocalSettingsReloaded: () => wsHandler.notifySessionsChanged("__local_settings__"),
|
|
294
|
+
});
|
|
295
|
+
registerRestApi(app, { api: backendApi });
|
|
296
|
+
const backendProtocolHandler = new BackendProtocolHandler(backendApi);
|
|
235
297
|
// ============================================================================
|
|
236
298
|
// Debug endpoints
|
|
237
299
|
// ============================================================================
|
|
@@ -290,7 +352,6 @@ setInterval(tick, 1000); tick();
|
|
|
290
352
|
// ============================================================================
|
|
291
353
|
// Sessions Directory Watcher
|
|
292
354
|
// ============================================================================
|
|
293
|
-
const SESSIONS_DIR = path.join(getAgentDir(), "sessions");
|
|
294
355
|
function startSessionsWatcher() {
|
|
295
356
|
if (!existsSync(SESSIONS_DIR)) {
|
|
296
357
|
console.log(`Sessions dir does not exist yet: ${SESSIONS_DIR}`);
|
|
@@ -308,15 +369,8 @@ function startSessionsWatcher() {
|
|
|
308
369
|
const fullPath = path.join(SESSIONS_DIR, lastChangedFile);
|
|
309
370
|
// Re-read detached session state and push it to active subscribers.
|
|
310
371
|
wsHandler.notifySessionFileChanged(fullPath);
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
if (client.readyState === WebSocket.OPEN) {
|
|
314
|
-
client.send(JSON.stringify({
|
|
315
|
-
type: "sessions_changed",
|
|
316
|
-
file: fullPath,
|
|
317
|
-
}));
|
|
318
|
-
}
|
|
319
|
-
});
|
|
372
|
+
// Notify every local WebSocket and authenticated DataChannel client.
|
|
373
|
+
wsHandler.notifySessionsChanged(fullPath);
|
|
320
374
|
}, 300);
|
|
321
375
|
});
|
|
322
376
|
console.log(`Watching sessions directory: ${SESSIONS_DIR}`);
|
|
@@ -333,7 +387,10 @@ if (PI_AVAILABLE) {
|
|
|
333
387
|
else {
|
|
334
388
|
console.log(`[pi] ${makePiNotFoundMessage(PI_LAUNCH.command)}`);
|
|
335
389
|
}
|
|
336
|
-
server.listen(REQUESTED_PORT,
|
|
390
|
+
server.listen(REQUESTED_PORT, () => {
|
|
391
|
+
void startRendezvousRegistration().catch((error) => {
|
|
392
|
+
console.warn("[rendezvous] registration stopped:", error);
|
|
393
|
+
});
|
|
337
394
|
const address = server.address();
|
|
338
395
|
const port = address && typeof address !== "string" ? address.port : REQUESTED_PORT;
|
|
339
396
|
const authUrl = `http://${PUBLIC_HOSTNAME}:${port}/auth?token=${encodeURIComponent(AUTH_TOKEN)}`;
|
|
@@ -353,11 +410,24 @@ server.listen(REQUESTED_PORT, async () => {
|
|
|
353
410
|
log(` Set PIPANE_AUTH_TOKEN to use a fixed token.`);
|
|
354
411
|
}
|
|
355
412
|
log("");
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
413
|
+
// The same asynchronous check feeds the web UI and preserves terminal hints.
|
|
414
|
+
void updateManager.check().then(({ notices }) => {
|
|
415
|
+
for (const notice of notices) {
|
|
416
|
+
if (notice.target === "pipane") {
|
|
417
|
+
log(` Update available: v${notice.currentVersion} → v${notice.latestVersion}`);
|
|
418
|
+
log(` Open pipane in the browser to install it, or run \`npm install -g ${PKG_NAME}\`.`);
|
|
419
|
+
}
|
|
420
|
+
else if (notice.target === "pi") {
|
|
421
|
+
log(` Pi update available: v${notice.currentVersion} → v${notice.latestVersion}`);
|
|
422
|
+
log(" Open pipane in the browser to install it, or run `pi update --self`.");
|
|
423
|
+
}
|
|
424
|
+
else if (notice.packages?.length) {
|
|
425
|
+
log(` Pi package updates available: ${notice.packages.join(", ")}`);
|
|
426
|
+
log(" Open pipane in the browser to install them, or run `pi update --extensions`.");
|
|
427
|
+
}
|
|
428
|
+
log("");
|
|
429
|
+
}
|
|
430
|
+
}).catch((error) => {
|
|
431
|
+
console.warn("[updates] Update check failed:", error);
|
|
432
|
+
});
|
|
363
433
|
});
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { getAgentDir, parseSessionEntries } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import {
|
|
4
|
+
import { createWorktreeNameResolver, } from "./worktree-name.js";
|
|
5
5
|
const CACHE_FORMAT_VERSION = 1;
|
|
6
|
-
const DEFAULT_EXTRACTOR_VERSION = "
|
|
6
|
+
const DEFAULT_EXTRACTOR_VERSION = "3";
|
|
7
|
+
const MAX_RECENT_TOOL_PATHS = 16;
|
|
8
|
+
const PATH_ACTIVITY_TOOLS = new Set([
|
|
9
|
+
"edit",
|
|
10
|
+
"hypa_find",
|
|
11
|
+
"hypa_grep",
|
|
12
|
+
"hypa_ls",
|
|
13
|
+
"hypa_read",
|
|
14
|
+
"read",
|
|
15
|
+
"write",
|
|
16
|
+
]);
|
|
7
17
|
export class SessionIndex {
|
|
8
18
|
agentDir;
|
|
9
19
|
extractorVersion;
|
|
@@ -16,7 +26,7 @@ export class SessionIndex {
|
|
|
16
26
|
this.extractorVersion = opts?.extractorVersion ?? DEFAULT_EXTRACTOR_VERSION;
|
|
17
27
|
this.cacheFilePath = path.join(this.agentDir, "cache", "pipane-session-index-v1.json");
|
|
18
28
|
this.cwdDisplayFormatter = opts?.cwdDisplayFormatter;
|
|
19
|
-
this.worktreeNameResolver = opts?.worktreeNameResolver
|
|
29
|
+
this.worktreeNameResolver = opts?.worktreeNameResolver;
|
|
20
30
|
}
|
|
21
31
|
async listSessions() {
|
|
22
32
|
const files = this.listSessionFiles();
|
|
@@ -41,17 +51,18 @@ export class SessionIndex {
|
|
|
41
51
|
sessions.push(cached.meta);
|
|
42
52
|
continue;
|
|
43
53
|
}
|
|
44
|
-
const
|
|
45
|
-
if (!
|
|
54
|
+
const extracted = this.extractSessionMeta(sessionPath, stat.mtimeMs);
|
|
55
|
+
if (!extracted) {
|
|
46
56
|
mutated = true;
|
|
47
57
|
continue;
|
|
48
58
|
}
|
|
49
59
|
nextEntries[sessionPath] = {
|
|
50
60
|
fileMtimeMs: stat.mtimeMs,
|
|
51
61
|
fileSize: stat.size,
|
|
52
|
-
meta,
|
|
62
|
+
meta: extracted.meta,
|
|
63
|
+
recentToolPaths: extracted.recentToolPaths,
|
|
53
64
|
};
|
|
54
|
-
sessions.push(meta);
|
|
65
|
+
sessions.push(extracted.meta);
|
|
55
66
|
mutated = true;
|
|
56
67
|
}
|
|
57
68
|
if (!mutated) {
|
|
@@ -83,17 +94,17 @@ export class SessionIndex {
|
|
|
83
94
|
// on read-only agent dirs or transient filesystem errors.
|
|
84
95
|
}
|
|
85
96
|
}
|
|
86
|
-
|
|
97
|
+
// A default resolver is scoped to one listing: its filesystem lookups are
|
|
98
|
+
// shared by every session, but discarded before the next request so newly
|
|
99
|
+
// created or removed worktrees are reflected immediately.
|
|
100
|
+
const worktreeNameResolver = this.worktreeNameResolver ?? createWorktreeNameResolver();
|
|
87
101
|
return sessions.map((session) => {
|
|
88
|
-
let worktreeName =
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
worktreeName = "root";
|
|
95
|
-
}
|
|
96
|
-
worktreeNames.set(session.cwd, worktreeName);
|
|
102
|
+
let worktreeName = "root";
|
|
103
|
+
try {
|
|
104
|
+
worktreeName = worktreeNameResolver(session.cwd, nextEntries[session.path]?.recentToolPaths ?? []) || "root";
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
worktreeName = "root";
|
|
97
108
|
}
|
|
98
109
|
return { ...session, worktreeName };
|
|
99
110
|
});
|
|
@@ -141,11 +152,14 @@ export class SessionIndex {
|
|
|
141
152
|
const header = entries[0];
|
|
142
153
|
if (header?.type !== "session" || typeof header.id !== "string")
|
|
143
154
|
return null;
|
|
155
|
+
const cwd = typeof header.cwd === "string" ? header.cwd : "";
|
|
144
156
|
let name;
|
|
145
157
|
let messageCount = 0;
|
|
146
158
|
let firstMessage = "";
|
|
147
159
|
let lastActivityTime;
|
|
148
160
|
let lastUserPromptTimeMs = 0;
|
|
161
|
+
const pendingToolPaths = new Map();
|
|
162
|
+
const recentToolPaths = [];
|
|
149
163
|
for (const entry of entries) {
|
|
150
164
|
if (entry?.type === "session_info" && typeof entry.name === "string") {
|
|
151
165
|
const trimmed = entry.name.trim();
|
|
@@ -158,6 +172,19 @@ export class SessionIndex {
|
|
|
158
172
|
const msg = entry.message;
|
|
159
173
|
if (!msg || typeof msg.role !== "string" || !Object.prototype.hasOwnProperty.call(msg, "content"))
|
|
160
174
|
continue;
|
|
175
|
+
if (msg.role === "assistant") {
|
|
176
|
+
this.rememberPendingToolPaths(msg.content, cwd, pendingToolPaths);
|
|
177
|
+
}
|
|
178
|
+
else if (msg.role === "toolResult" && typeof msg.toolCallId === "string") {
|
|
179
|
+
const completedPaths = pendingToolPaths.get(msg.toolCallId);
|
|
180
|
+
pendingToolPaths.delete(msg.toolCallId);
|
|
181
|
+
if (msg.isError !== true && completedPaths) {
|
|
182
|
+
recentToolPaths.push(...completedPaths);
|
|
183
|
+
if (recentToolPaths.length > MAX_RECENT_TOOL_PATHS) {
|
|
184
|
+
recentToolPaths.splice(0, recentToolPaths.length - MAX_RECENT_TOOL_PATHS);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
161
188
|
if (msg.role !== "user" && msg.role !== "assistant")
|
|
162
189
|
continue;
|
|
163
190
|
const messageTs = typeof msg.timestamp === "number" ? msg.timestamp : undefined;
|
|
@@ -186,24 +213,74 @@ export class SessionIndex {
|
|
|
186
213
|
return new Date(createdMs);
|
|
187
214
|
return new Date(statMtimeMs);
|
|
188
215
|
})();
|
|
189
|
-
const cwd = typeof header.cwd === "string" ? header.cwd : "";
|
|
190
216
|
return {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
217
|
+
meta: {
|
|
218
|
+
id: header.id,
|
|
219
|
+
path: sessionPath,
|
|
220
|
+
cwd,
|
|
221
|
+
cwdDisplay: cwd && this.cwdDisplayFormatter ? this.cwdDisplayFormatter(cwd) : cwd,
|
|
222
|
+
name,
|
|
223
|
+
created: created.toISOString(),
|
|
224
|
+
modified: modified.toISOString(),
|
|
225
|
+
lastUserPromptTime: lastUserPromptTimeMs > 0 ? new Date(lastUserPromptTimeMs).toISOString() : undefined,
|
|
226
|
+
messageCount,
|
|
227
|
+
firstMessage: firstMessage || "(no messages)",
|
|
228
|
+
},
|
|
229
|
+
recentToolPaths,
|
|
201
230
|
};
|
|
202
231
|
}
|
|
203
232
|
catch {
|
|
204
233
|
return null;
|
|
205
234
|
}
|
|
206
235
|
}
|
|
236
|
+
rememberPendingToolPaths(content, cwd, pendingToolPaths) {
|
|
237
|
+
if (!Array.isArray(content))
|
|
238
|
+
return;
|
|
239
|
+
for (const chunk of content) {
|
|
240
|
+
if (chunk?.type !== "toolCall" || typeof chunk.id !== "string")
|
|
241
|
+
continue;
|
|
242
|
+
pendingToolPaths.set(chunk.id, this.extractToolPaths(chunk.name, chunk.arguments, cwd));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
extractToolPaths(toolName, rawArguments, cwd) {
|
|
246
|
+
if (typeof toolName !== "string")
|
|
247
|
+
return [];
|
|
248
|
+
const args = this.parseToolArguments(rawArguments);
|
|
249
|
+
if (!args)
|
|
250
|
+
return [];
|
|
251
|
+
if (Array.isArray(args.tool_uses)) {
|
|
252
|
+
return args.tool_uses.flatMap((toolUse) => this.extractToolPaths(toolUse?.recipient_name, toolUse?.parameters, cwd));
|
|
253
|
+
}
|
|
254
|
+
const shortName = toolName.toLowerCase().split(".").pop() ?? "";
|
|
255
|
+
if (!PATH_ACTIVITY_TOOLS.has(shortName))
|
|
256
|
+
return [];
|
|
257
|
+
const candidate = typeof args.path === "string"
|
|
258
|
+
? args.path
|
|
259
|
+
: typeof args.file_path === "string"
|
|
260
|
+
? args.file_path
|
|
261
|
+
: "";
|
|
262
|
+
if (!candidate || candidate.includes("\0"))
|
|
263
|
+
return [];
|
|
264
|
+
if (!path.isAbsolute(candidate) && !cwd)
|
|
265
|
+
return [];
|
|
266
|
+
return [path.resolve(cwd || path.parse(candidate).root, candidate)];
|
|
267
|
+
}
|
|
268
|
+
parseToolArguments(rawArguments) {
|
|
269
|
+
if (rawArguments && typeof rawArguments === "object" && !Array.isArray(rawArguments)) {
|
|
270
|
+
return rawArguments;
|
|
271
|
+
}
|
|
272
|
+
if (typeof rawArguments !== "string")
|
|
273
|
+
return null;
|
|
274
|
+
try {
|
|
275
|
+
const parsed = JSON.parse(rawArguments);
|
|
276
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
277
|
+
? parsed
|
|
278
|
+
: null;
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
207
284
|
extractTextContent(content) {
|
|
208
285
|
if (typeof content === "string")
|
|
209
286
|
return content;
|