pi-agent-browser-native 0.2.33 → 0.2.35
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.
- package/CHANGELOG.md +46 -0
- package/README.md +47 -17
- package/docs/ARCHITECTURE.md +25 -13
- package/docs/COMMAND_REFERENCE.md +285 -47
- package/docs/ELECTRON.md +3 -3
- package/docs/RELEASE.md +22 -14
- package/docs/REQUIREMENTS.md +5 -5
- package/docs/SUPPORT_MATRIX.md +26 -22
- package/docs/TOOL_CONTRACT.md +97 -32
- package/extensions/agent-browser/index.ts +519 -2402
- package/extensions/agent-browser/lib/argv-descriptor.ts +90 -0
- package/extensions/agent-browser/lib/argv-grammar.ts +128 -0
- package/extensions/agent-browser/lib/command-policy.ts +71 -0
- package/extensions/agent-browser/lib/command-taxonomy.ts +336 -0
- package/extensions/agent-browser/lib/electron/cleanup.ts +1 -0
- package/extensions/agent-browser/lib/executable-path.ts +19 -0
- package/extensions/agent-browser/lib/input-modes/job.ts +62 -0
- package/extensions/agent-browser/lib/input-modes/params.ts +8 -8
- package/extensions/agent-browser/lib/input-modes.ts +3 -0
- package/extensions/agent-browser/lib/orchestration/batch-stdin.ts +65 -0
- package/extensions/agent-browser/lib/orchestration/browser-run/browser-action-model.ts +154 -0
- package/extensions/agent-browser/lib/orchestration/browser-run/click-dispatch.ts +149 -0
- package/extensions/agent-browser/lib/orchestration/browser-run/diagnostics.ts +77 -29
- package/extensions/agent-browser/lib/orchestration/browser-run/final-result.ts +6 -2
- package/extensions/agent-browser/lib/orchestration/browser-run/index.ts +33 -27
- package/extensions/agent-browser/lib/orchestration/browser-run/prepare.ts +74 -23
- package/extensions/agent-browser/lib/orchestration/browser-run/process-output.ts +67 -17
- package/extensions/agent-browser/lib/orchestration/browser-run/prompt-guards.ts +93 -0
- package/extensions/agent-browser/lib/orchestration/browser-run/session-state.ts +19 -123
- package/extensions/agent-browser/lib/orchestration/browser-run/types.ts +32 -1
- package/extensions/agent-browser/lib/orchestration/electron-host/index.ts +860 -0
- package/extensions/agent-browser/lib/playbook.ts +24 -23
- package/extensions/agent-browser/lib/prompt-policy.ts +122 -0
- package/extensions/agent-browser/lib/results/action-recommendations.ts +3 -23
- package/extensions/agent-browser/lib/results/categories.ts +1 -1
- package/extensions/agent-browser/lib/results/presentation/navigation.ts +2 -34
- package/extensions/agent-browser/lib/results/presentation/registry.ts +34 -6
- package/extensions/agent-browser/lib/results/presentation/semantic-action.ts +133 -0
- package/extensions/agent-browser/lib/results/presentation.ts +11 -6
- package/extensions/agent-browser/lib/runtime.ts +93 -227
- package/extensions/agent-browser/lib/session-page-state.ts +31 -14
- package/extensions/agent-browser/lib/temp.ts +148 -23
- package/package.json +4 -4
- package/scripts/agent-browser-capability-baseline.mjs +198 -1
|
@@ -6,10 +6,9 @@
|
|
|
6
6
|
* Invariants/Assumptions: agent-browser is installed separately on PATH, the wrapper targets the current locally installed upstream version only, and no backward-compatibility shims are provided.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { constants as fsConstants } from "node:fs";
|
|
10
9
|
import type { ChildProcess } from "node:child_process";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
12
|
import { fileURLToPath } from "node:url";
|
|
14
13
|
|
|
15
14
|
import {
|
|
@@ -18,89 +17,40 @@ import {
|
|
|
18
17
|
keyHint,
|
|
19
18
|
type AgentToolResult,
|
|
20
19
|
type ExtensionAPI,
|
|
20
|
+
type ExtensionContext,
|
|
21
21
|
type Theme,
|
|
22
22
|
type ToolResultEvent,
|
|
23
23
|
} from "@earendil-works/pi-coding-agent";
|
|
24
24
|
import { Text } from "@earendil-works/pi-tui";
|
|
25
|
-
import {
|
|
26
|
-
discoverElectronApps,
|
|
27
|
-
ELECTRON_DISCOVERY_DEFAULT_MAX_RESULTS,
|
|
28
|
-
ELECTRON_DISCOVERY_MAX_RESULTS,
|
|
29
|
-
type ElectronDiscoveryResult,
|
|
30
|
-
} from "./lib/electron/discovery.js";
|
|
31
|
-
import {
|
|
32
|
-
cleanupElectronLaunchResources,
|
|
33
|
-
inspectElectronLaunchStatus,
|
|
34
|
-
type ElectronCleanupResult,
|
|
35
|
-
type ElectronLaunchStatus,
|
|
36
|
-
} from "./lib/electron/cleanup.js";
|
|
37
|
-
import {
|
|
38
|
-
launchElectronApp,
|
|
39
|
-
type ElectronCdpTarget,
|
|
40
|
-
type ElectronLaunchFailure,
|
|
41
|
-
type ElectronLaunchRecord,
|
|
42
|
-
type ElectronLaunchSuccess,
|
|
43
|
-
} from "./lib/electron/launch.js";
|
|
44
25
|
import {
|
|
45
26
|
PROJECT_RULE_PROMPT,
|
|
46
27
|
buildToolPromptGuidelines,
|
|
47
28
|
} from "./lib/playbook.js";
|
|
48
|
-
import { SAFE_AGENT_BROWSER_OPERATION_TIMEOUT_MS, runAgentBrowserProcess } from "./lib/process.js";
|
|
49
29
|
import {
|
|
50
|
-
buildAgentBrowserNextActions,
|
|
51
|
-
buildAgentBrowserResultCategoryDetails,
|
|
52
30
|
buildToolPresentation,
|
|
53
|
-
getAgentBrowserErrorText,
|
|
54
|
-
parseAgentBrowserEnvelope,
|
|
55
|
-
type AgentBrowserBatchResult,
|
|
56
31
|
type AgentBrowserEnvelope,
|
|
57
|
-
type AgentBrowserNextAction,
|
|
58
32
|
type AgentBrowserPageChangeSummary,
|
|
59
33
|
} from "./lib/results.js";
|
|
60
|
-
import {
|
|
61
|
-
SessionPageState,
|
|
62
|
-
buildNoActivePageRefSnapshotInvalidation,
|
|
63
|
-
commandExplicitlyTargetsAboutBlank,
|
|
64
|
-
deriveSessionTabTarget,
|
|
65
|
-
extractLatestRefSnapshotStateFromBatchResults,
|
|
66
|
-
extractRefSnapshotFromData,
|
|
67
|
-
extractSessionTabTargetFromBatchResults,
|
|
68
|
-
extractSessionTabTargetFromCommandData,
|
|
69
|
-
isAboutBlankSessionTabTarget,
|
|
70
|
-
isAboutBlankUrl,
|
|
71
|
-
isNoActivePageSnapshotFailure,
|
|
72
|
-
normalizeComparableUrl,
|
|
73
|
-
normalizeSessionTabTarget,
|
|
74
|
-
targetsMatch,
|
|
75
|
-
type SessionRefSnapshot,
|
|
76
|
-
type SessionRefSnapshotInvalidation,
|
|
77
|
-
type SessionTabTarget,
|
|
78
|
-
} from "./lib/session-page-state.js";
|
|
34
|
+
import { SessionPageState } from "./lib/session-page-state.js";
|
|
79
35
|
import {
|
|
80
36
|
buildExecutionPlan,
|
|
81
|
-
buildPromptPolicy,
|
|
82
|
-
chooseOpenResultTabCorrection,
|
|
83
37
|
createEphemeralSessionSeed,
|
|
84
38
|
createFreshSessionName,
|
|
85
39
|
createImplicitSessionName,
|
|
86
40
|
extractCommandTokens,
|
|
87
41
|
getImplicitSessionCloseTimeoutMs,
|
|
88
42
|
getImplicitSessionIdleTimeoutMs,
|
|
89
|
-
getLatestUserPrompt,
|
|
90
43
|
hasLaunchScopedTabCorrectionFlag,
|
|
91
44
|
hasUsableBraveApiKey,
|
|
92
45
|
extractExplicitSessionName,
|
|
93
46
|
redactInvocationArgs,
|
|
94
|
-
redactSensitiveText,
|
|
95
|
-
redactSensitiveValue,
|
|
96
47
|
restoreManagedSessionStateFromBranch,
|
|
97
48
|
resolveManagedSessionState,
|
|
98
|
-
shouldAppendBrowserSystemPrompt,
|
|
99
49
|
validateToolArgs,
|
|
100
|
-
type CommandInfo,
|
|
101
50
|
type CompatibilityWorkaround,
|
|
102
|
-
type OpenResultTabCorrection,
|
|
103
51
|
} from "./lib/runtime.js";
|
|
52
|
+
import { buildPromptPolicy, getLatestUserPrompt, shouldAppendBrowserSystemPrompt } from "./lib/prompt-policy.js";
|
|
53
|
+
import { isCloseCommand } from "./lib/command-taxonomy.js";
|
|
104
54
|
import {
|
|
105
55
|
cleanupSecureTempArtifacts,
|
|
106
56
|
type PersistentSessionArtifactEviction,
|
|
@@ -135,7 +85,17 @@ import {
|
|
|
135
85
|
type CompiledAgentBrowserSemanticAction,
|
|
136
86
|
type CompiledAgentBrowserSourceLookup,
|
|
137
87
|
} from "./lib/input-modes.js";
|
|
138
|
-
import { runAgentBrowserTool, type BrowserRunState } from "./lib/orchestration/browser-run.js";
|
|
88
|
+
import { closeManagedSession, runAgentBrowserTool, type BrowserRunState, type TraceOwner } from "./lib/orchestration/browser-run.js";
|
|
89
|
+
import { findElectronLaunchRecordForSession, getActiveElectronRecords } from "./lib/orchestration/browser-run/session-state.js";
|
|
90
|
+
import { parseBatchStdinJsonArray } from "./lib/orchestration/batch-stdin.js";
|
|
91
|
+
import {
|
|
92
|
+
ELECTRON_POST_COMMAND_STATUS_SETTLE_MS,
|
|
93
|
+
ELECTRON_PROFILE_ISOLATION_DETAILS,
|
|
94
|
+
cleanupActiveElectronHostLaunches,
|
|
95
|
+
handleElectronHostInput,
|
|
96
|
+
restoreElectronLaunchRecordsFromBranch,
|
|
97
|
+
type ElectronLaunchRecord,
|
|
98
|
+
} from "./lib/orchestration/electron-host/index.js";
|
|
139
99
|
import { buildValidationFailureResult, resolveAgentBrowserInput } from "./lib/orchestration/input-plan.js";
|
|
140
100
|
import type { SessionArtifactManifest } from "./lib/results/contracts.js";
|
|
141
101
|
import {
|
|
@@ -157,99 +117,12 @@ import {
|
|
|
157
117
|
type RichInputRecoveryDiagnostic,
|
|
158
118
|
type VisibleRefFallbackDiagnostic,
|
|
159
119
|
} from "./lib/results/selector-recovery.js";
|
|
160
|
-
import {
|
|
161
|
-
AgentBrowserNextActionCollector,
|
|
162
|
-
alignPageChangeSummaryNextActionIds,
|
|
163
|
-
appendUniqueAgentBrowserNextActions,
|
|
164
|
-
isStandaloneSnapshotNextAction,
|
|
165
|
-
withOptionalSessionArgs,
|
|
166
|
-
} from "./lib/results/next-actions.js";
|
|
167
|
-
import {
|
|
168
|
-
buildConnectedSessionNextActions,
|
|
169
|
-
buildNoActivePageNextActions,
|
|
170
|
-
buildSessionAwareStaleRefNextActions,
|
|
171
|
-
buildSessionTabRecoveryNextActions,
|
|
172
|
-
} from "./lib/results/recovery-next-actions.js";
|
|
120
|
+
import { withOptionalSessionArgs } from "./lib/results/next-actions.js";
|
|
173
121
|
|
|
174
122
|
const DEFAULT_SESSION_MODE = "auto" as const;
|
|
175
123
|
const DIRECT_AGENT_BROWSER_BASH_BYPASS_ENV = "PI_AGENT_BROWSER_ALLOW_DIRECT_BASH";
|
|
176
124
|
const PACKAGE_NAME = "pi-agent-browser-native";
|
|
177
125
|
|
|
178
|
-
const ELECTRON_PROFILE_ISOLATION_NOTE = "Profile note: electron.launch starts an isolated temporary profile; it does not reuse the app's normal signed-in profile or attach to an already-running authenticated app.";
|
|
179
|
-
const ELECTRON_EXISTING_AUTH_GUIDANCE = "For already-authenticated desktop app content, do not stop here: if host tools are allowed and the app is not running, launch the normal app with --remote-debugging-port=<port>, verify the port, then run agent_browser connect <port>; if it is already running without a debug port, ask before relaunching it.";
|
|
180
|
-
const ELECTRON_PROFILE_ISOLATION_DETAILS = {
|
|
181
|
-
attachesToAlreadyRunningApp: false,
|
|
182
|
-
existingAuthenticatedAppGuidance: ELECTRON_EXISTING_AUTH_GUIDANCE,
|
|
183
|
-
hostDebugLaunchExample: "macOS: open -a <App Name> --args --remote-debugging-port=9222 --remote-allow-origins='*'; then agent_browser connect 9222 with sessionMode=fresh",
|
|
184
|
-
isolatedLaunch: true,
|
|
185
|
-
note: ELECTRON_PROFILE_ISOLATION_NOTE,
|
|
186
|
-
reusesExistingSignedInProfile: false,
|
|
187
|
-
} as const;
|
|
188
|
-
const ELECTRON_PROBE_MAX_TABS = 6;
|
|
189
|
-
const ELECTRON_PROBE_MAX_REF_IDS = 20;
|
|
190
|
-
const ELECTRON_PROBE_MAX_SNAPSHOT_LINES = 12;
|
|
191
|
-
const ELECTRON_PROBE_MAX_SNAPSHOT_CHARS = 1_600;
|
|
192
|
-
const ELECTRON_POST_COMMAND_STATUS_SETTLE_MS = 250;
|
|
193
|
-
const ELECTRON_FILL_VERIFICATION_TIMEOUT_MS = 2_000;
|
|
194
|
-
|
|
195
|
-
interface ScrollPositionSnapshot {
|
|
196
|
-
containerCount: number;
|
|
197
|
-
containers: Array<{ id: string; scrollLeft: number; scrollTop: number }>;
|
|
198
|
-
innerHeight: number;
|
|
199
|
-
innerWidth: number;
|
|
200
|
-
scrollHeight: number;
|
|
201
|
-
scrollWidth: number;
|
|
202
|
-
scrollX: number;
|
|
203
|
-
scrollY: number;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
interface ScrollNoopDiagnostic {
|
|
207
|
-
after: ScrollPositionSnapshot;
|
|
208
|
-
before: ScrollPositionSnapshot;
|
|
209
|
-
message: string;
|
|
210
|
-
reason: "no-observed-scroll-position-change";
|
|
211
|
-
recommendations: string[];
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
interface ComboboxFocusDiagnostic {
|
|
215
|
-
activeElement: {
|
|
216
|
-
expanded?: string;
|
|
217
|
-
hasPopup?: string;
|
|
218
|
-
name?: string;
|
|
219
|
-
role?: string;
|
|
220
|
-
tagName?: string;
|
|
221
|
-
};
|
|
222
|
-
message: string;
|
|
223
|
-
reason: "focused-combobox-without-visible-options";
|
|
224
|
-
recommendations: string[];
|
|
225
|
-
visibleListboxCount: number;
|
|
226
|
-
visibleOptionCount: number;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
interface RecordingDependencyWarning {
|
|
230
|
-
command: "record start" | "record restart";
|
|
231
|
-
dependency: "ffmpeg";
|
|
232
|
-
message: string;
|
|
233
|
-
reason: "ffmpeg-missing-for-recording";
|
|
234
|
-
recommendations: string[];
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const SEMANTIC_ACTION_CANDIDATE_ACTION_IDS = new Set([
|
|
240
|
-
"try-button-name-candidate",
|
|
241
|
-
"try-link-name-candidate",
|
|
242
|
-
]);
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
interface SemanticActionVisibleRefResolution {
|
|
248
|
-
args: string[];
|
|
249
|
-
snapshot: SessionRefSnapshot;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
|
|
253
126
|
const TUI_COLLAPSED_OUTPUT_MAX_LINES = 10;
|
|
254
127
|
const TUI_INVOCATION_PREVIEW_MAX_CHARS = 120;
|
|
255
128
|
const ANSI_CONTROL_SEQUENCE_PATTERN = /\x1B(?:\][^\x07\x1B]*(?:\x07|\x1B\\)|\[[0-?]*[ -/]*[@-~]|P[^\x1B]*(?:\x1B\\)|_[^\x1B]*(?:\x1B\\)|\^[^\x1B]*(?:\x1B\\)|[@-Z\\-_])/g;
|
|
@@ -393,6 +266,20 @@ type AgentBrowserToolResultPatch = {
|
|
|
393
266
|
isError?: boolean;
|
|
394
267
|
};
|
|
395
268
|
|
|
269
|
+
type OwnedManagedSession = {
|
|
270
|
+
branchOwned: boolean;
|
|
271
|
+
cwd: string;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// Event ranks are local to the branch being restored. Keep them out of owned-resource
|
|
275
|
+
// state so branch switches never compare unrelated branch histories.
|
|
276
|
+
interface BranchManagedResourceEvents {
|
|
277
|
+
electronLaunchActiveRanks: Map<string, number>;
|
|
278
|
+
electronLaunchCleanupRanks: Map<string, number>;
|
|
279
|
+
managedSessionActiveRanks: Map<string, number>;
|
|
280
|
+
managedSessionCloseRanks: Map<string, number>;
|
|
281
|
+
}
|
|
282
|
+
|
|
396
283
|
function agentBrowserToolResultRequestedJson(event: ToolResultEvent): boolean {
|
|
397
284
|
const details = isRecord(event.details) ? event.details : undefined;
|
|
398
285
|
const detailArgs = Array.isArray(details?.args) ? details.args : undefined;
|
|
@@ -672,331 +559,20 @@ async function isDirectAgentBrowserBashAllowed(cwd: string): Promise<boolean> {
|
|
|
672
559
|
return isTruthyEnvValue(process.env[DIRECT_AGENT_BROWSER_BASH_BYPASS_ENV]) || await isPackageDevelopmentCwd(cwd);
|
|
673
560
|
}
|
|
674
561
|
|
|
675
|
-
const NAVIGATION_SUMMARY_COMMANDS = new Set(["back", "click", "dblclick", "forward", "reload"]);
|
|
676
|
-
const NAVIGATION_SUMMARY_EVAL = `({ title: document.title, url: location.href })`;
|
|
677
|
-
// These commands can expose URLs for inspected resources (request URLs, cookie/storage scope, or log sources),
|
|
678
|
-
// but they do not navigate the active tab and must not poison page-scoped ref guards.
|
|
679
|
-
const READ_ONLY_DIAGNOSTIC_SESSION_TARGET_COMMANDS = new Set(["console", "cookies", "errors", "network", "storage"]);
|
|
680
|
-
|
|
681
|
-
interface NavigationSummary {
|
|
682
|
-
title?: string;
|
|
683
|
-
url?: string;
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
interface OverlayBlockerCandidate {
|
|
687
|
-
args: string[];
|
|
688
|
-
name?: string;
|
|
689
|
-
reason: string;
|
|
690
|
-
ref: string;
|
|
691
|
-
role?: string;
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
interface OverlayBlockerDiagnostic {
|
|
695
|
-
candidates: OverlayBlockerCandidate[];
|
|
696
|
-
snapshot: SessionRefSnapshot;
|
|
697
|
-
summary: string;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
interface SelectorTextVisibilityDiagnostic {
|
|
701
|
-
firstMatchVisible?: boolean;
|
|
702
|
-
firstVisibleTextPreview?: string;
|
|
703
|
-
matchCount: number;
|
|
704
|
-
selector: string;
|
|
705
|
-
summary: string;
|
|
706
|
-
visibleCount: number;
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
interface ElectronBroadGetTextScopeDiagnostic {
|
|
710
|
-
electronContext: {
|
|
711
|
-
launchId?: string;
|
|
712
|
-
sessionName?: string;
|
|
713
|
-
url?: string;
|
|
714
|
-
};
|
|
715
|
-
selector: string;
|
|
716
|
-
summary: string;
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
interface QaAttachedTarget {
|
|
720
|
-
sessionName: string;
|
|
721
|
-
title?: string;
|
|
722
|
-
url?: string;
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
interface TimeoutArtifactEvidence {
|
|
726
|
-
absolutePath: string;
|
|
727
|
-
exists: boolean;
|
|
728
|
-
path: string;
|
|
729
|
-
sizeBytes?: number;
|
|
730
|
-
stepIndex: number;
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
interface TimeoutPartialProgress {
|
|
734
|
-
artifacts: TimeoutArtifactEvidence[];
|
|
735
|
-
currentPage?: {
|
|
736
|
-
title?: string;
|
|
737
|
-
url?: string;
|
|
738
|
-
};
|
|
739
|
-
steps?: Array<{ args: string[]; index: number }>;
|
|
740
|
-
summary: string;
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
interface EvalStdinHint {
|
|
744
|
-
reason: string;
|
|
745
|
-
suggestion: string;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
interface ArtifactCleanupGuidance {
|
|
749
|
-
explicitArtifactPaths: string[];
|
|
750
|
-
note: string;
|
|
751
|
-
owner: "host-file-tools";
|
|
752
|
-
summary: string;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
interface ManagedSessionOutcome {
|
|
756
|
-
activeAfter: boolean;
|
|
757
|
-
activeBefore: boolean;
|
|
758
|
-
attemptedSessionName?: string;
|
|
759
|
-
currentSessionName: string;
|
|
760
|
-
previousSessionName: string;
|
|
761
|
-
replacedSessionName?: string;
|
|
762
|
-
sessionMode: "auto" | "fresh";
|
|
763
|
-
status: "abandoned" | "closed" | "created" | "preserved" | "replaced" | "unchanged";
|
|
764
|
-
succeeded: boolean;
|
|
765
|
-
summary: string;
|
|
766
|
-
}
|
|
767
|
-
|
|
768
562
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
769
563
|
return typeof value === "object" && value !== null;
|
|
770
564
|
}
|
|
771
565
|
|
|
772
|
-
const SCREENSHOT_VALUE_FLAGS = new Set(["--screenshot-dir", "--screenshot-format", "--screenshot-quality"]);
|
|
773
|
-
const SCREENSHOT_IMAGE_EXTENSIONS = new Set([".jpeg", ".jpg", ".png", ".webp"]);
|
|
774
|
-
|
|
775
|
-
interface ScreenshotPathRequest {
|
|
776
|
-
absolutePath: string;
|
|
777
|
-
path: string;
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
interface PreparedAgentBrowserArgs {
|
|
781
|
-
args: string[];
|
|
782
|
-
batchScreenshotPathRequests?: Array<ScreenshotPathRequest | undefined>;
|
|
783
|
-
screenshotPathRequest?: ScreenshotPathRequest;
|
|
784
|
-
stdin?: string;
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
interface ScreenshotArtifactRequest extends ScreenshotPathRequest {
|
|
788
|
-
status?: "missing" | "repaired-from-temp" | "saved" | "upstream-temp-only";
|
|
789
|
-
tempPath?: string;
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
type TraceOwner = "profiler" | "trace";
|
|
793
|
-
|
|
794
|
-
function isImagePathToken(token: string): boolean {
|
|
795
|
-
const extension = extname(token).toLowerCase();
|
|
796
|
-
return SCREENSHOT_IMAGE_EXTENSIONS.has(extension);
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
function getScreenshotPathTokenIndex(commandTokens: string[]): number | undefined {
|
|
800
|
-
if (commandTokens[0] !== "screenshot") {
|
|
801
|
-
return undefined;
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
const positionalIndices: number[] = [];
|
|
805
|
-
for (let index = 1; index < commandTokens.length; index += 1) {
|
|
806
|
-
const token = commandTokens[index];
|
|
807
|
-
if (token === "--") {
|
|
808
|
-
for (let positionalIndex = index + 1; positionalIndex < commandTokens.length; positionalIndex += 1) {
|
|
809
|
-
positionalIndices.push(positionalIndex);
|
|
810
|
-
}
|
|
811
|
-
break;
|
|
812
|
-
}
|
|
813
|
-
if (token.startsWith("-")) {
|
|
814
|
-
const normalizedToken = token.split("=", 1)[0] ?? token;
|
|
815
|
-
if (SCREENSHOT_VALUE_FLAGS.has(normalizedToken) && !token.includes("=")) {
|
|
816
|
-
index += 1;
|
|
817
|
-
}
|
|
818
|
-
continue;
|
|
819
|
-
}
|
|
820
|
-
positionalIndices.push(index);
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
if (positionalIndices.length === 0) {
|
|
824
|
-
return undefined;
|
|
825
|
-
}
|
|
826
|
-
const candidateIndex = positionalIndices[positionalIndices.length - 1];
|
|
827
|
-
const candidate = commandTokens[candidateIndex];
|
|
828
|
-
if (positionalIndices.length >= 2 || isImagePathToken(candidate) || isAbsolute(candidate) || candidate.startsWith("./") || candidate.startsWith("../")) {
|
|
829
|
-
return candidateIndex;
|
|
830
|
-
}
|
|
831
|
-
return undefined;
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
async function normalizeScreenshotPathInTokens(commandTokens: string[], cwd: string): Promise<{
|
|
835
|
-
request?: ScreenshotPathRequest;
|
|
836
|
-
tokens: string[];
|
|
837
|
-
}> {
|
|
838
|
-
const screenshotPathTokenIndex = getScreenshotPathTokenIndex(commandTokens);
|
|
839
|
-
if (screenshotPathTokenIndex === undefined) {
|
|
840
|
-
return { tokens: commandTokens };
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
const requestedPath = commandTokens[screenshotPathTokenIndex];
|
|
844
|
-
const absolutePath = resolve(cwd, requestedPath);
|
|
845
|
-
await mkdir(dirname(absolutePath), { recursive: true });
|
|
846
|
-
|
|
847
|
-
const tokens = [...commandTokens];
|
|
848
|
-
tokens[screenshotPathTokenIndex] = absolutePath;
|
|
849
|
-
const terminatorIndex = tokens.indexOf("--");
|
|
850
|
-
if (terminatorIndex >= 0) {
|
|
851
|
-
tokens.splice(terminatorIndex, 1);
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
return {
|
|
855
|
-
request: {
|
|
856
|
-
absolutePath,
|
|
857
|
-
path: requestedPath,
|
|
858
|
-
},
|
|
859
|
-
tokens,
|
|
860
|
-
};
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
async function prepareBatchScreenshotPaths(args: string[], stdin: string | undefined, cwd: string): Promise<PreparedAgentBrowserArgs | undefined> {
|
|
864
|
-
const commandTokens = extractCommandTokens(args);
|
|
865
|
-
if (commandTokens[0] !== "batch" || stdin === undefined) {
|
|
866
|
-
return undefined;
|
|
867
|
-
}
|
|
868
|
-
let steps: unknown;
|
|
869
|
-
try {
|
|
870
|
-
steps = JSON.parse(stdin);
|
|
871
|
-
} catch {
|
|
872
|
-
return undefined;
|
|
873
|
-
}
|
|
874
|
-
if (!Array.isArray(steps)) {
|
|
875
|
-
return undefined;
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
let changed = false;
|
|
879
|
-
const batchScreenshotPathRequests: Array<ScreenshotPathRequest | undefined> = [];
|
|
880
|
-
const preparedSteps = await Promise.all(steps.map(async (step, index) => {
|
|
881
|
-
if (!Array.isArray(step) || !step.every((item) => typeof item === "string") || step[0] !== "screenshot") {
|
|
882
|
-
return step;
|
|
883
|
-
}
|
|
884
|
-
const normalized = await normalizeScreenshotPathInTokens(step, cwd);
|
|
885
|
-
batchScreenshotPathRequests[index] = normalized.request;
|
|
886
|
-
if (normalized.request) {
|
|
887
|
-
changed = true;
|
|
888
|
-
}
|
|
889
|
-
return normalized.tokens;
|
|
890
|
-
}));
|
|
891
|
-
|
|
892
|
-
return changed
|
|
893
|
-
? {
|
|
894
|
-
args,
|
|
895
|
-
batchScreenshotPathRequests,
|
|
896
|
-
stdin: JSON.stringify(preparedSteps),
|
|
897
|
-
}
|
|
898
|
-
: undefined;
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
function parseMillisecondsToken(token: string | undefined): number | undefined {
|
|
902
|
-
if (token === undefined || !/^\d+$/.test(token)) {
|
|
903
|
-
return undefined;
|
|
904
|
-
}
|
|
905
|
-
const parsed = Number(token);
|
|
906
|
-
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
function findWaitTimeoutMs(commandTokens: string[]): { timeoutMs: number; source: string } | undefined {
|
|
910
|
-
if (commandTokens[0] !== "wait") {
|
|
911
|
-
return undefined;
|
|
912
|
-
}
|
|
913
|
-
for (let index = 1; index < commandTokens.length; index += 1) {
|
|
914
|
-
const token = commandTokens[index];
|
|
915
|
-
if (token === "--timeout") {
|
|
916
|
-
const timeoutMs = parseMillisecondsToken(commandTokens[index + 1]);
|
|
917
|
-
return timeoutMs === undefined ? undefined : { source: "wait --timeout", timeoutMs };
|
|
918
|
-
}
|
|
919
|
-
if (token.startsWith("--timeout=")) {
|
|
920
|
-
const timeoutMs = parseMillisecondsToken(token.slice("--timeout=".length));
|
|
921
|
-
return timeoutMs === undefined ? undefined : { source: "wait --timeout", timeoutMs };
|
|
922
|
-
}
|
|
923
|
-
if (!token.startsWith("-")) {
|
|
924
|
-
const timeoutMs = parseMillisecondsToken(token);
|
|
925
|
-
if (timeoutMs !== undefined) {
|
|
926
|
-
return { source: "wait", timeoutMs };
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
return undefined;
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
function buildIpcUnsafeWaitError(source: string, timeoutMs: number, batchStep?: number): string {
|
|
934
|
-
const location = batchStep === undefined ? source : `batch step ${batchStep + 1} (${source})`;
|
|
935
|
-
return `${location} requests ${timeoutMs}ms, but upstream agent-browser CLI calls must stay under its 30s IPC read timeout. Use ${SAFE_AGENT_BROWSER_OPERATION_TIMEOUT_MS}ms or less per wait, split long waits into multiple tool calls, or use a page-specific shorter condition.`;
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
async function pathExists(path: string): Promise<boolean> {
|
|
941
|
-
try {
|
|
942
|
-
await stat(path);
|
|
943
|
-
return true;
|
|
944
|
-
} catch {
|
|
945
|
-
return false;
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
async function repairScreenshotData(options: {
|
|
950
|
-
cwd: string;
|
|
951
|
-
data: Record<string, unknown>;
|
|
952
|
-
request: ScreenshotPathRequest;
|
|
953
|
-
}): Promise<{ data: Record<string, unknown>; request: ScreenshotArtifactRequest }> {
|
|
954
|
-
const { cwd, data, request } = options;
|
|
955
|
-
const reportedPath = typeof data.path === "string" ? data.path : undefined;
|
|
956
|
-
const reportedAbsolutePath = reportedPath ? resolve(cwd, reportedPath) : undefined;
|
|
957
|
-
let status: ScreenshotArtifactRequest["status"] = await pathExists(request.absolutePath) ? "saved" : "missing";
|
|
958
|
-
let tempPath: string | undefined;
|
|
959
|
-
|
|
960
|
-
if (reportedAbsolutePath && reportedAbsolutePath !== request.absolutePath) {
|
|
961
|
-
tempPath = reportedAbsolutePath;
|
|
962
|
-
if (status === "missing" && await pathExists(reportedAbsolutePath)) {
|
|
963
|
-
await mkdir(dirname(request.absolutePath), { recursive: true });
|
|
964
|
-
await copyFile(reportedAbsolutePath, request.absolutePath);
|
|
965
|
-
status = "repaired-from-temp";
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
return {
|
|
970
|
-
data: {
|
|
971
|
-
...data,
|
|
972
|
-
path: request.absolutePath,
|
|
973
|
-
},
|
|
974
|
-
request: {
|
|
975
|
-
...request,
|
|
976
|
-
status,
|
|
977
|
-
tempPath,
|
|
978
|
-
},
|
|
979
|
-
};
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
566
|
function getBatchAnnotateValidationError(args: string[], stdin: string | undefined): string | undefined {
|
|
986
567
|
const commandTokens = extractCommandTokens(args);
|
|
987
568
|
if (commandTokens[0] !== "batch" || stdin === undefined) {
|
|
988
569
|
return undefined;
|
|
989
570
|
}
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
steps = JSON.parse(stdin);
|
|
993
|
-
} catch {
|
|
994
|
-
return undefined;
|
|
995
|
-
}
|
|
996
|
-
if (!Array.isArray(steps)) {
|
|
571
|
+
const parsed = parseBatchStdinJsonArray(stdin);
|
|
572
|
+
if (parsed.error || parsed.steps === undefined) {
|
|
997
573
|
return undefined;
|
|
998
574
|
}
|
|
999
|
-
const badStepIndex = steps.findIndex((step) => Array.isArray(step) && step[0] === "screenshot" && step.includes("--annotate"));
|
|
575
|
+
const badStepIndex = parsed.steps.findIndex((step) => Array.isArray(step) && step[0] === "screenshot" && step.includes("--annotate"));
|
|
1000
576
|
if (badStepIndex < 0) {
|
|
1001
577
|
return undefined;
|
|
1002
578
|
}
|
|
@@ -1006,70 +582,6 @@ function getBatchAnnotateValidationError(args: string[], stdin: string | undefin
|
|
|
1006
582
|
].join("\n");
|
|
1007
583
|
}
|
|
1008
584
|
|
|
1009
|
-
function getTraceOwner(command: string | undefined): TraceOwner | undefined {
|
|
1010
|
-
return command === "trace" || command === "profiler" ? command : undefined;
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
function extractStringResultField(data: unknown, fieldName: "result" | "title" | "url" | "value"): string | undefined {
|
|
1017
|
-
if (typeof data === "string") {
|
|
1018
|
-
if (fieldName === "value") return data;
|
|
1019
|
-
const text = data.trim();
|
|
1020
|
-
return text.length > 0 ? text : undefined;
|
|
1021
|
-
}
|
|
1022
|
-
if (!isRecord(data) || typeof data[fieldName] !== "string") {
|
|
1023
|
-
return undefined;
|
|
1024
|
-
}
|
|
1025
|
-
if (fieldName === "value") return data[fieldName];
|
|
1026
|
-
const text = data[fieldName].trim();
|
|
1027
|
-
return text.length > 0 ? text : undefined;
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
function extractNavigationSummaryFromData(data: unknown): NavigationSummary | undefined {
|
|
1031
|
-
const result = isRecord(data) && isRecord(data.result) ? data.result : data;
|
|
1032
|
-
const title = extractStringResultField(result, "title");
|
|
1033
|
-
const url = extractStringResultField(result, "url");
|
|
1034
|
-
return title || url ? { title, url } : undefined;
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
const SESSION_TAB_PINNING_EXCLUDED_COMMANDS = new Set(["close", "goto", "navigate", "open", "session", "tab"]);
|
|
1038
|
-
const SESSION_TAB_POST_COMMAND_CORRECTION_EXCLUDED_COMMANDS = new Set(["batch", "close", "session", "tab"]);
|
|
1039
|
-
|
|
1040
|
-
type PinnedBatchUnwrapMode = "single-command" | "user-batch";
|
|
1041
|
-
|
|
1042
|
-
type AgentBrowserToolResult = AgentToolResult<unknown> & { isError?: boolean };
|
|
1043
|
-
|
|
1044
|
-
type BatchCommandStep = [string, ...string[]];
|
|
1045
|
-
|
|
1046
|
-
interface PinnedBatchPlan {
|
|
1047
|
-
includeNavigationSummary: boolean;
|
|
1048
|
-
steps: BatchCommandStep[];
|
|
1049
|
-
unwrapMode: PinnedBatchUnwrapMode;
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
interface StaleRefPreflight {
|
|
1053
|
-
message: string;
|
|
1054
|
-
refIds: string[];
|
|
1055
|
-
snapshot?: SessionRefSnapshot;
|
|
1056
|
-
snapshotInvalidation?: SessionRefSnapshotInvalidation;
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
interface AboutBlankSessionMismatch {
|
|
1060
|
-
activeUrl: "about:blank";
|
|
1061
|
-
recoveryApplied: boolean;
|
|
1062
|
-
recoveryHint: string;
|
|
1063
|
-
targetTitle?: string;
|
|
1064
|
-
targetUrl: string;
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
function extractBatchResultCommand(item: Record<string, unknown>): string[] {
|
|
1070
|
-
return Array.isArray(item.command) ? item.command.filter((token): token is string => typeof token === "string") : [];
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
585
|
function restoreArtifactManifestFromBranch(branch: unknown[]): SessionArtifactManifest | undefined {
|
|
1074
586
|
let restoredManifest: SessionArtifactManifest | undefined;
|
|
1075
587
|
for (const entry of branch) {
|
|
@@ -1084,1869 +596,354 @@ function restoreArtifactManifestFromBranch(branch: unknown[]): SessionArtifactMa
|
|
|
1084
596
|
return restoredManifest;
|
|
1085
597
|
}
|
|
1086
598
|
|
|
1087
|
-
function
|
|
1088
|
-
|
|
599
|
+
function trackOwnedManagedSession(
|
|
600
|
+
sessions: Map<string, OwnedManagedSession>,
|
|
601
|
+
sessionName: string | undefined,
|
|
602
|
+
cwd: string,
|
|
603
|
+
options: { branchOwned?: boolean } = {},
|
|
604
|
+
): void {
|
|
605
|
+
if (!sessionName) return;
|
|
606
|
+
const existing = sessions.get(sessionName);
|
|
607
|
+
const branchOwned = existing && !existing.branchOwned ? false : options.branchOwned === true;
|
|
608
|
+
sessions.set(sessionName, { branchOwned, cwd });
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function untrackOwnedManagedSession(sessions: Map<string, OwnedManagedSession>, sessionName: string | undefined): void {
|
|
612
|
+
if (sessionName) sessions.delete(sessionName);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function untrackOwnedManagedSessionFromBranchClose(
|
|
616
|
+
sessions: Map<string, OwnedManagedSession>,
|
|
617
|
+
sessionName: string | undefined,
|
|
618
|
+
activeBranchRank: number | undefined,
|
|
619
|
+
closeBranchRank: number | undefined,
|
|
620
|
+
): void {
|
|
621
|
+
if (!sessionName || closeBranchRank === undefined) return;
|
|
622
|
+
const ownedSession = sessions.get(sessionName);
|
|
623
|
+
if (!ownedSession?.branchOwned) return;
|
|
624
|
+
if (activeBranchRank !== undefined && closeBranchRank <= activeBranchRank) return;
|
|
625
|
+
sessions.delete(sessionName);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function syncOwnedManagedSessionsFromResult(sessions: Map<string, OwnedManagedSession>, result: AgentToolResult<unknown>, cwd: string): void {
|
|
629
|
+
const details = isRecord(result.details) ? result.details : undefined;
|
|
630
|
+
const outcome = isRecord(details?.managedSessionOutcome) ? details.managedSessionOutcome : undefined;
|
|
631
|
+
if (!outcome) return;
|
|
632
|
+
const succeeded = outcome.succeeded === true;
|
|
633
|
+
const status = typeof outcome.status === "string" ? outcome.status : undefined;
|
|
634
|
+
const currentSessionName = typeof outcome.currentSessionName === "string" ? outcome.currentSessionName : undefined;
|
|
635
|
+
const attemptedSessionName = typeof outcome.attemptedSessionName === "string" ? outcome.attemptedSessionName : undefined;
|
|
636
|
+
if (succeeded && outcome.activeAfter === true && (status === "created" || status === "replaced" || status === "unchanged")) {
|
|
637
|
+
trackOwnedManagedSession(sessions, currentSessionName, cwd);
|
|
638
|
+
}
|
|
639
|
+
if (succeeded && status === "closed") {
|
|
640
|
+
untrackOwnedManagedSession(sessions, attemptedSessionName ?? currentSessionName);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function getTouchedElectronLaunchIds(sessionName: string | undefined, records: Map<string, ElectronLaunchRecord>): Set<string> | undefined {
|
|
645
|
+
const record = findElectronLaunchRecordForSession(sessionName, records);
|
|
646
|
+
return record ? new Set([record.launchId]) : undefined;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function mergeActiveElectronLaunchRecords(
|
|
650
|
+
target: Map<string, ElectronLaunchRecord>,
|
|
651
|
+
source: Map<string, ElectronLaunchRecord>,
|
|
652
|
+
options: {
|
|
653
|
+
branchOwnedLaunchIds?: Set<string>;
|
|
654
|
+
markBranchOwned?: boolean;
|
|
655
|
+
touchedLaunchIds?: Set<string>;
|
|
656
|
+
} = {},
|
|
657
|
+
): void {
|
|
658
|
+
for (const record of getActiveElectronRecords(source)) {
|
|
659
|
+
const alreadyRuntimeOwned = target.has(record.launchId) && options.branchOwnedLaunchIds?.has(record.launchId) === false;
|
|
660
|
+
target.set(record.launchId, record);
|
|
661
|
+
if (options.branchOwnedLaunchIds) {
|
|
662
|
+
if (alreadyRuntimeOwned) {
|
|
663
|
+
// Already runtime-owned from a prior live result; keep it that way.
|
|
664
|
+
} else if (options.markBranchOwned === true) {
|
|
665
|
+
options.branchOwnedLaunchIds.add(record.launchId);
|
|
666
|
+
} else if (options.touchedLaunchIds?.has(record.launchId)) {
|
|
667
|
+
options.branchOwnedLaunchIds.delete(record.launchId);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
1089
671
|
}
|
|
1090
672
|
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
673
|
+
function removeInactiveOwnedElectronLaunchRecords(
|
|
674
|
+
target: Map<string, ElectronLaunchRecord>,
|
|
675
|
+
branchOwnedLaunchIds: Set<string>,
|
|
676
|
+
source: Map<string, ElectronLaunchRecord>,
|
|
677
|
+
activeBranchRanks: Map<string, number>,
|
|
678
|
+
cleanupBranchRanks: Map<string, number>,
|
|
679
|
+
): void {
|
|
680
|
+
const activeLaunchIds = new Set(getActiveElectronRecords(source).map((record) => record.launchId));
|
|
681
|
+
const launchIds = new Set([...source.keys(), ...cleanupBranchRanks.keys()]);
|
|
682
|
+
for (const launchId of launchIds) {
|
|
683
|
+
if (!target.has(launchId) || !branchOwnedLaunchIds.has(launchId)) continue;
|
|
684
|
+
const activeBranchRank = activeBranchRanks.get(launchId);
|
|
685
|
+
const cleanupBranchRank = cleanupBranchRanks.get(launchId);
|
|
686
|
+
const restoredInactiveRecord = source.has(launchId) && !activeLaunchIds.has(launchId);
|
|
687
|
+
const cleanupIsLatest = cleanupBranchRank !== undefined && (activeBranchRank === undefined || cleanupBranchRank > activeBranchRank);
|
|
688
|
+
if (!restoredInactiveRecord && !cleanupIsLatest) continue;
|
|
689
|
+
target.delete(launchId);
|
|
690
|
+
branchOwnedLaunchIds.delete(launchId);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function mergeElectronLaunchRecordMaps(...maps: Array<Map<string, ElectronLaunchRecord>>): Map<string, ElectronLaunchRecord> {
|
|
695
|
+
const merged = new Map<string, ElectronLaunchRecord>();
|
|
696
|
+
for (const map of maps) {
|
|
697
|
+
for (const [launchId, record] of map) merged.set(launchId, record);
|
|
698
|
+
}
|
|
699
|
+
return merged;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function replaceWithActiveElectronLaunchRecords(
|
|
703
|
+
target: Map<string, ElectronLaunchRecord>,
|
|
704
|
+
source: Map<string, ElectronLaunchRecord>,
|
|
705
|
+
branchOwnedLaunchIds?: Set<string>,
|
|
706
|
+
cleanedLaunchIds?: Set<string>,
|
|
707
|
+
): void {
|
|
708
|
+
target.clear();
|
|
709
|
+
if (branchOwnedLaunchIds) {
|
|
710
|
+
if (cleanedLaunchIds) {
|
|
711
|
+
for (const launchId of cleanedLaunchIds) branchOwnedLaunchIds.delete(launchId);
|
|
712
|
+
} else {
|
|
713
|
+
branchOwnedLaunchIds.clear();
|
|
714
|
+
}
|
|
1096
715
|
}
|
|
1097
|
-
|
|
716
|
+
mergeActiveElectronLaunchRecords(target, source, branchOwnedLaunchIds ? { branchOwnedLaunchIds } : {});
|
|
1098
717
|
}
|
|
1099
718
|
|
|
1100
|
-
function
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
719
|
+
function shouldSerializeElectronHostInput(compiledElectron: CompiledAgentBrowserElectron | undefined): boolean {
|
|
720
|
+
return compiledElectron?.action === "status" || compiledElectron?.action === "probe" || compiledElectron?.action === "cleanup";
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function getElectronHostLaunchRecordsForInput(options: {
|
|
724
|
+
branchRecords: Map<string, ElectronLaunchRecord>;
|
|
725
|
+
compiledElectron: CompiledAgentBrowserElectron | undefined;
|
|
726
|
+
ownedRecords: Map<string, ElectronLaunchRecord>;
|
|
727
|
+
}): Map<string, ElectronLaunchRecord> {
|
|
728
|
+
if (
|
|
729
|
+
options.compiledElectron?.action === "status" ||
|
|
730
|
+
options.compiledElectron?.action === "cleanup" ||
|
|
731
|
+
(options.compiledElectron?.action === "probe" && options.compiledElectron.launchId)
|
|
732
|
+
) {
|
|
733
|
+
return mergeElectronLaunchRecordMaps(options.branchRecords, options.ownedRecords);
|
|
1106
734
|
}
|
|
1107
|
-
|
|
1108
|
-
|
|
735
|
+
return options.branchRecords;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function getCleanupResultClosedManagedSessionNames(result: unknown): string[] {
|
|
739
|
+
if (!isRecord(result) || !Array.isArray(result.steps)) return [];
|
|
740
|
+
const closedSessionNames = new Set<string>();
|
|
741
|
+
const record = isRecord(result.record) ? result.record : undefined;
|
|
742
|
+
for (const step of result.steps) {
|
|
743
|
+
if (!isRecord(step) || step.resource !== "managed-session") continue;
|
|
744
|
+
if (step.state !== "removed" && step.state !== "already-gone") continue;
|
|
745
|
+
const sessionName = typeof step.sessionName === "string"
|
|
746
|
+
? step.sessionName
|
|
747
|
+
: typeof record?.sessionName === "string" ? record.sessionName : undefined;
|
|
748
|
+
if (sessionName) closedSessionNames.add(sessionName);
|
|
1109
749
|
}
|
|
1110
|
-
|
|
1111
|
-
|
|
750
|
+
return [...closedSessionNames];
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function getCleanupResultsClosedManagedSessionNames(cleanupResults: unknown[]): string[] {
|
|
754
|
+
const closedSessionNames = new Set<string>();
|
|
755
|
+
for (const result of cleanupResults) {
|
|
756
|
+
for (const sessionName of getCleanupResultClosedManagedSessionNames(result)) closedSessionNames.add(sessionName);
|
|
1112
757
|
}
|
|
1113
|
-
return
|
|
758
|
+
return [...closedSessionNames];
|
|
1114
759
|
}
|
|
1115
760
|
|
|
1116
|
-
function
|
|
1117
|
-
|
|
761
|
+
function isElectronLaunchRecord(value: unknown): value is ElectronLaunchRecord {
|
|
762
|
+
if (!isRecord(value)) return false;
|
|
763
|
+
return value.version === 1
|
|
764
|
+
&& value.launchedByWrapper === true
|
|
765
|
+
&& typeof value.launchId === "string"
|
|
766
|
+
&& typeof value.appName === "string"
|
|
767
|
+
&& typeof value.executablePath === "string"
|
|
768
|
+
&& typeof value.userDataDir === "string"
|
|
769
|
+
&& typeof value.port === "number"
|
|
770
|
+
&& typeof value.createdAtMs === "number";
|
|
1118
771
|
}
|
|
1119
772
|
|
|
1120
|
-
function
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
? `Electron apps (${result.apps.length} shown, ${result.omittedCount} omitted):`
|
|
1125
|
-
: `Electron apps (${result.apps.length} found):`;
|
|
1126
|
-
const lines = [header];
|
|
1127
|
-
if (visibleApps.length === 0) {
|
|
1128
|
-
lines.push(result.query ? `No Electron apps matched query "${result.query}".` : "No Electron apps found in the supported scan locations.");
|
|
1129
|
-
} else {
|
|
1130
|
-
for (const app of visibleApps) {
|
|
1131
|
-
const identifier = app.bundleId ?? app.desktopId;
|
|
1132
|
-
const path = app.appPath ?? app.executablePath;
|
|
1133
|
-
const sensitivity = app.sensitivity ? ` [likely sensitive: ${app.sensitivity.categories.join(", ")}]` : "";
|
|
1134
|
-
lines.push(`- ${app.name}${identifier ? ` (${identifier})` : ""}${sensitivity} — ${path}`);
|
|
1135
|
-
}
|
|
1136
|
-
}
|
|
1137
|
-
if (visibleOmittedCount > 0) {
|
|
1138
|
-
lines.push(`${visibleOmittedCount} additional app(s) omitted from visible output; see details.electron.apps.`);
|
|
1139
|
-
}
|
|
1140
|
-
if (result.omittedCount > 0) {
|
|
1141
|
-
lines.push(`${result.omittedCount} app(s) omitted by maxResults=${result.maxResults}.`);
|
|
1142
|
-
}
|
|
1143
|
-
if (result.apps.some((app) => app.sensitivity?.level === "likely-sensitive")) {
|
|
1144
|
-
lines.push("Review likely-sensitive apps and use caller-owned allow/deny policy before launch.");
|
|
1145
|
-
lines.push(ELECTRON_PROFILE_ISOLATION_NOTE);
|
|
1146
|
-
lines.push(ELECTRON_EXISTING_AUTH_GUIDANCE);
|
|
1147
|
-
}
|
|
1148
|
-
return lines.join("\n");
|
|
773
|
+
function getCleanupResultsElectronRecords(cleanupResults: unknown[]): ElectronLaunchRecord[] {
|
|
774
|
+
return cleanupResults
|
|
775
|
+
.map((result) => isRecord(result) ? result.record : undefined)
|
|
776
|
+
.filter(isElectronLaunchRecord);
|
|
1149
777
|
}
|
|
1150
778
|
|
|
1151
|
-
function
|
|
1152
|
-
const
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
args: [] as string[],
|
|
1156
|
-
compiledElectron,
|
|
1157
|
-
electron: {
|
|
1158
|
-
action: "list" as const,
|
|
1159
|
-
apps: discovery.apps,
|
|
1160
|
-
maxResults: discovery.maxResults,
|
|
1161
|
-
omittedCount: discovery.omittedCount || undefined,
|
|
1162
|
-
platform: discovery.platform,
|
|
1163
|
-
profileIsolation: ELECTRON_PROFILE_ISOLATION_DETAILS,
|
|
1164
|
-
query: discovery.query,
|
|
1165
|
-
sensitiveAppCount: sensitiveAppCount || undefined,
|
|
1166
|
-
skippedCount: discovery.skippedCount,
|
|
1167
|
-
status: "succeeded" as const,
|
|
1168
|
-
},
|
|
1169
|
-
...buildAgentBrowserResultCategoryDetails({ args: [], succeeded: true }),
|
|
1170
|
-
summary: discovery.omittedCount > 0
|
|
1171
|
-
? `Electron app discovery found ${discovery.apps.length} app(s) and omitted ${discovery.omittedCount}.`
|
|
1172
|
-
: `Electron app discovery found ${discovery.apps.length} app(s).`,
|
|
1173
|
-
};
|
|
1174
|
-
return {
|
|
1175
|
-
content: [{ type: "text", text }],
|
|
1176
|
-
details: redactToolDetails(details, []),
|
|
1177
|
-
isError: false,
|
|
1178
|
-
};
|
|
779
|
+
function mergeElectronCleanupRecords(target: Map<string, ElectronLaunchRecord>, cleanupResults: unknown[]): void {
|
|
780
|
+
for (const record of getCleanupResultsElectronRecords(cleanupResults)) {
|
|
781
|
+
target.set(record.launchId, record);
|
|
782
|
+
}
|
|
1179
783
|
}
|
|
1180
784
|
|
|
1181
|
-
function
|
|
1182
|
-
|
|
1183
|
-
const text = redactSensitiveText(`Electron app discovery failed: ${errorText}`);
|
|
1184
|
-
const details = {
|
|
1185
|
-
args: [] as string[],
|
|
1186
|
-
compiledElectron,
|
|
1187
|
-
electron: {
|
|
1188
|
-
action: "list" as const,
|
|
1189
|
-
error: errorText,
|
|
1190
|
-
status: "failed" as const,
|
|
1191
|
-
},
|
|
1192
|
-
...buildAgentBrowserResultCategoryDetails({ args: [], errorText, succeeded: false }),
|
|
1193
|
-
summary: "Electron app discovery failed.",
|
|
1194
|
-
};
|
|
1195
|
-
return {
|
|
1196
|
-
content: [{ type: "text", text }],
|
|
1197
|
-
details: redactToolDetails(details, []),
|
|
1198
|
-
isError: true,
|
|
1199
|
-
};
|
|
785
|
+
function getManagedSessionOutcome(details: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
786
|
+
return isRecord(details.managedSessionOutcome) ? details.managedSessionOutcome : undefined;
|
|
1200
787
|
}
|
|
1201
788
|
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
snapshot?: unknown;
|
|
1207
|
-
snapshotRetryCount?: number;
|
|
1208
|
-
tabs?: unknown;
|
|
789
|
+
function getSuccessfulToolResult(details: Record<string, unknown>, message: Record<string, unknown>): boolean {
|
|
790
|
+
const messageIsError = typeof message.isError === "boolean" ? message.isError : undefined;
|
|
791
|
+
const exitCode = typeof details.exitCode === "number" ? details.exitCode : undefined;
|
|
792
|
+
return messageIsError === undefined ? exitCode === undefined || exitCode === 0 : !messageIsError;
|
|
1209
793
|
}
|
|
1210
794
|
|
|
1211
|
-
function
|
|
1212
|
-
if (
|
|
1213
|
-
return value.version === 1 &&
|
|
1214
|
-
value.launchedByWrapper === true &&
|
|
1215
|
-
typeof value.launchId === "string" &&
|
|
1216
|
-
typeof value.appName === "string" &&
|
|
1217
|
-
typeof value.executablePath === "string" &&
|
|
1218
|
-
typeof value.userDataDir === "string" &&
|
|
1219
|
-
typeof value.port === "number" &&
|
|
1220
|
-
typeof value.createdAtMs === "number";
|
|
795
|
+
function setBranchRankForString(map: Map<string, number>, value: unknown, rank: number): void {
|
|
796
|
+
if (typeof value === "string" && value.length > 0) map.set(value, rank);
|
|
1221
797
|
}
|
|
1222
798
|
|
|
1223
|
-
function
|
|
1224
|
-
const
|
|
799
|
+
function collectBranchManagedResourceEvents(branch: unknown[]): BranchManagedResourceEvents {
|
|
800
|
+
const events: BranchManagedResourceEvents = {
|
|
801
|
+
electronLaunchActiveRanks: new Map<string, number>(),
|
|
802
|
+
electronLaunchCleanupRanks: new Map<string, number>(),
|
|
803
|
+
managedSessionActiveRanks: new Map<string, number>(),
|
|
804
|
+
managedSessionCloseRanks: new Map<string, number>(),
|
|
805
|
+
};
|
|
806
|
+
let eventRank = 0;
|
|
1225
807
|
for (const entry of branch) {
|
|
1226
808
|
if (!isRecord(entry) || entry.type !== "message") continue;
|
|
1227
809
|
const message = isRecord(entry.message) ? entry.message : undefined;
|
|
1228
810
|
if (!message || message.toolName !== "agent_browser") continue;
|
|
1229
811
|
const details = isRecord(message.details) ? message.details : undefined;
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
const
|
|
1233
|
-
|
|
1234
|
-
const
|
|
812
|
+
if (!details) continue;
|
|
813
|
+
eventRank += 1;
|
|
814
|
+
const succeeded = getSuccessfulToolResult(details, message);
|
|
815
|
+
const args = Array.isArray(details.args) && details.args.every((arg) => typeof arg === "string") ? details.args : [];
|
|
816
|
+
const command = typeof details.command === "string" ? details.command : extractCommandTokens(args)[0];
|
|
817
|
+
const sessionName = typeof details.sessionName === "string" ? details.sessionName : undefined;
|
|
818
|
+
const sessionMode = details.sessionMode === "fresh" || details.sessionMode === "auto" ? details.sessionMode : undefined;
|
|
819
|
+
const usedImplicitSession = details.usedImplicitSession === true;
|
|
820
|
+
const explicitSessionName = extractExplicitSessionName(args);
|
|
821
|
+
const outcome = getManagedSessionOutcome(details);
|
|
822
|
+
const outcomeSucceeded = outcome?.succeeded === true;
|
|
823
|
+
const outcomeStatus = typeof outcome?.status === "string" ? outcome.status : undefined;
|
|
824
|
+
const outcomeCurrentSessionName = typeof outcome?.currentSessionName === "string" ? outcome.currentSessionName : undefined;
|
|
825
|
+
const outcomeAttemptedSessionName = typeof outcome?.attemptedSessionName === "string" ? outcome.attemptedSessionName : undefined;
|
|
826
|
+
if (outcomeSucceeded && outcome.activeAfter === true && (outcomeStatus === "created" || outcomeStatus === "replaced" || outcomeStatus === "unchanged")) {
|
|
827
|
+
setBranchRankForString(events.managedSessionActiveRanks, outcomeCurrentSessionName, eventRank);
|
|
828
|
+
}
|
|
829
|
+
if (outcomeSucceeded && outcomeStatus === "closed") {
|
|
830
|
+
setBranchRankForString(events.managedSessionCloseRanks, outcomeAttemptedSessionName ?? outcomeCurrentSessionName ?? sessionName, eventRank);
|
|
831
|
+
}
|
|
832
|
+
if (outcomeSucceeded && outcomeStatus === "replaced") {
|
|
833
|
+
setBranchRankForString(events.managedSessionCloseRanks, outcome.replacedSessionName, eventRank);
|
|
834
|
+
}
|
|
835
|
+
if (succeeded && !isCloseCommand(command) && sessionName && (usedImplicitSession || sessionMode === "fresh")) {
|
|
836
|
+
events.managedSessionActiveRanks.set(sessionName, eventRank);
|
|
837
|
+
}
|
|
838
|
+
if (succeeded && isCloseCommand(command)) {
|
|
839
|
+
setBranchRankForString(events.managedSessionCloseRanks, explicitSessionName ?? sessionName ?? outcomeAttemptedSessionName ?? outcomeCurrentSessionName, eventRank);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const electron = isRecord(details.electron) ? details.electron : undefined;
|
|
843
|
+
const launch = electron && isElectronLaunchRecord(electron.launch) ? electron.launch : undefined;
|
|
844
|
+
if (launch && getActiveElectronRecords(new Map([[launch.launchId, launch]])).length > 0) {
|
|
845
|
+
events.electronLaunchActiveRanks.set(launch.launchId, eventRank);
|
|
846
|
+
}
|
|
847
|
+
const cleanup = isRecord(electron?.cleanup) ? electron.cleanup : undefined;
|
|
848
|
+
const cleanupRecords = Array.isArray(cleanup?.records) ? cleanup.records : [];
|
|
1235
849
|
for (const cleanupRecord of cleanupRecords) {
|
|
1236
|
-
if (isElectronLaunchRecord(cleanupRecord))
|
|
850
|
+
if (isElectronLaunchRecord(cleanupRecord)) events.electronLaunchCleanupRanks.set(cleanupRecord.launchId, eventRank);
|
|
851
|
+
}
|
|
852
|
+
const cleanupResults = Array.isArray(cleanup?.results) ? cleanup.results : [];
|
|
853
|
+
for (const cleanupResult of cleanupResults) {
|
|
854
|
+
if (isRecord(cleanupResult) && isElectronLaunchRecord(cleanupResult.record)) {
|
|
855
|
+
events.electronLaunchCleanupRanks.set(cleanupResult.record.launchId, eventRank);
|
|
856
|
+
}
|
|
857
|
+
for (const closedSessionName of getCleanupResultClosedManagedSessionNames(cleanupResult)) {
|
|
858
|
+
events.managedSessionCloseRanks.set(closedSessionName, eventRank);
|
|
859
|
+
}
|
|
1237
860
|
}
|
|
1238
861
|
}
|
|
1239
|
-
return
|
|
862
|
+
return events;
|
|
1240
863
|
}
|
|
1241
864
|
|
|
1242
|
-
function
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
return record ? { records: [record] } : { error: `No wrapper-tracked Electron launch found for launchId ${compiledElectron.launchId}.` };
|
|
865
|
+
function getCleanupResultsPreservedUserDataDirs(cleanupResults: unknown[]): string[] {
|
|
866
|
+
const userDataDirs = new Set<string>();
|
|
867
|
+
for (const result of cleanupResults) {
|
|
868
|
+
if (!isRecord(result) || !Array.isArray(result.steps) || !isElectronLaunchRecord(result.record)) continue;
|
|
869
|
+
const userDataDirStep = result.steps.find((step) => isRecord(step) && step.resource === "user-data-dir");
|
|
870
|
+
if (!isRecord(userDataDirStep)) continue;
|
|
871
|
+
if (userDataDirStep.state === "skipped" || userDataDirStep.state === "failed") userDataDirs.add(result.record.userDataDir);
|
|
1250
872
|
}
|
|
1251
|
-
|
|
1252
|
-
const activeRecords = getActiveElectronRecords(records);
|
|
1253
|
-
if (activeRecords.length === 0) return { records: [] };
|
|
1254
|
-
if (activeRecords.length > 1) return { error: "Multiple wrapper-tracked Electron launches are active; pass electron.launchId or electron.all." };
|
|
1255
|
-
return { records: activeRecords };
|
|
873
|
+
return [...userDataDirs];
|
|
1256
874
|
}
|
|
1257
875
|
|
|
1258
|
-
function
|
|
1259
|
-
const
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
return `- ${label}${target.url ? ` — ${target.url}` : ""}`;
|
|
1263
|
-
});
|
|
1264
|
-
if (targets.length > shownTargets.length) lines.push(`- ... ${targets.length - shownTargets.length} more target(s) omitted`);
|
|
1265
|
-
return lines;
|
|
876
|
+
function syncElectronCleanupManagedSessions(sessions: Map<string, OwnedManagedSession>, cleanupResults: unknown[]): void {
|
|
877
|
+
for (const sessionName of getCleanupResultsClosedManagedSessionNames(cleanupResults)) {
|
|
878
|
+
untrackOwnedManagedSession(sessions, sessionName);
|
|
879
|
+
}
|
|
1266
880
|
}
|
|
1267
881
|
|
|
1268
|
-
function
|
|
1269
|
-
|
|
882
|
+
async function closeOwnedManagedSessionsExcept(sessions: Map<string, OwnedManagedSession>, keepSessionName: string | undefined, timeoutMs: number): Promise<void> {
|
|
883
|
+
for (const [sessionName, owner] of [...sessions]) {
|
|
884
|
+
if (sessionName === keepSessionName) continue;
|
|
885
|
+
const error = await closeManagedSession({ cwd: owner.cwd, sessionName, timeoutMs });
|
|
886
|
+
if (!error) sessions.delete(sessionName);
|
|
887
|
+
}
|
|
1270
888
|
}
|
|
1271
889
|
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
sessionName: string;
|
|
1275
|
-
title?: string;
|
|
1276
|
-
url?: string;
|
|
890
|
+
async function closeOwnedManagedSessions(sessions: Map<string, OwnedManagedSession>, timeoutMs: number): Promise<void> {
|
|
891
|
+
await closeOwnedManagedSessionsExcept(sessions, undefined, timeoutMs);
|
|
1277
892
|
}
|
|
1278
893
|
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
liveTarget?: ElectronCdpTarget;
|
|
1287
|
-
managedSession: ElectronManagedSessionTarget;
|
|
1288
|
-
nextActionIds: string[];
|
|
1289
|
-
reason: ElectronSessionMismatchReason;
|
|
1290
|
-
sessionName?: string;
|
|
1291
|
-
statusTargets: ElectronCdpTarget[];
|
|
1292
|
-
summary: string;
|
|
894
|
+
function getOffBranchOwnedElectronLaunchRecords(ownedRecords: Map<string, ElectronLaunchRecord>, branchRecords: Map<string, ElectronLaunchRecord>): Map<string, ElectronLaunchRecord> {
|
|
895
|
+
const activeBranchLaunchIds = new Set(getActiveElectronRecords(branchRecords).map((record) => record.launchId));
|
|
896
|
+
const offBranchRecords = new Map<string, ElectronLaunchRecord>();
|
|
897
|
+
for (const record of getActiveElectronRecords(ownedRecords)) {
|
|
898
|
+
if (!activeBranchLaunchIds.has(record.launchId)) offBranchRecords.set(record.launchId, record);
|
|
899
|
+
}
|
|
900
|
+
return offBranchRecords;
|
|
1293
901
|
}
|
|
1294
902
|
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
summary: string;
|
|
1306
|
-
target?: SessionTabTarget;
|
|
903
|
+
function shouldSerializeBrowserCommand(options: {
|
|
904
|
+
explicitSessionName?: string;
|
|
905
|
+
managedSessionName: string;
|
|
906
|
+
ownedElectronLaunchRecords: Map<string, ElectronLaunchRecord>;
|
|
907
|
+
ownedManagedSessions: Map<string, OwnedManagedSession>;
|
|
908
|
+
}): boolean {
|
|
909
|
+
if (!options.explicitSessionName) return true;
|
|
910
|
+
if (options.explicitSessionName === options.managedSessionName) return true;
|
|
911
|
+
if (options.ownedManagedSessions.has(options.explicitSessionName)) return true;
|
|
912
|
+
return getActiveElectronRecords(options.ownedElectronLaunchRecords).some((record) => record.sessionName === options.explicitSessionName);
|
|
1307
913
|
}
|
|
1308
914
|
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
nextActionIds: string[];
|
|
1313
|
-
selector: string;
|
|
1314
|
-
status: "mismatch";
|
|
1315
|
-
summary: string;
|
|
1316
|
-
}
|
|
915
|
+
// Serializes managed-session read/modify/write work so overlapping tool calls cannot promote stale state or close an in-use session.
|
|
916
|
+
class AsyncExecutionQueue {
|
|
917
|
+
private tail: Promise<void> = Promise.resolve();
|
|
1317
918
|
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
}
|
|
919
|
+
run<T>(work: () => Promise<T>): Promise<T> {
|
|
920
|
+
const previous = this.tail;
|
|
921
|
+
let release!: () => void;
|
|
922
|
+
this.tail = new Promise<void>((resolve) => {
|
|
923
|
+
release = resolve;
|
|
924
|
+
});
|
|
1325
925
|
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
926
|
+
return (async () => {
|
|
927
|
+
await previous;
|
|
928
|
+
try {
|
|
929
|
+
return await work();
|
|
930
|
+
} finally {
|
|
931
|
+
release();
|
|
932
|
+
}
|
|
933
|
+
})();
|
|
934
|
+
}
|
|
1331
935
|
}
|
|
1332
936
|
|
|
1333
|
-
function
|
|
1334
|
-
const
|
|
1335
|
-
|
|
1336
|
-
|
|
937
|
+
function getInstalledDocsPaths(): { readmePath: string; commandReferencePath: string; toolContractPath: string } {
|
|
938
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
939
|
+
return {
|
|
940
|
+
readmePath: join(packageRoot, "README.md"),
|
|
941
|
+
commandReferencePath: join(packageRoot, "docs", "COMMAND_REFERENCE.md"),
|
|
942
|
+
toolContractPath: join(packageRoot, "docs", "TOOL_CONTRACT.md"),
|
|
943
|
+
};
|
|
1337
944
|
}
|
|
1338
945
|
|
|
1339
|
-
function getLiveElectronRendererTargets(targets: ElectronCdpTarget[]): ElectronCdpTarget[] {
|
|
1340
|
-
return targets.filter(isLiveElectronRendererTarget);
|
|
1341
|
-
}
|
|
1342
946
|
|
|
1343
|
-
function electronTargetLabel(target: ElectronCdpTarget | undefined): string {
|
|
1344
|
-
if (!target) return "unknown target";
|
|
1345
|
-
return [target.title, target.url, target.id].find((value) => typeof value === "string" && value.trim().length > 0) ?? "unknown target";
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
function findElectronLaunchRecordForSession(sessionName: string | undefined, records: Map<string, ElectronLaunchRecord>): ElectronLaunchRecord | undefined {
|
|
1349
|
-
if (!sessionName) return undefined;
|
|
1350
|
-
return getActiveElectronRecords(records).find((record) => record.sessionName === sessionName);
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
function findUnambiguousActiveElectronLaunchRecord(records: Map<string, ElectronLaunchRecord>): ElectronLaunchRecord | undefined {
|
|
1354
|
-
const activeRecords = getActiveElectronRecords(records);
|
|
1355
|
-
return activeRecords.length === 1 ? activeRecords[0] : undefined;
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
function buildElectronReattachNextAction(record: ElectronLaunchRecord, liveTarget?: ElectronCdpTarget): AgentBrowserNextAction {
|
|
1359
|
-
const endpoint = liveTarget?.webSocketDebuggerUrl ?? record.webSocketDebuggerUrl ?? String(record.port);
|
|
1360
|
-
return {
|
|
1361
|
-
id: "reattach-electron-launch",
|
|
1362
|
-
params: { args: ["connect", endpoint], sessionMode: "fresh" },
|
|
1363
|
-
reason: "Attach a fresh managed session to the same wrapper-tracked Electron debug endpoint when the current session no longer matches the live renderer.",
|
|
1364
|
-
safety: "Creates a new managed browser session; it does not mutate the Electron app. Keep the launchId for later status and cleanup.",
|
|
1365
|
-
tool: "agent_browser",
|
|
1366
|
-
};
|
|
1367
|
-
}
|
|
1368
|
-
|
|
1369
|
-
function buildElectronMismatchNextActions(record: ElectronLaunchRecord, liveTarget?: ElectronCdpTarget): AgentBrowserNextAction[] {
|
|
1370
|
-
const baseActions = buildAgentBrowserNextActions({
|
|
1371
|
-
electron: { launchId: record.launchId, sessionName: record.sessionName, status: record.cleanupState },
|
|
1372
|
-
resultCategory: "success",
|
|
1373
|
-
successCategory: "completed",
|
|
1374
|
-
}) ?? [];
|
|
1375
|
-
const reattachAction = buildElectronReattachNextAction(record, liveTarget);
|
|
1376
|
-
const actions: AgentBrowserNextAction[] = [];
|
|
1377
|
-
for (const action of baseActions) {
|
|
1378
|
-
actions.push(action);
|
|
1379
|
-
if (action.id === "probe-electron-launch") actions.push(reattachAction);
|
|
1380
|
-
}
|
|
1381
|
-
if (!actions.some((action) => action.id === reattachAction.id)) actions.push(reattachAction);
|
|
1382
|
-
return actions;
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
function buildElectronSessionMismatch(options: {
|
|
1386
|
-
managedSession: ElectronManagedSessionTarget;
|
|
1387
|
-
record: ElectronLaunchRecord;
|
|
1388
|
-
statusTargets: ElectronCdpTarget[];
|
|
1389
|
-
}): ElectronSessionMismatch | undefined {
|
|
1390
|
-
const liveTargets = getLiveElectronRendererTargets(options.statusTargets);
|
|
1391
|
-
if (liveTargets.length === 0) return undefined;
|
|
1392
|
-
const managedUrl = normalizeComparableUrl(options.managedSession.url);
|
|
1393
|
-
const matchingLiveTarget = managedUrl
|
|
1394
|
-
? liveTargets.find((target) => normalizeComparableUrl(target.url) === managedUrl)
|
|
1395
|
-
: undefined;
|
|
1396
|
-
if (matchingLiveTarget) return undefined;
|
|
1397
|
-
|
|
1398
|
-
const liveTarget = liveTargets[0];
|
|
1399
|
-
let reason: ElectronSessionMismatchReason | undefined;
|
|
1400
|
-
if (isAboutBlankUrl(options.managedSession.url)) {
|
|
1401
|
-
reason = "managed-session-about-blank-while-launch-target-live";
|
|
1402
|
-
} else if (options.record.sessionName && options.record.sessionName !== options.managedSession.sessionName) {
|
|
1403
|
-
reason = "launch-session-not-current";
|
|
1404
|
-
} else if (managedUrl) {
|
|
1405
|
-
reason = "managed-session-target-not-in-launch-status";
|
|
1406
|
-
}
|
|
1407
|
-
if (!reason) return undefined;
|
|
1408
|
-
|
|
1409
|
-
const managedDescription = options.managedSession.url ?? options.managedSession.title ?? options.managedSession.sessionName;
|
|
1410
|
-
const liveDescription = electronTargetLabel(liveTarget);
|
|
1411
|
-
const summary = reason === "launch-session-not-current"
|
|
1412
|
-
? `Electron session mismatch: current managed session ${options.managedSession.sessionName} is not the wrapper launch session ${options.record.sessionName ?? "unknown"}, while launch ${options.record.launchId} still has live target ${liveDescription}.`
|
|
1413
|
-
: `Electron session mismatch: managed session ${options.managedSession.sessionName} is on ${managedDescription}, but launch ${options.record.launchId} still has live target ${liveDescription}.`;
|
|
1414
|
-
const nextActions = buildElectronMismatchNextActions(options.record, liveTarget);
|
|
1415
|
-
return {
|
|
1416
|
-
launchId: options.record.launchId,
|
|
1417
|
-
liveTarget,
|
|
1418
|
-
managedSession: options.managedSession,
|
|
1419
|
-
nextActionIds: nextActions.map((action) => action.id),
|
|
1420
|
-
reason,
|
|
1421
|
-
sessionName: options.record.sessionName,
|
|
1422
|
-
statusTargets: options.statusTargets,
|
|
1423
|
-
summary,
|
|
1424
|
-
};
|
|
1425
|
-
}
|
|
1426
|
-
|
|
1427
|
-
async function collectManagedSessionCommandData(options: {
|
|
1428
|
-
args: string[];
|
|
1429
|
-
cwd: string;
|
|
1430
|
-
sessionName: string;
|
|
1431
|
-
signal?: AbortSignal;
|
|
1432
|
-
timeoutMs?: number;
|
|
1433
|
-
}): Promise<{ data?: unknown; error?: string }> {
|
|
1434
|
-
try {
|
|
1435
|
-
return { data: await runSessionCommandData(options) };
|
|
1436
|
-
} catch (error) {
|
|
1437
|
-
return { error: error instanceof Error ? error.message : String(error) };
|
|
1438
|
-
}
|
|
1439
|
-
}
|
|
1440
|
-
|
|
1441
|
-
async function collectElectronManagedSessionTarget(options: {
|
|
1442
|
-
cwd: string;
|
|
1443
|
-
sessionName?: string;
|
|
1444
|
-
signal?: AbortSignal;
|
|
1445
|
-
timeoutMs?: number;
|
|
1446
|
-
}): Promise<ElectronManagedSessionTarget | undefined> {
|
|
1447
|
-
if (!options.sessionName) return undefined;
|
|
1448
|
-
const [titleResult, urlResult] = await Promise.all([
|
|
1449
|
-
collectManagedSessionCommandData({ args: ["get", "title"], cwd: options.cwd, sessionName: options.sessionName, signal: options.signal, timeoutMs: options.timeoutMs }),
|
|
1450
|
-
collectManagedSessionCommandData({ args: ["get", "url"], cwd: options.cwd, sessionName: options.sessionName, signal: options.signal, timeoutMs: options.timeoutMs }),
|
|
1451
|
-
]);
|
|
1452
|
-
const title = boundElectronProbeString(extractStringResultField(titleResult.data, "result") ?? extractStringResultField(titleResult.data, "title"), 160);
|
|
1453
|
-
const url = boundElectronProbeString(extractStringResultField(urlResult.data, "result") ?? extractStringResultField(urlResult.data, "url"), 300);
|
|
1454
|
-
const errors = [titleResult.error, urlResult.error].filter((value): value is string => value !== undefined);
|
|
1455
|
-
return { sessionName: options.sessionName, title, url, ...(errors.length > 0 ? { error: errors.join("; ") } : {}) };
|
|
1456
|
-
}
|
|
1457
|
-
|
|
1458
|
-
function formatElectronSessionMismatchText(mismatch: ElectronSessionMismatch): string {
|
|
1459
|
-
return `${mismatch.summary}\nNext: run electron.status/electron.probe with launchId ${mismatch.launchId}, reattach with the reattach-electron-launch nextAction if needed, or cleanup when finished.`;
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
const ELECTRON_POST_COMMAND_HEALTH_COMMANDS = new Set([
|
|
1463
|
-
"back",
|
|
1464
|
-
"check",
|
|
1465
|
-
"click",
|
|
1466
|
-
"dblclick",
|
|
1467
|
-
"fill",
|
|
1468
|
-
"find",
|
|
1469
|
-
"forward",
|
|
1470
|
-
"keyboard",
|
|
1471
|
-
"mouse",
|
|
1472
|
-
"press",
|
|
1473
|
-
"reload",
|
|
1474
|
-
"select",
|
|
1475
|
-
"type",
|
|
1476
|
-
"uncheck",
|
|
1477
|
-
]);
|
|
1478
|
-
|
|
1479
|
-
function shouldInspectElectronPostCommandHealth(command: string | undefined): boolean {
|
|
1480
|
-
return command !== undefined && ELECTRON_POST_COMMAND_HEALTH_COMMANDS.has(command);
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
function buildElectronLifecycleNextActions(record: ElectronLaunchRecord): AgentBrowserNextAction[] {
|
|
1484
|
-
return buildAgentBrowserNextActions({
|
|
1485
|
-
electron: { launchId: record.launchId, sessionName: record.sessionName, status: record.cleanupState },
|
|
1486
|
-
resultCategory: "success",
|
|
1487
|
-
successCategory: "completed",
|
|
1488
|
-
}) ?? [];
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
function buildElectronIdentifiers(record: ElectronLaunchRecord): { appName: string; launchId: string; sessionName?: string } {
|
|
1494
|
-
return { appName: record.appName, launchId: record.launchId, sessionName: record.sessionName };
|
|
1495
|
-
}
|
|
1496
|
-
|
|
1497
|
-
function formatElectronStatusVisibleText(statuses: ElectronLaunchStatus[], records: ElectronLaunchRecord[], mismatches: ElectronSessionMismatch[] = [], managedSessions: ElectronManagedSessionTarget[] = []): string {
|
|
1498
|
-
if (statuses.length === 0) return "Electron status: no active wrapper-tracked launches.";
|
|
1499
|
-
const recordsByLaunchId = new Map(records.map((record) => [record.launchId, record]));
|
|
1500
|
-
const managedSessionsByName = new Map(managedSessions.map((managedSession) => [managedSession.sessionName, managedSession]));
|
|
1501
|
-
const lines = [`Electron status: ${statuses.length} wrapper-tracked launch(es).`];
|
|
1502
|
-
for (const status of statuses) {
|
|
1503
|
-
const record = recordsByLaunchId.get(status.launchId);
|
|
1504
|
-
const sessionName = record?.sessionName;
|
|
1505
|
-
const appName = record?.appName ?? "Electron launch";
|
|
1506
|
-
const sessionText = sessionName ? `, sessionName ${sessionName}` : "";
|
|
1507
|
-
lines.push(`- ${status.launchId}: ${appName}${sessionText}; ${status.portAlive ? "debug port alive" : "debug port dead"}${status.pidAlive === undefined ? "" : status.pidAlive ? ", pid alive" : ", pid dead"} (port ${status.port})`);
|
|
1508
|
-
lines.push(` Identifiers: launchId ${status.launchId}; sessionName ${sessionName ?? "not attached"}.`);
|
|
1509
|
-
for (const targetLine of formatElectronTargetLines(status.targets, 4)) lines.push(` ${targetLine}`);
|
|
1510
|
-
const managedSession = sessionName ? managedSessionsByName.get(sessionName) : undefined;
|
|
1511
|
-
if (managedSession?.error) lines.push(` Managed session warning: ${managedSession.error}`);
|
|
1512
|
-
}
|
|
1513
|
-
for (const mismatch of mismatches) lines.push("", formatElectronSessionMismatchText(mismatch));
|
|
1514
|
-
return lines.join("\n");
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
function buildElectronStatusResult(options: {
|
|
1518
|
-
compiledElectron: CompiledAgentBrowserElectron;
|
|
1519
|
-
managedSessions?: ElectronManagedSessionTarget[];
|
|
1520
|
-
mismatches?: ElectronSessionMismatch[];
|
|
1521
|
-
records: ElectronLaunchRecord[];
|
|
1522
|
-
statuses: ElectronLaunchStatus[];
|
|
1523
|
-
}): AgentBrowserToolResult {
|
|
1524
|
-
const baseNextActions = options.records.flatMap((record) => buildAgentBrowserNextActions({
|
|
1525
|
-
electron: { launchId: record.launchId, sessionName: record.sessionName, status: record.cleanupState },
|
|
1526
|
-
resultCategory: "success",
|
|
1527
|
-
successCategory: "completed",
|
|
1528
|
-
}) ?? []);
|
|
1529
|
-
const mismatchNextActions = (options.mismatches ?? []).flatMap((mismatch) => {
|
|
1530
|
-
const record = options.records.find((candidate) => candidate.launchId === mismatch.launchId);
|
|
1531
|
-
return record ? buildElectronMismatchNextActions(record, mismatch.liveTarget) : [];
|
|
1532
|
-
});
|
|
1533
|
-
const nextActions = options.mismatches?.length
|
|
1534
|
-
? appendUniqueAgentBrowserNextActions([...mismatchNextActions], baseNextActions)
|
|
1535
|
-
: appendUniqueAgentBrowserNextActions([...baseNextActions], mismatchNextActions);
|
|
1536
|
-
const details = {
|
|
1537
|
-
args: [] as string[],
|
|
1538
|
-
compiledElectron: options.compiledElectron,
|
|
1539
|
-
electron: {
|
|
1540
|
-
action: "status" as const,
|
|
1541
|
-
identifierList: options.records.length > 1 ? options.records.map(buildElectronIdentifiers) : undefined,
|
|
1542
|
-
identifiers: options.records.length === 1 && options.records[0] ? buildElectronIdentifiers(options.records[0]) : undefined,
|
|
1543
|
-
launches: options.records,
|
|
1544
|
-
managedSession: options.managedSessions?.length === 1 ? options.managedSessions[0] : undefined,
|
|
1545
|
-
managedSessions: options.managedSessions && options.managedSessions.length > 0 ? options.managedSessions : undefined,
|
|
1546
|
-
sessionMismatch: options.mismatches?.length === 1 ? options.mismatches[0] : undefined,
|
|
1547
|
-
sessionMismatches: options.mismatches && options.mismatches.length > 1 ? options.mismatches : undefined,
|
|
1548
|
-
status: "succeeded" as const,
|
|
1549
|
-
statuses: options.statuses,
|
|
1550
|
-
targets: extractTargetsFromStatus(options.statuses),
|
|
1551
|
-
},
|
|
1552
|
-
nextActions: nextActions.length > 0 ? nextActions : undefined,
|
|
1553
|
-
...buildAgentBrowserResultCategoryDetails({ args: [], succeeded: true }),
|
|
1554
|
-
summary: options.statuses.length === 0 ? "Electron status found no active wrapper-tracked launches." : `Electron status inspected ${options.statuses.length} launch(es).`,
|
|
1555
|
-
};
|
|
1556
|
-
return { content: [{ type: "text", text: redactSensitiveText(formatElectronStatusVisibleText(options.statuses, options.records, options.mismatches, options.managedSessions)) }], details: redactToolDetails(details, []), isError: false };
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
|
-
function formatElectronCleanupVisibleText(results: ElectronCleanupResult[]): string {
|
|
1560
|
-
if (results.length === 0) return "Electron cleanup: no active wrapper-tracked launches.";
|
|
1561
|
-
const lines = [`Electron cleanup: ${results.filter((result) => !result.partial).length}/${results.length} launch(es) fully cleaned.`];
|
|
1562
|
-
for (const result of results) {
|
|
1563
|
-
lines.push(`- ${result.summary}`);
|
|
1564
|
-
for (const step of result.steps) lines.push(` - ${step.resource}: ${step.state}${step.error ? ` (${step.error})` : ""}`);
|
|
1565
|
-
}
|
|
1566
|
-
return lines.join("\n");
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
function buildElectronCleanupResult(compiledElectron: CompiledAgentBrowserElectron, cleanupResults: ElectronCleanupResult[]): AgentBrowserToolResult {
|
|
1570
|
-
const partial = cleanupResults.some((result) => result.partial);
|
|
1571
|
-
const records = cleanupResults.map((result) => result.record);
|
|
1572
|
-
const nextActions = cleanupResults.flatMap((result) => buildAgentBrowserNextActions({
|
|
1573
|
-
electron: { launchId: result.launchId, sessionName: result.record.sessionName, status: result.record.cleanupState },
|
|
1574
|
-
failureCategory: partial ? "cleanup-failed" : undefined,
|
|
1575
|
-
resultCategory: partial ? "failure" : "success",
|
|
1576
|
-
successCategory: partial ? undefined : "completed",
|
|
1577
|
-
}) ?? []);
|
|
1578
|
-
const errorText = partial ? cleanupResults.map((result) => result.summary).join("\n") : undefined;
|
|
1579
|
-
const details = {
|
|
1580
|
-
args: [] as string[],
|
|
1581
|
-
compiledElectron,
|
|
1582
|
-
electron: {
|
|
1583
|
-
action: "cleanup" as const,
|
|
1584
|
-
cleanup: { partial, records, results: cleanupResults },
|
|
1585
|
-
status: partial ? "partial" as const : "succeeded" as const,
|
|
1586
|
-
},
|
|
1587
|
-
nextActions: nextActions.length > 0 ? nextActions : undefined,
|
|
1588
|
-
...buildAgentBrowserResultCategoryDetails({ args: [], errorText, failureCategory: partial ? "cleanup-failed" : undefined, succeeded: !partial }),
|
|
1589
|
-
summary: partial ? "Electron cleanup was partial." : "Electron cleanup completed.",
|
|
1590
|
-
};
|
|
1591
|
-
return { content: [{ type: "text", text: redactSensitiveText(formatElectronCleanupVisibleText(cleanupResults)) }], details: redactToolDetails(details, []), isError: partial };
|
|
1592
|
-
}
|
|
1593
|
-
|
|
1594
|
-
function formatElectronLaunchFailureDiagnostics(failure: ElectronLaunchFailure | undefined): string | undefined {
|
|
1595
|
-
const diagnostics = failure?.diagnostics;
|
|
1596
|
-
if (!diagnostics) return undefined;
|
|
1597
|
-
const lines = ["Electron launch diagnostics:"];
|
|
1598
|
-
if (diagnostics.pid !== undefined) {
|
|
1599
|
-
const pidState = diagnostics.pidAlive === undefined ? "state unknown" : diagnostics.pidAlive ? "alive before cleanup" : "not alive before cleanup";
|
|
1600
|
-
lines.push(`- PID: ${diagnostics.pid} (${pidState}).`);
|
|
1601
|
-
}
|
|
1602
|
-
if (diagnostics.exitCode !== undefined || diagnostics.exitSignal !== undefined) {
|
|
1603
|
-
const exitParts = [diagnostics.exitCode !== undefined ? `code ${diagnostics.exitCode}` : undefined, diagnostics.exitSignal ? `signal ${diagnostics.exitSignal}` : undefined].filter(Boolean).join(", ");
|
|
1604
|
-
lines.push(`- Process exit: ${exitParts || "not observed before cleanup"}.`);
|
|
1605
|
-
}
|
|
1606
|
-
if (diagnostics.userDataDir) lines.push(`- Wrapper profile: ${diagnostics.userDataDir}`);
|
|
1607
|
-
if (diagnostics.devToolsActivePort) {
|
|
1608
|
-
const activePort = diagnostics.devToolsActivePort;
|
|
1609
|
-
const state = activePort.port
|
|
1610
|
-
? `found port ${activePort.port}`
|
|
1611
|
-
: activePort.found
|
|
1612
|
-
? `found but invalid${activePort.error ? ` (${activePort.error})` : ""}`
|
|
1613
|
-
: `missing${activePort.error ? ` (${activePort.error})` : ""}`;
|
|
1614
|
-
lines.push(`- DevToolsActivePort: ${state} at ${activePort.path}.`);
|
|
1615
|
-
}
|
|
1616
|
-
if (diagnostics.cdpVersionReached === false) lines.push("- CDP /json/version: did not return a valid payload before timeout.");
|
|
1617
|
-
if (diagnostics.timeoutMs !== undefined || diagnostics.elapsedMs !== undefined) {
|
|
1618
|
-
lines.push(`- Timing: ${diagnostics.elapsedMs ?? "unknown"}ms elapsed${diagnostics.timeoutMs !== undefined ? ` of ${diagnostics.timeoutMs}ms timeout` : ""}.`);
|
|
1619
|
-
}
|
|
1620
|
-
if (diagnostics.outputCaptured === false) lines.push("- App stdout/stderr: not captured by this wrapper launch path.");
|
|
1621
|
-
lines.push("Retry guidance: increase electron.timeoutMs, try targetType:'any', pass an explicit appPath/executablePath, quit any already-running singleton instance, then retry launch.");
|
|
1622
|
-
return lines.join("\n");
|
|
1623
|
-
}
|
|
1624
|
-
|
|
1625
|
-
function buildElectronHostFailureResult(options: {
|
|
1626
|
-
compiledElectron: CompiledAgentBrowserElectron;
|
|
1627
|
-
errorText: string;
|
|
1628
|
-
failureCategory?: "cleanup-failed" | "policy-blocked" | "timeout" | "upstream-error" | "validation-error";
|
|
1629
|
-
launchFailure?: ElectronLaunchFailure;
|
|
1630
|
-
managedSessionOutcome?: ManagedSessionOutcome;
|
|
1631
|
-
status?: string;
|
|
1632
|
-
}): AgentBrowserToolResult {
|
|
1633
|
-
const text = [
|
|
1634
|
-
options.errorText,
|
|
1635
|
-
formatElectronLaunchFailureDiagnostics(options.launchFailure),
|
|
1636
|
-
options.launchFailure?.cleanupError ? `Electron launch cleanup warning: ${options.launchFailure.cleanupError}` : undefined,
|
|
1637
|
-
].filter((item): item is string => item !== undefined && item.length > 0).join("\n");
|
|
1638
|
-
const details = {
|
|
1639
|
-
args: [] as string[],
|
|
1640
|
-
compiledElectron: options.compiledElectron,
|
|
1641
|
-
electron: {
|
|
1642
|
-
action: options.compiledElectron.action,
|
|
1643
|
-
error: options.errorText,
|
|
1644
|
-
failure: options.launchFailure,
|
|
1645
|
-
status: options.status ?? "failed",
|
|
1646
|
-
},
|
|
1647
|
-
managedSessionOutcome: options.managedSessionOutcome,
|
|
1648
|
-
...buildAgentBrowserResultCategoryDetails({ args: [], errorText: options.errorText, failureCategory: options.failureCategory, succeeded: false, timedOut: options.failureCategory === "timeout" }),
|
|
1649
|
-
summary: options.errorText,
|
|
1650
|
-
};
|
|
1651
|
-
return { content: [{ type: "text", text: redactSensitiveText(text) }], details: redactToolDetails(details, []), isError: true };
|
|
1652
|
-
}
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
function sleepMs(ms: number): Promise<void> {
|
|
1656
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1657
|
-
}
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
interface ElectronProbeFocusedElement {
|
|
1661
|
-
ariaLabel?: string;
|
|
1662
|
-
id?: string;
|
|
1663
|
-
isContentEditable?: boolean;
|
|
1664
|
-
name?: string;
|
|
1665
|
-
placeholder?: string;
|
|
1666
|
-
role?: string;
|
|
1667
|
-
tagName?: string;
|
|
1668
|
-
textLength?: number;
|
|
1669
|
-
textPreview?: string;
|
|
1670
|
-
title?: string;
|
|
1671
|
-
type?: string;
|
|
1672
|
-
valueLength?: number;
|
|
1673
|
-
}
|
|
1674
|
-
|
|
1675
|
-
interface ElectronProbeTab {
|
|
1676
|
-
active?: boolean;
|
|
1677
|
-
index?: number;
|
|
1678
|
-
tabId?: string;
|
|
1679
|
-
title?: string;
|
|
1680
|
-
type?: string;
|
|
1681
|
-
url?: string;
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
|
-
interface ElectronProbeSnapshotSummary {
|
|
1685
|
-
lineCount: number;
|
|
1686
|
-
omittedLineCount?: number;
|
|
1687
|
-
omittedRefCount?: number;
|
|
1688
|
-
refCount: number;
|
|
1689
|
-
refIds: string[];
|
|
1690
|
-
text?: string;
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
interface ElectronProbeResult {
|
|
1694
|
-
activeTab?: ElectronProbeTab;
|
|
1695
|
-
errors?: string[];
|
|
1696
|
-
focusedElement?: ElectronProbeFocusedElement;
|
|
1697
|
-
refSnapshot?: SessionRefSnapshot;
|
|
1698
|
-
sessionName: string;
|
|
1699
|
-
snapshot?: ElectronProbeSnapshotSummary;
|
|
1700
|
-
status: "partial" | "succeeded";
|
|
1701
|
-
summary: string;
|
|
1702
|
-
tabs?: {
|
|
1703
|
-
omittedCount?: number;
|
|
1704
|
-
shown: ElectronProbeTab[];
|
|
1705
|
-
total: number;
|
|
1706
|
-
};
|
|
1707
|
-
title?: string;
|
|
1708
|
-
url?: string;
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
const ELECTRON_FOCUSED_ELEMENT_EVAL = `(() => {
|
|
1712
|
-
const clean = (value, max = 80) => {
|
|
1713
|
-
if (typeof value !== "string") return undefined;
|
|
1714
|
-
const normalized = value.replace(/\\s+/g, " ").trim();
|
|
1715
|
-
if (!normalized) return undefined;
|
|
1716
|
-
return normalized.length > max ? normalized.slice(0, max - 3) + "..." : normalized;
|
|
1717
|
-
};
|
|
1718
|
-
const describeElement = (element) => {
|
|
1719
|
-
if (!element || !(element instanceof Element)) return undefined;
|
|
1720
|
-
const tagName = element.tagName.toLowerCase();
|
|
1721
|
-
const inputLike = element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement;
|
|
1722
|
-
const contentEditable = element instanceof HTMLElement && element.isContentEditable;
|
|
1723
|
-
const containerLike = tagName === "body" || tagName === "html";
|
|
1724
|
-
const rawText = element.textContent || "";
|
|
1725
|
-
const exposeText = !inputLike && !contentEditable && !containerLike;
|
|
1726
|
-
const text = exposeText ? clean(rawText) : undefined;
|
|
1727
|
-
return {
|
|
1728
|
-
tagName: clean(tagName, 40),
|
|
1729
|
-
role: clean(element.getAttribute("role") || "", 60),
|
|
1730
|
-
name: clean(element.getAttribute("aria-label") || element.getAttribute("title") || text || "", 80),
|
|
1731
|
-
id: clean(element.id || "", 80),
|
|
1732
|
-
type: clean(element.getAttribute("type") || "", 40),
|
|
1733
|
-
placeholder: clean(element.getAttribute("placeholder") || "", 80),
|
|
1734
|
-
ariaLabel: clean(element.getAttribute("aria-label") || "", 80),
|
|
1735
|
-
title: clean(element.getAttribute("title") || "", 80),
|
|
1736
|
-
textLength: !exposeText && rawText ? rawText.length : undefined,
|
|
1737
|
-
textPreview: text,
|
|
1738
|
-
valueLength: inputLike && typeof element.value === "string" ? element.value.length : undefined,
|
|
1739
|
-
isContentEditable: contentEditable || undefined,
|
|
1740
|
-
};
|
|
1741
|
-
};
|
|
1742
|
-
return { focusedElement: describeElement(document.activeElement) };
|
|
1743
|
-
})()`;
|
|
1744
|
-
|
|
1745
|
-
function boundElectronProbeString(value: string | undefined, maxLength = 240): string | undefined {
|
|
1746
|
-
const trimmed = value?.trim();
|
|
1747
|
-
if (!trimmed) return undefined;
|
|
1748
|
-
return trimmed.length > maxLength ? `${trimmed.slice(0, Math.max(0, maxLength - 3))}...` : trimmed;
|
|
1749
|
-
}
|
|
1750
|
-
|
|
1751
|
-
function getTrimmedString(value: unknown): string | undefined {
|
|
1752
|
-
return typeof value === "string" ? boundElectronProbeString(value) : undefined;
|
|
1753
|
-
}
|
|
1754
|
-
|
|
1755
|
-
function getOptionalBoolean(value: unknown): boolean | undefined {
|
|
1756
|
-
return typeof value === "boolean" ? value : undefined;
|
|
1757
|
-
}
|
|
1758
|
-
|
|
1759
|
-
function getOptionalNumber(value: unknown): number | undefined {
|
|
1760
|
-
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
1761
|
-
}
|
|
1762
|
-
|
|
1763
|
-
function extractElectronFocusedElement(data: unknown): ElectronProbeFocusedElement | undefined {
|
|
1764
|
-
const payload = isRecord(data) && isRecord(data.result) ? data.result : data;
|
|
1765
|
-
const rawFocusedElement = isRecord(payload) && isRecord(payload.focusedElement) ? payload.focusedElement : isRecord(payload) ? payload : undefined;
|
|
1766
|
-
if (!rawFocusedElement) return undefined;
|
|
1767
|
-
const focusedElement: ElectronProbeFocusedElement = {
|
|
1768
|
-
ariaLabel: getTrimmedString(rawFocusedElement.ariaLabel),
|
|
1769
|
-
id: getTrimmedString(rawFocusedElement.id),
|
|
1770
|
-
isContentEditable: getOptionalBoolean(rawFocusedElement.isContentEditable),
|
|
1771
|
-
name: getTrimmedString(rawFocusedElement.name),
|
|
1772
|
-
placeholder: getTrimmedString(rawFocusedElement.placeholder),
|
|
1773
|
-
role: getTrimmedString(rawFocusedElement.role),
|
|
1774
|
-
tagName: getTrimmedString(rawFocusedElement.tagName),
|
|
1775
|
-
textLength: getOptionalNumber(rawFocusedElement.textLength),
|
|
1776
|
-
textPreview: getTrimmedString(rawFocusedElement.textPreview),
|
|
1777
|
-
title: getTrimmedString(rawFocusedElement.title),
|
|
1778
|
-
type: getTrimmedString(rawFocusedElement.type),
|
|
1779
|
-
valueLength: getOptionalNumber(rawFocusedElement.valueLength),
|
|
1780
|
-
};
|
|
1781
|
-
return Object.values(focusedElement).some((value) => value !== undefined) ? focusedElement : undefined;
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
function extractElectronProbeTabs(data: unknown): { activeTab?: ElectronProbeTab; tabs?: ElectronProbeResult["tabs"] } {
|
|
1785
|
-
const rawTabs = isRecord(data) && Array.isArray(data.tabs) ? data.tabs : Array.isArray(data) ? data : [];
|
|
1786
|
-
const allTabs = rawTabs.filter(isRecord).map((tab, index): ElectronProbeTab => ({
|
|
1787
|
-
active: getOptionalBoolean(tab.active),
|
|
1788
|
-
index: typeof tab.index === "number" && Number.isInteger(tab.index) ? tab.index : index,
|
|
1789
|
-
tabId: getTrimmedString(tab.tabId) ?? getTrimmedString(tab.id),
|
|
1790
|
-
title: getTrimmedString(tab.title) ?? getTrimmedString(tab.label),
|
|
1791
|
-
type: getTrimmedString(tab.type),
|
|
1792
|
-
url: getTrimmedString(tab.url),
|
|
1793
|
-
}));
|
|
1794
|
-
if (allTabs.length === 0) return {};
|
|
1795
|
-
const shown = allTabs.slice(0, ELECTRON_PROBE_MAX_TABS);
|
|
1796
|
-
return {
|
|
1797
|
-
activeTab: allTabs.find((tab) => tab.active) ?? allTabs[0],
|
|
1798
|
-
tabs: {
|
|
1799
|
-
omittedCount: allTabs.length > shown.length ? allTabs.length - shown.length : undefined,
|
|
1800
|
-
shown,
|
|
1801
|
-
total: allTabs.length,
|
|
1802
|
-
},
|
|
1803
|
-
};
|
|
1804
|
-
}
|
|
1805
|
-
|
|
1806
|
-
function truncateElectronProbeSnapshotText(snapshotText: string | undefined): { lineCount: number; omittedLineCount?: number; text?: string } {
|
|
1807
|
-
if (!snapshotText) return { lineCount: 0 };
|
|
1808
|
-
const lines = snapshotText.split(/\r?\n/);
|
|
1809
|
-
const shownLines: string[] = [];
|
|
1810
|
-
let usedChars = 0;
|
|
1811
|
-
for (const line of lines) {
|
|
1812
|
-
if (shownLines.length >= ELECTRON_PROBE_MAX_SNAPSHOT_LINES) break;
|
|
1813
|
-
const nextLength = usedChars + line.length + (shownLines.length > 0 ? 1 : 0);
|
|
1814
|
-
if (nextLength > ELECTRON_PROBE_MAX_SNAPSHOT_CHARS) {
|
|
1815
|
-
if (shownLines.length === 0) shownLines.push(`${line.slice(0, ELECTRON_PROBE_MAX_SNAPSHOT_CHARS - 3)}...`);
|
|
1816
|
-
break;
|
|
1817
|
-
}
|
|
1818
|
-
shownLines.push(line);
|
|
1819
|
-
usedChars = nextLength;
|
|
1820
|
-
}
|
|
1821
|
-
return {
|
|
1822
|
-
lineCount: lines.length,
|
|
1823
|
-
omittedLineCount: lines.length > shownLines.length ? lines.length - shownLines.length : undefined,
|
|
1824
|
-
text: shownLines.length > 0 ? shownLines.join("\n") : undefined,
|
|
1825
|
-
};
|
|
1826
|
-
}
|
|
1827
|
-
|
|
1828
|
-
function summarizeElectronProbeSnapshot(data: unknown): { refSnapshot?: SessionRefSnapshot; snapshot?: ElectronProbeSnapshotSummary } {
|
|
1829
|
-
const refSnapshot = extractRefSnapshotFromData(data);
|
|
1830
|
-
const rawSnapshotText = isRecord(data) ? getTrimmedString(data.snapshot) : undefined;
|
|
1831
|
-
const truncatedText = truncateElectronProbeSnapshotText(rawSnapshotText);
|
|
1832
|
-
const refIds = refSnapshot?.refIds ?? [];
|
|
1833
|
-
const shownRefIds = refIds.slice(0, ELECTRON_PROBE_MAX_REF_IDS);
|
|
1834
|
-
const snapshot = refSnapshot || truncatedText.text
|
|
1835
|
-
? {
|
|
1836
|
-
lineCount: truncatedText.lineCount,
|
|
1837
|
-
omittedLineCount: truncatedText.omittedLineCount,
|
|
1838
|
-
omittedRefCount: refIds.length > shownRefIds.length ? refIds.length - shownRefIds.length : undefined,
|
|
1839
|
-
refCount: refIds.length,
|
|
1840
|
-
refIds: shownRefIds,
|
|
1841
|
-
text: truncatedText.text,
|
|
1842
|
-
}
|
|
1843
|
-
: undefined;
|
|
1844
|
-
return { refSnapshot, snapshot };
|
|
1845
|
-
}
|
|
1846
|
-
|
|
1847
|
-
function getElectronProbeSummary(probe: Omit<ElectronProbeResult, "summary">): string {
|
|
1848
|
-
const parts = [
|
|
1849
|
-
probe.title ? `title "${probe.title}"` : undefined,
|
|
1850
|
-
probe.url ? `url ${probe.url}` : undefined,
|
|
1851
|
-
probe.focusedElement ? "focused element" : undefined,
|
|
1852
|
-
probe.tabs ? `${probe.tabs.total} tab(s)` : undefined,
|
|
1853
|
-
probe.snapshot ? `${probe.snapshot.refCount} ref(s)` : undefined,
|
|
1854
|
-
].filter((item): item is string => item !== undefined);
|
|
1855
|
-
return parts.length > 0 ? `Electron probe collected ${parts.join(", ")}.` : "Electron probe did not return current session state.";
|
|
1856
|
-
}
|
|
1857
|
-
|
|
1858
|
-
async function runElectronProbeCommandData(options: {
|
|
1859
|
-
args: string[];
|
|
1860
|
-
cwd: string;
|
|
1861
|
-
sessionName: string;
|
|
1862
|
-
signal?: AbortSignal;
|
|
1863
|
-
stdin?: string;
|
|
1864
|
-
timeoutMs?: number;
|
|
1865
|
-
}): Promise<{ data?: unknown; error?: string }> {
|
|
1866
|
-
try {
|
|
1867
|
-
return { data: await runSessionCommandData(options) };
|
|
1868
|
-
} catch (error) {
|
|
1869
|
-
return { error: error instanceof Error ? error.message : String(error) };
|
|
1870
|
-
}
|
|
1871
|
-
}
|
|
1872
|
-
|
|
1873
|
-
async function collectElectronProbe(options: {
|
|
1874
|
-
cwd: string;
|
|
1875
|
-
sessionName: string;
|
|
1876
|
-
signal?: AbortSignal;
|
|
1877
|
-
timeoutMs?: number;
|
|
1878
|
-
}): Promise<ElectronProbeResult> {
|
|
1879
|
-
const titleResult = await runElectronProbeCommandData({ args: ["get", "title"], cwd: options.cwd, sessionName: options.sessionName, signal: options.signal, timeoutMs: options.timeoutMs });
|
|
1880
|
-
const urlResult = await runElectronProbeCommandData({ args: ["get", "url"], cwd: options.cwd, sessionName: options.sessionName, signal: options.signal, timeoutMs: options.timeoutMs });
|
|
1881
|
-
const focusedResult = await runElectronProbeCommandData({ args: ["eval", "--stdin"], cwd: options.cwd, sessionName: options.sessionName, signal: options.signal, stdin: ELECTRON_FOCUSED_ELEMENT_EVAL, timeoutMs: options.timeoutMs });
|
|
1882
|
-
const tabsResult = await runElectronProbeCommandData({ args: ["tab", "list"], cwd: options.cwd, sessionName: options.sessionName, signal: options.signal, timeoutMs: options.timeoutMs });
|
|
1883
|
-
const snapshotResult = await runElectronProbeCommandData({ args: ["snapshot", "-i"], cwd: options.cwd, sessionName: options.sessionName, signal: options.signal, timeoutMs: options.timeoutMs });
|
|
1884
|
-
const errors = [
|
|
1885
|
-
titleResult.error ? `get title: ${titleResult.error}` : undefined,
|
|
1886
|
-
urlResult.error ? `get url: ${urlResult.error}` : undefined,
|
|
1887
|
-
focusedResult.error ? `focused element: ${focusedResult.error}` : undefined,
|
|
1888
|
-
tabsResult.error ? `tab list: ${tabsResult.error}` : undefined,
|
|
1889
|
-
snapshotResult.error ? `snapshot: ${snapshotResult.error}` : undefined,
|
|
1890
|
-
].filter((item): item is string => item !== undefined).map((error) => boundElectronProbeString(error, 240) ?? "probe command failed");
|
|
1891
|
-
const title = boundElectronProbeString(extractStringResultField(titleResult.data, "result") ?? extractStringResultField(titleResult.data, "title"), 160);
|
|
1892
|
-
const url = boundElectronProbeString(extractStringResultField(urlResult.data, "result") ?? extractStringResultField(urlResult.data, "url"), 300);
|
|
1893
|
-
const focusedElement = extractElectronFocusedElement(focusedResult.data);
|
|
1894
|
-
const { activeTab, tabs } = extractElectronProbeTabs(tabsResult.data);
|
|
1895
|
-
const { refSnapshot, snapshot } = summarizeElectronProbeSnapshot(snapshotResult.data);
|
|
1896
|
-
const probeWithoutSummary = {
|
|
1897
|
-
activeTab,
|
|
1898
|
-
focusedElement,
|
|
1899
|
-
errors: errors.length > 0 ? errors : undefined,
|
|
1900
|
-
refSnapshot,
|
|
1901
|
-
sessionName: options.sessionName,
|
|
1902
|
-
snapshot,
|
|
1903
|
-
status: errors.length === 0 && (title || url || focusedElement || tabs || snapshot) ? "succeeded" as const : "partial" as const,
|
|
1904
|
-
tabs,
|
|
1905
|
-
title,
|
|
1906
|
-
url,
|
|
1907
|
-
};
|
|
1908
|
-
return { ...probeWithoutSummary, summary: getElectronProbeSummary(probeWithoutSummary) };
|
|
1909
|
-
}
|
|
1910
|
-
|
|
1911
|
-
function formatElectronProbeFocusedElement(focusedElement: ElectronProbeFocusedElement | undefined): string | undefined {
|
|
1912
|
-
if (!focusedElement) return undefined;
|
|
1913
|
-
const label = focusedElement.name ?? focusedElement.textPreview ?? focusedElement.placeholder ?? focusedElement.ariaLabel ?? focusedElement.title;
|
|
1914
|
-
const descriptor = [focusedElement.role, focusedElement.tagName].filter(Boolean).join("/") || "element";
|
|
1915
|
-
const suffix = [
|
|
1916
|
-
focusedElement.id ? `#${focusedElement.id}` : undefined,
|
|
1917
|
-
focusedElement.type ? `type=${focusedElement.type}` : undefined,
|
|
1918
|
-
focusedElement.valueLength !== undefined ? `valueLength=${focusedElement.valueLength}` : undefined,
|
|
1919
|
-
focusedElement.textLength !== undefined ? `textLength=${focusedElement.textLength}` : undefined,
|
|
1920
|
-
].filter((item): item is string => item !== undefined).join(", ");
|
|
1921
|
-
return `Focused: ${descriptor}${label ? ` "${label}"` : ""}${suffix ? ` (${suffix})` : ""}`;
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
function formatElectronProbeContextText(context: ElectronProbeContext): string {
|
|
1925
|
-
if (context.mode === "launchId") {
|
|
1926
|
-
return `Probe context: wrapper launch ${context.launchId} session ${context.sessionName}.`;
|
|
1927
|
-
}
|
|
1928
|
-
if (context.note) {
|
|
1929
|
-
return `Probe context: current managed session ${context.sessionName}; ${context.note}`;
|
|
1930
|
-
}
|
|
1931
|
-
if (context.launchId) {
|
|
1932
|
-
return `Probe context: current managed session ${context.sessionName} maps to Electron launch ${context.launchId}.`;
|
|
1933
|
-
}
|
|
1934
|
-
return `Probe context: current managed session ${context.sessionName} only; pass electron.probe.launchId to compare wrapper-tracked launch status.`;
|
|
1935
|
-
}
|
|
1936
|
-
|
|
1937
|
-
function formatElectronProbeLaunchStatusText(status: ElectronLaunchStatus | undefined, probe: ElectronProbeResult): string | undefined {
|
|
1938
|
-
if (!status) return undefined;
|
|
1939
|
-
const lines = [`Launch status: ${status.portAlive ? "debug port alive" : "debug port dead"}${status.pidAlive === undefined ? "" : status.pidAlive ? ", pid alive" : ", pid dead"}; ${status.targets.length} CDP target(s).`];
|
|
1940
|
-
if (isAboutBlankUrl(probe.url) && (!status.portAlive || status.pidAlive === false || getLiveElectronRendererTargets(status.targets).length === 0)) {
|
|
1941
|
-
lines.push("Electron lifecycle warning: the browser session is on about:blank and the wrapper launch has no live renderer target to reattach. Run electron.status, cleanup if dead, or relaunch the app.");
|
|
1942
|
-
}
|
|
1943
|
-
return lines.join("\n");
|
|
1944
|
-
}
|
|
1945
|
-
|
|
1946
|
-
function formatElectronProbeVisibleText(options: {
|
|
1947
|
-
context?: ElectronProbeContext;
|
|
1948
|
-
mismatch?: ElectronSessionMismatch;
|
|
1949
|
-
probe: ElectronProbeResult;
|
|
1950
|
-
status?: ElectronLaunchStatus;
|
|
1951
|
-
}): string {
|
|
1952
|
-
const { context, mismatch, probe, status } = options;
|
|
1953
|
-
const page = [probe.title, probe.url].filter(Boolean).join(" — ");
|
|
1954
|
-
const lines = [`Electron probe: ${page || probe.sessionName}`];
|
|
1955
|
-
if (context) lines.push(formatElectronProbeContextText(context));
|
|
1956
|
-
const launchStatusText = formatElectronProbeLaunchStatusText(status, probe);
|
|
1957
|
-
if (launchStatusText) lines.push(launchStatusText);
|
|
1958
|
-
if (mismatch) lines.push(formatElectronSessionMismatchText(mismatch));
|
|
1959
|
-
const focusedLine = formatElectronProbeFocusedElement(probe.focusedElement);
|
|
1960
|
-
if (focusedLine) lines.push(focusedLine);
|
|
1961
|
-
if (probe.tabs) {
|
|
1962
|
-
const active = probe.activeTab;
|
|
1963
|
-
lines.push(`Tabs: ${probe.tabs.total} total${probe.tabs.omittedCount ? ` (${probe.tabs.omittedCount} omitted)` : ""}${active ? `; active ${active.index ?? "?"}: ${[active.title, active.url].filter(Boolean).join(" — ") || active.tabId || "tab"}` : ""}`);
|
|
1964
|
-
}
|
|
1965
|
-
if (probe.snapshot) {
|
|
1966
|
-
lines.push(`Snapshot: ${probe.snapshot.refCount} interactive ref(s)${probe.snapshot.omittedRefCount ? ` (${probe.snapshot.omittedRefCount} ref id(s) omitted)` : ""}.`);
|
|
1967
|
-
if (probe.snapshot.text) lines.push(probe.snapshot.text);
|
|
1968
|
-
if (probe.snapshot.omittedLineCount) lines.push(`... ${probe.snapshot.omittedLineCount} snapshot line(s) omitted`);
|
|
1969
|
-
}
|
|
1970
|
-
if (probe.status === "partial") lines.push("Some probe commands did not return data; use raw agent_browser commands for deeper diagnostics.");
|
|
1971
|
-
if (probe.errors && probe.errors.length > 0) lines.push(`Probe warning: ${probe.errors.slice(0, 2).join("; ")}${probe.errors.length > 2 ? "; ..." : ""}`);
|
|
1972
|
-
return lines.join("\n");
|
|
1973
|
-
}
|
|
1974
|
-
|
|
1975
|
-
function buildElectronProbeResult(options: {
|
|
1976
|
-
compiledElectron: CompiledAgentBrowserElectron;
|
|
1977
|
-
mismatch?: ElectronSessionMismatch;
|
|
1978
|
-
probe: ElectronProbeResult;
|
|
1979
|
-
probeContext: ElectronProbeContext;
|
|
1980
|
-
record?: ElectronLaunchRecord;
|
|
1981
|
-
sessionTabTarget?: SessionTabTarget;
|
|
1982
|
-
status?: ElectronLaunchStatus;
|
|
1983
|
-
}): AgentBrowserToolResult {
|
|
1984
|
-
const { refSnapshot: _refSnapshot, ...boundedProbe } = options.probe;
|
|
1985
|
-
const baseNextActions = options.record ? buildAgentBrowserNextActions({
|
|
1986
|
-
electron: { launchId: options.record.launchId, sessionName: options.record.sessionName, status: options.record.cleanupState },
|
|
1987
|
-
resultCategory: "success",
|
|
1988
|
-
successCategory: "completed",
|
|
1989
|
-
}) ?? [] : [];
|
|
1990
|
-
const mismatchNextActions = options.mismatch && options.record ? buildElectronMismatchNextActions(options.record, options.mismatch.liveTarget) : [];
|
|
1991
|
-
const nextActions = options.mismatch
|
|
1992
|
-
? appendUniqueAgentBrowserNextActions([...mismatchNextActions], baseNextActions)
|
|
1993
|
-
: appendUniqueAgentBrowserNextActions([...baseNextActions], mismatchNextActions);
|
|
1994
|
-
const details = {
|
|
1995
|
-
args: [] as string[],
|
|
1996
|
-
compiledElectron: options.compiledElectron,
|
|
1997
|
-
electron: {
|
|
1998
|
-
action: "probe" as const,
|
|
1999
|
-
identifiers: options.record ? buildElectronIdentifiers(options.record) : undefined,
|
|
2000
|
-
probe: boundedProbe,
|
|
2001
|
-
probeContext: options.probeContext,
|
|
2002
|
-
sessionMismatch: options.mismatch,
|
|
2003
|
-
status: options.probe.status,
|
|
2004
|
-
statusTargets: options.status?.targets,
|
|
2005
|
-
launchStatus: options.status,
|
|
2006
|
-
},
|
|
2007
|
-
nextActions: nextActions.length > 0 ? nextActions : undefined,
|
|
2008
|
-
...buildAgentBrowserResultCategoryDetails({ args: [], succeeded: true }),
|
|
2009
|
-
sessionName: options.probe.sessionName,
|
|
2010
|
-
sessionTabTarget: options.sessionTabTarget,
|
|
2011
|
-
summary: options.mismatch?.summary ?? options.probe.summary,
|
|
2012
|
-
usedImplicitSession: options.probeContext.mode === "current-managed-session",
|
|
2013
|
-
};
|
|
2014
|
-
return {
|
|
2015
|
-
content: [{ type: "text", text: redactSensitiveText(formatElectronProbeVisibleText({ context: options.probeContext, mismatch: options.mismatch, probe: options.probe, status: options.status })) }],
|
|
2016
|
-
details: redactToolDetails(details, []),
|
|
2017
|
-
isError: false,
|
|
2018
|
-
};
|
|
2019
|
-
}
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
function supportsPinnedStdinCommand(options: { command?: string; commandTokens: string[]; stdin?: string }): boolean {
|
|
2024
|
-
if (options.command === "batch") {
|
|
2025
|
-
return options.stdin !== undefined;
|
|
2026
|
-
}
|
|
2027
|
-
if (options.stdin === undefined) {
|
|
2028
|
-
return true;
|
|
2029
|
-
}
|
|
2030
|
-
if (options.command === "eval") {
|
|
2031
|
-
return options.commandTokens.includes("--stdin");
|
|
2032
|
-
}
|
|
2033
|
-
return false;
|
|
2034
|
-
}
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
function validateUserBatchStep(
|
|
2038
|
-
step: unknown,
|
|
2039
|
-
index: number,
|
|
2040
|
-
):
|
|
2041
|
-
| { ok: true; step: BatchCommandStep }
|
|
2042
|
-
| { ok: false; error: string } {
|
|
2043
|
-
if (!Array.isArray(step)) {
|
|
2044
|
-
return {
|
|
2045
|
-
ok: false,
|
|
2046
|
-
error: `agent_browser batch stdin step ${index} must be a non-empty array of string command tokens.`,
|
|
2047
|
-
};
|
|
2048
|
-
}
|
|
2049
|
-
if (step.length === 0) {
|
|
2050
|
-
return {
|
|
2051
|
-
ok: false,
|
|
2052
|
-
error: `agent_browser batch stdin step ${index} must not be empty.`,
|
|
2053
|
-
};
|
|
2054
|
-
}
|
|
2055
|
-
const invalidTokenIndex = step.findIndex((token) => typeof token !== "string");
|
|
2056
|
-
if (invalidTokenIndex !== -1) {
|
|
2057
|
-
return {
|
|
2058
|
-
ok: false,
|
|
2059
|
-
error: `agent_browser batch stdin step ${index} token ${invalidTokenIndex} must be a string.`,
|
|
2060
|
-
};
|
|
2061
|
-
}
|
|
2062
|
-
return { ok: true, step: step as BatchCommandStep };
|
|
2063
|
-
}
|
|
2064
|
-
|
|
2065
|
-
function parseUserBatchStdin(stdin: string | undefined): { error?: string; steps?: BatchCommandStep[] } {
|
|
2066
|
-
if (stdin === undefined) {
|
|
2067
|
-
return { steps: [] };
|
|
2068
|
-
}
|
|
2069
|
-
try {
|
|
2070
|
-
const parsed = JSON.parse(stdin) as unknown;
|
|
2071
|
-
if (!Array.isArray(parsed)) {
|
|
2072
|
-
return { error: "agent_browser batch stdin must be a JSON array of command steps." };
|
|
2073
|
-
}
|
|
2074
|
-
const steps: BatchCommandStep[] = [];
|
|
2075
|
-
for (const [index, rawStep] of parsed.entries()) {
|
|
2076
|
-
const validated = validateUserBatchStep(rawStep, index);
|
|
2077
|
-
if (!validated.ok) {
|
|
2078
|
-
return { error: validated.error };
|
|
2079
|
-
}
|
|
2080
|
-
steps.push(validated.step);
|
|
2081
|
-
}
|
|
2082
|
-
return { steps };
|
|
2083
|
-
} catch (error) {
|
|
2084
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
2085
|
-
return { error: `agent_browser batch stdin could not be parsed as JSON: ${message}` };
|
|
2086
|
-
}
|
|
2087
|
-
}
|
|
2088
|
-
|
|
2089
|
-
const REF_INVALIDATING_BATCH_COMMANDS = new Set([
|
|
2090
|
-
"back",
|
|
2091
|
-
"check",
|
|
2092
|
-
"click",
|
|
2093
|
-
"dblclick",
|
|
2094
|
-
"drag",
|
|
2095
|
-
"forward",
|
|
2096
|
-
"goto",
|
|
2097
|
-
"keyboard",
|
|
2098
|
-
"mouse",
|
|
2099
|
-
"navigate",
|
|
2100
|
-
"open",
|
|
2101
|
-
"press",
|
|
2102
|
-
"reload",
|
|
2103
|
-
"select",
|
|
2104
|
-
"type",
|
|
2105
|
-
"uncheck",
|
|
2106
|
-
"upload",
|
|
2107
|
-
]);
|
|
2108
|
-
|
|
2109
|
-
const REF_GUARDED_COMMANDS = new Set([
|
|
2110
|
-
"check",
|
|
2111
|
-
"click",
|
|
2112
|
-
"dblclick",
|
|
2113
|
-
"download",
|
|
2114
|
-
"drag",
|
|
2115
|
-
"fill",
|
|
2116
|
-
"focus",
|
|
2117
|
-
"hover",
|
|
2118
|
-
"keyboard",
|
|
2119
|
-
"mouse",
|
|
2120
|
-
"press",
|
|
2121
|
-
"scrollintoview",
|
|
2122
|
-
"select",
|
|
2123
|
-
"type",
|
|
2124
|
-
"uncheck",
|
|
2125
|
-
"upload",
|
|
2126
|
-
]);
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
function collectRefsFromTokens(tokens: string[]): string[] {
|
|
2130
|
-
return tokens.filter((token) => /^@e\d+\b/.test(token)).map((token) => token.slice(1));
|
|
2131
|
-
}
|
|
2132
|
-
|
|
2133
|
-
function getGuardedRefUsage(commandTokens: string[], stdin?: string, options: { includeRefsAfterBatchSnapshot?: boolean } = {}): string[] {
|
|
2134
|
-
const collectFromStep = (step: string[]) => REF_GUARDED_COMMANDS.has(step[0] ?? "") ? collectRefsFromTokens(step) : [];
|
|
2135
|
-
if (commandTokens[0] !== "batch" || stdin === undefined) {
|
|
2136
|
-
return collectFromStep(commandTokens);
|
|
2137
|
-
}
|
|
2138
|
-
const parsed = parseUserBatchStdin(stdin);
|
|
2139
|
-
if (parsed.error || parsed.steps === undefined) {
|
|
2140
|
-
return collectFromStep(commandTokens);
|
|
2141
|
-
}
|
|
2142
|
-
const refsBeforeInBatchSnapshot: string[] = [];
|
|
2143
|
-
for (const step of parsed.steps) {
|
|
2144
|
-
if (!options.includeRefsAfterBatchSnapshot && (step[0] ?? "") === "snapshot") break;
|
|
2145
|
-
refsBeforeInBatchSnapshot.push(...collectFromStep(step));
|
|
2146
|
-
}
|
|
2147
|
-
return refsBeforeInBatchSnapshot;
|
|
2148
|
-
}
|
|
2149
|
-
|
|
2150
|
-
function getBatchRefInvalidationMessage(commandTokens: string[], stdin?: string): string | undefined {
|
|
2151
|
-
if (commandTokens[0] !== "batch" || stdin === undefined) return undefined;
|
|
2152
|
-
const parsed = parseUserBatchStdin(stdin);
|
|
2153
|
-
if (parsed.error || parsed.steps === undefined) return undefined;
|
|
2154
|
-
let priorStepInvalidatesRefs = false;
|
|
2155
|
-
for (const step of parsed.steps) {
|
|
2156
|
-
if ((step[0] ?? "") === "snapshot") {
|
|
2157
|
-
priorStepInvalidatesRefs = false;
|
|
2158
|
-
}
|
|
2159
|
-
const refIds = collectRefsFromTokens(step);
|
|
2160
|
-
if (refIds.length > 0 && REF_GUARDED_COMMANDS.has(step[0] ?? "") && priorStepInvalidatesRefs) {
|
|
2161
|
-
return `Batch step ${step[0]} uses page-scoped ref ${refIds.map((refId) => `@${refId}`).join(", ")} after an earlier batch step can navigate or mutate the page. Split the batch, run snapshot -i after the page-changing step, then retry with current refs.`;
|
|
2162
|
-
}
|
|
2163
|
-
if (REF_INVALIDATING_BATCH_COMMANDS.has(step[0] ?? "")) {
|
|
2164
|
-
priorStepInvalidatesRefs = true;
|
|
2165
|
-
}
|
|
2166
|
-
}
|
|
2167
|
-
return undefined;
|
|
2168
|
-
}
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
function selectSessionTargetTab(options: {
|
|
2174
|
-
tabs: Array<{ active?: boolean; index?: number; label?: string; tabId?: string; title?: string; url?: string }>;
|
|
2175
|
-
target: SessionTabTarget;
|
|
2176
|
-
}): OpenResultTabCorrection | undefined {
|
|
2177
|
-
return chooseOpenResultTabCorrection({
|
|
2178
|
-
tabs: options.tabs,
|
|
2179
|
-
targetTitle: options.target.title,
|
|
2180
|
-
targetUrl: options.target.url,
|
|
2181
|
-
});
|
|
2182
|
-
}
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
async function runSessionCommandData(options: {
|
|
2186
|
-
args: string[];
|
|
2187
|
-
cwd: string;
|
|
2188
|
-
sessionName?: string;
|
|
2189
|
-
signal?: AbortSignal;
|
|
2190
|
-
stdin?: string;
|
|
2191
|
-
timeoutMs?: number;
|
|
2192
|
-
}): Promise<unknown | undefined> {
|
|
2193
|
-
const { args, cwd, sessionName, signal, stdin, timeoutMs } = options;
|
|
2194
|
-
if (!sessionName) return undefined;
|
|
2195
|
-
|
|
2196
|
-
const processResult = await runAgentBrowserProcess({
|
|
2197
|
-
args: ["--json", "--session", sessionName, ...args],
|
|
2198
|
-
cwd,
|
|
2199
|
-
signal,
|
|
2200
|
-
stdin,
|
|
2201
|
-
timeoutMs,
|
|
2202
|
-
});
|
|
2203
|
-
try {
|
|
2204
|
-
if (processResult.aborted || processResult.spawnError || processResult.exitCode !== 0) {
|
|
2205
|
-
return undefined;
|
|
2206
|
-
}
|
|
2207
|
-
const parsed = await parseAgentBrowserEnvelope({
|
|
2208
|
-
stdout: processResult.stdout,
|
|
2209
|
-
stdoutPath: processResult.stdoutSpillPath,
|
|
2210
|
-
});
|
|
2211
|
-
if (parsed.parseError || parsed.envelope?.success === false) {
|
|
2212
|
-
return undefined;
|
|
2213
|
-
}
|
|
2214
|
-
return parsed.envelope?.data;
|
|
2215
|
-
} finally {
|
|
2216
|
-
if (processResult.stdoutSpillPath) {
|
|
2217
|
-
await rm(processResult.stdoutSpillPath, { force: true }).catch(() => undefined);
|
|
2218
|
-
}
|
|
2219
|
-
}
|
|
2220
|
-
}
|
|
2221
|
-
|
|
2222
|
-
function getTopLevelFillInvocation(commandTokens: string[]): { expected: string; selector: string } | undefined {
|
|
2223
|
-
if (commandTokens[0] !== "fill" || commandTokens.length < 3) return undefined;
|
|
2224
|
-
const selector = commandTokens[1];
|
|
2225
|
-
const expected = commandTokens.slice(2).join(" ");
|
|
2226
|
-
if (!selector || expected.length === 0) return undefined;
|
|
2227
|
-
return { expected, selector };
|
|
2228
|
-
}
|
|
2229
|
-
|
|
2230
|
-
function buildFillVerificationNextActions(diagnostic: FillVerificationDiagnostic, sessionName: string | undefined): AgentBrowserNextAction[] {
|
|
2231
|
-
return [
|
|
2232
|
-
{
|
|
2233
|
-
id: "inspect-after-fill-verification",
|
|
2234
|
-
params: { args: withOptionalSessionArgs(sessionName, ["snapshot", "-i"]) },
|
|
2235
|
-
reason: "Refresh the UI after a fill that reported success but did not appear to update the input value.",
|
|
2236
|
-
safety: "Read-only snapshot; use current refs before retrying.",
|
|
2237
|
-
tool: "agent_browser",
|
|
2238
|
-
},
|
|
2239
|
-
{
|
|
2240
|
-
id: "verify-filled-value",
|
|
2241
|
-
params: { args: withOptionalSessionArgs(sessionName, ["get", "value", diagnostic.selector]) },
|
|
2242
|
-
reason: "Check the target input value directly before submitting or creating files.",
|
|
2243
|
-
safety: "Read-only value check; selector may still be stale if the Electron UI rerendered.",
|
|
2244
|
-
tool: "agent_browser",
|
|
2245
|
-
},
|
|
2246
|
-
];
|
|
2247
|
-
}
|
|
2248
|
-
|
|
2249
|
-
function extractFillVerificationValue(data: unknown): string | undefined {
|
|
2250
|
-
if (typeof data === "string") return data;
|
|
2251
|
-
if (!isRecord(data)) return undefined;
|
|
2252
|
-
if (typeof data.value === "string") return data.value;
|
|
2253
|
-
if (typeof data.result === "string") return data.result;
|
|
2254
|
-
return undefined;
|
|
2255
|
-
}
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
function buildElectronRefFreshnessNextActions(sessionName: string | undefined): AgentBrowserNextAction[] {
|
|
2260
|
-
return [{
|
|
2261
|
-
id: "refresh-electron-refs-after-rerender",
|
|
2262
|
-
params: { args: withOptionalSessionArgs(sessionName, ["snapshot", "-i"]) },
|
|
2263
|
-
reason: "Electron UIs often rerender without changing URL; refresh refs before using old @e handles again.",
|
|
2264
|
-
safety: "Read-only snapshot; avoids stale same-URL refs after quick-pick, modal, theme, or editor rerenders.",
|
|
2265
|
-
tool: "agent_browser",
|
|
2266
|
-
}];
|
|
2267
|
-
}
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
function extractScrollPositionSnapshot(data: unknown): ScrollPositionSnapshot | undefined {
|
|
2273
|
-
const result = isRecord(data) && isRecord(data.result) ? data.result : data;
|
|
2274
|
-
if (!isRecord(result)) return undefined;
|
|
2275
|
-
const scrollX = typeof result.scrollX === "number" ? result.scrollX : undefined;
|
|
2276
|
-
const scrollY = typeof result.scrollY === "number" ? result.scrollY : undefined;
|
|
2277
|
-
const innerHeight = typeof result.innerHeight === "number" ? result.innerHeight : undefined;
|
|
2278
|
-
const innerWidth = typeof result.innerWidth === "number" ? result.innerWidth : undefined;
|
|
2279
|
-
const scrollHeight = typeof result.scrollHeight === "number" ? result.scrollHeight : undefined;
|
|
2280
|
-
const scrollWidth = typeof result.scrollWidth === "number" ? result.scrollWidth : undefined;
|
|
2281
|
-
if (scrollX === undefined || scrollY === undefined || innerHeight === undefined || innerWidth === undefined || scrollHeight === undefined || scrollWidth === undefined) return undefined;
|
|
2282
|
-
const containers = Array.isArray(result.containers)
|
|
2283
|
-
? result.containers.flatMap((entry, index): ScrollPositionSnapshot["containers"] => {
|
|
2284
|
-
if (!isRecord(entry)) return [];
|
|
2285
|
-
const rawId = typeof entry.id === "string" ? entry.id : undefined;
|
|
2286
|
-
const id = rawId && /^\d+:[a-z][a-z0-9-]*(?:\[role=[a-z-]+\])?$/i.test(rawId) ? rawId : `sample-${index}`;
|
|
2287
|
-
const scrollTop = typeof entry.scrollTop === "number" ? entry.scrollTop : undefined;
|
|
2288
|
-
const scrollLeft = typeof entry.scrollLeft === "number" ? entry.scrollLeft : undefined;
|
|
2289
|
-
return scrollTop !== undefined && scrollLeft !== undefined ? [{ id, scrollLeft, scrollTop }] : [];
|
|
2290
|
-
})
|
|
2291
|
-
: [];
|
|
2292
|
-
return {
|
|
2293
|
-
containerCount: typeof result.containerCount === "number" ? result.containerCount : containers.length,
|
|
2294
|
-
containers,
|
|
2295
|
-
innerHeight,
|
|
2296
|
-
innerWidth,
|
|
2297
|
-
scrollHeight,
|
|
2298
|
-
scrollWidth,
|
|
2299
|
-
scrollX,
|
|
2300
|
-
scrollY,
|
|
2301
|
-
};
|
|
2302
|
-
}
|
|
2303
|
-
|
|
2304
|
-
const SCROLL_POSITION_EVAL = `(() => {
|
|
2305
|
-
const viewport = {
|
|
2306
|
-
scrollX: window.scrollX,
|
|
2307
|
-
scrollY: window.scrollY,
|
|
2308
|
-
innerHeight: window.innerHeight,
|
|
2309
|
-
innerWidth: window.innerWidth,
|
|
2310
|
-
scrollHeight: Math.max(document.documentElement?.scrollHeight || 0, document.body?.scrollHeight || 0),
|
|
2311
|
-
scrollWidth: Math.max(document.documentElement?.scrollWidth || 0, document.body?.scrollWidth || 0),
|
|
2312
|
-
};
|
|
2313
|
-
const describe = (element, index) => {
|
|
2314
|
-
const role = element.getAttribute("role") || "";
|
|
2315
|
-
const id = element.tagName.toLowerCase();
|
|
2316
|
-
return {
|
|
2317
|
-
id: String(index) + ":" + id + (role ? "[role=" + role + "]" : ""),
|
|
2318
|
-
scrollTop: element.scrollTop,
|
|
2319
|
-
scrollLeft: element.scrollLeft,
|
|
2320
|
-
area: element.clientWidth * element.clientHeight,
|
|
2321
|
-
};
|
|
2322
|
-
};
|
|
2323
|
-
const containers = Array.from(document.querySelectorAll("body *"))
|
|
2324
|
-
.filter((element) => element instanceof HTMLElement && (element.scrollHeight > element.clientHeight + 1 || element.scrollWidth > element.clientWidth + 1))
|
|
2325
|
-
.map(describe)
|
|
2326
|
-
.sort((left, right) => right.area - left.area)
|
|
2327
|
-
.slice(0, 10)
|
|
2328
|
-
.map(({ area, ...entry }) => entry);
|
|
2329
|
-
return { ...viewport, containerCount: containers.length, containers };
|
|
2330
|
-
})()`;
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
function sameScrollPositionSnapshot(left: ScrollPositionSnapshot, right: ScrollPositionSnapshot): boolean {
|
|
2334
|
-
if (
|
|
2335
|
-
left.scrollX !== right.scrollX ||
|
|
2336
|
-
left.scrollY !== right.scrollY ||
|
|
2337
|
-
left.scrollHeight !== right.scrollHeight ||
|
|
2338
|
-
left.scrollWidth !== right.scrollWidth ||
|
|
2339
|
-
left.containers.length !== right.containers.length
|
|
2340
|
-
) {
|
|
2341
|
-
return false;
|
|
2342
|
-
}
|
|
2343
|
-
return left.containers.every((container, index) => {
|
|
2344
|
-
const other = right.containers[index];
|
|
2345
|
-
return other?.id === container.id && other.scrollTop === container.scrollTop && other.scrollLeft === container.scrollLeft;
|
|
2346
|
-
});
|
|
2347
|
-
}
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
const COMBOBOX_FOCUS_EVAL = `(() => {
|
|
2354
|
-
const isVisible = (element) => {
|
|
2355
|
-
if (!(element instanceof HTMLElement)) return false;
|
|
2356
|
-
const style = window.getComputedStyle(element);
|
|
2357
|
-
if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return false;
|
|
2358
|
-
return element.getClientRects().length > 0;
|
|
2359
|
-
};
|
|
2360
|
-
const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
2361
|
-
const role = active?.getAttribute("role") || undefined;
|
|
2362
|
-
const hasPopup = active?.getAttribute("aria-haspopup") || undefined;
|
|
2363
|
-
const expanded = active?.getAttribute("aria-expanded") || undefined;
|
|
2364
|
-
const tagName = active?.tagName.toLowerCase();
|
|
2365
|
-
const name = (active?.getAttribute("aria-label") || active?.getAttribute("placeholder") || active?.getAttribute("title") || active?.textContent || "").trim().slice(0, 80) || undefined;
|
|
2366
|
-
const visibleListboxCount = Array.from(document.querySelectorAll('[role="listbox"], [role="menu"]')).filter(isVisible).length;
|
|
2367
|
-
const visibleOptionCount = Array.from(document.querySelectorAll('[role="option"], option, [role="menuitem"]')).filter(isVisible).length;
|
|
2368
|
-
const comboboxLike = role === "combobox" || hasPopup === "listbox" || hasPopup === "menu" || tagName === "select" || active?.getAttribute("aria-autocomplete") !== null;
|
|
2369
|
-
return { activeElement: active ? { expanded, hasPopup, name, role, tagName } : undefined, comboboxLike, visibleListboxCount, visibleOptionCount };
|
|
2370
|
-
})()`;
|
|
2371
|
-
|
|
2372
|
-
function extractComboboxFocusDiagnostic(data: unknown): ComboboxFocusDiagnostic | undefined {
|
|
2373
|
-
const result = isRecord(data) && isRecord(data.result) ? data.result : data;
|
|
2374
|
-
if (!isRecord(result) || result.comboboxLike !== true || !isRecord(result.activeElement)) return undefined;
|
|
2375
|
-
const visibleListboxCount = typeof result.visibleListboxCount === "number" ? result.visibleListboxCount : 0;
|
|
2376
|
-
const visibleOptionCount = typeof result.visibleOptionCount === "number" ? result.visibleOptionCount : 0;
|
|
2377
|
-
const expanded = typeof result.activeElement.expanded === "string" ? result.activeElement.expanded : undefined;
|
|
2378
|
-
if ((expanded !== "false" && expanded !== "true") || visibleListboxCount > 0 || visibleOptionCount > 0) return undefined;
|
|
2379
|
-
return {
|
|
2380
|
-
activeElement: {
|
|
2381
|
-
expanded,
|
|
2382
|
-
hasPopup: typeof result.activeElement.hasPopup === "string" ? result.activeElement.hasPopup : undefined,
|
|
2383
|
-
name: typeof result.activeElement.name === "string" ? redactSensitiveText(result.activeElement.name) : undefined,
|
|
2384
|
-
role: typeof result.activeElement.role === "string" ? result.activeElement.role : undefined,
|
|
2385
|
-
tagName: typeof result.activeElement.tagName === "string" ? result.activeElement.tagName : undefined,
|
|
2386
|
-
},
|
|
2387
|
-
message: "A combobox-like control is focused, but no listbox or option elements are visibly open.",
|
|
2388
|
-
reason: "focused-combobox-without-visible-options",
|
|
2389
|
-
recommendations: [
|
|
2390
|
-
"Run snapshot -i to inspect whether options appeared under a different role or portal.",
|
|
2391
|
-
"Try ArrowDown or Enter to open the option list before selecting, or use select/visible option refs when available.",
|
|
2392
|
-
],
|
|
2393
|
-
visibleListboxCount,
|
|
2394
|
-
visibleOptionCount,
|
|
2395
|
-
};
|
|
2396
|
-
}
|
|
2397
|
-
|
|
2398
|
-
function isComboboxFocusDiagnosticCommand(command: string | undefined, commandTokens: string[]): boolean {
|
|
2399
|
-
const explicitlyTargetsCombobox = commandTokens.some((token) => /^(?:combobox|listbox)$/i.test(token));
|
|
2400
|
-
if (!explicitlyTargetsCombobox) return false;
|
|
2401
|
-
if (command === "click" || command === "fill") return true;
|
|
2402
|
-
return command === "find" && commandTokens.some((token) => ["click", "fill"].includes(token));
|
|
2403
|
-
}
|
|
2404
|
-
|
|
2405
|
-
function getCompiledSemanticActionRoleValue(compiled: CompiledAgentBrowserSemanticAction): string | undefined {
|
|
2406
|
-
if (compiled.locator !== "role") return undefined;
|
|
2407
|
-
const findIndex = compiled.args.indexOf("find");
|
|
2408
|
-
if (findIndex < 0 || compiled.args[findIndex + 1] !== "role") return undefined;
|
|
2409
|
-
return compiled.args[findIndex + 2];
|
|
2410
|
-
}
|
|
2411
|
-
|
|
2412
|
-
function isComboboxFocusDiagnosticSemanticAction(compiled: CompiledAgentBrowserSemanticAction | undefined): boolean {
|
|
2413
|
-
if (!compiled || !["click", "fill"].includes(compiled.action)) return false;
|
|
2414
|
-
return /^(?:combobox|listbox)$/i.test(getCompiledSemanticActionRoleValue(compiled) ?? "");
|
|
2415
|
-
}
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
function getRecordStartLikeCommand(command: string | undefined, commandTokens: string[]): RecordingDependencyWarning["command"] | undefined {
|
|
2421
|
-
if (command !== "record") return undefined;
|
|
2422
|
-
const subcommand = commandTokens[1]?.toLowerCase();
|
|
2423
|
-
if (subcommand === "start") return "record start";
|
|
2424
|
-
if (subcommand === "restart") return "record restart";
|
|
2425
|
-
return undefined;
|
|
2426
|
-
}
|
|
2427
|
-
|
|
2428
|
-
async function executableExistsOnPath(command: string): Promise<boolean> {
|
|
2429
|
-
const pathValue = process.env.PATH ?? "";
|
|
2430
|
-
const extensions = process.platform === "win32"
|
|
2431
|
-
? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean)
|
|
2432
|
-
: [""];
|
|
2433
|
-
for (const directory of pathValue.split(delimiter).filter(Boolean)) {
|
|
2434
|
-
for (const extension of extensions) {
|
|
2435
|
-
try {
|
|
2436
|
-
const candidate = join(directory, `${command}${extension}`);
|
|
2437
|
-
await access(candidate, fsConstants.X_OK);
|
|
2438
|
-
if ((await stat(candidate)).isFile()) return true;
|
|
2439
|
-
} catch {
|
|
2440
|
-
// Try the next candidate.
|
|
2441
|
-
}
|
|
2442
|
-
}
|
|
2443
|
-
}
|
|
2444
|
-
return false;
|
|
2445
|
-
}
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
function getSnapshotRefRecord(data: unknown): Record<string, unknown> | undefined {
|
|
2450
|
-
return isRecord(data) && isRecord(data.refs) ? data.refs : undefined;
|
|
2451
|
-
}
|
|
2452
|
-
|
|
2453
|
-
const OVERLAY_CLOSE_NAME_PATTERN = /(?:\b(?:close|dismiss|no thanks|not now|maybe later|hide|skip|continue without|x)\b|^\s*×\s*$)/i;
|
|
2454
|
-
const OVERLAY_CONTEXT_ROLES = new Set(["alertdialog", "dialog"]);
|
|
2455
|
-
const OVERLAY_ACTION_ROLES = new Set(["button", "link", "menuitem"]);
|
|
2456
|
-
const OVERLAY_BLOCKER_CANDIDATE_LIMIT = 3;
|
|
2457
|
-
|
|
2458
|
-
function getOverlayBlockerCandidates(snapshotData: unknown): OverlayBlockerCandidate[] {
|
|
2459
|
-
const refs = getSnapshotRefRecord(snapshotData);
|
|
2460
|
-
if (!refs) return [];
|
|
2461
|
-
const hasOverlayContext = Object.values(refs).some((entry) => {
|
|
2462
|
-
if (!isRecord(entry)) return false;
|
|
2463
|
-
const role = typeof entry.role === "string" ? entry.role : "";
|
|
2464
|
-
return OVERLAY_CONTEXT_ROLES.has(role.toLowerCase());
|
|
2465
|
-
});
|
|
2466
|
-
if (!hasOverlayContext) return [];
|
|
2467
|
-
const candidates: OverlayBlockerCandidate[] = [];
|
|
2468
|
-
for (const [ref, entry] of Object.entries(refs)) {
|
|
2469
|
-
if (!/^e\d+$/.test(ref) || !isRecord(entry)) continue;
|
|
2470
|
-
const role = typeof entry.role === "string" ? entry.role : undefined;
|
|
2471
|
-
const name = typeof entry.name === "string" ? entry.name : undefined;
|
|
2472
|
-
if (!role || !OVERLAY_ACTION_ROLES.has(role.toLowerCase()) || !name || !OVERLAY_CLOSE_NAME_PATTERN.test(name)) continue;
|
|
2473
|
-
candidates.push({
|
|
2474
|
-
args: ["click", `@${ref}`],
|
|
2475
|
-
name,
|
|
2476
|
-
reason: `Visible ${role} ${JSON.stringify(name)} appears in a snapshot that also contains overlay/banner/dialog context.`,
|
|
2477
|
-
ref: `@${ref}`,
|
|
2478
|
-
role,
|
|
2479
|
-
});
|
|
2480
|
-
if (candidates.length >= OVERLAY_BLOCKER_CANDIDATE_LIMIT) break;
|
|
2481
|
-
}
|
|
2482
|
-
return candidates;
|
|
2483
|
-
}
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
function buildVisibleTextProbeScript(selector: string): string {
|
|
2488
|
-
return `(() => {\n const selector = ${JSON.stringify(selector)};\n const isVisible = (element) => {\n const style = window.getComputedStyle(element);\n if (!style || style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse' || Number(style.opacity) === 0) return false;\n return Array.from(element.getClientRects()).some((rect) => rect.width > 0 && rect.height > 0);\n };\n let matches = [];\n try {\n matches = Array.from(document.querySelectorAll(selector));\n } catch (error) {\n return JSON.stringify({ selector, error: error instanceof Error ? error.message : String(error) });\n }\n const visible = matches.filter(isVisible);\n const trim = (value) => typeof value === 'string' ? value.trim().replace(/\\s+/g, ' ').slice(0, 200) : undefined;\n return JSON.stringify({\n selector,\n matchCount: matches.length,\n visibleCount: visible.length,\n firstMatchVisible: matches[0] ? isVisible(matches[0]) : undefined,\n firstTextPreview: trim(matches[0]?.textContent),\n firstVisibleTextPreview: trim(visible[0]?.textContent),\n });\n})()`;
|
|
2489
|
-
}
|
|
2490
|
-
|
|
2491
|
-
function parseSelectorTextVisibilityProbe(data: unknown, selector: string): Omit<SelectorTextVisibilityDiagnostic, "summary"> | undefined {
|
|
2492
|
-
const result = extractStringResultField(data, "result");
|
|
2493
|
-
if (!result) return undefined;
|
|
2494
|
-
let parsed: unknown;
|
|
2495
|
-
try {
|
|
2496
|
-
parsed = JSON.parse(result);
|
|
2497
|
-
} catch {
|
|
2498
|
-
return undefined;
|
|
2499
|
-
}
|
|
2500
|
-
if (!isRecord(parsed) || typeof parsed.error === "string") return undefined;
|
|
2501
|
-
const matchCount = typeof parsed.matchCount === "number" ? parsed.matchCount : undefined;
|
|
2502
|
-
const visibleCount = typeof parsed.visibleCount === "number" ? parsed.visibleCount : undefined;
|
|
2503
|
-
if (matchCount === undefined || visibleCount === undefined) return undefined;
|
|
2504
|
-
return {
|
|
2505
|
-
firstMatchVisible: typeof parsed.firstMatchVisible === "boolean" ? parsed.firstMatchVisible : undefined,
|
|
2506
|
-
firstVisibleTextPreview: typeof parsed.firstVisibleTextPreview === "string" && parsed.firstVisibleTextPreview.length > 0 ? redactSensitiveText(parsed.firstVisibleTextPreview) : undefined,
|
|
2507
|
-
matchCount,
|
|
2508
|
-
selector,
|
|
2509
|
-
visibleCount,
|
|
2510
|
-
};
|
|
2511
|
-
}
|
|
2512
|
-
|
|
2513
|
-
function selectorMayExposeSensitiveLiteral(selector: string): boolean {
|
|
2514
|
-
return redactSensitiveText(selector) !== selector || /\[[^\]]*[~|^$*]?=\s*(?:"[^"]*"|'[^']*'|[^\]\s]+)\s*(?:[is]\s*)?\]/.test(selector);
|
|
2515
|
-
}
|
|
2516
|
-
|
|
2517
|
-
async function collectSelectorTextVisibilityDiagnosticForSelector(options: {
|
|
2518
|
-
cwd: string;
|
|
2519
|
-
selector: string | undefined;
|
|
2520
|
-
sessionName?: string;
|
|
2521
|
-
signal?: AbortSignal;
|
|
2522
|
-
}): Promise<SelectorTextVisibilityDiagnostic | undefined> {
|
|
2523
|
-
const { selector } = options;
|
|
2524
|
-
if (!selector || /^@e\d+$/.test(selector) || selectorMayExposeSensitiveLiteral(selector)) return undefined;
|
|
2525
|
-
const probe = await runSessionCommandData({
|
|
2526
|
-
args: ["eval", "--stdin"],
|
|
2527
|
-
cwd: options.cwd,
|
|
2528
|
-
sessionName: options.sessionName,
|
|
2529
|
-
signal: options.signal,
|
|
2530
|
-
stdin: buildVisibleTextProbeScript(selector),
|
|
2531
|
-
});
|
|
2532
|
-
const parsed = parseSelectorTextVisibilityProbe(probe, selector);
|
|
2533
|
-
if (!parsed || parsed.matchCount <= 1 && parsed.firstMatchVisible !== false) return undefined;
|
|
2534
|
-
if (parsed.visibleCount === 0) return undefined;
|
|
2535
|
-
const visibleMatchNoun = `visible match${parsed.visibleCount === 1 ? "" : "es"}`;
|
|
2536
|
-
const visibleMatchVerb = parsed.visibleCount === 1 ? "exists" : "exist";
|
|
2537
|
-
const summary = parsed.firstMatchVisible === false
|
|
2538
|
-
? `Selector ${JSON.stringify(selector)} matched ${parsed.matchCount} elements; the first match is hidden while ${parsed.visibleCount} ${visibleMatchNoun} ${visibleMatchVerb}.`
|
|
2539
|
-
: `Selector ${JSON.stringify(selector)} matched ${parsed.matchCount} elements; get text reads the first upstream match, which may not be the intended visible tab/panel.`;
|
|
2540
|
-
return { ...parsed, summary };
|
|
2541
|
-
}
|
|
2542
|
-
|
|
2543
|
-
function getBatchGetTextSelectors(data: unknown): string[] {
|
|
2544
|
-
if (!Array.isArray(data)) return [];
|
|
2545
|
-
return data.flatMap((item) => {
|
|
2546
|
-
if (!isRecord(item) || item.success === false) return [];
|
|
2547
|
-
const [command, subcommand, selector] = extractBatchResultCommand(item);
|
|
2548
|
-
return command === "get" && subcommand === "text" && selector ? [selector] : [];
|
|
2549
|
-
});
|
|
2550
|
-
}
|
|
2551
|
-
|
|
2552
|
-
function getSuccessfulGetTextSelectors(options: { commandInfo: CommandInfo; commandTokens: string[]; data: unknown }): string[] {
|
|
2553
|
-
return options.commandInfo.command === "get" && options.commandInfo.subcommand === "text"
|
|
2554
|
-
? [options.commandTokens[2]].filter((selector): selector is string => typeof selector === "string" && selector.length > 0)
|
|
2555
|
-
: options.commandInfo.command === "batch"
|
|
2556
|
-
? getBatchGetTextSelectors(options.data)
|
|
2557
|
-
: [];
|
|
2558
|
-
}
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
function isElectronLikeRendererUrl(url: string | undefined): boolean {
|
|
2563
|
-
if (!url) return false;
|
|
2564
|
-
return /^(?:app|file|vscode-file|vscode|chrome-extension):/i.test(url);
|
|
2565
|
-
}
|
|
2566
|
-
|
|
2567
|
-
function normalizeSelectorForScopeHeuristic(selector: string): string {
|
|
2568
|
-
return selector.trim().replace(/\s+/g, " ").toLowerCase();
|
|
2569
|
-
}
|
|
2570
|
-
|
|
2571
|
-
function isBroadGetTextSelector(selector: string | undefined): selector is string {
|
|
2572
|
-
if (!selector || /^@e\d+$/.test(selector) || selectorMayExposeSensitiveLiteral(selector)) return false;
|
|
2573
|
-
const normalized = normalizeSelectorForScopeHeuristic(selector);
|
|
2574
|
-
return normalized === "body" ||
|
|
2575
|
-
normalized === "html" ||
|
|
2576
|
-
normalized === ":root" ||
|
|
2577
|
-
normalized === "*" ||
|
|
2578
|
-
normalized === "main" ||
|
|
2579
|
-
normalized === "div" ||
|
|
2580
|
-
normalized === "section" ||
|
|
2581
|
-
normalized === "article" ||
|
|
2582
|
-
/^\[role=(?:"application"|'application'|application)\]$/i.test(normalized);
|
|
2583
|
-
}
|
|
2584
|
-
|
|
2585
|
-
function getElectronTextScopeContext(options: {
|
|
2586
|
-
currentTarget?: SessionTabTarget;
|
|
2587
|
-
electronLaunchRecords: Map<string, ElectronLaunchRecord>;
|
|
2588
|
-
priorTarget?: SessionTabTarget;
|
|
2589
|
-
sessionName?: string;
|
|
2590
|
-
}): ElectronBroadGetTextScopeDiagnostic["electronContext"] | undefined {
|
|
2591
|
-
const record = findElectronLaunchRecordForSession(options.sessionName, options.electronLaunchRecords);
|
|
2592
|
-
const url = options.currentTarget?.url ?? options.priorTarget?.url;
|
|
2593
|
-
if (record) return { launchId: record.launchId, sessionName: record.sessionName ?? options.sessionName, url };
|
|
2594
|
-
if (isElectronLikeRendererUrl(url)) return { sessionName: options.sessionName, url };
|
|
2595
|
-
return undefined;
|
|
2596
|
-
}
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
function looksLikeFunctionEvalStdin(stdin: string | undefined): boolean {
|
|
2604
|
-
const trimmed = stdin?.trim();
|
|
2605
|
-
if (!trimmed) return false;
|
|
2606
|
-
return /^(?:async\s+)?function\b/.test(trimmed) || /^(?:async\s*)?\([^)]*\)\s*=>/.test(trimmed) || /^(?:async\s+)?[A-Za-z_$][\w$]*\s*=>/.test(trimmed);
|
|
2607
|
-
}
|
|
2608
|
-
|
|
2609
|
-
function isPlainEmptyObject(value: unknown): boolean {
|
|
2610
|
-
if (!isRecord(value) || Array.isArray(value)) return false;
|
|
2611
|
-
const prototype = Object.getPrototypeOf(value);
|
|
2612
|
-
return (prototype === Object.prototype || prototype === null) && Object.keys(value).length === 0;
|
|
2613
|
-
}
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
function getTimeoutProgressSteps(compiledJob: CompiledAgentBrowserJob | undefined, command: string | undefined, stdin: string | undefined): Array<{ args: string[]; index: number }> {
|
|
2623
|
-
if (compiledJob) return compiledJob.steps.map((step, index) => ({ args: step.args, index: index + 1 }));
|
|
2624
|
-
if (command !== "batch" || !stdin) return [];
|
|
2625
|
-
try {
|
|
2626
|
-
const parsed = JSON.parse(stdin) as unknown;
|
|
2627
|
-
if (!Array.isArray(parsed)) return [];
|
|
2628
|
-
return parsed.flatMap((step, index) => Array.isArray(step) && step.every((token) => typeof token === "string") ? [{ args: step as string[], index: index + 1 }] : []);
|
|
2629
|
-
} catch {
|
|
2630
|
-
return [];
|
|
2631
|
-
}
|
|
2632
|
-
}
|
|
2633
|
-
|
|
2634
|
-
function getLastPositionalToken(args: string[], startIndex = 1): string | undefined {
|
|
2635
|
-
for (let index = args.length - 1; index >= startIndex; index -= 1) {
|
|
2636
|
-
const token = args[index];
|
|
2637
|
-
if (token && !token.startsWith("-")) return token;
|
|
2638
|
-
}
|
|
2639
|
-
return undefined;
|
|
2640
|
-
}
|
|
2641
|
-
|
|
2642
|
-
function getTimeoutStepArtifactPath(args: string[]): string | undefined {
|
|
2643
|
-
const [command] = args;
|
|
2644
|
-
if (command === "screenshot") {
|
|
2645
|
-
const index = getScreenshotPathTokenIndex(args);
|
|
2646
|
-
return index === undefined ? undefined : args[index];
|
|
2647
|
-
}
|
|
2648
|
-
if (command === "pdf") return getLastPositionalToken(args);
|
|
2649
|
-
if (command === "download") return getLastPositionalToken(args, 2);
|
|
2650
|
-
if (command === "wait") {
|
|
2651
|
-
const inlineDownload = args.find((token) => token.startsWith("--download="));
|
|
2652
|
-
if (inlineDownload) return inlineDownload.slice("--download=".length) || undefined;
|
|
2653
|
-
const downloadIndex = args.indexOf("--download");
|
|
2654
|
-
const downloadPath = downloadIndex >= 0 ? args[downloadIndex + 1] : undefined;
|
|
2655
|
-
if (downloadPath && !downloadPath.startsWith("-")) return downloadPath;
|
|
2656
|
-
}
|
|
2657
|
-
return undefined;
|
|
2658
|
-
}
|
|
2659
|
-
|
|
2660
|
-
async function collectTimeoutArtifactEvidence(cwd: string, steps: Array<{ args: string[]; index: number }>): Promise<TimeoutArtifactEvidence[]> {
|
|
2661
|
-
const evidence: TimeoutArtifactEvidence[] = [];
|
|
2662
|
-
for (const step of steps) {
|
|
2663
|
-
const path = getTimeoutStepArtifactPath(step.args);
|
|
2664
|
-
if (!path) continue;
|
|
2665
|
-
const absolutePath = isAbsolute(path) ? path : resolve(cwd, path);
|
|
2666
|
-
try {
|
|
2667
|
-
const stats = await stat(absolutePath);
|
|
2668
|
-
evidence.push({ absolutePath, exists: true, path, sizeBytes: stats.size, stepIndex: step.index });
|
|
2669
|
-
} catch {
|
|
2670
|
-
evidence.push({ absolutePath, exists: false, path, stepIndex: step.index });
|
|
2671
|
-
}
|
|
2672
|
-
}
|
|
2673
|
-
return evidence;
|
|
2674
|
-
}
|
|
2675
|
-
|
|
2676
|
-
function getPlannedCurrentPageUrl(steps: Array<{ args: string[]; index: number }>): string | undefined {
|
|
2677
|
-
for (let index = steps.length - 1; index >= 0; index -= 1) {
|
|
2678
|
-
const args = steps[index]?.args ?? [];
|
|
2679
|
-
if (args[0] === "open" || args[0] === "navigate" || args[0] === "pushstate") {
|
|
2680
|
-
return getLastPositionalToken(args);
|
|
2681
|
-
}
|
|
2682
|
-
}
|
|
2683
|
-
return undefined;
|
|
2684
|
-
}
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
function redactSensitivePathSegmentsForDiagnostic(path: string): string {
|
|
2688
|
-
return path.split(/([/\\]+)/).map((segment) => {
|
|
2689
|
-
if (segment === "/" || segment === "\\" || /^[/\\]+$/.test(segment)) return segment;
|
|
2690
|
-
return redactSensitiveText(segment) !== segment || /(?:secret|token|password|passwd|credential|auth|api[-_]?key|bearer)/i.test(segment) ? "[REDACTED]" : segment;
|
|
2691
|
-
}).join("");
|
|
2692
|
-
}
|
|
2693
|
-
|
|
2694
|
-
function sanitizeCurrentPageUrlForTimeoutDiagnostic(url: string): string {
|
|
2695
|
-
try {
|
|
2696
|
-
const parsedUrl = new URL(url);
|
|
2697
|
-
parsedUrl.pathname = parsedUrl.pathname.split("/").map((segment) => redactSensitivePathSegmentsForDiagnostic(segment)).join("/");
|
|
2698
|
-
for (const [key, value] of parsedUrl.searchParams.entries()) {
|
|
2699
|
-
if (redactSensitiveText(key) !== key || redactSensitiveText(value) !== value || /(?:secret|token|password|passwd|credential|auth|api[-_]?key|bearer)/i.test(`${key} ${value}`)) {
|
|
2700
|
-
parsedUrl.searchParams.set(key, "[REDACTED]");
|
|
2701
|
-
}
|
|
2702
|
-
}
|
|
2703
|
-
if (parsedUrl.hash) {
|
|
2704
|
-
parsedUrl.hash = redactSensitivePathSegmentsForDiagnostic(redactSensitiveText(parsedUrl.hash));
|
|
2705
|
-
}
|
|
2706
|
-
return redactSensitiveText(parsedUrl.toString());
|
|
2707
|
-
} catch {
|
|
2708
|
-
return redactSensitivePathSegmentsForDiagnostic(redactSensitiveText(url));
|
|
2709
|
-
}
|
|
2710
|
-
}
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
// Serializes managed-session read/modify/write work so overlapping tool calls cannot promote stale state or close an in-use session.
|
|
2724
|
-
class AsyncExecutionQueue {
|
|
2725
|
-
private tail: Promise<void> = Promise.resolve();
|
|
2726
|
-
|
|
2727
|
-
run<T>(work: () => Promise<T>): Promise<T> {
|
|
2728
|
-
const previous = this.tail;
|
|
2729
|
-
let release!: () => void;
|
|
2730
|
-
this.tail = new Promise<void>((resolve) => {
|
|
2731
|
-
release = resolve;
|
|
2732
|
-
});
|
|
2733
|
-
|
|
2734
|
-
return (async () => {
|
|
2735
|
-
await previous;
|
|
2736
|
-
try {
|
|
2737
|
-
return await work();
|
|
2738
|
-
} finally {
|
|
2739
|
-
release();
|
|
2740
|
-
}
|
|
2741
|
-
})();
|
|
2742
|
-
}
|
|
2743
|
-
}
|
|
2744
|
-
|
|
2745
|
-
async function closeManagedSession(options: { cwd: string; sessionName: string; timeoutMs: number }): Promise<string | undefined> {
|
|
2746
|
-
const controller = new AbortController();
|
|
2747
|
-
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
|
|
2748
|
-
let stdoutSpillPath: string | undefined;
|
|
2749
|
-
const closeArgs = ["--session", options.sessionName, "close"];
|
|
2750
|
-
try {
|
|
2751
|
-
const processResult = await runAgentBrowserProcess({
|
|
2752
|
-
args: closeArgs,
|
|
2753
|
-
cwd: options.cwd,
|
|
2754
|
-
signal: controller.signal,
|
|
2755
|
-
});
|
|
2756
|
-
stdoutSpillPath = processResult.stdoutSpillPath;
|
|
2757
|
-
return getAgentBrowserErrorText({
|
|
2758
|
-
aborted: processResult.aborted,
|
|
2759
|
-
command: "close",
|
|
2760
|
-
effectiveArgs: redactInvocationArgs(closeArgs),
|
|
2761
|
-
exitCode: processResult.exitCode,
|
|
2762
|
-
plainTextInspection: false,
|
|
2763
|
-
spawnError: processResult.spawnError,
|
|
2764
|
-
stderr: processResult.stderr,
|
|
2765
|
-
timedOut: processResult.timedOut,
|
|
2766
|
-
timeoutMs: processResult.timeoutMs,
|
|
2767
|
-
});
|
|
2768
|
-
} catch (error) {
|
|
2769
|
-
return error instanceof Error ? error.message : String(error);
|
|
2770
|
-
} finally {
|
|
2771
|
-
clearTimeout(timer);
|
|
2772
|
-
if (stdoutSpillPath) {
|
|
2773
|
-
await rm(stdoutSpillPath, { force: true }).catch(() => undefined);
|
|
2774
|
-
}
|
|
2775
|
-
}
|
|
2776
|
-
}
|
|
2777
|
-
|
|
2778
|
-
function getInstalledDocsPaths(): { readmePath: string; commandReferencePath: string; toolContractPath: string } {
|
|
2779
|
-
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
2780
|
-
return {
|
|
2781
|
-
readmePath: join(packageRoot, "README.md"),
|
|
2782
|
-
commandReferencePath: join(packageRoot, "docs", "COMMAND_REFERENCE.md"),
|
|
2783
|
-
toolContractPath: join(packageRoot, "docs", "TOOL_CONTRACT.md"),
|
|
2784
|
-
};
|
|
2785
|
-
}
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
async function handleElectronHostInput(options: {
|
|
2790
|
-
cleanupTrackedElectronLaunches: (records: ElectronLaunchRecord[], cwd: string, timeoutMs?: number) => Promise<ElectronCleanupResult[]>;
|
|
2791
|
-
compiledElectron?: CompiledAgentBrowserElectron;
|
|
2792
|
-
cwd: string;
|
|
2793
|
-
electronLaunchRecords: Map<string, ElectronLaunchRecord>;
|
|
2794
|
-
implicitSessionCloseTimeoutMs: number;
|
|
2795
|
-
managedSessionActive: boolean;
|
|
2796
|
-
managedSessionExecutionQueue: { run<T>(task: () => Promise<T>): Promise<T> };
|
|
2797
|
-
managedSessionName: string;
|
|
2798
|
-
redactedCompiledElectron?: CompiledAgentBrowserElectron;
|
|
2799
|
-
sessionPageState: SessionPageState;
|
|
2800
|
-
signal?: AbortSignal;
|
|
2801
|
-
}): Promise<AgentBrowserToolResult | undefined> {
|
|
2802
|
-
const {
|
|
2803
|
-
cleanupTrackedElectronLaunches,
|
|
2804
|
-
compiledElectron,
|
|
2805
|
-
cwd,
|
|
2806
|
-
electronLaunchRecords,
|
|
2807
|
-
implicitSessionCloseTimeoutMs,
|
|
2808
|
-
managedSessionActive,
|
|
2809
|
-
managedSessionExecutionQueue,
|
|
2810
|
-
managedSessionName,
|
|
2811
|
-
redactedCompiledElectron,
|
|
2812
|
-
sessionPageState,
|
|
2813
|
-
signal,
|
|
2814
|
-
} = options;
|
|
2815
|
-
if (compiledElectron?.action === "list") {
|
|
2816
|
-
try {
|
|
2817
|
-
const discovery = await discoverElectronApps({ maxResults: compiledElectron.maxResults, query: compiledElectron.query });
|
|
2818
|
-
return buildElectronListSuccessResult(redactedCompiledElectron ?? compiledElectron, discovery);
|
|
2819
|
-
} catch (error) {
|
|
2820
|
-
return buildElectronListFailureResult(redactedCompiledElectron ?? compiledElectron, error);
|
|
2821
|
-
}
|
|
2822
|
-
}
|
|
2823
|
-
if (compiledElectron?.action === "status") {
|
|
2824
|
-
return managedSessionExecutionQueue.run(async () => {
|
|
2825
|
-
const selection = selectElectronRecords(compiledElectron, electronLaunchRecords);
|
|
2826
|
-
if (selection.error) return buildElectronHostFailureResult({ compiledElectron: redactedCompiledElectron ?? compiledElectron, errorText: selection.error, failureCategory: "validation-error" });
|
|
2827
|
-
const records = selection.records ?? [];
|
|
2828
|
-
const statuses = await Promise.all(records.map((record) => inspectElectronLaunchStatus(record)));
|
|
2829
|
-
const managedSessions = (await Promise.all(records.map((record) => collectElectronManagedSessionTarget({
|
|
2830
|
-
cwd,
|
|
2831
|
-
sessionName: record.sessionName,
|
|
2832
|
-
signal,
|
|
2833
|
-
timeoutMs: compiledElectron.timeoutMs,
|
|
2834
|
-
})))).filter((managedSession): managedSession is ElectronManagedSessionTarget => managedSession !== undefined);
|
|
2835
|
-
const mismatches = managedSessions
|
|
2836
|
-
.map((managedSession) => {
|
|
2837
|
-
const record = records.find((candidate) => candidate.sessionName === managedSession.sessionName);
|
|
2838
|
-
const status = record ? statuses.find((candidate) => candidate.launchId === record.launchId) : undefined;
|
|
2839
|
-
return record && status ? buildElectronSessionMismatch({ managedSession, record, statusTargets: status.targets }) : undefined;
|
|
2840
|
-
})
|
|
2841
|
-
.filter((mismatch): mismatch is ElectronSessionMismatch => mismatch !== undefined);
|
|
2842
|
-
return buildElectronStatusResult({
|
|
2843
|
-
compiledElectron: redactedCompiledElectron ?? compiledElectron,
|
|
2844
|
-
managedSessions,
|
|
2845
|
-
mismatches,
|
|
2846
|
-
records,
|
|
2847
|
-
statuses,
|
|
2848
|
-
});
|
|
2849
|
-
});
|
|
2850
|
-
}
|
|
2851
|
-
if (compiledElectron?.action === "probe") {
|
|
2852
|
-
return managedSessionExecutionQueue.run(async () => {
|
|
2853
|
-
const launchRecord = compiledElectron.launchId
|
|
2854
|
-
? electronLaunchRecords.get(compiledElectron.launchId)
|
|
2855
|
-
: findElectronLaunchRecordForSession(managedSessionName, electronLaunchRecords) ?? findUnambiguousActiveElectronLaunchRecord(electronLaunchRecords);
|
|
2856
|
-
if (compiledElectron.launchId && !launchRecord) {
|
|
2857
|
-
return buildElectronHostFailureResult({
|
|
2858
|
-
compiledElectron: redactedCompiledElectron ?? compiledElectron,
|
|
2859
|
-
errorText: `No wrapper-tracked Electron launch found for launchId ${compiledElectron.launchId}.`,
|
|
2860
|
-
failureCategory: "validation-error",
|
|
2861
|
-
});
|
|
2862
|
-
}
|
|
2863
|
-
if (compiledElectron.launchId && !launchRecord?.sessionName) {
|
|
2864
|
-
return buildElectronHostFailureResult({
|
|
2865
|
-
compiledElectron: redactedCompiledElectron ?? compiledElectron,
|
|
2866
|
-
errorText: `electron.probe launchId ${compiledElectron.launchId} has no attached managed sessionName; reattach with connect or run electron.launch again.`,
|
|
2867
|
-
failureCategory: "validation-error",
|
|
2868
|
-
});
|
|
2869
|
-
}
|
|
2870
|
-
if (!compiledElectron.launchId && !managedSessionActive) {
|
|
2871
|
-
return buildElectronHostFailureResult({
|
|
2872
|
-
compiledElectron: redactedCompiledElectron ?? compiledElectron,
|
|
2873
|
-
errorText: "electron.probe requires an active attached session. Run electron.launch or connect to an Electron debug port first.",
|
|
2874
|
-
failureCategory: "validation-error",
|
|
2875
|
-
});
|
|
2876
|
-
}
|
|
2877
|
-
const probeSessionName = compiledElectron.launchId ? launchRecord?.sessionName : managedSessionName;
|
|
2878
|
-
if (!probeSessionName) {
|
|
2879
|
-
return buildElectronHostFailureResult({
|
|
2880
|
-
compiledElectron: redactedCompiledElectron ?? compiledElectron,
|
|
2881
|
-
errorText: "electron.probe could not resolve a managed session to inspect.",
|
|
2882
|
-
failureCategory: "validation-error",
|
|
2883
|
-
});
|
|
2884
|
-
}
|
|
2885
|
-
try {
|
|
2886
|
-
const status = launchRecord ? await inspectElectronLaunchStatus(launchRecord) : undefined;
|
|
2887
|
-
const probe = await collectElectronProbe({ cwd, sessionName: probeSessionName, signal, timeoutMs: compiledElectron.timeoutMs });
|
|
2888
|
-
const managedSession: ElectronManagedSessionTarget = {
|
|
2889
|
-
sessionName: probe.sessionName,
|
|
2890
|
-
title: probe.title ?? probe.activeTab?.title,
|
|
2891
|
-
url: probe.url ?? probe.activeTab?.url,
|
|
2892
|
-
};
|
|
2893
|
-
const sessionMismatch = launchRecord && status
|
|
2894
|
-
? buildElectronSessionMismatch({ managedSession, record: launchRecord, statusTargets: status.targets })
|
|
2895
|
-
: undefined;
|
|
2896
|
-
const probeContextNote = !launchRecord
|
|
2897
|
-
? "No wrapper-tracked Electron launch matched this current managed session."
|
|
2898
|
-
: !compiledElectron.launchId && launchRecord.sessionName && launchRecord.sessionName !== probe.sessionName
|
|
2899
|
-
? `single active Electron launch ${launchRecord.launchId} uses wrapper session ${launchRecord.sessionName}; pass electron.probe.launchId to inspect that launch session directly.`
|
|
2900
|
-
: undefined;
|
|
2901
|
-
const probeContext: ElectronProbeContext = {
|
|
2902
|
-
launchId: launchRecord?.launchId,
|
|
2903
|
-
mode: compiledElectron.launchId ? "launchId" : "current-managed-session",
|
|
2904
|
-
note: probeContextNote,
|
|
2905
|
-
sessionName: probe.sessionName,
|
|
2906
|
-
};
|
|
2907
|
-
const sessionTabTarget = normalizeSessionTabTarget({
|
|
2908
|
-
title: probe.title ?? probe.activeTab?.title ?? probe.refSnapshot?.target?.title,
|
|
2909
|
-
url: probe.url ?? probe.activeTab?.url ?? probe.refSnapshot?.target?.url,
|
|
2910
|
-
});
|
|
2911
|
-
const pageStateUpdate = sessionPageState.beginUpdate();
|
|
2912
|
-
if (sessionTabTarget) {
|
|
2913
|
-
sessionPageState.applyTabTarget({ sessionName: probe.sessionName, target: sessionTabTarget, update: pageStateUpdate });
|
|
2914
|
-
}
|
|
2915
|
-
if (probe.refSnapshot) {
|
|
2916
|
-
sessionPageState.applyRefSnapshot({
|
|
2917
|
-
fallbackTarget: sessionTabTarget,
|
|
2918
|
-
sessionName: probe.sessionName,
|
|
2919
|
-
snapshot: probe.refSnapshot,
|
|
2920
|
-
update: pageStateUpdate,
|
|
2921
|
-
});
|
|
2922
|
-
}
|
|
2923
|
-
return buildElectronProbeResult({
|
|
2924
|
-
compiledElectron: redactedCompiledElectron ?? compiledElectron,
|
|
2925
|
-
mismatch: sessionMismatch,
|
|
2926
|
-
probe,
|
|
2927
|
-
probeContext,
|
|
2928
|
-
record: launchRecord,
|
|
2929
|
-
sessionTabTarget,
|
|
2930
|
-
status,
|
|
2931
|
-
});
|
|
2932
|
-
} catch (error) {
|
|
2933
|
-
const errorText = error instanceof Error ? error.message : String(error);
|
|
2934
|
-
return buildElectronHostFailureResult({
|
|
2935
|
-
compiledElectron: redactedCompiledElectron ?? compiledElectron,
|
|
2936
|
-
errorText: `Electron probe failed: ${errorText}`,
|
|
2937
|
-
failureCategory: "upstream-error",
|
|
2938
|
-
});
|
|
2939
|
-
}
|
|
2940
|
-
});
|
|
2941
|
-
}
|
|
2942
|
-
if (compiledElectron?.action === "cleanup") {
|
|
2943
|
-
const selection = selectElectronRecords(compiledElectron, electronLaunchRecords);
|
|
2944
|
-
if (selection.error) return buildElectronHostFailureResult({ compiledElectron: redactedCompiledElectron ?? compiledElectron, errorText: selection.error, failureCategory: "validation-error" });
|
|
2945
|
-
const cleanupResults = await cleanupTrackedElectronLaunches(selection.records ?? [], cwd, compiledElectron.timeoutMs ?? implicitSessionCloseTimeoutMs);
|
|
2946
|
-
return buildElectronCleanupResult(redactedCompiledElectron ?? compiledElectron, cleanupResults);
|
|
2947
|
-
}
|
|
2948
|
-
return undefined;
|
|
2949
|
-
}
|
|
2950
947
|
|
|
2951
948
|
export default function agentBrowserExtension(pi: ExtensionAPI) {
|
|
2952
949
|
const ephemeralSessionSeed = createEphemeralSessionSeed();
|
|
@@ -2963,63 +960,118 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
|
|
|
2963
960
|
let traceOwners = new Map<string, TraceOwner>();
|
|
2964
961
|
let artifactManifest: SessionArtifactManifest | undefined;
|
|
2965
962
|
let electronLaunchRecords = new Map<string, ElectronLaunchRecord>();
|
|
963
|
+
let ownedElectronLaunchRecords = new Map<string, ElectronLaunchRecord>();
|
|
964
|
+
let branchOwnedElectronLaunchIds = new Set<string>();
|
|
2966
965
|
let electronChildProcesses = new Map<string, ChildProcess>();
|
|
966
|
+
const ownedManagedSessions = new Map<string, OwnedManagedSession>();
|
|
2967
967
|
const managedSessionExecutionQueue = new AsyncExecutionQueue();
|
|
968
|
+
let branchStateGeneration = 0;
|
|
2968
969
|
|
|
2969
|
-
const
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
: undefined;
|
|
2975
|
-
const cleanupResult = await cleanupElectronLaunchResources({
|
|
2976
|
-
child: electronChildProcesses.get(record.launchId),
|
|
2977
|
-
record,
|
|
2978
|
-
timeoutMs,
|
|
2979
|
-
});
|
|
2980
|
-
const result: ElectronCleanupResult = managedSessionCloseError
|
|
2981
|
-
? {
|
|
2982
|
-
...cleanupResult,
|
|
2983
|
-
partial: true,
|
|
2984
|
-
record: { ...cleanupResult.record, cleanupState: "partial" },
|
|
2985
|
-
remainingResources: [...new Set(["managed-session", ...cleanupResult.remainingResources])],
|
|
2986
|
-
steps: [{ error: managedSessionCloseError, resource: "managed-session", state: "failed" }, ...cleanupResult.steps],
|
|
2987
|
-
summary: `Electron cleanup for ${record.launchId} is partial; managed session close failed.`,
|
|
2988
|
-
}
|
|
2989
|
-
: cleanupResult;
|
|
2990
|
-
results.push(result);
|
|
2991
|
-
electronLaunchRecords.set(record.launchId, result.record);
|
|
2992
|
-
if (!result.partial) electronChildProcesses.delete(record.launchId);
|
|
2993
|
-
}
|
|
2994
|
-
return results;
|
|
2995
|
-
};
|
|
2996
|
-
|
|
2997
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
970
|
+
const restoreBranchBackedState = (ctx: ExtensionContext, options: { resetRuntimeOwnership: boolean }): void => {
|
|
971
|
+
branchStateGeneration += 1;
|
|
972
|
+
const previousManagedSessionActive = managedSessionActive;
|
|
973
|
+
const previousManagedSessionName = managedSessionName;
|
|
974
|
+
const previousFreshSessionOrdinal = freshSessionOrdinal;
|
|
2998
975
|
managedSessionBaseName = createImplicitSessionName(ctx.sessionManager.getSessionId(), ctx.cwd, ephemeralSessionSeed);
|
|
2999
|
-
const
|
|
976
|
+
const branch = ctx.sessionManager.getBranch();
|
|
977
|
+
const branchResourceEvents = collectBranchManagedResourceEvents(branch);
|
|
978
|
+
const restoredState = restoreManagedSessionStateFromBranch(branch, managedSessionBaseName);
|
|
3000
979
|
managedSessionActive = restoredState.active;
|
|
3001
|
-
|
|
980
|
+
const restoredFreshSessionOrdinal = options.resetRuntimeOwnership
|
|
981
|
+
? restoredState.freshSessionOrdinal
|
|
982
|
+
: Math.max(previousFreshSessionOrdinal, restoredState.freshSessionOrdinal);
|
|
983
|
+
const shouldReservePostCloseSession = !restoredState.active && restoredState.closedSessionName === restoredState.sessionName;
|
|
984
|
+
const alreadyReservedPostCloseSession = shouldReservePostCloseSession
|
|
985
|
+
&& !options.resetRuntimeOwnership
|
|
986
|
+
&& !previousManagedSessionActive
|
|
987
|
+
&& previousFreshSessionOrdinal > restoredState.freshSessionOrdinal
|
|
988
|
+
&& previousFreshSessionOrdinal === restoredFreshSessionOrdinal
|
|
989
|
+
&& previousManagedSessionName === createFreshSessionName(managedSessionBaseName, ephemeralSessionSeed, restoredFreshSessionOrdinal);
|
|
990
|
+
const nextFreshSessionOrdinal = shouldReservePostCloseSession && !alreadyReservedPostCloseSession
|
|
991
|
+
? restoredFreshSessionOrdinal + 1
|
|
992
|
+
: restoredFreshSessionOrdinal;
|
|
993
|
+
managedSessionName = shouldReservePostCloseSession
|
|
994
|
+
? alreadyReservedPostCloseSession
|
|
995
|
+
? previousManagedSessionName
|
|
996
|
+
: createFreshSessionName(managedSessionBaseName, ephemeralSessionSeed, nextFreshSessionOrdinal)
|
|
997
|
+
: restoredState.sessionName;
|
|
3002
998
|
managedSessionCwd = ctx.cwd;
|
|
3003
|
-
freshSessionOrdinal =
|
|
3004
|
-
const branch = ctx.sessionManager.getBranch();
|
|
999
|
+
freshSessionOrdinal = nextFreshSessionOrdinal;
|
|
3005
1000
|
sessionPageState = SessionPageState.fromBranch(branch);
|
|
1001
|
+
traceOwners = new Map<string, TraceOwner>();
|
|
3006
1002
|
artifactManifest = restoreArtifactManifestFromBranch(branch);
|
|
3007
|
-
electronLaunchRecords = restoreElectronLaunchRecordsFromBranch(
|
|
1003
|
+
electronLaunchRecords = restoreElectronLaunchRecordsFromBranch(branch);
|
|
1004
|
+
if (options.resetRuntimeOwnership) {
|
|
1005
|
+
ownedManagedSessions.clear();
|
|
1006
|
+
ownedElectronLaunchRecords = new Map<string, ElectronLaunchRecord>();
|
|
1007
|
+
branchOwnedElectronLaunchIds = new Set<string>();
|
|
1008
|
+
} else {
|
|
1009
|
+
for (const [sessionName, closeRank] of branchResourceEvents.managedSessionCloseRanks) {
|
|
1010
|
+
untrackOwnedManagedSessionFromBranchClose(
|
|
1011
|
+
ownedManagedSessions,
|
|
1012
|
+
sessionName,
|
|
1013
|
+
branchResourceEvents.managedSessionActiveRanks.get(sessionName),
|
|
1014
|
+
closeRank,
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
removeInactiveOwnedElectronLaunchRecords(
|
|
1018
|
+
ownedElectronLaunchRecords,
|
|
1019
|
+
branchOwnedElectronLaunchIds,
|
|
1020
|
+
electronLaunchRecords,
|
|
1021
|
+
branchResourceEvents.electronLaunchActiveRanks,
|
|
1022
|
+
branchResourceEvents.electronLaunchCleanupRanks,
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
if (restoredState.active) {
|
|
1026
|
+
trackOwnedManagedSession(ownedManagedSessions, restoredState.sessionName, ctx.cwd, { branchOwned: true });
|
|
1027
|
+
}
|
|
1028
|
+
mergeActiveElectronLaunchRecords(ownedElectronLaunchRecords, electronLaunchRecords, {
|
|
1029
|
+
branchOwnedLaunchIds: branchOwnedElectronLaunchIds,
|
|
1030
|
+
markBranchOwned: true,
|
|
1031
|
+
});
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1035
|
+
restoreBranchBackedState(ctx, { resetRuntimeOwnership: true });
|
|
3008
1036
|
electronChildProcesses = new Map<string, ChildProcess>();
|
|
3009
1037
|
});
|
|
3010
1038
|
|
|
1039
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
1040
|
+
await managedSessionExecutionQueue.run(async () => {
|
|
1041
|
+
restoreBranchBackedState(ctx, { resetRuntimeOwnership: false });
|
|
1042
|
+
});
|
|
1043
|
+
});
|
|
1044
|
+
|
|
3011
1045
|
pi.on("session_shutdown", async (event, ctx) => {
|
|
1046
|
+
let preservedElectronProfileDirs: string[] = [];
|
|
3012
1047
|
await managedSessionExecutionQueue.run(async () => {
|
|
3013
|
-
const
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
1048
|
+
const shutdownCwd = ctx?.cwd ?? managedSessionCwd;
|
|
1049
|
+
const quitting = event?.reason === "quit";
|
|
1050
|
+
preservedElectronProfileDirs = quitting
|
|
1051
|
+
? []
|
|
1052
|
+
: getActiveElectronRecords(electronLaunchRecords).map((record) => record.userDataDir);
|
|
1053
|
+
const electronRecordsToCleanup = quitting
|
|
1054
|
+
? ownedElectronLaunchRecords
|
|
1055
|
+
: getOffBranchOwnedElectronLaunchRecords(ownedElectronLaunchRecords, electronLaunchRecords);
|
|
1056
|
+
const electronCleanupResults = await cleanupActiveElectronHostLaunches({
|
|
1057
|
+
cwd: shutdownCwd,
|
|
1058
|
+
electronChildProcesses,
|
|
1059
|
+
electronLaunchRecords: electronRecordsToCleanup,
|
|
1060
|
+
timeoutMs: implicitSessionCloseTimeoutMs,
|
|
1061
|
+
});
|
|
1062
|
+
preservedElectronProfileDirs = [...new Set([
|
|
1063
|
+
...preservedElectronProfileDirs,
|
|
1064
|
+
...getCleanupResultsPreservedUserDataDirs(electronCleanupResults),
|
|
1065
|
+
])];
|
|
1066
|
+
syncElectronCleanupManagedSessions(ownedManagedSessions, electronCleanupResults);
|
|
1067
|
+
if (quitting) {
|
|
1068
|
+
await closeOwnedManagedSessions(ownedManagedSessions, implicitSessionCloseTimeoutMs);
|
|
1069
|
+
} else {
|
|
1070
|
+
await closeOwnedManagedSessionsExcept(
|
|
1071
|
+
ownedManagedSessions,
|
|
1072
|
+
managedSessionActive ? managedSessionName : undefined,
|
|
1073
|
+
implicitSessionCloseTimeoutMs,
|
|
1074
|
+
);
|
|
3023
1075
|
}
|
|
3024
1076
|
});
|
|
3025
1077
|
managedSessionActive = false;
|
|
@@ -3027,8 +1079,11 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
|
|
|
3027
1079
|
traceOwners = new Map<string, TraceOwner>();
|
|
3028
1080
|
artifactManifest = undefined;
|
|
3029
1081
|
electronLaunchRecords = new Map<string, ElectronLaunchRecord>();
|
|
1082
|
+
ownedElectronLaunchRecords = new Map<string, ElectronLaunchRecord>();
|
|
1083
|
+
branchOwnedElectronLaunchIds = new Set<string>();
|
|
3030
1084
|
electronChildProcesses = new Map<string, ChildProcess>();
|
|
3031
|
-
|
|
1085
|
+
ownedManagedSessions.clear();
|
|
1086
|
+
await cleanupSecureTempArtifacts({ preservePaths: preservedElectronProfileDirs });
|
|
3032
1087
|
});
|
|
3033
1088
|
|
|
3034
1089
|
pi.on("before_agent_start", async (event) => {
|
|
@@ -3062,7 +1117,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
|
|
|
3062
1117
|
name: "agent_browser",
|
|
3063
1118
|
label: "Agent Browser",
|
|
3064
1119
|
description:
|
|
3065
|
-
"Browse and interact with websites using agent-browser. Use this for web research, reading live docs, opening pages, taking snapshots or screenshots, clicking links, filling forms, extracting page content, and authenticated/profile-based browser work.",
|
|
1120
|
+
"Browse and interact with websites using agent-browser. Use this for web research, reading live docs, opening pages, taking snapshots or screenshots, clicking links, filling forms, extracting page content, and authenticated/profile-based browser work. Input choice: default `args` for open → snapshot -i → click/fill @refs; `semanticAction` for stable role/text/label targets; `job` or `qa` for multi-step checks; `electron` only for desktop apps; experimental `sourceLookup` / `networkSourceLookup` for candidates only.",
|
|
3066
1121
|
promptSnippet:
|
|
3067
1122
|
"Browse websites, read live docs, click and fill pages, extract browser content, take screenshots, and automate real web workflows.",
|
|
3068
1123
|
promptGuidelines: toolPromptGuidelines,
|
|
@@ -3080,6 +1135,7 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
|
|
|
3080
1135
|
return component;
|
|
3081
1136
|
},
|
|
3082
1137
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
1138
|
+
const promptPolicy = buildPromptPolicy(getLatestUserPrompt(ctx.sessionManager.getBranch()));
|
|
3083
1139
|
const resolvedInput = resolveAgentBrowserInput({
|
|
3084
1140
|
getBatchAnnotateValidationError,
|
|
3085
1141
|
managedSessionActive,
|
|
@@ -3091,27 +1147,73 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
|
|
|
3091
1147
|
const { toolArgs } = resolvedInput;
|
|
3092
1148
|
const compiledElectron = resolvedInput.kind === "electron" ? resolvedInput.compiledElectron : undefined;
|
|
3093
1149
|
const redactedCompiledElectron = resolvedInput.kind === "electron" ? resolvedInput.redactedCompiledElectron : undefined;
|
|
3094
|
-
const
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
1150
|
+
const runElectronHostInput = async () => {
|
|
1151
|
+
const electronHostLaunchRecords = getElectronHostLaunchRecordsForInput({
|
|
1152
|
+
branchRecords: electronLaunchRecords,
|
|
1153
|
+
compiledElectron,
|
|
1154
|
+
ownedRecords: ownedElectronLaunchRecords,
|
|
1155
|
+
});
|
|
1156
|
+
const electronHostResult = await handleElectronHostInput({
|
|
1157
|
+
compiledElectron,
|
|
1158
|
+
cwd: ctx.cwd,
|
|
1159
|
+
electronChildProcesses,
|
|
1160
|
+
electronLaunchRecords: electronHostLaunchRecords,
|
|
1161
|
+
implicitSessionCloseTimeoutMs,
|
|
1162
|
+
managedSessionActive,
|
|
1163
|
+
managedSessionName,
|
|
1164
|
+
redactedCompiledElectron,
|
|
1165
|
+
sessionPageState,
|
|
1166
|
+
signal,
|
|
1167
|
+
});
|
|
1168
|
+
if (electronHostResult && compiledElectron?.action === "cleanup") {
|
|
1169
|
+
branchStateGeneration += 1;
|
|
1170
|
+
const cleanupRecords = isRecord(electronHostResult.details)
|
|
1171
|
+
&& isRecord(electronHostResult.details.electron)
|
|
1172
|
+
&& isRecord(electronHostResult.details.electron.cleanup)
|
|
1173
|
+
&& Array.isArray(electronHostResult.details.electron.cleanup.results)
|
|
1174
|
+
? electronHostResult.details.electron.cleanup.results
|
|
1175
|
+
: [];
|
|
1176
|
+
const cleanedLaunchIds = new Set<string>();
|
|
1177
|
+
for (const cleanupResult of cleanupRecords) {
|
|
1178
|
+
if (isRecord(cleanupResult) && isElectronLaunchRecord(cleanupResult.record)) {
|
|
1179
|
+
cleanedLaunchIds.add(cleanupResult.record.launchId);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
replaceWithActiveElectronLaunchRecords(ownedElectronLaunchRecords, electronHostLaunchRecords, branchOwnedElectronLaunchIds, cleanedLaunchIds);
|
|
1183
|
+
mergeElectronCleanupRecords(electronLaunchRecords, cleanupRecords);
|
|
1184
|
+
const closedSessionNames = getCleanupResultsClosedManagedSessionNames(cleanupRecords);
|
|
1185
|
+
syncElectronCleanupManagedSessions(ownedManagedSessions, cleanupRecords);
|
|
1186
|
+
for (const closedSessionName of closedSessionNames) {
|
|
1187
|
+
sessionPageState.clearSession(closedSessionName);
|
|
1188
|
+
if (closedSessionName === managedSessionName) {
|
|
1189
|
+
managedSessionActive = false;
|
|
1190
|
+
freshSessionOrdinal += 1;
|
|
1191
|
+
managedSessionName = createFreshSessionName(managedSessionBaseName, ephemeralSessionSeed, freshSessionOrdinal);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
return electronHostResult;
|
|
1196
|
+
};
|
|
1197
|
+
const electronHostResult = shouldSerializeElectronHostInput(compiledElectron)
|
|
1198
|
+
? await managedSessionExecutionQueue.run(runElectronHostInput)
|
|
1199
|
+
: await runElectronHostInput();
|
|
3107
1200
|
if (electronHostResult) {
|
|
3108
1201
|
return electronHostResult;
|
|
3109
1202
|
}
|
|
3110
1203
|
|
|
3111
|
-
const
|
|
1204
|
+
const explicitSessionName = extractExplicitSessionName(toolArgs);
|
|
1205
|
+
const serializeBrowserCommand = shouldSerializeBrowserCommand({
|
|
1206
|
+
explicitSessionName,
|
|
1207
|
+
managedSessionName,
|
|
1208
|
+
ownedElectronLaunchRecords,
|
|
1209
|
+
ownedManagedSessions,
|
|
1210
|
+
});
|
|
3112
1211
|
const runBrowserCommand = async () => {
|
|
1212
|
+
const generationAtStart = branchStateGeneration;
|
|
1213
|
+
const sessionPageStateUpdate = sessionPageState.beginUpdate();
|
|
3113
1214
|
const browserRunState: BrowserRunState = {
|
|
3114
1215
|
artifactManifest,
|
|
1216
|
+
closedManagedSessionNames: new Set<string>(),
|
|
3115
1217
|
electronChildProcesses,
|
|
3116
1218
|
electronLaunchRecords,
|
|
3117
1219
|
ephemeralSessionSeed,
|
|
@@ -3133,21 +1235,36 @@ export default function agentBrowserExtension(pi: ExtensionAPI) {
|
|
|
3133
1235
|
input: resolvedInput,
|
|
3134
1236
|
onUpdate,
|
|
3135
1237
|
params,
|
|
1238
|
+
promptPolicy,
|
|
3136
1239
|
sessionPageStateUpdate,
|
|
3137
1240
|
signal,
|
|
3138
1241
|
state: browserRunState,
|
|
3139
1242
|
});
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
1243
|
+
const branchStateStillCurrent = generationAtStart === branchStateGeneration;
|
|
1244
|
+
if (serializeBrowserCommand || branchStateStillCurrent) {
|
|
1245
|
+
artifactManifest = browserRunState.artifactManifest;
|
|
1246
|
+
freshSessionOrdinal = Math.max(freshSessionOrdinal, browserRunState.freshSessionOrdinal);
|
|
1247
|
+
managedSessionActive = browserRunState.managedSessionActive;
|
|
1248
|
+
managedSessionCwd = browserRunState.managedSessionCwd;
|
|
1249
|
+
managedSessionName = browserRunState.managedSessionName;
|
|
1250
|
+
for (const closedSessionName of browserRunState.closedManagedSessionNames) {
|
|
1251
|
+
untrackOwnedManagedSession(ownedManagedSessions, closedSessionName);
|
|
1252
|
+
}
|
|
1253
|
+
syncOwnedManagedSessionsFromResult(ownedManagedSessions, result, browserRunState.managedSessionCwd);
|
|
1254
|
+
mergeActiveElectronLaunchRecords(ownedElectronLaunchRecords, electronLaunchRecords, {
|
|
1255
|
+
branchOwnedLaunchIds: branchOwnedElectronLaunchIds,
|
|
1256
|
+
touchedLaunchIds: !result.isError
|
|
1257
|
+
? getTouchedElectronLaunchIds(explicitSessionName ?? browserRunState.managedSessionName, electronLaunchRecords)
|
|
1258
|
+
: undefined,
|
|
1259
|
+
});
|
|
1260
|
+
if (serializeBrowserCommand) branchStateGeneration += 1;
|
|
1261
|
+
}
|
|
3145
1262
|
return result;
|
|
3146
1263
|
};
|
|
3147
1264
|
|
|
3148
|
-
return
|
|
3149
|
-
? runBrowserCommand
|
|
3150
|
-
:
|
|
1265
|
+
return serializeBrowserCommand
|
|
1266
|
+
? managedSessionExecutionQueue.run(runBrowserCommand)
|
|
1267
|
+
: runBrowserCommand();
|
|
3151
1268
|
},
|
|
3152
1269
|
});
|
|
3153
1270
|
}
|