pi-agent-browser-native 0.2.11 → 0.2.13

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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Purpose: Share stable result-rendering types and small data-shaping helpers across the focused result modules.
3
- * Responsibilities: Define upstream envelope/presentation types, provide safe record/string utilities, and expose lightweight text helpers used by envelope parsing, snapshot compaction, and presentation rendering.
3
+ * Responsibilities: Define upstream envelope/presentation types, provide safe string utilities, and expose lightweight text helpers used by envelope parsing, snapshot compaction, and presentation rendering.
4
4
  * Scope: Shared result helpers only; higher-level parsing, snapshot compaction, and image attachment orchestration live in neighboring modules.
5
5
  * Usage: Imported by the focused result modules that back the public `lib/results.ts` facade.
6
6
  * Invariants/Assumptions: Helpers stay generic, side-effect free, and small enough to reuse without reintroducing a new god module.
@@ -9,7 +9,7 @@
9
9
  export interface AgentBrowserEnvelope {
10
10
  data?: unknown;
11
11
  error?: unknown;
12
- success?: boolean;
12
+ success: boolean;
13
13
  }
14
14
 
15
15
  export interface AgentBrowserBatchResult {
@@ -19,7 +19,164 @@ export interface AgentBrowserBatchResult {
19
19
  success?: boolean;
20
20
  }
21
21
 
22
+ export type FileArtifactKind = "download" | "file" | "har" | "image" | "pdf" | "profile" | "trace" | "video";
23
+
24
+ export interface FileArtifactMetadata {
25
+ absolutePath: string;
26
+ command?: string;
27
+ exists?: boolean;
28
+ extension?: string;
29
+ kind: FileArtifactKind;
30
+ mediaType?: string;
31
+ path: string;
32
+ sizeBytes?: number;
33
+ subcommand?: string;
34
+ }
35
+
36
+ export interface SavedFilePresentationDetails {
37
+ command: "download" | "pdf" | "wait";
38
+ kind: "download" | "pdf";
39
+ metadata?: Record<string, unknown>;
40
+ path: string;
41
+ subcommand?: string;
42
+ }
43
+
44
+ export type ArtifactRetentionState = "evicted" | "ephemeral" | "live" | "missing";
45
+
46
+ export type ArtifactStorageScope = "explicit-path" | "persistent-session" | "process-temp";
47
+
48
+ export interface SessionArtifactManifestEntry {
49
+ absolutePath?: string;
50
+ command?: string;
51
+ createdAtMs: number;
52
+ evictedAtMs?: number;
53
+ exists?: boolean;
54
+ extension?: string;
55
+ kind: FileArtifactKind | "spill";
56
+ mediaType?: string;
57
+ path: string;
58
+ retentionState: ArtifactRetentionState;
59
+ sizeBytes?: number;
60
+ storageScope: ArtifactStorageScope;
61
+ subcommand?: string;
62
+ }
63
+
64
+ export interface SessionArtifactManifest {
65
+ entries: SessionArtifactManifestEntry[];
66
+ evictedCount: number;
67
+ liveCount: number;
68
+ maxEntries: number;
69
+ updatedAtMs: number;
70
+ version: 1;
71
+ }
72
+
73
+ export const SESSION_ARTIFACT_MANIFEST_VERSION = 1;
74
+ export const SESSION_ARTIFACT_MANIFEST_MAX_ENTRIES_ENV = "PI_AGENT_BROWSER_SESSION_ARTIFACT_MANIFEST_MAX_ENTRIES";
75
+ export const DEFAULT_SESSION_ARTIFACT_MANIFEST_MAX_ENTRIES = 100;
76
+
77
+ function parsePositiveSafeInteger(value: string | undefined): number | undefined {
78
+ if (value === undefined) return undefined;
79
+ const parsed = Number(value);
80
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) return undefined;
81
+ return parsed;
82
+ }
83
+
84
+ export function getSessionArtifactManifestMaxEntries(env: NodeJS.ProcessEnv = process.env): number {
85
+ return parsePositiveSafeInteger(env[SESSION_ARTIFACT_MANIFEST_MAX_ENTRIES_ENV]) ?? DEFAULT_SESSION_ARTIFACT_MANIFEST_MAX_ENTRIES;
86
+ }
87
+
88
+ function isRecord(value: unknown): value is Record<string, unknown> {
89
+ return typeof value === "object" && value !== null;
90
+ }
91
+
92
+ function isManifestEntry(value: unknown): value is SessionArtifactManifestEntry {
93
+ if (!isRecord(value)) return false;
94
+ if (typeof value.path !== "string" || value.path.trim().length === 0) return false;
95
+ if (typeof value.createdAtMs !== "number" || !Number.isFinite(value.createdAtMs)) return false;
96
+ if (!["evicted", "ephemeral", "live", "missing"].includes(String(value.retentionState))) return false;
97
+ if (!["explicit-path", "persistent-session", "process-temp"].includes(String(value.storageScope))) return false;
98
+ if (typeof value.kind !== "string" || value.kind.trim().length === 0) return false;
99
+ return true;
100
+ }
101
+
102
+ export function isSessionArtifactManifest(value: unknown): value is SessionArtifactManifest {
103
+ if (!isRecord(value)) return false;
104
+ if (value.version !== SESSION_ARTIFACT_MANIFEST_VERSION) return false;
105
+ if (!Array.isArray(value.entries) || !value.entries.every(isManifestEntry)) return false;
106
+ if (typeof value.updatedAtMs !== "number" || !Number.isFinite(value.updatedAtMs)) return false;
107
+ if (typeof value.maxEntries !== "number" || !Number.isSafeInteger(value.maxEntries) || value.maxEntries <= 0) return false;
108
+ if (typeof value.liveCount !== "number" || !Number.isSafeInteger(value.liveCount) || value.liveCount < 0) return false;
109
+ if (typeof value.evictedCount !== "number" || !Number.isSafeInteger(value.evictedCount) || value.evictedCount < 0) return false;
110
+ return true;
111
+ }
112
+
113
+ export function buildEvictedSessionArtifactEntries(
114
+ evictedArtifacts: Array<{ mtimeMs: number; path: string; sizeBytes: number }>,
115
+ nowMs: number,
116
+ ): SessionArtifactManifestEntry[] {
117
+ return evictedArtifacts.map((artifact) => ({
118
+ createdAtMs: artifact.mtimeMs,
119
+ evictedAtMs: nowMs,
120
+ kind: "spill",
121
+ path: artifact.path,
122
+ retentionState: "evicted",
123
+ sizeBytes: artifact.sizeBytes,
124
+ storageScope: "persistent-session",
125
+ }));
126
+ }
127
+
128
+ export function formatSessionArtifactRetentionSummary(manifest: SessionArtifactManifest): string {
129
+ const ephemeralCount = manifest.entries.filter((entry) => entry.retentionState === "ephemeral").length;
130
+ const missingCount = manifest.entries.filter((entry) => entry.retentionState === "missing").length;
131
+ const parts = [`${manifest.liveCount} live`, `${manifest.evictedCount} evicted`];
132
+ if (ephemeralCount > 0) parts.push(`${ephemeralCount} ephemeral`);
133
+ if (missingCount > 0) parts.push(`${missingCount} missing`);
134
+ return `Session artifacts: ${parts.join(", ")} (${manifest.entries.length}/${manifest.maxEntries} recent).`;
135
+ }
136
+
137
+ export function mergeSessionArtifactManifest(options: {
138
+ base?: SessionArtifactManifest;
139
+ entries?: SessionArtifactManifestEntry[];
140
+ nowMs?: number;
141
+ }): SessionArtifactManifest | undefined {
142
+ const nowMs = options.nowMs ?? Date.now();
143
+ const maxEntries = getSessionArtifactManifestMaxEntries();
144
+ const getEntryKey = (entry: SessionArtifactManifestEntry) =>
145
+ entry.storageScope === "explicit-path" && entry.absolutePath ? `${entry.storageScope}:${entry.absolutePath}` : `${entry.storageScope}:${entry.path}`;
146
+ const byPath = new Map<string, SessionArtifactManifestEntry>();
147
+ for (const entry of options.base?.entries ?? []) {
148
+ byPath.set(getEntryKey(entry), entry);
149
+ }
150
+ for (const entry of options.entries ?? []) {
151
+ const key = getEntryKey(entry);
152
+ const existing = byPath.get(key);
153
+ byPath.set(key, {
154
+ ...existing,
155
+ ...entry,
156
+ createdAtMs: existing?.createdAtMs ?? entry.createdAtMs,
157
+ evictedAtMs: entry.retentionState === "evicted" ? (entry.evictedAtMs ?? nowMs) : entry.evictedAtMs,
158
+ });
159
+ }
160
+ if (byPath.size === 0) return undefined;
161
+ const entries = [...byPath.values()]
162
+ .sort((left, right) => {
163
+ const leftTime = left.evictedAtMs ?? left.createdAtMs;
164
+ const rightTime = right.evictedAtMs ?? right.createdAtMs;
165
+ return rightTime - leftTime || left.path.localeCompare(right.path);
166
+ })
167
+ .slice(0, maxEntries);
168
+ return {
169
+ entries,
170
+ evictedCount: entries.filter((entry) => entry.retentionState === "evicted").length,
171
+ liveCount: entries.filter((entry) => entry.retentionState === "live").length,
172
+ maxEntries,
173
+ updatedAtMs: nowMs,
174
+ version: SESSION_ARTIFACT_MANIFEST_VERSION,
175
+ };
176
+ }
177
+
22
178
  export interface BatchStepPresentationDetails {
179
+ artifacts?: FileArtifactMetadata[];
23
180
  command?: string[];
24
181
  commandText: string;
25
182
  data?: unknown;
@@ -28,6 +185,8 @@ export interface BatchStepPresentationDetails {
28
185
  imagePath?: string;
29
186
  imagePaths?: string[];
30
187
  index: number;
188
+ savedFile?: SavedFilePresentationDetails;
189
+ savedFilePath?: string;
31
190
  success: boolean;
32
191
  summary: string;
33
192
  text: string;
@@ -41,6 +200,9 @@ export interface BatchFailurePresentationDetails {
41
200
  }
42
201
 
43
202
  export interface ToolPresentation {
203
+ artifactManifest?: SessionArtifactManifest;
204
+ artifactRetentionSummary?: string;
205
+ artifacts?: FileArtifactMetadata[];
44
206
  batchFailure?: BatchFailurePresentationDetails;
45
207
  batchSteps?: BatchStepPresentationDetails[];
46
208
  content: Array<{ text: string; type: "text" } | { data: string; mimeType: string; type: "image" }>;
@@ -49,13 +211,11 @@ export interface ToolPresentation {
49
211
  fullOutputPaths?: string[];
50
212
  imagePath?: string;
51
213
  imagePaths?: string[];
214
+ savedFile?: SavedFilePresentationDetails;
215
+ savedFilePath?: string;
52
216
  summary: string;
53
217
  }
54
218
 
55
- export function isRecord(value: unknown): value is Record<string, unknown> {
56
- return typeof value === "object" && value !== null;
57
- }
58
-
59
219
  export function stringifyUnknown(value: unknown): string {
60
220
  if (typeof value === "string") return value;
61
221
  if (typeof value === "number" || typeof value === "boolean") return String(value);
@@ -67,15 +227,6 @@ export function stringifyUnknown(value: unknown): string {
67
227
  }
68
228
  }
69
229
 
70
- export function parsePositiveInteger(rawValue: string | undefined): number | undefined {
71
- if (typeof rawValue !== "string") return undefined;
72
- const normalizedValue = rawValue.trim();
73
- if (!/^\d+$/.test(normalizedValue)) return undefined;
74
- const parsedValue = Number(normalizedValue);
75
- if (!Number.isSafeInteger(parsedValue) || parsedValue <= 0) return undefined;
76
- return parsedValue;
77
- }
78
-
79
230
  export function countLines(text: string): number {
80
231
  return text.length === 0 ? 0 : text.split("\n").length;
81
232
  }
@@ -6,8 +6,25 @@
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 { type PersistentSessionArtifactStore, writePersistentSessionArtifactFile, writeSecureTempFile } from "../temp.js";
10
- import { type ToolPresentation, compareRefIds, countLines, isRecord, normalizeWhitespace, truncateText } from "./shared.js";
9
+ import { isRecord } from "../parsing.js";
10
+ import {
11
+ type PersistentSessionArtifactEviction,
12
+ type PersistentSessionArtifactStore,
13
+ writePersistentSessionArtifactFile,
14
+ writeSecureTempFile,
15
+ } from "../temp.js";
16
+ import {
17
+ type SessionArtifactManifest,
18
+ type SessionArtifactManifestEntry,
19
+ type ToolPresentation,
20
+ buildEvictedSessionArtifactEntries,
21
+ compareRefIds,
22
+ countLines,
23
+ formatSessionArtifactRetentionSummary,
24
+ mergeSessionArtifactManifest,
25
+ normalizeWhitespace,
26
+ truncateText,
27
+ } from "./shared.js";
11
28
 
12
29
  const SNAPSHOT_INLINE_MAX_CHARS = 6_000;
13
30
  const SNAPSHOT_INLINE_MAX_LINES = 80;
@@ -463,18 +480,49 @@ function canUseStructuredSnapshotPreview(snapshotLines: SnapshotLine[], refEntri
463
480
  );
464
481
  }
465
482
 
483
+ interface SnapshotSpillWriteResult {
484
+ evictedArtifacts: PersistentSessionArtifactEviction[];
485
+ path: string;
486
+ storageScope: "persistent-session" | "process-temp";
487
+ }
488
+
466
489
  async function writeSnapshotSpillFile(
467
490
  data: Record<string, unknown>,
468
491
  persistentArtifactStore: PersistentSessionArtifactStore | undefined,
469
- ): Promise<string> {
492
+ ): Promise<SnapshotSpillWriteResult> {
470
493
  const options = {
471
494
  content: JSON.stringify(data, null, 2),
472
495
  prefix: SNAPSHOT_SPILL_FILE_PREFIX,
473
496
  suffix: ".json",
474
497
  };
475
- return persistentArtifactStore
476
- ? await writePersistentSessionArtifactFile({ ...options, store: persistentArtifactStore })
477
- : await writeSecureTempFile(options);
498
+ if (persistentArtifactStore) {
499
+ const result = await writePersistentSessionArtifactFile({ ...options, store: persistentArtifactStore });
500
+ return { ...result, storageScope: "persistent-session" };
501
+ }
502
+ return { evictedArtifacts: [], path: await writeSecureTempFile(options), storageScope: "process-temp" };
503
+ }
504
+
505
+ function applySnapshotArtifactManifest(options: {
506
+ baseManifest?: SessionArtifactManifest;
507
+ command?: string;
508
+ fullOutputPath?: string;
509
+ spill?: SnapshotSpillWriteResult;
510
+ }): { artifactManifest?: SessionArtifactManifest; artifactRetentionSummary?: string } {
511
+ if (!options.fullOutputPath || !options.spill) return {};
512
+ const nowMs = Date.now();
513
+ const entries: SessionArtifactManifestEntry[] = [
514
+ {
515
+ command: options.command,
516
+ createdAtMs: nowMs,
517
+ kind: "spill",
518
+ path: options.fullOutputPath,
519
+ retentionState: options.spill.storageScope === "persistent-session" ? "live" : "ephemeral",
520
+ storageScope: options.spill.storageScope,
521
+ },
522
+ ...buildEvictedSessionArtifactEntries(options.spill.evictedArtifacts, nowMs),
523
+ ];
524
+ const artifactManifest = mergeSessionArtifactManifest({ base: options.baseManifest, entries, nowMs });
525
+ return artifactManifest ? { artifactManifest, artifactRetentionSummary: formatSessionArtifactRetentionSummary(artifactManifest) } : {};
478
526
  }
479
527
 
480
528
  export function formatSnapshotSummary(data: Record<string, unknown>): string {
@@ -496,6 +544,7 @@ export function formatRawSnapshotText(data: Record<string, unknown>): string {
496
544
  export async function buildSnapshotPresentation(
497
545
  data: Record<string, unknown>,
498
546
  persistentArtifactStore: PersistentSessionArtifactStore | undefined = undefined,
547
+ artifactManifest: SessionArtifactManifest | undefined = undefined,
499
548
  ): Promise<ToolPresentation> {
500
549
  const summary = formatSnapshotSummary(data);
501
550
  const rawText = formatRawSnapshotText(data);
@@ -508,9 +557,11 @@ export async function buildSnapshotPresentation(
508
557
  }
509
558
 
510
559
  let fullOutputPath: string | undefined;
560
+ let spill: SnapshotSpillWriteResult | undefined;
511
561
  let spillErrorText: string | undefined;
512
562
  try {
513
- fullOutputPath = await writeSnapshotSpillFile(data, persistentArtifactStore);
563
+ spill = await writeSnapshotSpillFile(data, persistentArtifactStore);
564
+ fullOutputPath = spill.path;
514
565
  } catch (error) {
515
566
  spillErrorText = error instanceof Error ? error.message : String(error);
516
567
  }
@@ -618,7 +669,18 @@ export async function buildSnapshotPresentation(
618
669
  : `Full raw snapshot unavailable: ${spillErrorText ?? "temp spill file could not be created."}`,
619
670
  );
620
671
 
672
+ const manifestFields = applySnapshotArtifactManifest({
673
+ baseManifest: artifactManifest,
674
+ command: "snapshot",
675
+ fullOutputPath,
676
+ spill,
677
+ });
678
+ if (manifestFields.artifactRetentionSummary) {
679
+ lines.push("", manifestFields.artifactRetentionSummary);
680
+ }
681
+
621
682
  return {
683
+ ...manifestFields,
622
684
  content: [{ type: "text", text: lines.join("\n") }],
623
685
  data: {
624
686
  compacted: true,
@@ -8,4 +8,10 @@
8
8
 
9
9
  export { getAgentBrowserErrorText, parseAgentBrowserEnvelope } from "./results/envelope.js";
10
10
  export { buildToolPresentation } from "./results/presentation.js";
11
- export type { AgentBrowserBatchResult, AgentBrowserEnvelope, ToolPresentation } from "./results/shared.js";
11
+ export type {
12
+ AgentBrowserBatchResult,
13
+ AgentBrowserEnvelope,
14
+ FileArtifactKind,
15
+ FileArtifactMetadata,
16
+ ToolPresentation,
17
+ } from "./results/shared.js";
@@ -3,13 +3,63 @@
3
3
  * Responsibilities: Validate raw tool arguments, derive extension-managed session names from the pi session identity, restore managed-session state from persisted tool details, redact sensitive invocation text, classify browser-oriented prompts, and build the effective CLI argument list passed to the upstream agent-browser binary.
4
4
  * Scope: Pure runtime-planning helpers only; no subprocess execution or filesystem access lives here.
5
5
  * Usage: Imported by the extension entrypoint and unit tests before spawning the upstream CLI.
6
- * Invariants/Assumptions: The wrapper stays thin, preserves upstream command vocabulary, keeps plain-text inspection stateless, and only injects `--json` plus an extension-managed `--session` when appropriate.
6
+ * Invariants/Assumptions: The wrapper stays thin, preserves upstream command vocabulary, keeps plain-text inspection stateless,
7
+ * and only injects wrapper-owned flags: `--json`, an extension-managed `--session` when appropriate, and the narrow
8
+ * OpenAI/ChatGPT headless compatibility `--user-agent` when that workaround applies.
7
9
  */
8
10
 
9
11
  import { createHash, randomUUID } from "node:crypto";
10
12
  import { basename } from "node:path";
11
13
 
12
- const STARTUP_SCOPED_FLAGS = ["--cdp", "--profile", "--session-name"] as const;
14
+ import { isRecord } from "./parsing.js";
15
+
16
+ /**
17
+ * Launch-scoped flags that select the upstream browser session/auth mechanism at launch time.
18
+ *
19
+ * These flags must not be silently appended after an already-active extension-managed session
20
+ * because upstream ignores or conflicts with them once a session is reused. Every flag here
21
+ * participates in implicit-session validation blocking and recovery-hint generation.
22
+ *
23
+ * Intentionally excluded from the tab-correction subset:
24
+ * - `--auto-connect` attaches to a running browser but is a general-purpose debug/attach mode,
25
+ * not a state-restore mechanism that typically leaves restored tabs stealing focus.
26
+ * - `--cdp` connects to an arbitrary endpoint; similar reasoning to `--auto-connect`.
27
+ *
28
+ * Other flags like `--headed`, `--engine`, `--executable-path`, `--user-agent`, and
29
+ * `--download-path` are first-launch-sensitive but not alternate session/auth attach
30
+ * mechanisms, so they are intentionally excluded from the full launch-scoped set.
31
+ */
32
+ const LAUNCH_SCOPED_FLAG_DEFINITIONS = [
33
+ {
34
+ flag: "--auto-connect",
35
+ reason: "attaches to an already-running browser at launch time instead of reusing an existing named session",
36
+ },
37
+ {
38
+ flag: "--cdp",
39
+ reason: "selects the browser/CDP endpoint used when an upstream session is launched",
40
+ },
41
+ {
42
+ flag: "--profile",
43
+ reason: "selects Chrome profile state for the upstream launch",
44
+ },
45
+ {
46
+ flag: "--session-name",
47
+ reason: "selects upstream saved auth/session state for the launch",
48
+ },
49
+ {
50
+ flag: "--state",
51
+ reason: "loads persisted upstream browser/auth state at launch time",
52
+ },
53
+ ] as const;
54
+
55
+ const LAUNCH_SCOPED_FLAG_LABEL = LAUNCH_SCOPED_FLAG_DEFINITIONS.map((definition) => definition.flag).join(", ");
56
+
57
+ /**
58
+ * The subset of launch-scoped flags that can restore browser/auth state with pre-existing tabs
59
+ * and are plausible wrong-active-tab sources after a fresh launch. These trigger post-open
60
+ * tab-correction (the `tab list` + re-select cycle).
61
+ */
62
+ const LAUNCH_SCOPED_TAB_CORRECTION_FLAGS = new Set(["--profile", "--session-name", "--state"] as const);
13
63
  const OPEN_COMMANDS = new Set(["goto", "navigate", "open"]);
14
64
  const OPENAI_HEADLESS_COMPAT_HOSTS = new Set(["chat.com", "chat.openai.com", "chatgpt.com"]);
15
65
  const BRAVE_API_KEY_ENV = "BRAVE_API_KEY";
@@ -140,10 +190,6 @@ export interface PromptPolicy {
140
190
  allowLegacyAgentBrowserBash: boolean;
141
191
  }
142
192
 
143
- function isRecord(value: unknown): value is Record<string, unknown> {
144
- return typeof value === "object" && value !== null;
145
- }
146
-
147
193
  function isStringArray(value: unknown): value is string[] {
148
194
  return Array.isArray(value) && value.every((item) => typeof item === "string");
149
195
  }
@@ -164,23 +210,26 @@ function redactUrlToken(token: string): string {
164
210
  return token;
165
211
  }
166
212
 
213
+ let mutated = false;
167
214
  if (parsed.username.length > 0) {
168
215
  parsed.username = "[REDACTED]";
216
+ mutated = true;
169
217
  }
170
218
  if (parsed.password.length > 0) {
171
219
  parsed.password = "[REDACTED]";
220
+ mutated = true;
172
221
  }
173
222
 
174
223
  for (const [name] of parsed.searchParams) {
175
224
  if (shouldRedactQueryParam(name)) {
176
225
  parsed.searchParams.set(name, "[REDACTED]");
226
+ mutated = true;
177
227
  }
178
228
  }
179
229
 
180
230
  const hashText = parsed.hash.startsWith("#") ? parsed.hash.slice(1) : parsed.hash;
181
231
  if (hashText.includes("=")) {
182
232
  const hashParams = new URLSearchParams(hashText);
183
- let mutated = false;
184
233
  for (const [name] of hashParams) {
185
234
  if (shouldRedactQueryParam(name)) {
186
235
  hashParams.set(name, "[REDACTED]");
@@ -199,10 +248,95 @@ function redactLooseUrlMatches(text: string): string {
199
248
  return text.replace(/\b(?:https?|wss?):\/\/[^\s"'`<>\])]+/g, (match) => redactUrlToken(match));
200
249
  }
201
250
 
251
+ function findBalancedJsonEnd(text: string, startIndex: number): number | undefined {
252
+ const opener = text[startIndex];
253
+ const closer = opener === "{" ? "}" : opener === "[" ? "]" : undefined;
254
+ if (!closer) return undefined;
255
+ const stack = [closer];
256
+ let inString = false;
257
+ let escaped = false;
258
+ for (let index = startIndex + 1; index < text.length; index += 1) {
259
+ const char = text[index];
260
+ if (inString) {
261
+ if (escaped) {
262
+ escaped = false;
263
+ continue;
264
+ }
265
+ if (char === "\\") {
266
+ escaped = true;
267
+ continue;
268
+ }
269
+ if (char === '"') {
270
+ inString = false;
271
+ }
272
+ continue;
273
+ }
274
+ if (char === '"') {
275
+ inString = true;
276
+ continue;
277
+ }
278
+ if (char === "{") {
279
+ stack.push("}");
280
+ continue;
281
+ }
282
+ if (char === "[") {
283
+ stack.push("]");
284
+ continue;
285
+ }
286
+ if (char === "}" || char === "]") {
287
+ if (stack.pop() !== char) return undefined;
288
+ if (stack.length === 0) return index;
289
+ }
290
+ }
291
+ return undefined;
292
+ }
293
+
294
+ function redactEmbeddedStructuredText(text: string): string {
295
+ let output = "";
296
+ let cursor = 0;
297
+ while (cursor < text.length) {
298
+ const char = text[cursor];
299
+ if (char !== "{" && char !== "[") {
300
+ output += char;
301
+ cursor += 1;
302
+ continue;
303
+ }
304
+ const endIndex = findBalancedJsonEnd(text, cursor);
305
+ if (endIndex === undefined) {
306
+ output += char;
307
+ cursor += 1;
308
+ continue;
309
+ }
310
+ const candidate = text.slice(cursor, endIndex + 1);
311
+ try {
312
+ const parsed = JSON.parse(candidate) as unknown;
313
+ const redacted = typeof parsed === "string" ? redactSensitiveText(parsed) : JSON.stringify(redactSensitiveValue(parsed));
314
+ const original = typeof parsed === "string" ? parsed : JSON.stringify(parsed);
315
+ output += redacted === original ? candidate : redacted;
316
+ } catch {
317
+ output += candidate;
318
+ }
319
+ cursor = endIndex + 1;
320
+ }
321
+ return output;
322
+ }
323
+
324
+ function redactStandaloneBasicCredential(text: string): string {
325
+ return text.replace(/\b(Basic)\s+([A-Za-z0-9+/=]{12,})/gi, (match, label: string, credential: string) => {
326
+ if (!/[0-9+/=]/.test(credential)) return match;
327
+ return `${label} [REDACTED]`;
328
+ });
329
+ }
330
+
202
331
  export function redactSensitiveText(text: string): string {
203
- return redactLooseUrlMatches(text)
204
- .replace(/\b(Bearer)\s+[^\s",]+/gi, "$1 [REDACTED]")
205
- .replace(/\b(Basic)\s+[^\s",]+/gi, "$1 [REDACTED]");
332
+ return redactEmbeddedStructuredText(
333
+ redactStandaloneBasicCredential(
334
+ redactLooseUrlMatches(text)
335
+ .replace(/\b(Bearer)\s+[^\s",]+/gi, "$1 [REDACTED]")
336
+ .replace(/\b(Authorization\s*:\s*Basic)\s+[^\s",]+/gi, "$1 [REDACTED]")
337
+ .replace(/\b(Cookie|Set-Cookie)\s*:\s*[^\n\r"]+/gi, "$1: [REDACTED]"),
338
+ ),
339
+ );
206
340
  }
207
341
 
208
342
  export function redactSensitiveValue(value: unknown): unknown {
@@ -327,6 +461,27 @@ function isRestorableManagedSessionName(sessionName: string, fallbackSessionName
327
461
  return sessionName === fallbackSessionName || sessionName.startsWith(`${fallbackSessionName}-fresh-`);
328
462
  }
329
463
 
464
+ function getManagedSessionRestoreRank(options: {
465
+ fallbackSessionName: string;
466
+ freshSessionRanks: Map<string, number>;
467
+ sessionName: string;
468
+ }): number | undefined {
469
+ const { fallbackSessionName, freshSessionRanks, sessionName } = options;
470
+ if (sessionName === fallbackSessionName) {
471
+ return 0;
472
+ }
473
+ if (!sessionName.startsWith(`${fallbackSessionName}-fresh-`)) {
474
+ return undefined;
475
+ }
476
+ const existingRank = freshSessionRanks.get(sessionName);
477
+ if (existingRank !== undefined) {
478
+ return existingRank;
479
+ }
480
+ const nextRank = freshSessionRanks.size + 1;
481
+ freshSessionRanks.set(sessionName, nextRank);
482
+ return nextRank;
483
+ }
484
+
330
485
  export function restoreManagedSessionStateFromBranch(
331
486
  branch: unknown[],
332
487
  fallbackSessionName: string,
@@ -335,7 +490,9 @@ export function restoreManagedSessionStateFromBranch(
335
490
  active: false,
336
491
  sessionName: fallbackSessionName,
337
492
  };
493
+ let activeRestoreRank = 0;
338
494
  let freshSessionOrdinal = 0;
495
+ const freshSessionRanks = new Map<string, number>();
339
496
 
340
497
  for (const entry of branch) {
341
498
  if (!isRecord(entry) || entry.type !== "message") {
@@ -369,10 +526,31 @@ export function restoreManagedSessionStateFromBranch(
369
526
  continue;
370
527
  }
371
528
 
529
+ const restoreRank = getManagedSessionRestoreRank({
530
+ fallbackSessionName,
531
+ freshSessionRanks,
532
+ sessionName: managedSessionName,
533
+ });
534
+ if (restoreRank === undefined) {
535
+ continue;
536
+ }
537
+
372
538
  const messageIsError = typeof message.isError === "boolean" ? message.isError : undefined;
373
539
  const exitCode = typeof details.exitCode === "number" ? details.exitCode : undefined;
374
540
  const succeeded = messageIsError === undefined ? exitCode === undefined || exitCode === 0 : !messageIsError;
375
541
  const command = typeof details.command === "string" ? details.command : parseCommandInfo(args).command;
542
+ if (succeeded && sessionMode === "fresh") {
543
+ freshSessionOrdinal += 1;
544
+ }
545
+ const staleCompletion = succeeded && command !== "close" && restoreRank < activeRestoreRank;
546
+ if (staleCompletion) {
547
+ continue;
548
+ }
549
+ const staleClose = command === "close" && restoredState.active && managedSessionName !== restoredState.sessionName;
550
+ if (staleClose) {
551
+ continue;
552
+ }
553
+
376
554
  restoredState = resolveManagedSessionState({
377
555
  command,
378
556
  managedSessionName,
@@ -380,8 +558,8 @@ export function restoreManagedSessionStateFromBranch(
380
558
  priorSessionName: restoredState.sessionName,
381
559
  succeeded,
382
560
  });
383
- if (succeeded && sessionMode === "fresh") {
384
- freshSessionOrdinal += 1;
561
+ if (succeeded && command !== "close" && restoredState.active) {
562
+ activeRestoreRank = restoreRank;
385
563
  }
386
564
  }
387
565
 
@@ -619,7 +797,18 @@ export function extractExplicitSessionName(args: string[]): string | undefined {
619
797
  }
620
798
 
621
799
  export function getStartupScopedFlags(args: string[]): string[] {
622
- return STARTUP_SCOPED_FLAGS.filter((flag) => hasFlagToken(args, flag));
800
+ return LAUNCH_SCOPED_FLAG_DEFINITIONS
801
+ .map((definition) => definition.flag)
802
+ .filter((flag) => hasFlagToken(args, flag));
803
+ }
804
+
805
+ export function hasLaunchScopedTabCorrectionFlag(args: string[]): boolean {
806
+ return args.some((token) => {
807
+ for (const flag of LAUNCH_SCOPED_TAB_CORRECTION_FLAGS) {
808
+ if (token === flag || token.startsWith(`${flag}=`)) return true;
809
+ }
810
+ return false;
811
+ });
623
812
  }
624
813
 
625
814
  export function buildPromptPolicy(prompt: string): PromptPolicy {
@@ -708,11 +897,11 @@ export function buildExecutionPlan(
708
897
  exampleArgs: args,
709
898
  exampleParams: { args, sessionMode: "fresh" },
710
899
  reason:
711
- "Startup-scoped flags like --profile, --session-name, and --cdp need a fresh upstream launch once the extension-managed session is already active.",
900
+ `Launch-scoped flags (${LAUNCH_SCOPED_FLAG_LABEL}) need a fresh upstream launch once the extension-managed session is already active.`,
712
901
  recommendedSessionMode: "fresh",
713
902
  };
714
903
  validationError = [
715
- `The current extension-managed agent-browser session is already running, so startup-scoped flags ${startupScopedFlags.join(", ")} would be ignored by upstream agent-browser.`,
904
+ `The current extension-managed agent-browser session is already running, so launch-scoped flags ${startupScopedFlags.join(", ")} would be ignored by upstream agent-browser.`,
716
905
  "Retry this call with `sessionMode: \"fresh\"` to force a fresh upstream launch, or pass an explicit `--session ...` if you want to name the new session yourself.",
717
906
  ].join(" ");
718
907
  } else {