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.
Files changed (58) hide show
  1. package/README.md +14 -3
  2. package/bin/pipane-rendezvous.js +21 -0
  3. package/bin/pipane.js +21 -1
  4. package/dist/build-info.json +5 -0
  5. package/dist/client/assets/__vite-browser-external-DtLbhLGx.js +1 -0
  6. package/dist/client/assets/app-runtime-Rw-O-AwW.js +1 -0
  7. package/dist/client/assets/backend-landing-page-B7SbyIXQ.js +1 -0
  8. package/dist/client/assets/index-C7_1ODks.js +2 -0
  9. package/dist/client/assets/index-DgI_gkjg.css +1 -0
  10. package/dist/client/assets/main-DEfSB8wO.js +2822 -0
  11. package/dist/client/assets/pairing-page-C0YByC2D.js +1 -0
  12. package/dist/client/assets/remote-backend-manager-DCSdS38m.js +1 -0
  13. package/dist/client/assets/rendezvous-trust-api-C2Zob5i4.js +4 -0
  14. package/dist/client/assets/webrtc-frame-transport-DGX5EO_A.js +1 -0
  15. package/dist/client/assets/ws-agent-adapter-DpkkZGPl.js +6 -0
  16. package/dist/client/index.html +2 -2
  17. package/dist/server/rendezvous/rendezvous-hub.js +426 -0
  18. package/dist/server/rendezvous/server.js +247 -0
  19. package/dist/server/rendezvous/trust-store.js +432 -0
  20. package/dist/server/server/auth-guard.js +61 -0
  21. package/dist/server/server/backend-connection-authorizer.js +29 -0
  22. package/dist/server/server/backend-identity.js +167 -0
  23. package/dist/server/server/backend-protocol-handler.js +132 -0
  24. package/dist/server/server/backend-trust-store.js +157 -0
  25. package/dist/server/server/backend-webrtc.js +245 -0
  26. package/dist/server/server/build-info.js +13 -0
  27. package/dist/server/server/conversation-file-access.js +97 -0
  28. package/dist/server/server/frame-connection.js +106 -0
  29. package/dist/server/server/frame-router.js +45 -0
  30. package/dist/server/server/ice-servers.js +24 -0
  31. package/dist/server/server/local-backend-api.js +389 -0
  32. package/dist/server/server/local-settings.js +17 -4
  33. package/dist/server/server/pi-rpc-protocol.js +407 -0
  34. package/dist/server/server/process-pool.js +27 -24
  35. package/dist/server/server/rendezvous-client.js +282 -0
  36. package/dist/server/server/rest-api.js +133 -176
  37. package/dist/server/server/server.js +167 -97
  38. package/dist/server/server/session-index.js +105 -28
  39. package/dist/server/server/session-jsonl.js +82 -5
  40. package/dist/server/server/session-path.js +145 -0
  41. package/dist/server/server/update-api.js +28 -0
  42. package/dist/server/server/update-check.js +33 -13
  43. package/dist/server/server/update-manager.js +233 -0
  44. package/dist/server/server/worktree-name.js +116 -31
  45. package/dist/server/server/ws-handler.js +267 -186
  46. package/dist/server/shared/backend-api.js +1 -0
  47. package/dist/server/shared/backend-protocol.js +164 -0
  48. package/dist/server/shared/data-channel-framing.js +137 -0
  49. package/dist/server/shared/node-trust-crypto.js +61 -0
  50. package/dist/server/shared/rendezvous-protocol.js +243 -0
  51. package/dist/server/shared/tool-runtime.js +1 -0
  52. package/dist/server/shared/trust-protocol.js +97 -0
  53. package/dist/server/shared/updates.js +4 -0
  54. package/dist/server/shared/ws-protocol.js +473 -1
  55. package/docs/protocol.md +129 -0
  56. package/package.json +21 -8
  57. package/dist/client/assets/index-Dl_wdLZH.css +0 -1
  58. 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, WebSocket } from "ws";
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 AUTH_COOKIE_NAME = "pipane_auth";
74
- const AUTH_TOKEN = process.env.PIPANE_AUTH_TOKEN || randomBytes(24).toString("base64url");
75
- const PUBLIC_HOSTNAME = process.env.PI_PUBLIC_HOSTNAME || hostname();
76
- function parseCookies(header) {
77
- const out = {};
78
- if (!header)
79
- return out;
80
- for (const part of header.split(";")) {
81
- const idx = part.indexOf("=");
82
- if (idx <= 0)
83
- continue;
84
- const k = part.slice(0, idx).trim();
85
- const v = part.slice(idx + 1).trim();
86
- out[k] = decodeURIComponent(v);
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
- app.get("/auth", (req, res) => {
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
- // Also notify all WS clients about the file change (for sidebar refresh)
312
- wss.clients.forEach((client) => {
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, async () => {
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
- // Non-blocking update check
357
- const latest = await fetchLatestVersion(PKG_NAME);
358
- if (latest && compareSemver(PKG_VERSION, latest) < 0) {
359
- log(` Update available: v${PKG_VERSION} → v${latest}`);
360
- log(` Run \`npm install -g ${PKG_NAME}\` to upgrade.`);
361
- log("");
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 { resolveWorktreeName } from "./worktree-name.js";
4
+ import { createWorktreeNameResolver, } from "./worktree-name.js";
5
5
  const CACHE_FORMAT_VERSION = 1;
6
- const DEFAULT_EXTRACTOR_VERSION = "2";
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 ?? resolveWorktreeName;
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 meta = this.extractSessionMeta(sessionPath, stat.mtimeMs);
45
- if (!meta) {
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
- const worktreeNames = new Map();
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 = worktreeNames.get(session.cwd);
89
- if (!worktreeName) {
90
- try {
91
- worktreeName = this.worktreeNameResolver(session.cwd) || "root";
92
- }
93
- catch {
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
- id: header.id,
192
- path: sessionPath,
193
- cwd,
194
- cwdDisplay: cwd && this.cwdDisplayFormatter ? this.cwdDisplayFormatter(cwd) : cwd,
195
- name,
196
- created: created.toISOString(),
197
- modified: modified.toISOString(),
198
- lastUserPromptTime: lastUserPromptTimeMs > 0 ? new Date(lastUserPromptTimeMs).toISOString() : undefined,
199
- messageCount,
200
- firstMessage: firstMessage || "(no messages)",
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;