@steipete/oracle 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/bin/oracle-cli.js +76 -76
- package/dist/bin/oracle-mcp.js +0 -0
- package/dist/src/browser/actions/archiveConversation.js +31 -7
- package/dist/src/browser/actions/assistantResponse.js +43 -1
- package/dist/src/browser/actions/attachmentContext.js +239 -0
- package/dist/src/browser/actions/attachmentDataTransfer.js +19 -1
- package/dist/src/browser/actions/attachmentEvidence.js +78 -0
- package/dist/src/browser/actions/attachmentProgress.js +41 -0
- package/dist/src/browser/actions/attachmentPrompt.js +130 -0
- package/dist/src/browser/actions/attachments.js +306 -355
- package/dist/src/browser/actions/deepResearch.js +170 -45
- package/dist/src/browser/actions/modelSelection.js +91 -4
- package/dist/src/browser/actions/navigation.js +1 -1
- package/dist/src/browser/actions/promptComposer.js +364 -186
- package/dist/src/browser/actions/remoteFileTransfer.js +4 -2
- package/dist/src/browser/actions/thinkingStatus.js +2 -5
- package/dist/src/browser/actions/thinkingTime.js +156 -16
- package/dist/src/browser/actions/webSearch.js +96 -0
- package/dist/src/browser/attachRunning.js +46 -2
- package/dist/src/browser/attachmentValidation.js +53 -0
- package/dist/src/browser/cancellation.js +105 -0
- package/dist/src/browser/chatgptImages.js +2 -0
- package/dist/src/browser/chatgptThrottle.js +104 -0
- package/dist/src/browser/chromeLifecycle.js +272 -47
- package/dist/src/browser/config.js +14 -1
- package/dist/src/browser/detect.js +1 -1
- package/dist/src/browser/index.js +525 -274
- package/dist/src/browser/liveTabs.js +3 -4
- package/dist/src/browser/modelDisplay.js +9 -0
- package/dist/src/browser/pageActions.js +2 -2
- package/dist/src/browser/profileState.js +114 -9
- package/dist/src/browser/projectSourcesRunner.js +83 -15
- package/dist/src/browser/prompt.js +272 -113
- package/dist/src/browser/providers/chatgptDomProvider.js +3 -0
- package/dist/src/browser/reattach.js +31 -4
- package/dist/src/browser/recoveryTarget.js +131 -0
- package/dist/src/browser/sessionRunner.js +73 -11
- package/dist/src/browser/tabLeaseRegistry.js +434 -38
- package/dist/src/browser/targetClaim.js +54 -0
- package/dist/src/browser/utils.js +20 -2
- package/dist/src/cli/browserConfig.js +36 -4
- package/dist/src/cli/browserDefaults.js +10 -0
- package/dist/src/cli/browserTabs.js +15 -29
- package/dist/src/cli/detachedSession.js +69 -0
- package/dist/src/cli/dryRun.js +58 -48
- package/dist/src/cli/harvestIntegrity.js +140 -0
- package/dist/src/cli/options.js +15 -0
- package/dist/src/cli/recoveredBrowserHarvest.js +50 -0
- package/dist/src/cli/runOptions.js +19 -4
- package/dist/src/cli/serveBrowserConfig.js +24 -0
- package/dist/src/cli/sessionDisplay.js +13 -7
- package/dist/src/cli/sessionRunner.js +95 -12
- package/dist/src/config.js +1 -0
- package/dist/src/gemini-web/client.js +67 -21
- package/dist/src/gemini-web/executor.js +4 -0
- package/dist/src/gemini-web/http.js +15 -0
- package/dist/src/gemini-web/models.js +15 -11
- package/dist/src/mcp/server.js +11 -14
- package/dist/src/mcp/tools/chatgptImage.js +4 -4
- package/dist/src/mcp/tools/consult.js +86 -24
- package/dist/src/mcp/tools/projectSources.js +4 -6
- package/dist/src/mcp/tools/sessionResources.js +1 -1
- package/dist/src/mcp/tools/sessions.js +2 -2
- package/dist/src/mcp/tools/wait.js +182 -0
- package/dist/src/mcp/types.js +8 -1
- package/dist/src/oracle/config.js +14 -0
- package/dist/src/oracle/errors.js +11 -0
- package/dist/src/oracle/geminiModels.js +1 -0
- package/dist/src/oracle/providerRoutePlan.js +3 -2
- package/dist/src/oracle/run.js +27 -4
- package/dist/src/remote/client.js +73 -6
- package/dist/src/remote/health.js +6 -2
- package/dist/src/remote/runSlots.js +83 -0
- package/dist/src/remote/server.js +346 -71
- package/dist/src/sessionManager.js +36 -1
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/package.json +25 -23
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/dist/bin/oracle.js +0 -683
- package/dist/docs-site/.nojekyll +0 -0
- package/dist/docs-site/CNAME +0 -1
- package/dist/docs-site/RELEASING.html +0 -410
- package/dist/docs-site/agents.html +0 -374
- package/dist/docs-site/anthropic.html +0 -368
- package/dist/docs-site/bridge.html +0 -400
- package/dist/docs-site/browser-mode.html +0 -588
- package/dist/docs-site/chromium-forks.html +0 -347
- package/dist/docs-site/cli-reference.html +0 -346
- package/dist/docs-site/configuration.html +0 -436
- package/dist/docs-site/favicon.svg +0 -14
- package/dist/docs-site/followup.html +0 -368
- package/dist/docs-site/gemini.html +0 -379
- package/dist/docs-site/grok.html +0 -325
- package/dist/docs-site/index.html +0 -360
- package/dist/docs-site/install.html +0 -335
- package/dist/docs-site/linux.html +0 -321
- package/dist/docs-site/llms.txt +0 -43
- package/dist/docs-site/manual-tests.html +0 -595
- package/dist/docs-site/mcp.html +0 -368
- package/dist/docs-site/multimodel.html +0 -364
- package/dist/docs-site/mythical-pro-agents.html +0 -360
- package/dist/docs-site/notifier.html +0 -338
- package/dist/docs-site/openai-endpoints.html +0 -387
- package/dist/docs-site/openrouter.html +0 -344
- package/dist/docs-site/quickstart.html +0 -369
- package/dist/docs-site/refactor/ux.html +0 -532
- package/dist/docs-site/sessions.html +0 -388
- package/dist/docs-site/social-card.png +0 -0
- package/dist/docs-site/social-card.svg +0 -79
- package/dist/docs-site/spec.html +0 -363
- package/dist/docs-site/testing.html +0 -320
- package/dist/docs-site/tui-debug.html +0 -326
- package/dist/docs-site/windows-work.html +0 -323
- package/dist/docs-site/windows.html +0 -320
- package/dist/markdansi/types/index.js +0 -4
- package/dist/oracle/bin/oracle-cli.js +0 -472
- package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
- package/dist/oracle/src/browser/actions/attachments.js +0 -82
- package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
- package/dist/oracle/src/browser/actions/navigation.js +0 -75
- package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
- package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
- package/dist/oracle/src/browser/config.js +0 -33
- package/dist/oracle/src/browser/constants.js +0 -40
- package/dist/oracle/src/browser/cookies.js +0 -210
- package/dist/oracle/src/browser/domDebug.js +0 -36
- package/dist/oracle/src/browser/index.js +0 -331
- package/dist/oracle/src/browser/pageActions.js +0 -5
- package/dist/oracle/src/browser/prompt.js +0 -88
- package/dist/oracle/src/browser/promptSummary.js +0 -20
- package/dist/oracle/src/browser/sessionRunner.js +0 -80
- package/dist/oracle/src/browser/types.js +0 -1
- package/dist/oracle/src/browser/utils.js +0 -62
- package/dist/oracle/src/browserMode.js +0 -1
- package/dist/oracle/src/cli/browserConfig.js +0 -44
- package/dist/oracle/src/cli/dryRun.js +0 -59
- package/dist/oracle/src/cli/engine.js +0 -17
- package/dist/oracle/src/cli/errorUtils.js +0 -9
- package/dist/oracle/src/cli/help.js +0 -70
- package/dist/oracle/src/cli/markdownRenderer.js +0 -15
- package/dist/oracle/src/cli/options.js +0 -103
- package/dist/oracle/src/cli/promptRequirement.js +0 -14
- package/dist/oracle/src/cli/rootAlias.js +0 -30
- package/dist/oracle/src/cli/sessionCommand.js +0 -77
- package/dist/oracle/src/cli/sessionDisplay.js +0 -270
- package/dist/oracle/src/cli/sessionRunner.js +0 -94
- package/dist/oracle/src/heartbeat.js +0 -43
- package/dist/oracle/src/oracle/client.js +0 -48
- package/dist/oracle/src/oracle/config.js +0 -29
- package/dist/oracle/src/oracle/errors.js +0 -101
- package/dist/oracle/src/oracle/files.js +0 -220
- package/dist/oracle/src/oracle/format.js +0 -33
- package/dist/oracle/src/oracle/fsAdapter.js +0 -7
- package/dist/oracle/src/oracle/oscProgress.js +0 -60
- package/dist/oracle/src/oracle/request.js +0 -48
- package/dist/oracle/src/oracle/run.js +0 -444
- package/dist/oracle/src/oracle/tokenStats.js +0 -39
- package/dist/oracle/src/oracle/types.js +0 -1
- package/dist/oracle/src/oracle.js +0 -9
- package/dist/oracle/src/sessionManager.js +0 -205
- package/dist/oracle/src/version.js +0 -39
- package/dist/scripts/chrome/browser-tools.js +0 -295
- package/dist/src/browser/chromeCookies.js +0 -312
- package/dist/src/browser/keytarShim.js +0 -56
- package/dist/src/browser/profileSync.js +0 -141
- package/dist/src/browser/windowsCookies.js +0 -219
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { lstat, mkdir, readFile, realpath, rename, rm, rmdir, stat, writeFile, } from "node:fs/promises";
|
|
5
|
+
import { isProcessAlive, readProcessStartTimeMs } from "./profileState.js";
|
|
5
6
|
import { delay } from "./utils.js";
|
|
7
|
+
import { normalizeChromeHost } from "./targetClaim.js";
|
|
6
8
|
export const DEFAULT_MAX_CONCURRENT_CHATGPT_TABS = 3;
|
|
7
9
|
const REGISTRY_FILENAME = "oracle-tab-leases.json";
|
|
8
10
|
const REGISTRY_LOCK_DIRNAME = "oracle-tab-leases.lock";
|
|
9
11
|
const DEFAULT_POLL_MS = 1000;
|
|
10
|
-
const DEFAULT_STALE_MS =
|
|
12
|
+
const DEFAULT_STALE_MS = 2 * 60 * 1000;
|
|
13
|
+
const LEASE_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
11
14
|
const REGISTRY_LOCK_TIMEOUT_MS = 10_000;
|
|
15
|
+
const REGISTRY_LOCK_OWNER_FILENAME = "owner.json";
|
|
16
|
+
const REGISTRY_RECOVERY_POLL_MS = 50;
|
|
17
|
+
const LEGACY_OWNERLESS_LOCK_STALE_MS = 5 * 60 * 1000;
|
|
18
|
+
const PROCESS_START_TIME_TOLERANCE_MS = 10_000;
|
|
19
|
+
const REGISTRY_RENAME_RETRY_ATTEMPTS = 6;
|
|
12
20
|
export function normalizeMaxConcurrentTabs(value) {
|
|
13
21
|
if (value === undefined || value === null) {
|
|
14
22
|
return DEFAULT_MAX_CONCURRENT_CHATGPT_TABS;
|
|
@@ -26,17 +34,21 @@ export async function acquireBrowserTabLease(profileDir, options, deps = {}) {
|
|
|
26
34
|
const staleMs = Math.max(60_000, options.staleMs ?? DEFAULT_STALE_MS);
|
|
27
35
|
const now = deps.now ?? Date.now;
|
|
28
36
|
const pid = deps.pid ?? process.pid;
|
|
37
|
+
const processStartedAtMs = await (deps.readProcessStartTimeMs ?? readProcessStartTimeMs)(pid);
|
|
29
38
|
const leaseId = randomUUID();
|
|
30
39
|
const startedAt = now();
|
|
31
40
|
let warned = false;
|
|
32
41
|
let lastHeartbeatAt = 0;
|
|
33
42
|
for (;;) {
|
|
43
|
+
options.signal?.throwIfAborted();
|
|
34
44
|
const acquired = await withRegistryLock(profileDir, async () => {
|
|
45
|
+
options.signal?.throwIfAborted();
|
|
35
46
|
const registry = await readRegistry(profileDir);
|
|
36
|
-
const active = pruneStaleLeases(registry.leases, {
|
|
47
|
+
const active = await pruneStaleLeases(registry.leases, {
|
|
37
48
|
nowMs: now(),
|
|
38
49
|
staleMs,
|
|
39
50
|
isProcessAlive: deps.isProcessAlive ?? isProcessAlive,
|
|
51
|
+
readProcessStartTimeMs: deps.readProcessStartTimeMs ?? readProcessStartTimeMs,
|
|
40
52
|
});
|
|
41
53
|
if (active.length >= maxConcurrentTabs) {
|
|
42
54
|
if (active.length !== registry.leases.length) {
|
|
@@ -48,6 +60,7 @@ export async function acquireBrowserTabLease(profileDir, options, deps = {}) {
|
|
|
48
60
|
const lease = {
|
|
49
61
|
id: leaseId,
|
|
50
62
|
pid,
|
|
63
|
+
...(processStartedAtMs === null ? {} : { processStartedAtMs }),
|
|
51
64
|
sessionId: options.sessionId,
|
|
52
65
|
chromeHost: options.chromeHost,
|
|
53
66
|
chromePort: options.chromePort,
|
|
@@ -56,13 +69,51 @@ export async function acquireBrowserTabLease(profileDir, options, deps = {}) {
|
|
|
56
69
|
};
|
|
57
70
|
await writeRegistry(profileDir, { version: 1, leases: [...active, lease] });
|
|
58
71
|
return lease;
|
|
59
|
-
});
|
|
72
|
+
}, options.signal);
|
|
60
73
|
if (acquired) {
|
|
61
74
|
options.logger?.(`[browser] Acquired ChatGPT browser slot ${leaseId.slice(0, 8)} (${maxConcurrentTabs} max).`);
|
|
75
|
+
let released = false;
|
|
76
|
+
let heartbeatInFlight = false;
|
|
77
|
+
let heartbeatPromise = null;
|
|
78
|
+
let heartbeatWarningEmitted = false;
|
|
79
|
+
const heartbeat = setInterval(() => {
|
|
80
|
+
if (released || heartbeatInFlight)
|
|
81
|
+
return;
|
|
82
|
+
heartbeatInFlight = true;
|
|
83
|
+
const heartbeatWork = updateBrowserTabLease(profileDir, leaseId, {})
|
|
84
|
+
.then(() => {
|
|
85
|
+
heartbeatWarningEmitted = false;
|
|
86
|
+
})
|
|
87
|
+
.catch((error) => {
|
|
88
|
+
if (!heartbeatWarningEmitted) {
|
|
89
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
90
|
+
options.logger?.(`[browser] ChatGPT browser slot heartbeat failed; preserving the slot until stale grace expires: ${message}`);
|
|
91
|
+
heartbeatWarningEmitted = true;
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
.finally(() => {
|
|
95
|
+
heartbeatInFlight = false;
|
|
96
|
+
if (heartbeatPromise === heartbeatWork)
|
|
97
|
+
heartbeatPromise = null;
|
|
98
|
+
});
|
|
99
|
+
heartbeatPromise = heartbeatWork;
|
|
100
|
+
}, Math.max(10, deps.heartbeatMs ?? LEASE_HEARTBEAT_INTERVAL_MS));
|
|
101
|
+
heartbeat.unref();
|
|
62
102
|
return {
|
|
63
103
|
id: leaseId,
|
|
64
|
-
release: async (releaseOptions) =>
|
|
65
|
-
|
|
104
|
+
release: async (releaseOptions) => {
|
|
105
|
+
if (released)
|
|
106
|
+
return;
|
|
107
|
+
released = true;
|
|
108
|
+
clearInterval(heartbeat);
|
|
109
|
+
await heartbeatPromise?.catch(() => undefined);
|
|
110
|
+
await releaseBrowserTabLease(profileDir, leaseId, options.logger, releaseOptions);
|
|
111
|
+
},
|
|
112
|
+
update: async (patch) => {
|
|
113
|
+
if (released)
|
|
114
|
+
return;
|
|
115
|
+
await updateBrowserTabLease(profileDir, leaseId, patch);
|
|
116
|
+
},
|
|
66
117
|
};
|
|
67
118
|
}
|
|
68
119
|
const elapsed = now() - startedAt;
|
|
@@ -74,12 +125,15 @@ export async function acquireBrowserTabLease(profileDir, options, deps = {}) {
|
|
|
74
125
|
if (timeoutMs > 0 && elapsed >= timeoutMs) {
|
|
75
126
|
throw new Error(`Timed out waiting for ChatGPT browser slot after ${Math.round(elapsed / 1000)}s (${maxConcurrentTabs} max).`);
|
|
76
127
|
}
|
|
77
|
-
await delay(timeoutMs > 0 ? Math.min(pollMs, timeoutMs - elapsed) : pollMs);
|
|
128
|
+
await delay(timeoutMs > 0 ? Math.min(pollMs, timeoutMs - elapsed) : pollMs, options.signal);
|
|
78
129
|
}
|
|
79
130
|
}
|
|
80
131
|
export async function updateBrowserTabLease(profileDir, leaseId, patch) {
|
|
81
132
|
await withRegistryLock(profileDir, async () => {
|
|
82
133
|
const registry = await readRegistry(profileDir);
|
|
134
|
+
if (!registry.leases.some((lease) => lease.id === leaseId)) {
|
|
135
|
+
throw new Error(`Oracle ChatGPT browser slot ${leaseId.slice(0, 8)} is no longer owned by this controller.`);
|
|
136
|
+
}
|
|
83
137
|
const leases = registry.leases.map((lease) => lease.id === leaseId
|
|
84
138
|
? { ...lease, ...patch, id: lease.id, updatedAt: new Date().toISOString() }
|
|
85
139
|
: lease);
|
|
@@ -89,26 +143,44 @@ export async function updateBrowserTabLease(profileDir, leaseId, patch) {
|
|
|
89
143
|
export async function releaseBrowserTabLease(profileDir, leaseId, logger, options = {}) {
|
|
90
144
|
await withRegistryLock(profileDir, async () => {
|
|
91
145
|
const registry = await readRegistry(profileDir);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
146
|
+
// Release is a safety-critical reference-count decrement. Never prune other
|
|
147
|
+
// controllers here: a transient cross-process liveness false-negative would
|
|
148
|
+
// make this run look like the final lease and allow it to terminate shared
|
|
149
|
+
// Chrome underneath live tabs. Stale records are reclaimed by a later acquire.
|
|
150
|
+
const releasedLease = registry.leases.find((lease) => lease.id === leaseId);
|
|
151
|
+
if (!releasedLease) {
|
|
152
|
+
throw new Error(`Oracle ChatGPT browser slot ${leaseId.slice(0, 8)} was already lost; refusing final shared-Chrome cleanup.`);
|
|
153
|
+
}
|
|
154
|
+
const leases = registry.leases.filter((lease) => lease.id !== leaseId);
|
|
98
155
|
await writeRegistry(profileDir, { version: 1, leases });
|
|
99
|
-
|
|
100
|
-
|
|
156
|
+
logger?.(`[browser] ChatGPT browser slot ${leaseId.slice(0, 8)} release decision: ` +
|
|
157
|
+
`isLastLease=${leases.length === 0}; remaining=${leases.length}; ` +
|
|
158
|
+
`remainingSessions=${formatRemainingLeaseSessions(leases)}.`);
|
|
159
|
+
await options.onRelease?.({
|
|
160
|
+
isLastLease: leases.length === 0,
|
|
161
|
+
releasedLease,
|
|
162
|
+
remainingLeases: leases,
|
|
163
|
+
});
|
|
164
|
+
});
|
|
101
165
|
logger?.(`[browser] Released ChatGPT browser slot ${leaseId.slice(0, 8)}.`);
|
|
102
166
|
}
|
|
167
|
+
function formatRemainingLeaseSessions(leases) {
|
|
168
|
+
if (leases.length === 0)
|
|
169
|
+
return "none";
|
|
170
|
+
return leases
|
|
171
|
+
.map((lease) => `${lease.id.slice(0, 8)}:${lease.sessionId ?? "unknown"}:pid${lease.pid}:target${lease.chromeTargetId ?? "pending"}`)
|
|
172
|
+
.join(",");
|
|
173
|
+
}
|
|
103
174
|
export async function hasOtherActiveBrowserTabLeases(profileDir, leaseId, options = {}) {
|
|
104
175
|
const now = options.now ?? Date.now;
|
|
105
176
|
const staleMs = Math.max(60_000, options.staleMs ?? DEFAULT_STALE_MS);
|
|
106
177
|
return withRegistryLock(profileDir, async () => {
|
|
107
178
|
const registry = await readRegistry(profileDir);
|
|
108
|
-
const active = pruneStaleLeases(registry.leases, {
|
|
179
|
+
const active = await pruneStaleLeases(registry.leases, {
|
|
109
180
|
nowMs: now(),
|
|
110
181
|
staleMs,
|
|
111
182
|
isProcessAlive: options.isProcessAlive ?? isProcessAlive,
|
|
183
|
+
readProcessStartTimeMs: options.readProcessStartTimeMs ?? readProcessStartTimeMs,
|
|
112
184
|
});
|
|
113
185
|
if (active.length !== registry.leases.length) {
|
|
114
186
|
await writeRegistry(profileDir, { version: 1, leases: active });
|
|
@@ -116,66 +188,385 @@ export async function hasOtherActiveBrowserTabLeases(profileDir, leaseId, option
|
|
|
116
188
|
return active.some((lease) => lease.id !== leaseId);
|
|
117
189
|
});
|
|
118
190
|
}
|
|
119
|
-
async function
|
|
191
|
+
export async function hasActiveBrowserTargetLease(profileDir, target) {
|
|
192
|
+
let raw;
|
|
193
|
+
try {
|
|
194
|
+
raw = await readFile(path.join(profileDir, REGISTRY_FILENAME), "utf8");
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (error.code === "ENOENT")
|
|
198
|
+
return false;
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
const registry = JSON.parse(raw);
|
|
202
|
+
if (registry.version !== 1 || !Array.isArray(registry.leases))
|
|
203
|
+
throw new Error("Unknown browser lease registry");
|
|
204
|
+
return registry.leases.some((lease) => lease.chromeTargetId === target.targetId &&
|
|
205
|
+
(!lease.chromeHost ||
|
|
206
|
+
normalizeChromeHost(lease.chromeHost) === normalizeChromeHost(target.host)) &&
|
|
207
|
+
(!lease.chromePort || lease.chromePort === target.port) &&
|
|
208
|
+
isProcessAlive(lease.pid));
|
|
209
|
+
}
|
|
210
|
+
async function withRegistryLock(profileDir, callback, signal) {
|
|
120
211
|
const lockDir = path.join(profileDir, REGISTRY_LOCK_DIRNAME);
|
|
212
|
+
const lockId = randomUUID();
|
|
213
|
+
const processStartedAtMs = await readProcessStartTimeMs(process.pid);
|
|
121
214
|
const startedAt = Date.now();
|
|
122
215
|
for (;;) {
|
|
216
|
+
signal?.throwIfAborted();
|
|
123
217
|
try {
|
|
218
|
+
// mkdir is exclusive on every platform; rename can replace a live empty
|
|
219
|
+
// directory on POSIX (including locks held by older Oracle controllers).
|
|
124
220
|
await mkdir(lockDir, { recursive: false });
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
const code = error.code;
|
|
224
|
+
if (code !== "EEXIST" && code !== "EPERM" && code !== "ENOTEMPTY") {
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
const owner = await readRegistryLockOwner(lockDir);
|
|
228
|
+
if (owner && !(await isRegistryLockOwnerAlive(owner))) {
|
|
229
|
+
await recoverDeadRegistryLock(profileDir, lockDir, owner);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (!owner && (await isRegistryOwnerFileMissing(lockDir))) {
|
|
233
|
+
const identity = await readRegistryLockIdentity(lockDir);
|
|
234
|
+
if (identity && Date.now() - identity.mtimeMs >= LEGACY_OWNERLESS_LOCK_STALE_MS) {
|
|
235
|
+
await recoverOwnerlessRegistryLock(profileDir, lockDir, identity);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (Date.now() - startedAt > REGISTRY_LOCK_TIMEOUT_MS) {
|
|
240
|
+
throw new Error(`Timed out waiting for Oracle tab lease registry lock ${lockDir}; preserving the existing lock to avoid split-brain cleanup.`);
|
|
241
|
+
}
|
|
242
|
+
await delay(50, signal);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const acquiredIdentity = await readRegistryLockIdentity(lockDir);
|
|
246
|
+
const temporaryOwner = path.join(lockDir, `${lockId}.tmp`);
|
|
247
|
+
try {
|
|
248
|
+
// Publish only complete ownership evidence. A crash before rename leaves
|
|
249
|
+
// an ownerless lock, which the aged-lock recovery path can safely reclaim.
|
|
250
|
+
await writeFile(temporaryOwner, `${JSON.stringify({
|
|
251
|
+
id: lockId,
|
|
252
|
+
pid: process.pid,
|
|
253
|
+
createdAt: new Date().toISOString(),
|
|
254
|
+
...(processStartedAtMs === null ? {} : { processStartedAtMs }),
|
|
255
|
+
})}\n`, { encoding: "utf8", flag: "wx" });
|
|
256
|
+
await renameRegistryFileWithRetry(temporaryOwner, path.join(lockDir, REGISTRY_LOCK_OWNER_FILENAME));
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
await rm(temporaryOwner, { force: true }).catch(() => undefined);
|
|
260
|
+
await removeRegistryLockIfOwned(lockDir, lockId).catch(() => undefined);
|
|
261
|
+
if (acquiredIdentity &&
|
|
262
|
+
sameRegistryLockDirectory(await readRegistryLockIdentity(lockDir), acquiredIdentity)) {
|
|
263
|
+
// Only an unchanged empty directory can be removed without an owner.
|
|
264
|
+
await rmdir(lockDir).catch(() => undefined);
|
|
265
|
+
}
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
return await callback();
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
await removeRegistryLockIfOwned(lockDir, lockId, { requireOwned: true });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async function readRegistryLockOwner(lockDir, readFileImpl = readFile) {
|
|
278
|
+
try {
|
|
279
|
+
const raw = await readFileImpl(path.join(lockDir, REGISTRY_LOCK_OWNER_FILENAME), "utf8");
|
|
280
|
+
const parsed = JSON.parse(raw);
|
|
281
|
+
if (typeof parsed.id !== "string" ||
|
|
282
|
+
typeof parsed.pid !== "number" ||
|
|
283
|
+
!Number.isSafeInteger(parsed.pid) ||
|
|
284
|
+
parsed.pid <= 0 ||
|
|
285
|
+
(parsed.processStartedAtMs !== undefined &&
|
|
286
|
+
(typeof parsed.processStartedAtMs !== "number" ||
|
|
287
|
+
!Number.isFinite(parsed.processStartedAtMs)))) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
id: parsed.id,
|
|
292
|
+
pid: parsed.pid,
|
|
293
|
+
...(typeof parsed.createdAt === "string" ? { createdAt: parsed.createdAt } : {}),
|
|
294
|
+
...(typeof parsed.processStartedAtMs === "number"
|
|
295
|
+
? { processStartedAtMs: parsed.processStartedAtMs }
|
|
296
|
+
: {}),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async function isRegistryLockOwnerAlive(owner, deps = { isProcessAlive, readProcessStartTimeMs }) {
|
|
304
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
305
|
+
const alive = deps.isProcessAlive(owner.pid);
|
|
306
|
+
const actualStartedAtMs = await deps.readProcessStartTimeMs(owner.pid);
|
|
307
|
+
// A positive independent identity probe outranks a transient native PID miss.
|
|
308
|
+
if (actualStartedAtMs !== null) {
|
|
309
|
+
if (owner.processStartedAtMs !== undefined) {
|
|
310
|
+
return (Math.abs(actualStartedAtMs - owner.processStartedAtMs) <= PROCESS_START_TIME_TOLERANCE_MS);
|
|
311
|
+
}
|
|
312
|
+
const createdAt = owner.createdAt ? Date.parse(owner.createdAt) : Number.NaN;
|
|
313
|
+
return (!Number.isFinite(createdAt) ||
|
|
314
|
+
actualStartedAtMs <= createdAt + PROCESS_START_TIME_TOLERANCE_MS);
|
|
315
|
+
}
|
|
316
|
+
if (alive)
|
|
317
|
+
return true;
|
|
318
|
+
if (attempt === 0)
|
|
319
|
+
await delay(REGISTRY_RECOVERY_POLL_MS);
|
|
320
|
+
}
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
async function isRegistryOwnerFileMissing(lockDir) {
|
|
324
|
+
try {
|
|
325
|
+
await lstat(path.join(lockDir, REGISTRY_LOCK_OWNER_FILENAME));
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
return error.code === "ENOENT";
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async function readRegistryLockIdentity(lockDir) {
|
|
333
|
+
try {
|
|
334
|
+
const value = await stat(lockDir);
|
|
335
|
+
return {
|
|
336
|
+
dev: value.dev,
|
|
337
|
+
ino: value.ino,
|
|
338
|
+
birthtimeMs: value.birthtimeMs,
|
|
339
|
+
mtimeMs: value.mtimeMs,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function sameRegistryLockIdentity(left, right) {
|
|
347
|
+
return sameRegistryLockDirectory(left, right) && left?.mtimeMs === right.mtimeMs;
|
|
348
|
+
}
|
|
349
|
+
function sameRegistryLockDirectory(left, right) {
|
|
350
|
+
return (left !== null &&
|
|
351
|
+
left.dev === right.dev &&
|
|
352
|
+
left.ino === right.ino &&
|
|
353
|
+
left.birthtimeMs === right.birthtimeMs);
|
|
354
|
+
}
|
|
355
|
+
async function removeRegistryLockIfOwned(lockDir, lockId, options = {}) {
|
|
356
|
+
const owner = await readRegistryLockOwnerWithRetry(lockDir, options.readFileImpl);
|
|
357
|
+
const lockIdentity = owner ? null : await readRegistryLockIdentity(lockDir);
|
|
358
|
+
if (!owner) {
|
|
359
|
+
if (lockIdentity && options.requireOwned) {
|
|
360
|
+
throw new Error(`Unable to verify ownership while releasing Oracle tab lease registry lock ${lockDir}.`);
|
|
361
|
+
}
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
if (owner?.id !== lockId) {
|
|
365
|
+
if (options.requireOwned) {
|
|
366
|
+
throw new Error(`Oracle tab lease registry lock ${lockDir} changed owners before release; preserving it.`);
|
|
367
|
+
}
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
await removeRegistryLockDirectoryWithRetry(lockDir, options.removeImpl);
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
async function readRegistryLockOwnerWithRetry(lockDir, readFileImpl = readFile) {
|
|
374
|
+
for (let attempt = 0;; attempt += 1) {
|
|
375
|
+
const owner = await readRegistryLockOwner(lockDir, readFileImpl);
|
|
376
|
+
if (owner)
|
|
377
|
+
return owner;
|
|
378
|
+
const identity = await readRegistryLockIdentity(lockDir);
|
|
379
|
+
if (!identity || attempt + 1 >= REGISTRY_RENAME_RETRY_ATTEMPTS)
|
|
380
|
+
return null;
|
|
381
|
+
await delay(25 * (attempt + 1));
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
async function removeRegistryLockDirectoryWithRetry(lockDir, removeImpl = rm) {
|
|
385
|
+
for (let attempt = 0;; attempt += 1) {
|
|
386
|
+
try {
|
|
387
|
+
await removeImpl(lockDir, { recursive: true, force: true });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
const code = error.code;
|
|
392
|
+
const transient = code === "EPERM" || code === "EBUSY" || code === "EACCES";
|
|
393
|
+
if (!transient || attempt + 1 >= REGISTRY_RENAME_RETRY_ATTEMPTS)
|
|
394
|
+
throw error;
|
|
395
|
+
await delay(50 * (attempt + 1));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
export async function removeRegistryLockIfOwnedForTest(lockDir, lockId, options = {}) {
|
|
400
|
+
return removeRegistryLockIfOwned(lockDir, lockId, { ...options, requireOwned: true });
|
|
401
|
+
}
|
|
402
|
+
async function recoverDeadRegistryLock(profileDir, lockDir, observedOwner) {
|
|
403
|
+
await withRegistryRecoveryLock(profileDir, async () => {
|
|
404
|
+
// Re-read after acquiring the crash-safe recovery mutex. Another process may
|
|
405
|
+
// already have reaped the dead lock and installed a live owner.
|
|
406
|
+
const currentOwner = await readRegistryLockOwner(lockDir);
|
|
407
|
+
if (currentOwner?.id !== observedOwner.id ||
|
|
408
|
+
currentOwner.pid !== observedOwner.pid ||
|
|
409
|
+
currentOwner.createdAt !== observedOwner.createdAt ||
|
|
410
|
+
currentOwner.processStartedAtMs !== observedOwner.processStartedAtMs ||
|
|
411
|
+
(await isRegistryLockOwnerAlive(currentOwner))) {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
await removeRegistryLockIfOwned(lockDir, currentOwner.id);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
async function recoverOwnerlessRegistryLock(profileDir, lockDir, observedIdentity) {
|
|
418
|
+
await withRegistryRecoveryLock(profileDir, async () => {
|
|
419
|
+
if ((await readRegistryLockOwner(lockDir)) || !(await isRegistryOwnerFileMissing(lockDir)))
|
|
420
|
+
return;
|
|
421
|
+
const currentIdentity = await readRegistryLockIdentity(lockDir);
|
|
422
|
+
if (!sameRegistryLockIdentity(currentIdentity, observedIdentity) ||
|
|
423
|
+
Date.now() - observedIdentity.mtimeMs < LEGACY_OWNERLESS_LOCK_STALE_MS) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
async function withRegistryRecoveryLock(profileDir, callback) {
|
|
430
|
+
const identity = await canonicalProfileIdentity(profileDir);
|
|
431
|
+
const digest = createHash("sha256").update(identity).digest("hex").slice(0, 24);
|
|
432
|
+
const windowsEndpoint = `\\\\.\\pipe\\oracle-tab-lease-recovery-${digest}`;
|
|
433
|
+
const posixPort = 20_000 + (Number.parseInt(digest.slice(0, 8), 16) % 40_000);
|
|
434
|
+
const endpoint = process.platform === "win32" ? windowsEndpoint : `127.0.0.1:${posixPort}`;
|
|
435
|
+
const startedAt = Date.now();
|
|
436
|
+
let server = null;
|
|
437
|
+
for (;;) {
|
|
438
|
+
const candidate = net.createServer((socket) => socket.destroy());
|
|
439
|
+
try {
|
|
440
|
+
await new Promise((resolve, reject) => {
|
|
441
|
+
candidate.once("error", reject);
|
|
442
|
+
const listenOptions = process.platform === "win32"
|
|
443
|
+
? windowsEndpoint
|
|
444
|
+
: { host: "127.0.0.1", port: posixPort, exclusive: true };
|
|
445
|
+
candidate.listen(listenOptions, () => {
|
|
446
|
+
candidate.removeListener("error", reject);
|
|
447
|
+
resolve();
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
server = candidate;
|
|
125
451
|
break;
|
|
126
452
|
}
|
|
127
453
|
catch (error) {
|
|
128
|
-
|
|
454
|
+
candidate.close();
|
|
455
|
+
if (error.code !== "EADDRINUSE") {
|
|
129
456
|
throw error;
|
|
130
457
|
}
|
|
131
458
|
if (Date.now() - startedAt > REGISTRY_LOCK_TIMEOUT_MS) {
|
|
132
|
-
|
|
133
|
-
continue;
|
|
459
|
+
throw new Error(`Timed out waiting for Oracle tab lease recovery mutex ${endpoint}.`);
|
|
134
460
|
}
|
|
135
|
-
await delay(
|
|
461
|
+
await delay(REGISTRY_RECOVERY_POLL_MS);
|
|
136
462
|
}
|
|
137
463
|
}
|
|
138
464
|
try {
|
|
139
465
|
return await callback();
|
|
140
466
|
}
|
|
141
467
|
finally {
|
|
142
|
-
|
|
468
|
+
if (server) {
|
|
469
|
+
await new Promise((resolve) => server?.close(() => resolve()));
|
|
470
|
+
}
|
|
143
471
|
}
|
|
144
472
|
}
|
|
473
|
+
async function canonicalProfileIdentity(profileDir) {
|
|
474
|
+
let resolved = path.resolve(profileDir);
|
|
475
|
+
try {
|
|
476
|
+
resolved = await realpath(resolved);
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
// The profile normally exists before lease acquisition. Keep an absolute,
|
|
480
|
+
// normalized fallback so an introspection failure remains fail-closed.
|
|
481
|
+
}
|
|
482
|
+
resolved = path.normalize(resolved);
|
|
483
|
+
if (process.platform === "win32") {
|
|
484
|
+
if (/^\\\\\?\\UNC\\/iu.test(resolved)) {
|
|
485
|
+
resolved = `\\\\${resolved.slice(8)}`;
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
resolved = resolved.replace(/^\\\\\?\\/u, "");
|
|
489
|
+
}
|
|
490
|
+
resolved = resolved.toLowerCase();
|
|
491
|
+
}
|
|
492
|
+
const root = path.parse(resolved).root;
|
|
493
|
+
while (resolved.length > root.length && /[\\/]$/u.test(resolved)) {
|
|
494
|
+
resolved = resolved.slice(0, -1);
|
|
495
|
+
}
|
|
496
|
+
return resolved;
|
|
497
|
+
}
|
|
498
|
+
export async function canonicalProfileIdentityForTest(profileDir) {
|
|
499
|
+
return canonicalProfileIdentity(profileDir);
|
|
500
|
+
}
|
|
145
501
|
async function readRegistry(profileDir) {
|
|
146
502
|
try {
|
|
147
503
|
const raw = await readFile(registryPath(profileDir), "utf8");
|
|
148
504
|
const parsed = JSON.parse(raw);
|
|
149
505
|
if (!Array.isArray(parsed.leases)) {
|
|
150
|
-
|
|
506
|
+
throw new Error("Oracle tab lease registry is missing its leases array.");
|
|
507
|
+
}
|
|
508
|
+
if (!parsed.leases.every(isLeaseRecord)) {
|
|
509
|
+
throw new Error("Oracle tab lease registry contains an invalid lease record.");
|
|
151
510
|
}
|
|
152
511
|
return {
|
|
153
512
|
version: 1,
|
|
154
|
-
leases: parsed.leases
|
|
513
|
+
leases: parsed.leases,
|
|
155
514
|
};
|
|
156
515
|
}
|
|
157
|
-
catch {
|
|
158
|
-
|
|
516
|
+
catch (error) {
|
|
517
|
+
if (error.code === "ENOENT") {
|
|
518
|
+
return { version: 1, leases: [] };
|
|
519
|
+
}
|
|
520
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
521
|
+
throw new Error(`Unable to read Oracle tab lease registry: ${message}`, { cause: error });
|
|
159
522
|
}
|
|
160
523
|
}
|
|
161
524
|
async function writeRegistry(profileDir, registry) {
|
|
162
525
|
await mkdir(profileDir, { recursive: true });
|
|
163
|
-
|
|
526
|
+
const destination = registryPath(profileDir);
|
|
527
|
+
const temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`;
|
|
528
|
+
try {
|
|
529
|
+
await writeFile(temporary, `${JSON.stringify(registry, null, 2)}\n`, "utf8");
|
|
530
|
+
await renameRegistryFileWithRetry(temporary, destination);
|
|
531
|
+
}
|
|
532
|
+
finally {
|
|
533
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
async function renameRegistryFileWithRetry(source, destination) {
|
|
537
|
+
for (let attempt = 0;; attempt += 1) {
|
|
538
|
+
try {
|
|
539
|
+
await rename(source, destination);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
catch (error) {
|
|
543
|
+
const code = error.code;
|
|
544
|
+
const transient = code === "EPERM" || code === "EBUSY" || code === "EACCES";
|
|
545
|
+
if (!transient || attempt + 1 >= REGISTRY_RENAME_RETRY_ATTEMPTS)
|
|
546
|
+
throw error;
|
|
547
|
+
await delay(25 * (attempt + 1));
|
|
548
|
+
}
|
|
549
|
+
}
|
|
164
550
|
}
|
|
165
551
|
function registryPath(profileDir) {
|
|
166
552
|
return path.join(profileDir, REGISTRY_FILENAME);
|
|
167
553
|
}
|
|
168
|
-
function pruneStaleLeases(leases, options) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return false;
|
|
172
|
-
}
|
|
554
|
+
async function pruneStaleLeases(leases, options) {
|
|
555
|
+
const active = [];
|
|
556
|
+
for (const lease of leases) {
|
|
173
557
|
const updatedAt = Date.parse(lease.updatedAt);
|
|
174
|
-
|
|
175
|
-
|
|
558
|
+
// One cross-process liveness probe is not trustworthy enough to delete a
|
|
559
|
+
// fresh foreign lease. Active controllers refresh updatedAt every 15s; only
|
|
560
|
+
// a missed heartbeat beyond the grace period permits identity verification.
|
|
561
|
+
// Invalid timestamps fail closed.
|
|
562
|
+
if (!Number.isFinite(updatedAt) || options.nowMs - updatedAt <= options.staleMs) {
|
|
563
|
+
active.push(lease);
|
|
564
|
+
continue;
|
|
176
565
|
}
|
|
177
|
-
|
|
178
|
-
|
|
566
|
+
if (await isRegistryLockOwnerAlive(lease, options))
|
|
567
|
+
active.push(lease);
|
|
568
|
+
}
|
|
569
|
+
return active;
|
|
179
570
|
}
|
|
180
571
|
function isLeaseRecord(value) {
|
|
181
572
|
if (!value || typeof value !== "object")
|
|
@@ -183,6 +574,11 @@ function isLeaseRecord(value) {
|
|
|
183
574
|
const record = value;
|
|
184
575
|
return (typeof record.id === "string" &&
|
|
185
576
|
typeof record.pid === "number" &&
|
|
577
|
+
Number.isSafeInteger(record.pid) &&
|
|
578
|
+
record.pid > 0 &&
|
|
579
|
+
(record.processStartedAtMs === undefined ||
|
|
580
|
+
(typeof record.processStartedAtMs === "number" &&
|
|
581
|
+
Number.isFinite(record.processStartedAtMs))) &&
|
|
186
582
|
typeof record.createdAt === "string" &&
|
|
187
583
|
typeof record.updatedAt === "string");
|
|
188
584
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { STOP_BUTTON_SELECTORS } from "./constants.js";
|
|
3
|
+
export function normalizeChromeHost(host) {
|
|
4
|
+
const value = host
|
|
5
|
+
.trim()
|
|
6
|
+
.toLowerCase()
|
|
7
|
+
.replace(/^\[|\]$/g, "");
|
|
8
|
+
return value === "localhost" || value === "::1" || (isIP(value) === 4 && value.startsWith("127."))
|
|
9
|
+
? "loopback"
|
|
10
|
+
: value;
|
|
11
|
+
}
|
|
12
|
+
export function buildTargetClaimExpression(claimId) {
|
|
13
|
+
return `(() => {
|
|
14
|
+
if (window !== window.top) return true;
|
|
15
|
+
try {
|
|
16
|
+
const claim = JSON.parse(sessionStorage.getItem('oracle:target-claim') || 'null');
|
|
17
|
+
if (claim?.retiring) return false;
|
|
18
|
+
sessionStorage.setItem('oracle:target-claim', JSON.stringify({ id: ${JSON.stringify(claimId)}, retiring: false }));
|
|
19
|
+
return true;
|
|
20
|
+
} catch { return false; }
|
|
21
|
+
})()`;
|
|
22
|
+
}
|
|
23
|
+
export function buildTargetRetirementExpression(claimId, conversationId, reservationId) {
|
|
24
|
+
return `(() => {
|
|
25
|
+
let claim;
|
|
26
|
+
try { claim = JSON.parse(sessionStorage.getItem('oracle:target-claim') || 'null'); } catch { return false; }
|
|
27
|
+
if (!claim || claim.id !== ${JSON.stringify(claimId)} || claim.retiring) return false;
|
|
28
|
+
const id = location.pathname.match(/\\/c\\/([^/]+)/)?.[1];
|
|
29
|
+
if (id !== ${JSON.stringify(conversationId)}) return false;
|
|
30
|
+
const generating = Array.from(document.querySelectorAll(${JSON.stringify(STOP_BUTTON_SELECTORS.join(","))})).some(node => node.getBoundingClientRect().width > 0 && node.getBoundingClientRect().height > 0);
|
|
31
|
+
if (generating) return false;
|
|
32
|
+
claim.retiring = true;
|
|
33
|
+
claim.retirementId = ${JSON.stringify(reservationId)};
|
|
34
|
+
try { sessionStorage.setItem('oracle:target-claim', JSON.stringify(claim)); } catch { return false; }
|
|
35
|
+
return true;
|
|
36
|
+
})()`;
|
|
37
|
+
}
|
|
38
|
+
export function buildTargetRetirementRollbackExpression(claimId, reservationId) {
|
|
39
|
+
return `(() => { try {
|
|
40
|
+
const claim = JSON.parse(sessionStorage.getItem('oracle:target-claim') || 'null');
|
|
41
|
+
if (claim?.id === ${JSON.stringify(claimId)} && claim.retirementId === ${JSON.stringify(reservationId)}) {
|
|
42
|
+
claim.retiring = false;
|
|
43
|
+
delete claim.retirementId;
|
|
44
|
+
sessionStorage.setItem('oracle:target-claim', JSON.stringify(claim));
|
|
45
|
+
}
|
|
46
|
+
} catch {} })()`;
|
|
47
|
+
}
|
|
48
|
+
/** Per-tab storage survives document replacement and serializes acquisition with retirement. */
|
|
49
|
+
export async function claimBrowserTarget(runtime, claimId) {
|
|
50
|
+
const source = buildTargetClaimExpression(claimId);
|
|
51
|
+
const claim = await runtime.evaluate({ expression: source, returnByValue: true });
|
|
52
|
+
if (claim.exceptionDetails || claim.result?.value !== true)
|
|
53
|
+
throw new Error("This Oracle tab is being retired after recovery; choose another browser tab.");
|
|
54
|
+
}
|