pi-agent-browser-native 0.2.2 → 0.2.4

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.
@@ -7,7 +7,8 @@
7
7
  */
8
8
 
9
9
  import { spawn } from "node:child_process";
10
- import { env as processEnv } from "node:process";
10
+ import { chmod, mkdir } from "node:fs/promises";
11
+ import { env as processEnv, platform as processPlatform } from "node:process";
11
12
 
12
13
  import { openSecureTempFile, writeSecureTempChunk } from "./temp.js";
13
14
 
@@ -15,6 +16,8 @@ const MAX_BUFFERED_STDOUT_BYTES = 512 * 1_024;
15
16
  const MAX_BUFFERED_STDERR_CHARS = 32_000;
16
17
  const MAX_BUFFERED_STDOUT_TAIL_CHARS = 32_000;
17
18
  const PROCESS_STDOUT_SPILL_FILE_PREFIX = "process-stdout";
19
+ const AGENT_BROWSER_SOCKET_DIR_ENV = "AGENT_BROWSER_SOCKET_DIR";
20
+ const DEFAULT_AGENT_BROWSER_SOCKET_DIR_PREFIX = "/tmp/piab";
18
21
  const httpProxyEnvName = "http_proxy";
19
22
  const httpsProxyEnvName = "https_proxy";
20
23
  const allProxyEnvName = "all_proxy";
@@ -81,6 +84,26 @@ function appendTail(text: string, addition: string, maxChars: number): string {
81
84
  return combined.length <= maxChars ? combined : combined.slice(combined.length - maxChars);
82
85
  }
83
86
 
87
+ export function getAgentBrowserSocketDir(
88
+ platform: NodeJS.Platform = processPlatform,
89
+ uid: number | undefined = typeof process.getuid === "function" ? process.getuid() : undefined,
90
+ ): string | undefined {
91
+ if (platform === "win32") {
92
+ return undefined;
93
+ }
94
+ return `${DEFAULT_AGENT_BROWSER_SOCKET_DIR_PREFIX}${typeof uid === "number" ? `-${uid}` : ""}`;
95
+ }
96
+
97
+ async function ensureAgentBrowserSocketDir(socketDir: string): Promise<boolean> {
98
+ try {
99
+ await mkdir(socketDir, { recursive: true, mode: 0o700 });
100
+ await chmod(socketDir, 0o700).catch(() => undefined);
101
+ return true;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
84
107
  export function buildAgentBrowserProcessEnv(
85
108
  baseEnv: NodeJS.ProcessEnv = processEnv,
86
109
  overrides: NodeJS.ProcessEnv | undefined = undefined,
@@ -117,6 +140,11 @@ export async function runAgentBrowserProcess(options: {
117
140
  stdin?: string;
118
141
  }): Promise<ProcessRunResult> {
119
142
  const { args, cwd, env, signal, stdin } = options;
143
+ let effectiveEnv = env;
144
+ const requestedSocketDir = env?.[AGENT_BROWSER_SOCKET_DIR_ENV] ?? getAgentBrowserSocketDir();
145
+ if (requestedSocketDir && (await ensureAgentBrowserSocketDir(requestedSocketDir))) {
146
+ effectiveEnv = { ...env, [AGENT_BROWSER_SOCKET_DIR_ENV]: requestedSocketDir };
147
+ }
120
148
 
121
149
  return await new Promise<ProcessRunResult>((resolve) => {
122
150
  let aborted = false;
@@ -191,7 +219,7 @@ export async function runAgentBrowserProcess(options: {
191
219
 
192
220
  const child = spawn("agent-browser", args, {
193
221
  cwd,
194
- env: buildAgentBrowserProcessEnv(processEnv, env),
222
+ env: buildAgentBrowserProcessEnv(processEnv, effectiveEnv),
195
223
  stdio: ["pipe", "pipe", "pipe"],
196
224
  });
197
225
 
@@ -10,6 +10,7 @@ import { readFile, stat } from "node:fs/promises";
10
10
  import { resolve } from "node:path";
11
11
 
12
12
  import { parseCommandInfo, type CommandInfo } from "../runtime.js";
13
+ import { type PersistentSessionArtifactStore } from "../temp.js";
13
14
  import { buildSnapshotPresentation, formatRawSnapshotText, formatSnapshotSummary } from "./snapshot.js";
14
15
  import {
15
16
  type AgentBrowserBatchResult,
@@ -106,6 +107,63 @@ function getScreenshotSummary(data: Record<string, unknown>): string | undefined
106
107
  return typeof data.path === "string" ? `Saved image: ${data.path}` : undefined;
107
108
  }
108
109
 
110
+ function getScalarExtractionResult(data: Record<string, unknown>): string | undefined {
111
+ const { result } = data;
112
+ if (typeof result === "string") {
113
+ return result.trim().length > 0 ? result : undefined;
114
+ }
115
+ if (typeof result === "number" || typeof result === "boolean") {
116
+ return String(result);
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ function getExtractionOrigin(data: Record<string, unknown>): string | undefined {
122
+ if (typeof data.origin === "string" && data.origin.trim().length > 0) {
123
+ return data.origin.trim();
124
+ }
125
+ if (typeof data.url === "string" && data.url.trim().length > 0) {
126
+ return data.url.trim();
127
+ }
128
+ return undefined;
129
+ }
130
+
131
+ function formatGetSummaryLabel(subcommand: string | undefined): string {
132
+ if (!subcommand) {
133
+ return "Get result";
134
+ }
135
+ if (subcommand.toLowerCase() === "url") {
136
+ return "URL";
137
+ }
138
+ return `${subcommand.slice(0, 1).toUpperCase()}${subcommand.slice(1)}`;
139
+ }
140
+
141
+ function formatExtractionSummary(commandInfo: CommandInfo, data: Record<string, unknown>): string | undefined {
142
+ const scalarResult = getScalarExtractionResult(data);
143
+ if (!scalarResult) {
144
+ return undefined;
145
+ }
146
+ if (commandInfo.command === "get") {
147
+ return `${formatGetSummaryLabel(commandInfo.subcommand)}: ${scalarResult.split("\n", 1)[0] ?? scalarResult}`;
148
+ }
149
+ if (commandInfo.command === "eval") {
150
+ return `Eval result: ${scalarResult.split("\n", 1)[0] ?? scalarResult}`;
151
+ }
152
+ return undefined;
153
+ }
154
+
155
+ function formatExtractionText(commandInfo: CommandInfo, data: Record<string, unknown>): string | undefined {
156
+ if (commandInfo.command !== "get" && commandInfo.command !== "eval") {
157
+ return undefined;
158
+ }
159
+ const scalarResult = getScalarExtractionResult(data);
160
+ if (!scalarResult) {
161
+ return undefined;
162
+ }
163
+ const origin = getExtractionOrigin(data);
164
+ return origin && origin !== scalarResult ? `${scalarResult}\n\nOrigin: ${origin}` : scalarResult;
165
+ }
166
+
109
167
  function isNavigationObservableCommand(command: string | undefined): boolean {
110
168
  return command !== undefined && NAVIGATION_SUMMARY_COMMANDS.has(command);
111
169
  }
@@ -207,8 +265,9 @@ async function buildBatchStepPresentation(options: {
207
265
  cwd: string;
208
266
  index: number;
209
267
  item: AgentBrowserBatchResult;
268
+ persistentArtifactStore?: PersistentSessionArtifactStore;
210
269
  }): Promise<{ details: BatchStepPresentationDetails; presentation: ToolPresentation }> {
211
- const { cwd, index, item } = options;
270
+ const { cwd, index, item, persistentArtifactStore } = options;
212
271
  const command = isStringArray(item.command) ? item.command : undefined;
213
272
  const commandText = formatBatchStepCommand(command, index);
214
273
 
@@ -236,6 +295,7 @@ async function buildBatchStepPresentation(options: {
236
295
  commandInfo: parseCommandInfo(command ?? []),
237
296
  cwd,
238
297
  envelope: { data: item.result, success: true },
298
+ persistentArtifactStore,
239
299
  });
240
300
  const fullOutputPaths = getPresentationPaths({
241
301
  primaryPath: presentation.fullOutputPath,
@@ -268,12 +328,28 @@ async function buildBatchStepPresentation(options: {
268
328
  async function buildBatchPresentation(options: {
269
329
  cwd: string;
270
330
  data: AgentBrowserBatchResult[];
331
+ persistentArtifactStore?: PersistentSessionArtifactStore;
271
332
  summary: string;
272
333
  }): Promise<ToolPresentation> {
273
- const { cwd, data, summary } = options;
334
+ const { cwd, data, persistentArtifactStore, summary } = options;
274
335
  const steps: Array<{ details: BatchStepPresentationDetails; presentation: ToolPresentation }> = [];
336
+ const protectedPersistentPaths: string[] = [];
275
337
  for (const [index, item] of data.entries()) {
276
- steps.push(await buildBatchStepPresentation({ cwd, index, item }));
338
+ const step = await buildBatchStepPresentation({
339
+ cwd,
340
+ index,
341
+ item,
342
+ persistentArtifactStore: persistentArtifactStore
343
+ ? { ...persistentArtifactStore, protectedPaths: protectedPersistentPaths }
344
+ : undefined,
345
+ });
346
+ steps.push(step);
347
+ protectedPersistentPaths.push(
348
+ ...getPresentationPaths({
349
+ primaryPath: step.presentation.fullOutputPath,
350
+ secondaryPaths: step.presentation.fullOutputPaths,
351
+ }),
352
+ );
277
353
  }
278
354
 
279
355
  const batchFailure = getBatchFailureDetails(steps);
@@ -354,6 +430,10 @@ function formatSummary(commandInfo: CommandInfo, data: unknown): string {
354
430
  if (commandInfo.command === "screenshot" && typeof data.path === "string") {
355
431
  return `Screenshot saved: ${data.path}`;
356
432
  }
433
+ const extractionSummary = formatExtractionSummary(commandInfo, data);
434
+ if (extractionSummary) {
435
+ return extractionSummary;
436
+ }
357
437
  const pageSummary = getPageSummary(data);
358
438
  if (pageSummary) {
359
439
  return pageSummary.split("\n", 1)[0] ?? "agent-browser result";
@@ -404,6 +484,11 @@ function formatContentText(commandInfo: CommandInfo, data: unknown): string {
404
484
  if (screenshotSummary) return screenshotSummary;
405
485
  }
406
486
 
487
+ const extractionText = formatExtractionText(commandInfo, data);
488
+ if (extractionText) {
489
+ return extractionText;
490
+ }
491
+
407
492
  const pageSummary = getPageSummary(data);
408
493
  if (pageSummary) {
409
494
  return pageSummary;
@@ -459,8 +544,9 @@ export async function buildToolPresentation(options: {
459
544
  cwd: string;
460
545
  envelope?: AgentBrowserEnvelope;
461
546
  errorText?: string;
547
+ persistentArtifactStore?: PersistentSessionArtifactStore;
462
548
  }): Promise<ToolPresentation> {
463
- const { commandInfo, cwd, envelope, errorText } = options;
549
+ const { commandInfo, cwd, envelope, errorText, persistentArtifactStore } = options;
464
550
  if (errorText) {
465
551
  return {
466
552
  content: [{ type: "text", text: errorText }],
@@ -472,9 +558,9 @@ export async function buildToolPresentation(options: {
472
558
  const summary = formatSummary(commandInfo, data);
473
559
  const presentation =
474
560
  commandInfo.command === "batch" && Array.isArray(data)
475
- ? await buildBatchPresentation({ cwd, data: data as AgentBrowserBatchResult[], summary })
561
+ ? await buildBatchPresentation({ cwd, data: data as AgentBrowserBatchResult[], persistentArtifactStore, summary })
476
562
  : commandInfo.command === "snapshot" && isRecord(data)
477
- ? await buildSnapshotPresentation(data)
563
+ ? await buildSnapshotPresentation(data, persistentArtifactStore)
478
564
  : {
479
565
  content: [{ type: "text" as const, text: formatContentText(commandInfo, data) }],
480
566
  data,
@@ -6,7 +6,7 @@
6
6
  * Invariants/Assumptions: Snapshot compaction should stay helpful even if upstream snapshot text formatting shifts, so structured parsing is best-effort and always has a resilient raw-outline fallback.
7
7
  */
8
8
 
9
- import { writeSecureTempFile } from "../temp.js";
9
+ import { type PersistentSessionArtifactStore, writePersistentSessionArtifactFile, writeSecureTempFile } from "../temp.js";
10
10
  import { type ToolPresentation, compareRefIds, countLines, isRecord, normalizeWhitespace, truncateText } from "./shared.js";
11
11
 
12
12
  const SNAPSHOT_INLINE_MAX_CHARS = 6_000;
@@ -463,12 +463,18 @@ function canUseStructuredSnapshotPreview(snapshotLines: SnapshotLine[], refEntri
463
463
  );
464
464
  }
465
465
 
466
- async function writeSnapshotSpillFile(data: Record<string, unknown>): Promise<string> {
467
- return await writeSecureTempFile({
466
+ async function writeSnapshotSpillFile(
467
+ data: Record<string, unknown>,
468
+ persistentArtifactStore: PersistentSessionArtifactStore | undefined,
469
+ ): Promise<string> {
470
+ const options = {
468
471
  content: JSON.stringify(data, null, 2),
469
472
  prefix: SNAPSHOT_SPILL_FILE_PREFIX,
470
473
  suffix: ".json",
471
- });
474
+ };
475
+ return persistentArtifactStore
476
+ ? await writePersistentSessionArtifactFile({ ...options, store: persistentArtifactStore })
477
+ : await writeSecureTempFile(options);
472
478
  }
473
479
 
474
480
  export function formatSnapshotSummary(data: Record<string, unknown>): string {
@@ -487,7 +493,10 @@ export function formatRawSnapshotText(data: Record<string, unknown>): string {
487
493
  return `Origin: ${origin}\nRefs: ${refs}\n\n${snapshot}`;
488
494
  }
489
495
 
490
- export async function buildSnapshotPresentation(data: Record<string, unknown>): Promise<ToolPresentation> {
496
+ export async function buildSnapshotPresentation(
497
+ data: Record<string, unknown>,
498
+ persistentArtifactStore: PersistentSessionArtifactStore | undefined = undefined,
499
+ ): Promise<ToolPresentation> {
491
500
  const summary = formatSnapshotSummary(data);
492
501
  const rawText = formatRawSnapshotText(data);
493
502
  if (!shouldCompactSnapshot(rawText, data)) {
@@ -501,7 +510,7 @@ export async function buildSnapshotPresentation(data: Record<string, unknown>):
501
510
  let fullOutputPath: string | undefined;
502
511
  let spillErrorText: string | undefined;
503
512
  try {
504
- fullOutputPath = await writeSnapshotSpillFile(data);
513
+ fullOutputPath = await writeSnapshotSpillFile(data, persistentArtifactStore);
505
514
  } catch (error) {
506
515
  spillErrorText = error instanceof Error ? error.message : String(error);
507
516
  }
@@ -10,6 +10,8 @@ import { createHash, randomUUID } from "node:crypto";
10
10
  import { basename } from "node:path";
11
11
 
12
12
  const STARTUP_SCOPED_FLAGS = ["--cdp", "--profile", "--session-name"] as const;
13
+ const OPEN_COMMANDS = new Set(["goto", "navigate", "open"]);
14
+ const OPENAI_HEADLESS_COMPAT_HOSTS = new Set(["chat.openai.com", "chatgpt.com"]);
13
15
  const BRAVE_API_KEY_ENV = "BRAVE_API_KEY";
14
16
  const AGENT_BROWSER_IDLE_TIMEOUT_ENV = "AGENT_BROWSER_IDLE_TIMEOUT_MS";
15
17
  const IMPLICIT_SESSION_IDLE_TIMEOUT_ENV = "PI_AGENT_BROWSER_IMPLICIT_SESSION_IDLE_TIMEOUT_MS";
@@ -57,7 +59,21 @@ const GLOBAL_FLAGS_WITH_VALUES = new Set([
57
59
  "--color-scheme",
58
60
  "--device",
59
61
  "--port",
62
+ "--args",
63
+ "--user-agent",
64
+ "--allowed-domains",
65
+ "--action-policy",
66
+ "--confirm-actions",
67
+ "--max-output",
68
+ "--model",
60
69
  ]);
70
+ const DEFAULT_HEADLESS_COMPAT_USER_AGENT_BY_PLATFORM: Partial<Record<NodeJS.Platform, string>> = {
71
+ darwin: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
72
+ linux: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
73
+ win32: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
74
+ };
75
+ const FALLBACK_HEADLESS_COMPAT_USER_AGENT =
76
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36";
61
77
  const SHELL_OPERATOR_TOKENS = new Set(["&&", "||", "|", ";", ">", ">>", "<"]);
62
78
  const MAX_PROJECT_SLUG_LENGTH = 24;
63
79
  const SESSION_NAME_CWD_HASH_LENGTH = 8;
@@ -84,8 +100,20 @@ export interface InvalidValueFlagDetails {
84
100
  receivedToken?: string;
85
101
  }
86
102
 
103
+ export interface CompatibilityWorkaround {
104
+ id: "chatgpt-headless-user-agent";
105
+ reason: string;
106
+ }
107
+
108
+ export interface OpenResultTabCorrection {
109
+ selectedIndex: number;
110
+ targetTitle?: string;
111
+ targetUrl: string;
112
+ }
113
+
87
114
  export interface ExecutionPlan {
88
115
  commandInfo: CommandInfo;
116
+ compatibilityWorkaround?: CompatibilityWorkaround;
89
117
  effectiveArgs: string[];
90
118
  invalidValueFlag?: InvalidValueFlagDetails;
91
119
  managedSessionName?: string;
@@ -467,6 +495,96 @@ function hasFlagToken(args: string[], flag: string): boolean {
467
495
  return args.some((token) => token === flag || token.startsWith(`${flag}=`));
468
496
  }
469
497
 
498
+ function getFlagValue(args: string[], flag: string): string | undefined {
499
+ for (const [index, token] of args.entries()) {
500
+ if (token === flag) {
501
+ return args[index + 1];
502
+ }
503
+ if (token.startsWith(`${flag}=`)) {
504
+ return token.slice(flag.length + 1);
505
+ }
506
+ }
507
+ return undefined;
508
+ }
509
+
510
+ function isBooleanFlagEnabled(args: string[], flag: string): boolean {
511
+ for (const [index, token] of args.entries()) {
512
+ if (token === flag) {
513
+ const nextToken = args[index + 1]?.trim().toLowerCase();
514
+ if (nextToken === "false") {
515
+ return false;
516
+ }
517
+ return true;
518
+ }
519
+ if (token.startsWith(`${flag}=`)) {
520
+ return token.slice(flag.length + 1).trim().toLowerCase() !== "false";
521
+ }
522
+ }
523
+ return false;
524
+ }
525
+
526
+ function normalizeComparableUrl(url: string): string | undefined {
527
+ const normalizedUrl = url.trim();
528
+ if (normalizedUrl.length === 0) {
529
+ return undefined;
530
+ }
531
+ try {
532
+ const parsedUrl = new URL(normalizedUrl);
533
+ parsedUrl.hash = "";
534
+ return parsedUrl.toString();
535
+ } catch {
536
+ return undefined;
537
+ }
538
+ }
539
+
540
+ function parseComparableNavigationUrl(url: string): URL | undefined {
541
+ try {
542
+ return new URL(url);
543
+ } catch {
544
+ try {
545
+ return new URL(`https://${url}`);
546
+ } catch {
547
+ return undefined;
548
+ }
549
+ }
550
+ }
551
+
552
+ function getDefaultHeadlessCompatUserAgent(platform: NodeJS.Platform = process.platform): string {
553
+ return DEFAULT_HEADLESS_COMPAT_USER_AGENT_BY_PLATFORM[platform] ?? FALLBACK_HEADLESS_COMPAT_USER_AGENT;
554
+ }
555
+
556
+ function getCompatibilityWorkaround(args: string[], commandInfo: CommandInfo): CompatibilityWorkaround | undefined {
557
+ if (!commandInfo.command || !OPEN_COMMANDS.has(commandInfo.command) || !commandInfo.subcommand) {
558
+ return undefined;
559
+ }
560
+ if (hasFlagToken(args, "--user-agent")) {
561
+ return undefined;
562
+ }
563
+ if (isBooleanFlagEnabled(args, "--headed")) {
564
+ return undefined;
565
+ }
566
+ if (hasFlagToken(args, "--cdp") || hasFlagToken(args, "--provider") || hasFlagToken(args, "-p") || hasFlagToken(args, "--auto-connect")) {
567
+ return undefined;
568
+ }
569
+ const engine = getFlagValue(args, "--engine");
570
+ if (engine && engine !== "chrome") {
571
+ return undefined;
572
+ }
573
+ const parsedTargetUrl = parseComparableNavigationUrl(commandInfo.subcommand);
574
+ if (!parsedTargetUrl || !["http:", "https:"].includes(parsedTargetUrl.protocol)) {
575
+ return undefined;
576
+ }
577
+ const hostname = parsedTargetUrl.hostname.toLowerCase();
578
+ if (!OPENAI_HEADLESS_COMPAT_HOSTS.has(hostname)) {
579
+ return undefined;
580
+ }
581
+ return {
582
+ id: "chatgpt-headless-user-agent",
583
+ reason:
584
+ "OpenAI web properties currently challenge the default headless Chrome user agent; inject a normal Chrome user agent to preserve the default headless workflow without requiring headed mode or auto-connect.",
585
+ };
586
+ }
587
+
470
588
  export function extractExplicitSessionName(args: string[]): string | undefined {
471
589
  for (const [index, token] of args.entries()) {
472
590
  if (token === "--session") {
@@ -556,6 +674,7 @@ export function buildExecutionPlan(
556
674
  const explicitSessionName = extractExplicitSessionName(args);
557
675
  const shouldCreateFreshManagedSession =
558
676
  !explicitSessionName && options.sessionMode === "fresh" && commandInfo.command !== undefined && commandInfo.command !== "close";
677
+ const compatibilityWorkaround = getCompatibilityWorkaround(args, commandInfo);
559
678
  let managedSessionName: string | undefined;
560
679
  let recoveryHint: SessionRecoveryHint | undefined;
561
680
  let sessionName = explicitSessionName;
@@ -587,10 +706,14 @@ export function buildExecutionPlan(
587
706
  sessionName = options.freshSessionName;
588
707
  }
589
708
 
709
+ if (compatibilityWorkaround) {
710
+ effectiveArgs.push("--user-agent", getDefaultHeadlessCompatUserAgent());
711
+ }
590
712
  effectiveArgs.push(...args);
591
713
 
592
714
  return {
593
715
  commandInfo,
716
+ compatibilityWorkaround,
594
717
  effectiveArgs,
595
718
  managedSessionName,
596
719
  plainTextInspection,
@@ -602,9 +725,54 @@ export function buildExecutionPlan(
602
725
  };
603
726
  }
604
727
 
728
+ export function chooseOpenResultTabCorrection(options: {
729
+ activeTabIndex?: number;
730
+ tabs: Array<{ active?: boolean; index?: number; title?: string; url?: string }>;
731
+ targetTitle?: string;
732
+ targetUrl?: string;
733
+ }): OpenResultTabCorrection | undefined {
734
+ const normalizedTargetUrl =
735
+ typeof options.targetUrl === "string" ? normalizeComparableUrl(options.targetUrl) : undefined;
736
+ if (!normalizedTargetUrl) {
737
+ return undefined;
738
+ }
739
+
740
+ const tabsWithIndices = options.tabs.map((tab, index) => ({
741
+ ...tab,
742
+ index: typeof tab.index === "number" ? tab.index : index,
743
+ }));
744
+ const activeTab =
745
+ tabsWithIndices.find((tab) => tab.active === true) ??
746
+ (typeof options.activeTabIndex === "number" ? tabsWithIndices.find((tab) => tab.index === options.activeTabIndex) : undefined);
747
+ if (activeTab && normalizeComparableUrl(activeTab.url ?? "") === normalizedTargetUrl) {
748
+ return undefined;
749
+ }
750
+
751
+ const matchingTabs = tabsWithIndices.filter((tab) => normalizeComparableUrl(tab.url ?? "") === normalizedTargetUrl);
752
+ if (matchingTabs.length === 0) {
753
+ return undefined;
754
+ }
755
+ const trimmedTargetTitle = typeof options.targetTitle === "string" ? options.targetTitle.trim() : "";
756
+ const titledMatch =
757
+ trimmedTargetTitle.length === 0
758
+ ? undefined
759
+ : matchingTabs.find((tab) => typeof tab.title === "string" && tab.title.trim() === trimmedTargetTitle);
760
+ const selectedTab = titledMatch ?? matchingTabs[0];
761
+ return selectedTab.index === undefined
762
+ ? undefined
763
+ : {
764
+ selectedIndex: selectedTab.index,
765
+ targetTitle: trimmedTargetTitle.length > 0 ? trimmedTargetTitle : undefined,
766
+ targetUrl: normalizedTargetUrl,
767
+ };
768
+ }
769
+
605
770
  export function parseCommandInfo(args: string[]): CommandInfo {
606
- const commands: string[] = [];
771
+ const commandTokens = extractCommandTokens(args);
772
+ return { command: commandTokens[0], subcommand: commandTokens[1] };
773
+ }
607
774
 
775
+ export function extractCommandTokens(args: string[]): string[] {
608
776
  for (let index = 0; index < args.length; index += 1) {
609
777
  const token = args[index];
610
778
  if (token.startsWith("--session=")) {
@@ -617,11 +785,7 @@ export function parseCommandInfo(args: string[]): CommandInfo {
617
785
  }
618
786
  continue;
619
787
  }
620
- commands.push(token);
621
- if (commands.length === 2) {
622
- break;
623
- }
788
+ return args.slice(index);
624
789
  }
625
-
626
- return { command: commands[0], subcommand: commands[1] };
790
+ return [];
627
791
  }
@@ -1,14 +1,14 @@
1
1
  /**
2
- * Purpose: Create private temporary files for the pi-agent-browser extension without leaking artifacts broadly on disk.
3
- * Responsibilities: Maintain a process-private temp root, stamp explicit ownership markers, enforce an aggregate temp-artifact disk budget, create securely permissioned temp files, prune explicitly owned stale temp roots from prior runs, and best-effort clean all owned roots on process exit.
4
- * Scope: Temporary artifact lifecycle only; callers decide what data to write and when to delete long-lived references.
2
+ * Purpose: Create private temporary and persisted spill files for the pi-agent-browser extension without leaking artifacts broadly on disk.
3
+ * Responsibilities: Maintain a process-private temp root, stamp explicit ownership markers, enforce an aggregate temp-artifact disk budget, create securely permissioned temp files, create session-scoped persisted spill files for resumable sessions, prune explicitly owned stale temp roots from prior runs, and best-effort clean all owned roots on process exit.
4
+ * Scope: Artifact lifecycle helpers only; callers decide what data to write and when to delete or retain long-lived references.
5
5
  * Usage: Imported by result/process helpers when they need secure spill files instead of world-readable shared tmp paths.
6
- * Invariants/Assumptions: Temp artifacts live under the OS temp directory, each active run uses a dedicated 0700 directory, files are created with exclusive 0600 permissions, and stale pruning only touches roots with an explicit pi-agent-browser ownership marker.
6
+ * Invariants/Assumptions: Temp artifacts live under the OS temp directory, each active run uses a dedicated 0700 directory, files are created with exclusive 0600 permissions, session-scoped persisted artifacts stay under the pi session directory, and stale pruning only touches roots with an explicit pi-agent-browser ownership marker.
7
7
  */
8
8
 
9
9
  import { randomBytes } from "node:crypto";
10
10
  import { rmSync } from "node:fs";
11
- import { chmod, mkdtemp, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
11
+ import { chmod, mkdir, mkdtemp, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
12
12
  import { tmpdir } from "node:os";
13
13
  import { dirname, join } from "node:path";
14
14
 
@@ -19,6 +19,15 @@ const TEMP_ROOT_MARKER_VERSION = 1;
19
19
  const STALE_TEMP_ROOT_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
20
20
  const TEMP_ROOT_MAX_BYTES_ENV = "PI_AGENT_BROWSER_TEMP_ROOT_MAX_BYTES";
21
21
  const DEFAULT_TEMP_ROOT_MAX_BYTES = 32 * 1_024 * 1_024;
22
+ const SESSION_ARTIFACT_MAX_BYTES_ENV = "PI_AGENT_BROWSER_SESSION_ARTIFACT_MAX_BYTES";
23
+ const DEFAULT_SESSION_ARTIFACT_MAX_BYTES = 32 * 1_024 * 1_024;
24
+ const SESSION_ARTIFACTS_ROOT_DIR_NAME = ".pi-agent-browser-artifacts";
25
+
26
+ export interface PersistentSessionArtifactStore {
27
+ protectedPaths?: readonly string[];
28
+ sessionDir: string;
29
+ sessionId: string;
30
+ }
22
31
 
23
32
  interface TempRootOwnershipRecord {
24
33
  createdAtMs: number;
@@ -69,18 +78,23 @@ function enqueueTempMutation<T>(task: () => Promise<T>): Promise<T> {
69
78
  return nextTask;
70
79
  }
71
80
 
72
- async function getTempRootArtifactBytes(tempRoot: string): Promise<number> {
73
- const entries = await readdir(tempRoot, { withFileTypes: true }).catch(() => []);
74
- let totalBytes = 0;
81
+ async function listArtifactFiles(directory: string, excludedNames: ReadonlySet<string> = new Set()): Promise<Array<{ mtimeMs: number; path: string; size: number }>> {
82
+ const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
83
+ const files: Array<{ mtimeMs: number; path: string; size: number }> = [];
75
84
  for (const entry of entries) {
76
- if (!entry.isFile() || entry.name === TEMP_ROOT_MARKER_FILE_NAME) continue;
77
- const path = join(tempRoot, entry.name);
85
+ if (!entry.isFile() || excludedNames.has(entry.name)) continue;
86
+ const path = join(directory, entry.name);
78
87
  const stats = await stat(path).catch(() => undefined);
79
88
  if (stats?.isFile()) {
80
- totalBytes += stats.size;
89
+ files.push({ mtimeMs: stats.mtimeMs, path, size: stats.size });
81
90
  }
82
91
  }
83
- return totalBytes;
92
+ return files;
93
+ }
94
+
95
+ async function getTempRootArtifactBytes(tempRoot: string): Promise<number> {
96
+ const files = await listArtifactFiles(tempRoot, new Set([TEMP_ROOT_MARKER_FILE_NAME]));
97
+ return files.reduce((totalBytes, file) => totalBytes + file.size, 0);
84
98
  }
85
99
 
86
100
  async function readTempRootOwnershipMarker(tempRoot: string): Promise<TempRootOwnershipRecord | undefined> {
@@ -153,6 +167,10 @@ export function getSecureTempRootMaxBytes(env: NodeJS.ProcessEnv = process.env):
153
167
  return parsePositiveInteger(env[TEMP_ROOT_MAX_BYTES_ENV]) ?? DEFAULT_TEMP_ROOT_MAX_BYTES;
154
168
  }
155
169
 
170
+ export function getPersistentSessionArtifactMaxBytes(env: NodeJS.ProcessEnv = process.env): number {
171
+ return parsePositiveInteger(env[SESSION_ARTIFACT_MAX_BYTES_ENV]) ?? DEFAULT_SESSION_ARTIFACT_MAX_BYTES;
172
+ }
173
+
156
174
  async function assertSecureTempRootBudget(tempRoot: string, additionalBytes: number): Promise<void> {
157
175
  if (additionalBytes <= 0) return;
158
176
  const currentBytes = await getTempRootArtifactBytes(tempRoot);
@@ -173,6 +191,42 @@ export async function cleanupSecureTempArtifacts(): Promise<void> {
173
191
  });
174
192
  }
175
193
 
194
+ async function ensurePersistentSessionArtifactDir(store: PersistentSessionArtifactStore): Promise<string> {
195
+ const rootDir = join(store.sessionDir, SESSION_ARTIFACTS_ROOT_DIR_NAME);
196
+ const sessionDir = join(rootDir, store.sessionId);
197
+ await mkdir(rootDir, { recursive: true, mode: 0o700 });
198
+ await chmod(rootDir, 0o700).catch(() => undefined);
199
+ await mkdir(sessionDir, { recursive: true, mode: 0o700 });
200
+ await chmod(sessionDir, 0o700).catch(() => undefined);
201
+ return sessionDir;
202
+ }
203
+
204
+ async function prunePersistentSessionArtifactsToBudget(
205
+ sessionArtifactDir: string,
206
+ additionalBytes: number,
207
+ protectedPaths: ReadonlySet<string>,
208
+ ): Promise<void> {
209
+ if (additionalBytes <= 0) return;
210
+ const maxBytes = getPersistentSessionArtifactMaxBytes();
211
+ let files = await listArtifactFiles(sessionArtifactDir);
212
+ let totalBytes = files.reduce((total, file) => total + file.size, 0);
213
+ if (totalBytes + additionalBytes <= maxBytes) {
214
+ return;
215
+ }
216
+ files = files.sort((left, right) => left.mtimeMs - right.mtimeMs || left.path.localeCompare(right.path));
217
+ for (const file of files) {
218
+ if (protectedPaths.has(file.path)) {
219
+ continue;
220
+ }
221
+ await rm(file.path, { force: true }).catch(() => undefined);
222
+ totalBytes -= file.size;
223
+ if (totalBytes + additionalBytes <= maxBytes) {
224
+ return;
225
+ }
226
+ }
227
+ throw new Error(`pi-agent-browser persisted spill budget exceeded (${totalBytes + additionalBytes} bytes > ${maxBytes} byte limit).`);
228
+ }
229
+
176
230
  async function getSessionTempRoot(): Promise<string> {
177
231
  if (!sessionTempRootPromise) {
178
232
  sessionTempRootPromise = (async () => {
@@ -228,6 +282,34 @@ export async function writeSecureTempFile(options: {
228
282
  return path;
229
283
  }
230
284
 
285
+ export async function writePersistentSessionArtifactFile(options: {
286
+ content: string | Uint8Array;
287
+ prefix: string;
288
+ store: PersistentSessionArtifactStore;
289
+ suffix: string;
290
+ }): Promise<string> {
291
+ const { content, prefix, store, suffix } = options;
292
+ return await enqueueTempMutation(async () => {
293
+ const artifactDir = await ensurePersistentSessionArtifactDir(store);
294
+ await prunePersistentSessionArtifactsToBudget(
295
+ artifactDir,
296
+ getTempArtifactByteLength(content),
297
+ new Set((store.protectedPaths ?? []).filter((path) => dirname(path) === artifactDir)),
298
+ );
299
+ const path = join(artifactDir, `${prefix}-${randomBytes(8).toString("hex")}${suffix}`);
300
+ const fileHandle = await open(path, "wx", 0o600);
301
+ try {
302
+ await fileHandle.writeFile(content);
303
+ } catch (error) {
304
+ await rm(path, { force: true }).catch(() => undefined);
305
+ throw error;
306
+ } finally {
307
+ await fileHandle.close().catch(() => undefined);
308
+ }
309
+ return path;
310
+ });
311
+ }
312
+
231
313
  export async function getSecureTempDebugState(): Promise<{ currentTempRoot?: string; ownedTempRoots: string[] }> {
232
314
  return {
233
315
  currentTempRoot: await sessionTempRootPromise?.catch(() => undefined),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-browser-native",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "pi extension that exposes agent-browser as a native tool for browser automation",
5
5
  "type": "module",
6
6
  "author": "Mitch Fultz (https://github.com/fitchmultz)",