pipane 0.1.6 → 0.1.7
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/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-DwbBcYUf.js +2 -0
- package/dist/client/assets/index-iblQYBAl.css +1 -0
- package/dist/client/assets/main-LGItV9Aj.js +2807 -0
- package/dist/client/assets/pairing-page-D-WxlWZR.js +1 -0
- package/dist/client/assets/remote-backend-manager-BWuGh30I.js +1 -0
- package/dist/client/assets/rendezvous-trust-api-C2Zob5i4.js +4 -0
- package/dist/client/assets/webrtc-frame-transport-BijNnA7N.js +1 -0
- package/dist/client/assets/ws-agent-adapter-BRysf-Y7.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 +239 -0
- package/dist/server/server/conversation-file-access.js +97 -0
- package/dist/server/server/frame-connection.js +48 -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 +163 -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 +231 -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/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 +127 -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,21 @@ 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 { registerUpdateApi } from "./update-api.js";
|
|
37
|
+
import { SessionPathGuard } from "./session-path.js";
|
|
38
|
+
import { AuthGuard } from "./auth-guard.js";
|
|
39
|
+
import { loadOrCreateBackendIdentity } from "./backend-identity.js";
|
|
40
|
+
import { BackendRendezvousClient } from "./rendezvous-client.js";
|
|
41
|
+
import { BackendWebRtcManager } from "./backend-webrtc.js";
|
|
42
|
+
import { BackendTrustStore } from "./backend-trust-store.js";
|
|
43
|
+
import { BackendConnectionAuthorizer } from "./backend-connection-authorizer.js";
|
|
44
|
+
import { DataChannelFrameConnection } from "./frame-connection.js";
|
|
45
|
+
import { routeFrameConnection } from "./frame-router.js";
|
|
46
|
+
import { BackendProtocolHandler } from "./backend-protocol-handler.js";
|
|
47
|
+
import { LocalBackendApi } from "./local-backend-api.js";
|
|
48
|
+
import qrcode from "qrcode-terminal";
|
|
34
49
|
const DEFAULT_PORT = process.env.NODE_ENV === "production" ? "8222" : "18111";
|
|
35
50
|
const REQUESTED_PORT = parseInt(process.env.PORT || DEFAULT_PORT, 10);
|
|
36
51
|
const INSTANCE_ID = process.env.PIPANE_INSTANCE_ID || null;
|
|
@@ -58,6 +73,9 @@ const PI_AVAILABLE = checkCommandAvailable(PI_LAUNCH.command);
|
|
|
58
73
|
const PI_MAX_PROCESSES = parseInt(process.env.PI_MAX_PROCESSES || "24", 10);
|
|
59
74
|
const PI_PREWARM_COUNT = parseInt(process.env.PI_PREWARM_COUNT || "2", 10);
|
|
60
75
|
const USAGE_EXTENSION_ENABLED = process.env.PIPANE_USAGE_EXTENSION !== "0";
|
|
76
|
+
const RENDEZVOUS_URL = process.env.PIPANE_RENDEZVOUS_URL;
|
|
77
|
+
let rendezvousPairingRuntime;
|
|
78
|
+
let registeredBackendId;
|
|
61
79
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
62
80
|
// Read own version from package.json
|
|
63
81
|
const PKG_JSON_PATH = path.resolve(__dirname, "../../../package.json");
|
|
@@ -70,44 +88,79 @@ const PKG_VERSION = (() => {
|
|
|
70
88
|
}
|
|
71
89
|
})();
|
|
72
90
|
const PKG_NAME = "pipane";
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
91
|
+
async function startRendezvousRegistration() {
|
|
92
|
+
if (!RENDEZVOUS_URL)
|
|
93
|
+
return;
|
|
94
|
+
const identity = loadOrCreateBackendIdentity(process.env.PIPANE_BACKEND_IDENTITY_FILE);
|
|
95
|
+
const trustStore = new BackendTrustStore({ filePath: process.env.PIPANE_BACKEND_TRUST_FILE });
|
|
96
|
+
registeredBackendId = identity.backendId;
|
|
97
|
+
const client = new BackendRendezvousClient({
|
|
98
|
+
url: RENDEZVOUS_URL,
|
|
99
|
+
identity,
|
|
100
|
+
metadata: {
|
|
101
|
+
name: process.env.PIPANE_BACKEND_NAME || hostname(),
|
|
102
|
+
softwareVersion: PKG_VERSION,
|
|
103
|
+
protocolVersions: [WS_PROTOCOL_VERSION, BACKEND_PROTOCOL_VERSION],
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
client.onError((error) => {
|
|
107
|
+
console.warn("[rendezvous]", error instanceof Error ? error.message : error.message);
|
|
108
|
+
});
|
|
109
|
+
let peers;
|
|
110
|
+
client.onAuthorizationRevoked(({ accountId, deviceId }) => {
|
|
111
|
+
trustStore.applyRevocation(accountId, deviceId);
|
|
112
|
+
peers?.closeAuthorization(accountId, deviceId);
|
|
113
|
+
});
|
|
114
|
+
const backendId = await client.start();
|
|
115
|
+
const authorizer = new BackendConnectionAuthorizer(trustStore, client);
|
|
116
|
+
peers = new BackendWebRtcManager({
|
|
117
|
+
signaling: client,
|
|
118
|
+
identity,
|
|
119
|
+
ticketPublicKey: () => client.ticketPublicKey,
|
|
120
|
+
authorize: (context) => authorizer.authorize(context),
|
|
121
|
+
iceTransportPolicy: process.env.PIPANE_ICE_TRANSPORT_POLICY === "relay" ? "relay" : "all",
|
|
122
|
+
});
|
|
123
|
+
peers.onDataChannel(({ channel, deviceId }) => {
|
|
124
|
+
const routes = routeFrameConnection(new DataChannelFrameConnection(channel));
|
|
125
|
+
wsHandler.acceptAuthenticatedConnection(routes.application);
|
|
126
|
+
backendProtocolHandler.accept(routes.semantic, deviceId);
|
|
127
|
+
});
|
|
128
|
+
peers.onError((_connectionId, error) => console.warn("[webrtc]", error.message));
|
|
129
|
+
const appUrl = new URL(process.env.PIPANE_APP_URL || RENDEZVOUS_URL);
|
|
130
|
+
if (appUrl.protocol === "ws:")
|
|
131
|
+
appUrl.protocol = "http:";
|
|
132
|
+
if (appUrl.protocol === "wss:")
|
|
133
|
+
appUrl.protocol = "https:";
|
|
134
|
+
appUrl.pathname = `/backend/${backendId}`;
|
|
135
|
+
appUrl.search = "";
|
|
136
|
+
appUrl.hash = "";
|
|
137
|
+
log(` Backend: ${backendId}`);
|
|
138
|
+
log(` Web: ${appUrl.toString()}`);
|
|
139
|
+
const createPairingUrl = async () => {
|
|
140
|
+
const pairing = trustStore.createPairing();
|
|
141
|
+
await client.openPairing(pairing.pairId, pairing.expiresAt);
|
|
142
|
+
const pairingUrl = new URL(appUrl);
|
|
143
|
+
pairingUrl.pathname = `/pair/${encodeURIComponent(pairing.pairId)}`;
|
|
144
|
+
pairingUrl.hash = new URLSearchParams({ backend: backendId, secret: pairing.secret }).toString();
|
|
145
|
+
return pairingUrl.toString();
|
|
146
|
+
};
|
|
147
|
+
rendezvousPairingRuntime = { createPairingUrl };
|
|
148
|
+
for (const pairing of trustStore.listActivePairings()) {
|
|
149
|
+
void client.openPairing(pairing.pairId, pairing.expiresAt).catch((error) => console.warn("[pairing]", error.message));
|
|
150
|
+
}
|
|
151
|
+
if (!trustStore.ownerAccountId || process.argv.includes("--pair")) {
|
|
152
|
+
const pairingUrl = await createPairingUrl();
|
|
153
|
+
log(` Pair: ${pairingUrl}`);
|
|
154
|
+
qrcode.generate(pairingUrl, { small: true }, (code) => log(code));
|
|
87
155
|
}
|
|
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
156
|
}
|
|
157
|
+
const AUTH_TOKEN = process.env.PIPANE_AUTH_TOKEN || randomBytes(24).toString("base64url");
|
|
158
|
+
const PUBLIC_HOSTNAME = process.env.PI_PUBLIC_HOSTNAME || hostname();
|
|
159
|
+
const authGuard = new AuthGuard({
|
|
160
|
+
token: AUTH_TOKEN,
|
|
161
|
+
disableLocalBypass: process.env.PIPANE_DISABLE_LOCAL_BYPASS === "1",
|
|
162
|
+
secureCookie: process.env.PIPANE_SECURE_COOKIE === "1",
|
|
163
|
+
});
|
|
111
164
|
// ============================================================================
|
|
112
165
|
// Express + HTTP server
|
|
113
166
|
// ============================================================================
|
|
@@ -138,56 +191,32 @@ const pingInterval = setInterval(() => {
|
|
|
138
191
|
}
|
|
139
192
|
}, WS_PING_INTERVAL);
|
|
140
193
|
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
|
-
});
|
|
194
|
+
authGuard.register(app);
|
|
160
195
|
// A harness can provide a unique ID and verify that it reached the child it
|
|
161
196
|
// launched, rather than an unrelated process that happened to acquire a port.
|
|
162
197
|
app.get("/api/debug/health", (_req, res) => {
|
|
163
198
|
res.json({ ok: true, instanceId: INSTANCE_ID, pid: process.pid });
|
|
164
199
|
});
|
|
200
|
+
app.post("/api/pairing", async (_req, res) => {
|
|
201
|
+
res.setHeader("Cache-Control", "no-store");
|
|
202
|
+
if (!rendezvousPairingRuntime) {
|
|
203
|
+
res.status(503).json({ error: "Backend is not registered with rendezvous" });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
res.json({ url: await rendezvousPairingRuntime.createPairingUrl() });
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
|
|
211
|
+
}
|
|
212
|
+
});
|
|
165
213
|
const localSettingsStore = new LocalSettingsStore();
|
|
166
214
|
const registry = new SessionRegistry();
|
|
215
|
+
const SESSIONS_DIR = path.join(getAgentDir(), "sessions");
|
|
216
|
+
const sessionPaths = new SessionPathGuard(SESSIONS_DIR);
|
|
167
217
|
// Serve static files in production
|
|
168
218
|
const clientDist = path.resolve(__dirname, "../../client");
|
|
169
219
|
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
220
|
// ============================================================================
|
|
192
221
|
// Core modules
|
|
193
222
|
// ============================================================================
|
|
@@ -221,6 +250,7 @@ const pool = new ProcessPool({
|
|
|
221
250
|
const wsHandler = new WsHandler({
|
|
222
251
|
registry,
|
|
223
252
|
pool,
|
|
253
|
+
sessionPaths,
|
|
224
254
|
defaultCwd: PI_CWD,
|
|
225
255
|
piLaunch: PI_LAUNCH,
|
|
226
256
|
ensurePool: () => {
|
|
@@ -228,10 +258,38 @@ const wsHandler = new WsHandler({
|
|
|
228
258
|
pool.prewarm(PI_CWD);
|
|
229
259
|
}
|
|
230
260
|
},
|
|
231
|
-
isRequestAuthorized: (req) => isAuthorizedRequest(req),
|
|
261
|
+
isRequestAuthorized: (req) => authGuard.isAuthorizedRequest(req),
|
|
232
262
|
});
|
|
233
263
|
// Register WS handler
|
|
234
264
|
wsHandler.register(wss);
|
|
265
|
+
const updateManager = new UpdateManager({
|
|
266
|
+
pipaneVersion: PKG_VERSION,
|
|
267
|
+
pipanePackageName: PKG_NAME,
|
|
268
|
+
piLaunch: PI_LAUNCH,
|
|
269
|
+
cwd: PI_CWD,
|
|
270
|
+
onPiRuntimeChanged: async () => {
|
|
271
|
+
pool.decommissionAll();
|
|
272
|
+
if (wsHandler.isPiAvailable)
|
|
273
|
+
await pool.prewarm(PI_CWD);
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
registerUpdateApi(app, updateManager);
|
|
277
|
+
const backendApi = new LocalBackendApi({
|
|
278
|
+
localSettingsStore,
|
|
279
|
+
sessionPaths,
|
|
280
|
+
backendId: () => registeredBackendId,
|
|
281
|
+
updateManager,
|
|
282
|
+
runSessionMutation: (sessionPath, operation, mutation) => {
|
|
283
|
+
const actor = registry.get(sessionPath);
|
|
284
|
+
return actor.enqueue(operation, async () => {
|
|
285
|
+
actor.assertAvailable(operation);
|
|
286
|
+
await mutation();
|
|
287
|
+
});
|
|
288
|
+
},
|
|
289
|
+
onLocalSettingsReloaded: () => wsHandler.notifySessionsChanged("__local_settings__"),
|
|
290
|
+
});
|
|
291
|
+
registerRestApi(app, { api: backendApi });
|
|
292
|
+
const backendProtocolHandler = new BackendProtocolHandler(backendApi);
|
|
235
293
|
// ============================================================================
|
|
236
294
|
// Debug endpoints
|
|
237
295
|
// ============================================================================
|
|
@@ -290,7 +348,6 @@ setInterval(tick, 1000); tick();
|
|
|
290
348
|
// ============================================================================
|
|
291
349
|
// Sessions Directory Watcher
|
|
292
350
|
// ============================================================================
|
|
293
|
-
const SESSIONS_DIR = path.join(getAgentDir(), "sessions");
|
|
294
351
|
function startSessionsWatcher() {
|
|
295
352
|
if (!existsSync(SESSIONS_DIR)) {
|
|
296
353
|
console.log(`Sessions dir does not exist yet: ${SESSIONS_DIR}`);
|
|
@@ -308,15 +365,8 @@ function startSessionsWatcher() {
|
|
|
308
365
|
const fullPath = path.join(SESSIONS_DIR, lastChangedFile);
|
|
309
366
|
// Re-read detached session state and push it to active subscribers.
|
|
310
367
|
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
|
-
});
|
|
368
|
+
// Notify every local WebSocket and authenticated DataChannel client.
|
|
369
|
+
wsHandler.notifySessionsChanged(fullPath);
|
|
320
370
|
}, 300);
|
|
321
371
|
});
|
|
322
372
|
console.log(`Watching sessions directory: ${SESSIONS_DIR}`);
|
|
@@ -333,7 +383,10 @@ if (PI_AVAILABLE) {
|
|
|
333
383
|
else {
|
|
334
384
|
console.log(`[pi] ${makePiNotFoundMessage(PI_LAUNCH.command)}`);
|
|
335
385
|
}
|
|
336
|
-
server.listen(REQUESTED_PORT,
|
|
386
|
+
server.listen(REQUESTED_PORT, () => {
|
|
387
|
+
void startRendezvousRegistration().catch((error) => {
|
|
388
|
+
console.warn("[rendezvous] registration stopped:", error);
|
|
389
|
+
});
|
|
337
390
|
const address = server.address();
|
|
338
391
|
const port = address && typeof address !== "string" ? address.port : REQUESTED_PORT;
|
|
339
392
|
const authUrl = `http://${PUBLIC_HOSTNAME}:${port}/auth?token=${encodeURIComponent(AUTH_TOKEN)}`;
|
|
@@ -353,11 +406,24 @@ server.listen(REQUESTED_PORT, async () => {
|
|
|
353
406
|
log(` Set PIPANE_AUTH_TOKEN to use a fixed token.`);
|
|
354
407
|
}
|
|
355
408
|
log("");
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
409
|
+
// The same asynchronous check feeds the web UI and preserves terminal hints.
|
|
410
|
+
void updateManager.check().then(({ notices }) => {
|
|
411
|
+
for (const notice of notices) {
|
|
412
|
+
if (notice.target === "pipane") {
|
|
413
|
+
log(` Update available: v${notice.currentVersion} → v${notice.latestVersion}`);
|
|
414
|
+
log(` Open pipane in the browser to install it, or run \`npm install -g ${PKG_NAME}\`.`);
|
|
415
|
+
}
|
|
416
|
+
else if (notice.target === "pi") {
|
|
417
|
+
log(` Pi update available: v${notice.currentVersion} → v${notice.latestVersion}`);
|
|
418
|
+
log(" Open pipane in the browser to install it, or run `pi update --self`.");
|
|
419
|
+
}
|
|
420
|
+
else if (notice.packages?.length) {
|
|
421
|
+
log(` Pi package updates available: ${notice.packages.join(", ")}`);
|
|
422
|
+
log(" Open pipane in the browser to install them, or run `pi update --extensions`.");
|
|
423
|
+
}
|
|
424
|
+
log("");
|
|
425
|
+
}
|
|
426
|
+
}).catch((error) => {
|
|
427
|
+
console.warn("[updates] Update check failed:", error);
|
|
428
|
+
});
|
|
363
429
|
});
|
|
@@ -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;
|