@steipete/oracle 0.18.0 → 0.19.0

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