@steipete/oracle 0.17.3 → 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 (161) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/oracle-cli.js +75 -64
  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 +267 -15
  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 +15 -3
  26. package/dist/src/browser/detect.js +1 -1
  27. package/dist/src/browser/index.js +447 -263
  28. package/dist/src/browser/modelDisplay.js +9 -0
  29. package/dist/src/browser/pageActions.js +2 -2
  30. package/dist/src/browser/policies.js +6 -2
  31. package/dist/src/browser/profileState.js +114 -9
  32. package/dist/src/browser/projectSourcesRunner.js +88 -17
  33. package/dist/src/browser/prompt.js +272 -113
  34. package/dist/src/browser/providers/chatgptDomProvider.js +2 -0
  35. package/dist/src/browser/reattach.js +14 -4
  36. package/dist/src/browser/sessionRunner.js +71 -11
  37. package/dist/src/browser/tabLeaseRegistry.js +414 -38
  38. package/dist/src/browser/utils.js +20 -2
  39. package/dist/src/cli/browserConfig.js +40 -15
  40. package/dist/src/cli/browserDefaults.js +13 -0
  41. package/dist/src/cli/browserTabs.js +12 -29
  42. package/dist/src/cli/detachedSession.js +69 -0
  43. package/dist/src/cli/dryRun.js +58 -47
  44. package/dist/src/cli/harvestIntegrity.js +140 -0
  45. package/dist/src/cli/projectSources.js +5 -3
  46. package/dist/src/cli/serveBrowserConfig.js +24 -0
  47. package/dist/src/cli/sessionDisplay.js +9 -2
  48. package/dist/src/cli/sessionRunner.js +91 -7
  49. package/dist/src/config.js +1 -0
  50. package/dist/src/gemini-web/client.js +67 -21
  51. package/dist/src/gemini-web/executor.js +4 -0
  52. package/dist/src/gemini-web/http.js +15 -0
  53. package/dist/src/gemini-web/models.js +15 -11
  54. package/dist/src/mcp/server.js +11 -14
  55. package/dist/src/mcp/tools/chatgptImage.js +4 -4
  56. package/dist/src/mcp/tools/consult.js +97 -26
  57. package/dist/src/mcp/tools/projectSources.js +4 -6
  58. package/dist/src/mcp/tools/sessionResources.js +1 -1
  59. package/dist/src/mcp/tools/sessions.js +2 -2
  60. package/dist/src/mcp/tools/wait.js +182 -0
  61. package/dist/src/mcp/types.js +7 -0
  62. package/dist/src/oracle/errors.js +11 -0
  63. package/dist/src/oracle/providerRoutePlan.js +3 -2
  64. package/dist/src/remote/client.js +70 -6
  65. package/dist/src/remote/health.js +6 -2
  66. package/dist/src/remote/runSlots.js +83 -0
  67. package/dist/src/remote/server.js +351 -72
  68. package/dist/src/sessionManager.js +36 -1
  69. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  70. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  71. package/package.json +25 -23
  72. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  73. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  74. package/dist/bin/oracle.js +0 -683
  75. package/dist/docs-site/.nojekyll +0 -0
  76. package/dist/docs-site/CNAME +0 -1
  77. package/dist/docs-site/RELEASING.html +0 -410
  78. package/dist/docs-site/agents.html +0 -374
  79. package/dist/docs-site/anthropic.html +0 -368
  80. package/dist/docs-site/bridge.html +0 -400
  81. package/dist/docs-site/browser-mode.html +0 -588
  82. package/dist/docs-site/chromium-forks.html +0 -347
  83. package/dist/docs-site/cli-reference.html +0 -346
  84. package/dist/docs-site/configuration.html +0 -436
  85. package/dist/docs-site/favicon.svg +0 -14
  86. package/dist/docs-site/followup.html +0 -368
  87. package/dist/docs-site/gemini.html +0 -379
  88. package/dist/docs-site/grok.html +0 -325
  89. package/dist/docs-site/index.html +0 -360
  90. package/dist/docs-site/install.html +0 -335
  91. package/dist/docs-site/linux.html +0 -321
  92. package/dist/docs-site/llms.txt +0 -43
  93. package/dist/docs-site/manual-tests.html +0 -595
  94. package/dist/docs-site/mcp.html +0 -368
  95. package/dist/docs-site/multimodel.html +0 -364
  96. package/dist/docs-site/mythical-pro-agents.html +0 -360
  97. package/dist/docs-site/notifier.html +0 -338
  98. package/dist/docs-site/openai-endpoints.html +0 -387
  99. package/dist/docs-site/openrouter.html +0 -344
  100. package/dist/docs-site/quickstart.html +0 -369
  101. package/dist/docs-site/refactor/ux.html +0 -532
  102. package/dist/docs-site/sessions.html +0 -388
  103. package/dist/docs-site/social-card.png +0 -0
  104. package/dist/docs-site/social-card.svg +0 -79
  105. package/dist/docs-site/spec.html +0 -363
  106. package/dist/docs-site/testing.html +0 -320
  107. package/dist/docs-site/tui-debug.html +0 -326
  108. package/dist/docs-site/windows-work.html +0 -323
  109. package/dist/docs-site/windows.html +0 -320
  110. package/dist/markdansi/types/index.js +0 -4
  111. package/dist/oracle/bin/oracle-cli.js +0 -472
  112. package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
  113. package/dist/oracle/src/browser/actions/attachments.js +0 -82
  114. package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
  115. package/dist/oracle/src/browser/actions/navigation.js +0 -75
  116. package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
  117. package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
  118. package/dist/oracle/src/browser/config.js +0 -33
  119. package/dist/oracle/src/browser/constants.js +0 -40
  120. package/dist/oracle/src/browser/cookies.js +0 -210
  121. package/dist/oracle/src/browser/domDebug.js +0 -36
  122. package/dist/oracle/src/browser/index.js +0 -331
  123. package/dist/oracle/src/browser/pageActions.js +0 -5
  124. package/dist/oracle/src/browser/prompt.js +0 -88
  125. package/dist/oracle/src/browser/promptSummary.js +0 -20
  126. package/dist/oracle/src/browser/sessionRunner.js +0 -80
  127. package/dist/oracle/src/browser/types.js +0 -1
  128. package/dist/oracle/src/browser/utils.js +0 -62
  129. package/dist/oracle/src/browserMode.js +0 -1
  130. package/dist/oracle/src/cli/browserConfig.js +0 -44
  131. package/dist/oracle/src/cli/dryRun.js +0 -59
  132. package/dist/oracle/src/cli/engine.js +0 -17
  133. package/dist/oracle/src/cli/errorUtils.js +0 -9
  134. package/dist/oracle/src/cli/help.js +0 -70
  135. package/dist/oracle/src/cli/markdownRenderer.js +0 -15
  136. package/dist/oracle/src/cli/options.js +0 -103
  137. package/dist/oracle/src/cli/promptRequirement.js +0 -14
  138. package/dist/oracle/src/cli/rootAlias.js +0 -30
  139. package/dist/oracle/src/cli/sessionCommand.js +0 -77
  140. package/dist/oracle/src/cli/sessionDisplay.js +0 -270
  141. package/dist/oracle/src/cli/sessionRunner.js +0 -94
  142. package/dist/oracle/src/heartbeat.js +0 -43
  143. package/dist/oracle/src/oracle/client.js +0 -48
  144. package/dist/oracle/src/oracle/config.js +0 -29
  145. package/dist/oracle/src/oracle/errors.js +0 -101
  146. package/dist/oracle/src/oracle/files.js +0 -220
  147. package/dist/oracle/src/oracle/format.js +0 -33
  148. package/dist/oracle/src/oracle/fsAdapter.js +0 -7
  149. package/dist/oracle/src/oracle/oscProgress.js +0 -60
  150. package/dist/oracle/src/oracle/request.js +0 -48
  151. package/dist/oracle/src/oracle/run.js +0 -444
  152. package/dist/oracle/src/oracle/tokenStats.js +0 -39
  153. package/dist/oracle/src/oracle/types.js +0 -1
  154. package/dist/oracle/src/oracle.js +0 -9
  155. package/dist/oracle/src/sessionManager.js +0 -205
  156. package/dist/oracle/src/version.js +0 -39
  157. package/dist/scripts/chrome/browser-tools.js +0 -295
  158. package/dist/src/browser/chromeCookies.js +0 -312
  159. package/dist/src/browser/keytarShim.js +0 -56
  160. package/dist/src/browser/profileSync.js +0 -141
  161. 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
  }