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.
Files changed (55) 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/client/assets/__vite-browser-external-DtLbhLGx.js +1 -0
  5. package/dist/client/assets/app-runtime-Rw-O-AwW.js +1 -0
  6. package/dist/client/assets/backend-landing-page-B7SbyIXQ.js +1 -0
  7. package/dist/client/assets/index-DwbBcYUf.js +2 -0
  8. package/dist/client/assets/index-iblQYBAl.css +1 -0
  9. package/dist/client/assets/main-LGItV9Aj.js +2807 -0
  10. package/dist/client/assets/pairing-page-D-WxlWZR.js +1 -0
  11. package/dist/client/assets/remote-backend-manager-BWuGh30I.js +1 -0
  12. package/dist/client/assets/rendezvous-trust-api-C2Zob5i4.js +4 -0
  13. package/dist/client/assets/webrtc-frame-transport-BijNnA7N.js +1 -0
  14. package/dist/client/assets/ws-agent-adapter-BRysf-Y7.js +6 -0
  15. package/dist/client/index.html +2 -2
  16. package/dist/server/rendezvous/rendezvous-hub.js +426 -0
  17. package/dist/server/rendezvous/server.js +247 -0
  18. package/dist/server/rendezvous/trust-store.js +432 -0
  19. package/dist/server/server/auth-guard.js +61 -0
  20. package/dist/server/server/backend-connection-authorizer.js +29 -0
  21. package/dist/server/server/backend-identity.js +167 -0
  22. package/dist/server/server/backend-protocol-handler.js +132 -0
  23. package/dist/server/server/backend-trust-store.js +157 -0
  24. package/dist/server/server/backend-webrtc.js +239 -0
  25. package/dist/server/server/conversation-file-access.js +97 -0
  26. package/dist/server/server/frame-connection.js +48 -0
  27. package/dist/server/server/frame-router.js +45 -0
  28. package/dist/server/server/ice-servers.js +24 -0
  29. package/dist/server/server/local-backend-api.js +389 -0
  30. package/dist/server/server/local-settings.js +17 -4
  31. package/dist/server/server/pi-rpc-protocol.js +407 -0
  32. package/dist/server/server/process-pool.js +27 -24
  33. package/dist/server/server/rendezvous-client.js +282 -0
  34. package/dist/server/server/rest-api.js +133 -176
  35. package/dist/server/server/server.js +163 -97
  36. package/dist/server/server/session-index.js +105 -28
  37. package/dist/server/server/session-jsonl.js +82 -5
  38. package/dist/server/server/session-path.js +145 -0
  39. package/dist/server/server/update-api.js +28 -0
  40. package/dist/server/server/update-check.js +33 -13
  41. package/dist/server/server/update-manager.js +231 -0
  42. package/dist/server/server/worktree-name.js +116 -31
  43. package/dist/server/server/ws-handler.js +267 -186
  44. package/dist/server/shared/backend-api.js +1 -0
  45. package/dist/server/shared/backend-protocol.js +164 -0
  46. package/dist/server/shared/node-trust-crypto.js +61 -0
  47. package/dist/server/shared/rendezvous-protocol.js +243 -0
  48. package/dist/server/shared/tool-runtime.js +1 -0
  49. package/dist/server/shared/trust-protocol.js +97 -0
  50. package/dist/server/shared/updates.js +4 -0
  51. package/dist/server/shared/ws-protocol.js +473 -1
  52. package/docs/protocol.md +127 -0
  53. package/package.json +21 -8
  54. package/dist/client/assets/index-Dl_wdLZH.css +0 -1
  55. 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,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
- 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);
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
- 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
- });
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
- // 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
- });
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, async () => {
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
- // 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
- }
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 { 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;