pi-lean-portal 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +608 -0
  3. package/backends/chromium/index.ts +50 -0
  4. package/backends/chromium-py/bridge.py +67 -0
  5. package/backends/firefox/index.ts +60 -0
  6. package/backends/firefox-py/bridge.py +64 -0
  7. package/backends/playwright-base/playwright-plugin.ts +1294 -0
  8. package/backends/python-adapter.ts +1141 -0
  9. package/backends/python-base/pi_browser_bridge/__init__.py +71 -0
  10. package/backends/python-base/pi_browser_bridge/accessibility.py +408 -0
  11. package/backends/python-base/pi_browser_bridge/bot_detection.py +115 -0
  12. package/backends/python-base/pi_browser_bridge/bridge.py +598 -0
  13. package/backends/python-base/pi_browser_bridge/playwright_base.py +1222 -0
  14. package/backends/python-base/pi_browser_bridge/transport.py +167 -0
  15. package/backends/python-base/pyproject.toml +15 -0
  16. package/browser-cookies.ts +88 -0
  17. package/browser-profile.ts +260 -0
  18. package/browser-status.ts +84 -0
  19. package/browser-toggle.ts +527 -0
  20. package/core/fetch-backend.ts +466 -0
  21. package/core/guides.ts +467 -0
  22. package/core/plugin-api.ts +302 -0
  23. package/core/plugin-config.ts +388 -0
  24. package/core/plugin-registry.ts +263 -0
  25. package/core/router.ts +1186 -0
  26. package/core/shared/accessibility-tree.ts +408 -0
  27. package/core/shared/bot-detection.ts +187 -0
  28. package/core/shared/browser-events.ts +111 -0
  29. package/core/shared/dom-extractor.ts +550 -0
  30. package/core/shared/nav-settle.ts +187 -0
  31. package/core/shared/paths.ts +56 -0
  32. package/core/shared/session-manager.ts +258 -0
  33. package/core/shared/settings-reader.ts +63 -0
  34. package/core/shared/snapshot-cache.ts +231 -0
  35. package/core/shared/storage-state.ts +560 -0
  36. package/core/shared/task-id.ts +77 -0
  37. package/core/shared/url-safety.ts +164 -0
  38. package/index.ts +253 -0
  39. package/package.json +63 -0
  40. package/ship-manifest.test.ts +12 -0
  41. package/tools/browser-back.ts +50 -0
  42. package/tools/browser-click.ts +74 -0
  43. package/tools/browser-console.ts +160 -0
  44. package/tools/browser-inspect.ts +136 -0
  45. package/tools/browser-navigate.ts +254 -0
  46. package/tools/browser-press.ts +80 -0
  47. package/tools/browser-scroll.ts +56 -0
  48. package/tools/browser-snapshot.ts +90 -0
  49. package/tools/browser-type.ts +60 -0
  50. package/tools/index.ts +19 -0
  51. package/tools/utils.ts +157 -0
  52. package/tools/web-fetch.ts +147 -0
  53. package/tools/web-guide.ts +55 -0
  54. package/tools/web-learn.ts +128 -0
  55. package/verify-ship-manifest.ts +126 -0
@@ -0,0 +1,560 @@
1
+ /**
2
+ * Storage State Persistence — profile-based save/restore for cookies,
3
+ * localStorage, and IndexedDB.
4
+ *
5
+ * Profiles are stored at
6
+ * ~/.pi/agent/pi-lean-portal/browser-state/<profile>/storage-state.json
7
+ * with version headers for forward compatibility.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ import {
13
+ mkdirSync,
14
+ readFileSync,
15
+ writeFileSync,
16
+ renameSync,
17
+ existsSync,
18
+ unlinkSync,
19
+ rmSync,
20
+ readdirSync,
21
+ } from "node:fs";
22
+ import { join, dirname } from "node:path";
23
+ import { homedir } from "node:os";
24
+ import { randomBytes } from "node:crypto";
25
+ import { PORTAL_DATA_DIR } from "./paths.js";
26
+
27
+ // ─── Constants ────────────────────────────────────────────────────────
28
+
29
+ /** Root directory for all browser profiles. */
30
+ export const PROFILE_DIR = join(PORTAL_DATA_DIR, "browser-state");
31
+
32
+ /** Current storage state version. Increment on breaking format changes. */
33
+ const STORAGE_STATE_VERSION = 1;
34
+
35
+ /** Default size limit (10 MB) before a warning is logged on save. */
36
+ const DEFAULT_MAX_STORAGE_STATE_SIZE = 10 * 1024 * 1024;
37
+
38
+ /** Profile name validation regex. */
39
+ const PROFILE_NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/;
40
+
41
+ /** Reserved keywords that cannot be used as profile names. */
42
+ const RESERVED_PROFILE_NAMES = new Set([
43
+ "none",
44
+ "session", // profile modes
45
+ "create",
46
+ "list",
47
+ "clear",
48
+ "clear-all",
49
+ "prune", // subcommands
50
+ ]);
51
+
52
+ /** Prefix for auto-generated session-scoped profiles. */
53
+ const SESSION_PROFILE_PREFIX = "_session-";
54
+
55
+ /** Directory where pi stores active session tracking files. */
56
+ export const SESSIONS_DIR = join(homedir(), ".pi", "agent", "sessions");
57
+
58
+ // ─── Types ────────────────────────────────────────────────────────────
59
+
60
+ /** A single cookie as stored by Playwright's storageState. */
61
+ interface StoredCookie {
62
+ name: string;
63
+ value: string;
64
+ domain: string;
65
+ path: string;
66
+ expires: number;
67
+ httpOnly: boolean;
68
+ secure: boolean;
69
+ sameSite: "Strict" | "Lax" | "None";
70
+ }
71
+
72
+ /** A localStorage entry for a given origin. */
73
+ interface StoredLocalStorageEntry {
74
+ name: string;
75
+ value: string;
76
+ }
77
+
78
+ /** An origin with its localStorage data. */
79
+ interface StoredOrigin {
80
+ origin: string;
81
+ localStorage: StoredLocalStorageEntry[];
82
+ }
83
+
84
+ /**
85
+ * The on-disk format for a storage state file.
86
+ *
87
+ * The `_piVersion` and `_savedAt` fields are added by this module;
88
+ * `cookies` and `origins` match Playwright's storageState output.
89
+ */
90
+ export interface StorageStateFile {
91
+ _piVersion: number;
92
+ _savedAt: string;
93
+ _playwrightVersion?: string;
94
+ cookies: StoredCookie[];
95
+ origins: StoredOrigin[];
96
+ }
97
+
98
+ // ─── Profile Name Validation ──────────────────────────────────────────
99
+
100
+ /**
101
+ * Validate a profile name.
102
+ *
103
+ * Rules:
104
+ * - Must be 1-64 characters long
105
+ * - Only alphanumeric, hyphens, and underscores allowed
106
+ * - Must not be a reserved keyword ("none", "session", "create", "list", "clear", "clear-all", "prune")
107
+ * - Session-scoped names (`_session-*`) are allowed with embedded ID validation
108
+ *
109
+ * @throws {Error} If the name is invalid.
110
+ * @returns The sanitized name (same as input on success).
111
+ */
112
+ export function sanitizeProfileName(name: string): string {
113
+ if (typeof name !== "string" || name.length === 0) {
114
+ throw new Error("Profile name must be a non-empty string");
115
+ }
116
+
117
+ // Session profiles: validate the embedded session ID, then bypass regex
118
+ if (name.startsWith(SESSION_PROFILE_PREFIX)) {
119
+ const sessionId = name.slice(SESSION_PROFILE_PREFIX.length);
120
+ if (!sessionId || /[/\\..\s]/.test(sessionId)) {
121
+ throw new Error(
122
+ `Invalid session profile name '${name}': embedded session ID must be non-empty ` +
123
+ "and must not contain path traversal characters.",
124
+ );
125
+ }
126
+ return name; // Bypass normal regex
127
+ }
128
+
129
+ if (!PROFILE_NAME_RE.test(name)) {
130
+ throw new Error(
131
+ `Invalid profile name '${name}'. ` +
132
+ "Profile names must be 1-64 characters, alphanumeric, hyphens, and underscores only.",
133
+ );
134
+ }
135
+
136
+ if (RESERVED_PROFILE_NAMES.has(name)) {
137
+ throw new Error(
138
+ `'${name}' is a reserved session mode and cannot be used as a profile name.`,
139
+ );
140
+ }
141
+
142
+ return name;
143
+ }
144
+
145
+ // ─── Path Helpers ─────────────────────────────────────────────────────
146
+
147
+ /**
148
+ * Get the filesystem path to a profile directory.
149
+ * Profile names are sanitized before path construction.
150
+ */
151
+ export function profileDir(profileName: string): string {
152
+ const safe = sanitizeProfileName(profileName);
153
+ return join(PROFILE_DIR, safe);
154
+ }
155
+
156
+ /**
157
+ * Get the filesystem path to a profile's storage state file.
158
+ */
159
+ export function profileFilePath(profileName: string): string {
160
+ return join(profileDir(profileName), "storage-state.json");
161
+ }
162
+
163
+ // ─── Read / Write ─────────────────────────────────────────────────────
164
+
165
+ /**
166
+ * Load storage state for a named profile.
167
+ *
168
+ * Returns `null` if no state file exists (first use).
169
+ * Logs a warning if the version is higher than the current code understands.
170
+ *
171
+ * @param profileName - The profile name.
172
+ * @param maxSizeBytes - Optional size limit for warning (default: 10 MB).
173
+ * @returns The parsed storage state, or null if no file exists.
174
+ */
175
+ export function loadStorageState(
176
+ profileName: string,
177
+ _maxSizeBytes: number = DEFAULT_MAX_STORAGE_STATE_SIZE,
178
+ ): StorageStateFile | null {
179
+ const path = profileFilePath(profileName);
180
+
181
+ if (!existsSync(path)) {
182
+ return null;
183
+ }
184
+
185
+ try {
186
+ const raw = readFileSync(path, "utf-8");
187
+ const parsed = JSON.parse(raw) as StorageStateFile;
188
+
189
+ // Version check — warn on newer versions
190
+ if (
191
+ typeof parsed._piVersion === "number" &&
192
+ parsed._piVersion > STORAGE_STATE_VERSION
193
+ ) {
194
+ console.warn(
195
+ `[pi-lean-portal] Storage state for profile '${profileName}' ` +
196
+ `has version ${parsed._piVersion}, but this extension ` +
197
+ `understands version ${STORAGE_STATE_VERSION}. ` +
198
+ "New fields may be ignored.",
199
+ );
200
+ }
201
+
202
+ return parsed;
203
+ } catch (err) {
204
+ console.warn(
205
+ `[pi-lean-portal] Failed to load storage state for profile ` +
206
+ `'${profileName}': ${err instanceof Error ? err.message : String(err)}. ` +
207
+ "Starting with fresh state.",
208
+ );
209
+ return null;
210
+ }
211
+ }
212
+
213
+ // ─── Private Helpers ──────────────────────────────────────────────────
214
+
215
+ /** Temp file prefix for atomic writes. */
216
+ const TEMP_FILE_PREFIX = ".storage-state.";
217
+ const TEMP_FILE_SUFFIX = ".tmp";
218
+
219
+ /** ReDoS-safe suffix char: hex digit. */
220
+ function tmpSuffix(): string {
221
+ return randomBytes(6).toString("hex");
222
+ }
223
+
224
+ /**
225
+ * Read + parse the storage state file at a given path, or null if
226
+ * missing or invalid. Unlike `loadStorageState`, this raw variant
227
+ * does NOT log version warnings — it's intended for the merge-read
228
+ * inside `saveStorageState`, where spurious warnings on every save
229
+ * would be noise.
230
+ */
231
+ function loadStorageStateRaw(path: string): StorageStateFile | null {
232
+ if (!existsSync(path)) return null;
233
+ try {
234
+ const raw = readFileSync(path, "utf-8");
235
+ return JSON.parse(raw) as StorageStateFile;
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Best-effort sweep of orphaned temp files in a profile directory.
243
+ * These can accumulate if a process crashes between write and rename.
244
+ * Failures are silently ignored.
245
+ */
246
+ function sweepOrphanedTempFiles(dir: string): void {
247
+ try {
248
+ if (!existsSync(dir)) return;
249
+ const entries = readdirSync(dir);
250
+ for (const entry of entries) {
251
+ if (
252
+ entry.startsWith(TEMP_FILE_PREFIX) &&
253
+ entry.endsWith(TEMP_FILE_SUFFIX)
254
+ ) {
255
+ try {
256
+ unlinkSync(join(dir, entry));
257
+ } catch {
258
+ /* best-effort */
259
+ }
260
+ }
261
+ }
262
+ } catch {
263
+ /* best-effort */
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Write `serialized` to `path` atomically: write to a sibling temp file,
269
+ * then rename over the target. On POSIX, rename is atomic on the same
270
+ * filesystem, so a concurrent reader sees either the old or the new file,
271
+ * never a partial write.
272
+ *
273
+ * The temp file lives in the same directory (guaranteed same filesystem)
274
+ * with a short random suffix to avoid collisions between concurrent
275
+ * writers. Best-effort cleanup of the temp file on rename failure.
276
+ *
277
+ * File mode is 0600. The directory is assumed to already exist
278
+ * (callers create it with mode 0700).
279
+ */
280
+ function atomicWriteFileSync(path: string, serialized: string): void {
281
+ const dir = dirname(path);
282
+ const tmp = join(dir, `${TEMP_FILE_PREFIX}${tmpSuffix()}${TEMP_FILE_SUFFIX}`);
283
+ try {
284
+ writeFileSync(tmp, serialized, { mode: 0o600 });
285
+ renameSync(tmp, path);
286
+ } catch (err) {
287
+ try {
288
+ if (existsSync(tmp)) unlinkSync(tmp);
289
+ } catch {
290
+ /* best-effort */
291
+ }
292
+ throw err;
293
+ }
294
+ }
295
+
296
+ /** Composite key identifying a unique cookie slot by name+domain+path. */
297
+ function cookieKey(c: { name: string; domain: string; path: string }): string {
298
+ return `${c.name}|${c.domain}|${c.path}`;
299
+ }
300
+
301
+ /**
302
+ * Merge two cookie arrays by `name+domain+path`, last-writer-wins.
303
+ * `incoming` (the just-captured in-memory state) overrides `existing`
304
+ * (the current on-disk state) on collision.
305
+ */
306
+ function mergeCookies(
307
+ existing: StoredCookie[],
308
+ incoming: StoredCookie[],
309
+ ): StoredCookie[] {
310
+ const map = new Map<string, StoredCookie>();
311
+ for (const c of existing) map.set(cookieKey(c), c);
312
+ for (const c of incoming) map.set(cookieKey(c), c); // incoming wins
313
+ return [...map.values()];
314
+ }
315
+
316
+ /**
317
+ * Merge two origins arrays by `origin`, unioning localStorage by `name`
318
+ * (incoming wins on collision). Origins only in `existing` are preserved;
319
+ * origins only in `incoming` are added.
320
+ */
321
+ function mergeOrigins(
322
+ existing: StoredOrigin[],
323
+ incoming: StoredOrigin[],
324
+ ): StoredOrigin[] {
325
+ const byOrigin = new Map<string, StoredOrigin>();
326
+
327
+ // Seed with existing, normalising localStorage into a keyed map
328
+ for (const o of existing) {
329
+ byOrigin.set(o.origin, {
330
+ origin: o.origin,
331
+ localStorage: [...o.localStorage],
332
+ });
333
+ }
334
+ // Merge incoming over existing
335
+ for (const inc of incoming) {
336
+ const cur = byOrigin.get(inc.origin);
337
+ if (!cur) {
338
+ byOrigin.set(inc.origin, {
339
+ origin: inc.origin,
340
+ localStorage: [...inc.localStorage],
341
+ });
342
+ continue;
343
+ }
344
+ const lsMap = new Map<string, StoredLocalStorageEntry>();
345
+ for (const e of cur.localStorage) lsMap.set(e.name, e);
346
+ for (const e of inc.localStorage) lsMap.set(e.name, e); // incoming wins
347
+ cur.localStorage = [...lsMap.values()];
348
+ }
349
+ return [...byOrigin.values()];
350
+ }
351
+
352
+ /**
353
+ * Save storage state for a named profile.
354
+ *
355
+ * Uses cookie-level merge (union by `name+domain+path`) and atomic
356
+ * writes (temp file + rename) to prevent two failure modes under
357
+ * concurrent use of a shared named profile:
358
+ *
359
+ * 1. **Half-write race**: a concurrent reader never sees a partial file.
360
+ * 2. **Wholesale-replace clobber**: non-overlapping cookies set by
361
+ * concurrent writers are preserved via merge, not erased.
362
+ *
363
+ * Creates the profile directory with 0700 permissions if needed.
364
+ * Logs a warning if the state exceeds `maxSizeBytes` but saves anyway.
365
+ *
366
+ * @param profileName - The profile name.
367
+ * @param state - The raw state object from Playwright's context.storageState().
368
+ * Must be `{ cookies: [...], origins: [...] }`.
369
+ * @param maxSizeBytes - Optional size limit for warning (default: 10 MB).
370
+ * @returns true if save succeeded, false on failure (logged via console.warn).
371
+ */
372
+ export function saveStorageState(
373
+ profileName: string,
374
+ state: { cookies: unknown[]; origins: unknown[] },
375
+ maxSizeBytes: number = DEFAULT_MAX_STORAGE_STATE_SIZE,
376
+ ): boolean {
377
+ const dir = profileDir(profileName);
378
+ const path = profileFilePath(profileName);
379
+
380
+ try {
381
+ // Create profile directory with restricted permissions
382
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
383
+
384
+ // Best-effort sweep of orphaned temp files from prior crashes
385
+ sweepOrphanedTempFiles(dir);
386
+
387
+ // Read current on-disk state for merge (null = missing/corrupt)
388
+ const diskState = loadStorageStateRaw(path);
389
+
390
+ // Merge: union by cookie key (name+domain+path) and origin+name,
391
+ // incoming wins on collision, non-overlapping entries preserved
392
+ const payload: StorageStateFile = {
393
+ _piVersion: STORAGE_STATE_VERSION,
394
+ _savedAt: new Date().toISOString(),
395
+ cookies: mergeCookies(
396
+ diskState?.cookies ?? [],
397
+ state.cookies as StoredCookie[],
398
+ ),
399
+ origins: mergeOrigins(
400
+ diskState?.origins ?? [],
401
+ state.origins as StoredOrigin[],
402
+ ),
403
+ };
404
+
405
+ const serialized = JSON.stringify(payload, null, 2);
406
+ const byteSize = Buffer.byteLength(serialized, "utf-8");
407
+
408
+ // Size warning (best-effort, don't block save)
409
+ if (byteSize > maxSizeBytes) {
410
+ const mb = (byteSize / (1024 * 1024)).toFixed(1);
411
+ console.warn(
412
+ `[pi-lean-portal] Storage state for profile '${profileName}' ` +
413
+ `is ${mb} MB — large states may impact startup/save latency. ` +
414
+ `Set browser.maxStorageStateSize to adjust the threshold.`,
415
+ );
416
+ }
417
+
418
+ // Atomic write: temp file + rename (atomic on POSIX)
419
+ atomicWriteFileSync(path, serialized);
420
+
421
+ return true;
422
+ } catch (err) {
423
+ console.warn(
424
+ `[pi-lean-portal] Failed to save storage state for profile ` +
425
+ `'${profileName}': ${err instanceof Error ? err.message : String(err)}. ` +
426
+ "Session state will be lost.",
427
+ );
428
+ return false;
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Delete the storage state file for a named profile.
434
+ *
435
+ * Also removes the profile directory if it becomes empty (no leftover state
436
+ * files or other artifacts). Does NOT throw — failures are logged and
437
+ * silently ignored.
438
+ *
439
+ * @param profileName - The profile name.
440
+ */
441
+ export function deleteStorageState(profileName: string): void {
442
+ const path = profileFilePath(profileName);
443
+ try {
444
+ if (existsSync(path)) {
445
+ unlinkSync(path);
446
+ }
447
+ // Clean up the profile directory if it's now empty
448
+ const dir = profileDir(profileName);
449
+ if (existsSync(dir)) {
450
+ const remaining = readdirSync(dir);
451
+ if (remaining.length === 0) {
452
+ rmSync(dir, { recursive: true, force: true });
453
+ }
454
+ }
455
+ } catch (err) {
456
+ console.warn(
457
+ `[pi-lean-portal] Failed to delete storage state for profile ` +
458
+ `'${profileName}': ${err instanceof Error ? err.message : String(err)}.`,
459
+ );
460
+ }
461
+ }
462
+
463
+ // ─── Session Profile Helpers ───────────────────────────────────────
464
+
465
+ /**
466
+ * Check whether a profile name follows the session-scoped naming convention.
467
+ *
468
+ * Session profiles start with `SESSION_PROFILE_PREFIX` (`_session-`) and
469
+ * encode the pi session ID. They are auto-created for `profile="session"`
470
+ * and cleaned up when the pi session ends.
471
+ *
472
+ * @param name - The profile name to check.
473
+ * @returns `true` if the name starts with `_session-`.
474
+ */
475
+ export function isSessionProfile(name: string): boolean {
476
+ return name.startsWith(SESSION_PROFILE_PREFIX);
477
+ }
478
+
479
+ /**
480
+ * Generate a session-scoped profile name from a pi session ID.
481
+ *
482
+ * The returned name follows the `_session-<piSessionId>` convention and
483
+ * can be used with `loadStorageState`/`saveStorageState`.
484
+ *
485
+ * @param piSessionId - The pi session ID (must be non-empty, no path chars).
486
+ * @returns The session-scoped profile name.
487
+ * @throws {Error} If the piSessionId is empty or contains path traversal characters.
488
+ */
489
+ export function sessionProfileName(piSessionId: string): string {
490
+ if (!piSessionId || /[/\\..]/.test(piSessionId)) {
491
+ throw new Error(
492
+ `Invalid piSessionId for session profile: '${piSessionId}'`,
493
+ );
494
+ }
495
+ return `${SESSION_PROFILE_PREFIX}${piSessionId}`;
496
+ }
497
+
498
+ /**
499
+ * Check whether a session-scoped profile's backing pi session still exists.
500
+ *
501
+ * Pi writes a session tracking file at `SESSIONS_DIR/<sessionId>.json` for
502
+ * each active conversation. This function checks for that file. If the file
503
+ * is missing, the session has ended and the profile state is stale.
504
+ *
505
+ * Non-session profiles always return `false` (not stale by this metric).
506
+ *
507
+ * @param profileName - The profile name to check.
508
+ * @returns `true` if the profile is session-scoped and its session file is missing.
509
+ */
510
+ export function isSessionStale(profileName: string): boolean {
511
+ if (!isSessionProfile(profileName)) return false;
512
+ const sessionId = profileName.slice(SESSION_PROFILE_PREFIX.length);
513
+ const sessionFile = join(SESSIONS_DIR, `${sessionId}.json`);
514
+ return !existsSync(sessionFile);
515
+ }
516
+
517
+ /**
518
+ * Scan the profile directory and remove state for stale session profiles.
519
+ *
520
+ * A session profile is stale when its backing pi session tracking file
521
+ * no longer exists at `SESSIONS_DIR/<sessionId>.json`. This can happen
522
+ * when a conversation ends, is deleted, or the session system is reset.
523
+ *
524
+ * Named profiles (non-`_session-*`) are never touched.
525
+ *
526
+ * @returns An object with `pruned` (removed profile names) and `kept` (active session profile names).
527
+ */
528
+ export function pruneStaleSessionProfiles(): {
529
+ pruned: string[];
530
+ kept: string[];
531
+ } {
532
+ const result = { pruned: [] as string[], kept: [] as string[] };
533
+
534
+ if (!existsSync(PROFILE_DIR)) return result;
535
+
536
+ let entries: string[];
537
+ try {
538
+ entries = readdirSync(PROFILE_DIR);
539
+ } catch {
540
+ return result; // Can't read directory — best-effort
541
+ }
542
+
543
+ for (const entry of entries) {
544
+ if (!isSessionProfile(entry)) continue;
545
+
546
+ try {
547
+ if (isSessionStale(entry)) {
548
+ const fullPath = join(PROFILE_DIR, entry);
549
+ rmSync(fullPath, { recursive: true, force: true });
550
+ result.pruned.push(entry);
551
+ } else {
552
+ result.kept.push(entry);
553
+ }
554
+ } catch {
555
+ // Best-effort — skip problematic entries
556
+ }
557
+ }
558
+
559
+ return result;
560
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Task ID Resolution — maps pi session IDs to stable "browser-N" keys.
3
+ *
4
+ * Previously lived in `tools/utils.ts`, which made the logic inaccessible
5
+ * to `core/` modules and command handlers. Moved here so all layers can
6
+ * share the same session-to-task mapping.
7
+ *
8
+ * Invariant: the return value is always filename-safe (matches
9
+ * /^[a-zA-Z0-9-]+$/). Callers do not need to apply `safeTaskId()`
10
+ * from `paths.js` to the output of this function.
11
+ *
12
+ * @module task-id
13
+ */
14
+
15
+ // ─── Internal state ──────────────────────────────────────────────
16
+
17
+ /** Maps pi session IDs to stable "browser-N" keys. */
18
+ const _sessionKeys = new Map<string, string>();
19
+
20
+ /** Monotonic counter for generating browser-N keys. */
21
+ let _sessionCounter = 0;
22
+
23
+ // ─── Public API ──────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Get a stable taskId for the current tool call context.
27
+ *
28
+ * Maps pi session IDs to monotonic "browser-N" keys so that all
29
+ * tool calls within the same pi conversation share the same browser
30
+ * session.
31
+ *
32
+ * - When a piSessionId is available, returns `browser-1`, `browser-2`, etc.
33
+ * - When no piSessionId is available, returns `"browser-default"`.
34
+ *
35
+ * @param ctx - An object that may contain a `sessionManager` with a
36
+ * `getSessionId()` method (e.g. the `ctx` argument from
37
+ * a pi tool's `execute` callback, or an `ExtensionContext`).
38
+ * @returns A stable, filename-safe task ID string.
39
+ */
40
+ export function taskId(ctx: {
41
+ sessionManager?: { getSessionId?(): string };
42
+ }): string {
43
+ const piSessionId = ctx?.sessionManager?.getSessionId?.();
44
+ if (piSessionId) {
45
+ if (!_sessionKeys.has(piSessionId)) {
46
+ _sessionKeys.set(piSessionId, `browser-${++_sessionCounter}`);
47
+ }
48
+ return _sessionKeys.get(piSessionId)!;
49
+ }
50
+ return "browser-default";
51
+ }
52
+
53
+ /**
54
+ * Remove a pi session ID from the internal mapping.
55
+ *
56
+ * Called during `session_shutdown` so that the ID-to-key mapping
57
+ * doesn't leak across conversations. Does nothing if the session
58
+ * ID is not found.
59
+ *
60
+ * @param piSessionId - The pi session ID to remove.
61
+ */
62
+ export function deleteSessionKey(piSessionId: string): void {
63
+ _sessionKeys.delete(piSessionId);
64
+ }
65
+
66
+ /**
67
+ * Reset all internal task-ID state.
68
+ *
69
+ * Clears the piSessionId-to-key mapping and resets the monotonic counter.
70
+ * Called at the start of the extension entry function to ensure safe
71
+ * re-invocation when pi reuses the cached module factory (e.g.
72
+ * during /resume to the same working directory).
73
+ */
74
+ export function resetTaskIds(): void {
75
+ _sessionKeys.clear();
76
+ _sessionCounter = 0;
77
+ }