@steipete/oracle 0.9.0 → 0.11.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 (194) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -49
  3. package/dist/bin/oracle-cli.js +551 -410
  4. package/dist/bin/oracle-mcp.js +2 -2
  5. package/dist/bin/oracle.js +165 -279
  6. package/dist/scripts/agent-send.js +31 -31
  7. package/dist/scripts/check.js +6 -6
  8. package/dist/scripts/debug/extract-chatgpt-response.js +10 -10
  9. package/dist/scripts/docs-list.js +30 -30
  10. package/dist/scripts/git-policy.js +25 -23
  11. package/dist/scripts/run-cli.js +8 -8
  12. package/dist/scripts/runner.js +203 -195
  13. package/dist/scripts/test-browser.js +21 -18
  14. package/dist/scripts/test-remote-chrome.js +20 -20
  15. package/dist/src/bridge/connection.js +18 -18
  16. package/dist/src/bridge/userConfigFile.js +7 -7
  17. package/dist/src/browser/actions/archiveConversation.js +224 -0
  18. package/dist/src/browser/actions/assistantResponse.js +175 -101
  19. package/dist/src/browser/actions/attachmentDataTransfer.js +49 -47
  20. package/dist/src/browser/actions/attachments.js +246 -150
  21. package/dist/src/browser/actions/deepResearch.js +662 -0
  22. package/dist/src/browser/actions/domEvents.js +2 -2
  23. package/dist/src/browser/actions/modelSelection.js +342 -119
  24. package/dist/src/browser/actions/navigation.js +183 -137
  25. package/dist/src/browser/actions/projectSources.js +491 -0
  26. package/dist/src/browser/actions/promptComposer.js +152 -91
  27. package/dist/src/browser/actions/remoteFileTransfer.js +10 -10
  28. package/dist/src/browser/actions/thinkingStatus.js +391 -0
  29. package/dist/src/browser/actions/thinkingTime.js +207 -110
  30. package/dist/src/browser/artifacts.js +150 -0
  31. package/dist/src/browser/attachRunning.js +31 -0
  32. package/dist/src/browser/chatgptImages.js +315 -0
  33. package/dist/src/browser/chromeLifecycle.js +276 -63
  34. package/dist/src/browser/config.js +59 -16
  35. package/dist/src/browser/constants.js +25 -12
  36. package/dist/src/browser/controlPlan.js +81 -0
  37. package/dist/src/browser/cookies.js +19 -19
  38. package/dist/src/browser/detect.js +250 -77
  39. package/dist/src/browser/domDebug.js +50 -1
  40. package/dist/src/browser/index.js +1559 -692
  41. package/dist/src/browser/liveTabs.js +434 -0
  42. package/dist/src/browser/modelStrategy.js +1 -1
  43. package/dist/src/browser/pageActions.js +5 -5
  44. package/dist/src/browser/policies.js +16 -13
  45. package/dist/src/browser/profileState.js +127 -42
  46. package/dist/src/browser/projectSourcesRunner.js +366 -0
  47. package/dist/src/browser/prompt.js +72 -42
  48. package/dist/src/browser/promptSummary.js +5 -5
  49. package/dist/src/browser/providerDomFlow.js +1 -1
  50. package/dist/src/browser/providers/chatgptDomProvider.js +9 -9
  51. package/dist/src/browser/providers/geminiDeepThinkDomProvider.js +51 -42
  52. package/dist/src/browser/providers/index.js +2 -2
  53. package/dist/src/browser/reattach.js +178 -73
  54. package/dist/src/browser/reattachHelpers.js +32 -27
  55. package/dist/src/browser/sessionRunner.js +89 -25
  56. package/dist/src/browser/tabLeaseRegistry.js +182 -0
  57. package/dist/src/browser/utils.js +9 -9
  58. package/dist/src/browserMode.js +1 -1
  59. package/dist/src/cli/bridge/claudeConfig.js +24 -20
  60. package/dist/src/cli/bridge/client.js +28 -20
  61. package/dist/src/cli/bridge/codexConfig.js +16 -16
  62. package/dist/src/cli/bridge/doctor.js +47 -39
  63. package/dist/src/cli/bridge/host.js +58 -56
  64. package/dist/src/cli/browserConfig.js +102 -48
  65. package/dist/src/cli/browserDefaults.js +51 -26
  66. package/dist/src/cli/browserTabs.js +228 -0
  67. package/dist/src/cli/bundleWarnings.js +1 -1
  68. package/dist/src/cli/clipboard.js +11 -2
  69. package/dist/src/cli/detach.js +2 -2
  70. package/dist/src/cli/dryRun.js +62 -26
  71. package/dist/src/cli/duplicatePromptGuard.js +12 -4
  72. package/dist/src/cli/engine.js +9 -9
  73. package/dist/src/cli/errorUtils.js +1 -1
  74. package/dist/src/cli/fileSize.js +3 -3
  75. package/dist/src/cli/format.js +2 -2
  76. package/dist/src/cli/help.js +28 -28
  77. package/dist/src/cli/hiddenAliases.js +3 -3
  78. package/dist/src/cli/markdownBundle.js +7 -7
  79. package/dist/src/cli/markdownRenderer.js +15 -15
  80. package/dist/src/cli/notifier.js +77 -67
  81. package/dist/src/cli/options.js +131 -106
  82. package/dist/src/cli/oscUtils.js +1 -1
  83. package/dist/src/cli/projectSources.js +116 -0
  84. package/dist/src/cli/promptRequirement.js +2 -2
  85. package/dist/src/cli/renderOutput.js +1 -1
  86. package/dist/src/cli/rootAlias.js +1 -1
  87. package/dist/src/cli/runOptions.js +32 -28
  88. package/dist/src/cli/sessionCommand.js +82 -21
  89. package/dist/src/cli/sessionDisplay.js +213 -87
  90. package/dist/src/cli/sessionLineage.js +6 -2
  91. package/dist/src/cli/sessionRunner.js +149 -95
  92. package/dist/src/cli/sessionTable.js +26 -23
  93. package/dist/src/cli/stdin.js +22 -0
  94. package/dist/src/cli/tagline.js +121 -124
  95. package/dist/src/cli/tui/index.js +139 -128
  96. package/dist/src/cli/writeOutputPath.js +5 -5
  97. package/dist/src/config.js +7 -7
  98. package/dist/src/gemini-web/browserSessionManager.js +19 -15
  99. package/dist/src/gemini-web/client.js +76 -70
  100. package/dist/src/gemini-web/executionMode.js +6 -8
  101. package/dist/src/gemini-web/executor.js +98 -93
  102. package/dist/src/gemini-web/index.js +1 -1
  103. package/dist/src/mcp/consultPresets.js +19 -0
  104. package/dist/src/mcp/server.js +18 -12
  105. package/dist/src/mcp/tools/consult.js +246 -67
  106. package/dist/src/mcp/tools/projectSources.js +123 -0
  107. package/dist/src/mcp/tools/sessionResources.js +12 -12
  108. package/dist/src/mcp/tools/sessions.js +26 -17
  109. package/dist/src/mcp/types.js +12 -5
  110. package/dist/src/mcp/utils.js +21 -8
  111. package/dist/src/oracle/background.js +15 -15
  112. package/dist/src/oracle/claude.js +53 -25
  113. package/dist/src/oracle/client.js +50 -41
  114. package/dist/src/oracle/config.js +96 -66
  115. package/dist/src/oracle/errors.js +38 -38
  116. package/dist/src/oracle/files.js +55 -46
  117. package/dist/src/oracle/finishLine.js +10 -8
  118. package/dist/src/oracle/format.js +3 -3
  119. package/dist/src/oracle/gemini.js +37 -33
  120. package/dist/src/oracle/logging.js +7 -7
  121. package/dist/src/oracle/markdown.js +28 -28
  122. package/dist/src/oracle/modelResolver.js +16 -16
  123. package/dist/src/oracle/multiModelRunner.js +12 -12
  124. package/dist/src/oracle/oscProgress.js +8 -8
  125. package/dist/src/oracle/promptAssembly.js +6 -3
  126. package/dist/src/oracle/request.js +16 -13
  127. package/dist/src/oracle/run.js +160 -135
  128. package/dist/src/oracle/runUtils.js +8 -5
  129. package/dist/src/oracle/tokenEstimate.js +6 -6
  130. package/dist/src/oracle/tokenStats.js +5 -5
  131. package/dist/src/oracle/tokenStringifier.js +5 -5
  132. package/dist/src/oracle.js +12 -12
  133. package/dist/src/oracleHome.js +3 -3
  134. package/dist/src/projectSources/plan.js +27 -0
  135. package/dist/src/projectSources/url.js +23 -0
  136. package/dist/src/remote/client.js +25 -25
  137. package/dist/src/remote/health.js +20 -20
  138. package/dist/src/remote/remoteServiceConfig.js +9 -9
  139. package/dist/src/remote/server.js +129 -118
  140. package/dist/src/sessionManager.js +78 -75
  141. package/dist/src/sessionStore.js +3 -3
  142. package/dist/src/version.js +10 -10
  143. package/dist/vendor/oracle-notifier/README.md +2 -0
  144. package/package.json +67 -62
  145. package/vendor/oracle-notifier/README.md +2 -0
  146. package/dist/markdansi/types/index.js +0 -4
  147. package/dist/oracle/bin/oracle-cli.js +0 -472
  148. package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
  149. package/dist/oracle/src/browser/actions/attachments.js +0 -82
  150. package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
  151. package/dist/oracle/src/browser/actions/navigation.js +0 -75
  152. package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
  153. package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
  154. package/dist/oracle/src/browser/config.js +0 -33
  155. package/dist/oracle/src/browser/constants.js +0 -40
  156. package/dist/oracle/src/browser/cookies.js +0 -210
  157. package/dist/oracle/src/browser/domDebug.js +0 -36
  158. package/dist/oracle/src/browser/index.js +0 -331
  159. package/dist/oracle/src/browser/pageActions.js +0 -5
  160. package/dist/oracle/src/browser/prompt.js +0 -88
  161. package/dist/oracle/src/browser/promptSummary.js +0 -20
  162. package/dist/oracle/src/browser/sessionRunner.js +0 -80
  163. package/dist/oracle/src/browser/utils.js +0 -62
  164. package/dist/oracle/src/browserMode.js +0 -1
  165. package/dist/oracle/src/cli/browserConfig.js +0 -44
  166. package/dist/oracle/src/cli/dryRun.js +0 -59
  167. package/dist/oracle/src/cli/engine.js +0 -17
  168. package/dist/oracle/src/cli/errorUtils.js +0 -9
  169. package/dist/oracle/src/cli/help.js +0 -70
  170. package/dist/oracle/src/cli/markdownRenderer.js +0 -15
  171. package/dist/oracle/src/cli/options.js +0 -103
  172. package/dist/oracle/src/cli/promptRequirement.js +0 -14
  173. package/dist/oracle/src/cli/rootAlias.js +0 -30
  174. package/dist/oracle/src/cli/sessionCommand.js +0 -77
  175. package/dist/oracle/src/cli/sessionDisplay.js +0 -270
  176. package/dist/oracle/src/cli/sessionRunner.js +0 -94
  177. package/dist/oracle/src/heartbeat.js +0 -43
  178. package/dist/oracle/src/oracle/client.js +0 -48
  179. package/dist/oracle/src/oracle/config.js +0 -29
  180. package/dist/oracle/src/oracle/errors.js +0 -101
  181. package/dist/oracle/src/oracle/files.js +0 -220
  182. package/dist/oracle/src/oracle/format.js +0 -33
  183. package/dist/oracle/src/oracle/fsAdapter.js +0 -7
  184. package/dist/oracle/src/oracle/oscProgress.js +0 -60
  185. package/dist/oracle/src/oracle/request.js +0 -48
  186. package/dist/oracle/src/oracle/run.js +0 -444
  187. package/dist/oracle/src/oracle/tokenStats.js +0 -39
  188. package/dist/oracle/src/oracle/types.js +0 -1
  189. package/dist/oracle/src/oracle.js +0 -9
  190. package/dist/oracle/src/sessionManager.js +0 -205
  191. package/dist/oracle/src/version.js +0 -39
  192. package/dist/scripts/chrome/browser-tools.js +0 -295
  193. package/dist/src/browser/profileSync.js +0 -141
  194. /package/dist/{oracle/src/browser → src/projectSources}/types.js +0 -0
@@ -1,9 +1,10 @@
1
- import chalk from 'chalk';
2
- import { formatTokenCount } from '../oracle/runUtils.js';
3
- import { formatFinishLine } from '../oracle/finishLine.js';
4
- import { runBrowserMode } from '../browserMode.js';
5
- import { assembleBrowserPrompt } from './prompt.js';
6
- import { BrowserAutomationError } from '../oracle/errors.js';
1
+ import chalk from "chalk";
2
+ import { formatTokenCount } from "../oracle/runUtils.js";
3
+ import { formatFinishLine } from "../oracle/finishLine.js";
4
+ import { runBrowserMode } from "../browserMode.js";
5
+ import { assembleBrowserPrompt } from "./prompt.js";
6
+ import { BrowserAutomationError } from "../oracle/errors.js";
7
+ import { appendArtifacts, saveBrowserTranscriptArtifact, saveDeepResearchReportArtifact, } from "./artifacts.js";
7
8
  export async function runBrowserSessionExecution({ runOptions, browserConfig, cwd, log }, deps = {}) {
8
9
  const assemblePrompt = deps.assemblePrompt ?? assembleBrowserPrompt;
9
10
  const executeBrowser = deps.executeBrowser ?? runBrowserMode;
@@ -14,14 +15,18 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
14
15
  })}`));
15
16
  log(chalk.dim(`[verbose] Browser prompt length: ${promptArtifacts.composerText.length} chars`));
16
17
  if (promptArtifacts.attachments.length > 0) {
17
- const attachmentList = promptArtifacts.attachments.map((attachment) => attachment.displayPath).join(', ');
18
+ const attachmentList = promptArtifacts.attachments
19
+ .map((attachment) => attachment.displayPath)
20
+ .join(", ");
18
21
  log(chalk.dim(`[verbose] Browser attachments: ${attachmentList}`));
19
22
  if (promptArtifacts.bundled) {
20
23
  log(chalk.yellow(`[browser] Bundled ${promptArtifacts.bundled.originalCount} files into ${promptArtifacts.bundled.bundlePath}.`));
21
24
  }
22
25
  }
23
- else if (runOptions.file && runOptions.file.length > 0 && promptArtifacts.attachmentMode === 'inline') {
24
- log(chalk.dim('[verbose] Browser will paste file contents inline (no uploads).'));
26
+ else if (runOptions.file &&
27
+ runOptions.file.length > 0 &&
28
+ promptArtifacts.attachmentMode === "inline") {
29
+ log(chalk.dim("[verbose] Browser will paste file contents inline (no uploads)."));
25
30
  }
26
31
  }
27
32
  if (promptArtifacts.bundled) {
@@ -29,19 +34,20 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
29
34
  }
30
35
  const headerLine = `Launching browser mode (${runOptions.model}) with ~${promptArtifacts.estimatedInputTokens.toLocaleString()} tokens.`;
31
36
  const automationLogger = ((message) => {
32
- if (typeof message !== 'string')
37
+ if (typeof message !== "string")
33
38
  return;
34
- const shouldAlwaysPrint = message.startsWith('[browser] ') && /fallback|retry/i.test(message);
39
+ const shouldAlwaysPrint = message.startsWith("[browser] ") &&
40
+ /archive|fallback|follow-up|retry|thinking|waiting for chatgpt|browser slot|browser control|browser guidance/i.test(message);
35
41
  if (!runOptions.verbose && !shouldAlwaysPrint)
36
42
  return;
37
43
  log(message);
38
44
  });
39
45
  automationLogger.verbose = Boolean(runOptions.verbose);
40
- automationLogger.sessionLog = runOptions.verbose ? log : (() => { });
46
+ automationLogger.sessionLog = runOptions.verbose ? log : () => { };
41
47
  log(headerLine);
42
- log(chalk.dim('This run can take up to an hour (usually ~10 minutes).'));
48
+ log(chalk.dim("This run can take up to an hour (usually ~10 minutes)."));
43
49
  if (runOptions.verbose) {
44
- log(chalk.dim('Chrome automation does not stream output; this may take a minute...'));
50
+ log(chalk.dim("Chrome automation does not stream output; this may take a minute..."));
45
51
  }
46
52
  const persistRuntimeHint = deps.persistRuntimeHint ?? (() => { });
47
53
  let browserResult;
@@ -50,14 +56,24 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
50
56
  prompt: promptArtifacts.composerText,
51
57
  attachments: promptArtifacts.attachments,
52
58
  fallbackSubmission: promptArtifacts.fallback
53
- ? { prompt: promptArtifacts.fallback.composerText, attachments: promptArtifacts.fallback.attachments }
59
+ ? {
60
+ prompt: promptArtifacts.fallback.composerText,
61
+ attachments: promptArtifacts.fallback.attachments,
62
+ }
54
63
  : undefined,
55
64
  config: browserConfig,
56
65
  log: automationLogger,
57
66
  heartbeatIntervalMs: runOptions.heartbeatIntervalMs,
58
67
  verbose: runOptions.verbose,
68
+ sessionId: runOptions.sessionId,
69
+ generateImagePath: runOptions.generateImage,
70
+ outputPath: runOptions.outputPath,
71
+ followUpPrompts: runOptions.browserFollowUps,
59
72
  runtimeHintCb: async (runtime) => {
60
- await persistRuntimeHint({ ...runtime, controllerPid: runtime.controllerPid ?? process.pid });
73
+ await persistRuntimeHint({
74
+ ...runtime,
75
+ controllerPid: runtime.controllerPid ?? process.pid,
76
+ });
61
77
  },
62
78
  });
63
79
  }
@@ -65,15 +81,24 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
65
81
  if (error instanceof BrowserAutomationError) {
66
82
  throw error;
67
83
  }
68
- const message = error instanceof Error ? error.message : 'Browser automation failed.';
69
- throw new BrowserAutomationError(message, { stage: 'execute-browser' }, error);
84
+ const message = error instanceof Error ? error.message : "Browser automation failed.";
85
+ throw new BrowserAutomationError(message, { stage: "execute-browser" }, error);
70
86
  }
71
87
  if (!runOptions.silent) {
72
- log(chalk.bold('Answer:'));
73
- log(browserResult.answerMarkdown || browserResult.answerText || chalk.dim('(no text output)'));
74
- log('');
88
+ log(chalk.bold("Answer:"));
89
+ log(browserResult.answerMarkdown || browserResult.answerText || chalk.dim("(no text output)"));
90
+ log("");
75
91
  }
76
- const answerText = browserResult.answerMarkdown || browserResult.answerText || '';
92
+ const answerText = browserResult.answerMarkdown || browserResult.answerText || "";
93
+ const savedArtifacts = await ensureSessionArtifacts({
94
+ sessionId: runOptions.sessionId,
95
+ prompt: promptArtifacts.composerText,
96
+ answerMarkdown: answerText,
97
+ conversationUrl: browserResult.tabUrl,
98
+ browserConfig,
99
+ existingArtifacts: browserResult.artifacts,
100
+ logger: automationLogger,
101
+ });
77
102
  const usage = {
78
103
  inputTokens: promptArtifacts.estimatedInputTokens,
79
104
  outputTokens: browserResult.answerTokens,
@@ -87,9 +112,9 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
87
112
  usage.totalTokens,
88
113
  ]
89
114
  .map((value) => formatTokenCount(value))
90
- .join('/');
115
+ .join("/");
91
116
  const tokensPart = (() => {
92
- const parts = tokensDisplay.split('/');
117
+ const parts = tokensDisplay.split("/");
93
118
  if (parts.length !== 4)
94
119
  return tokensDisplay;
95
120
  return `↑${parts[0]} ↓${parts[1]} ↻${parts[2]} Δ${parts[3]}`;
@@ -98,7 +123,9 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
98
123
  elapsedMs: browserResult.tookMs,
99
124
  model: `${runOptions.model}[browser]`,
100
125
  tokensPart,
101
- detailParts: [runOptions.file && runOptions.file.length > 0 ? `files=${runOptions.file.length}` : null],
126
+ detailParts: [
127
+ runOptions.file && runOptions.file.length > 0 ? `files=${runOptions.file.length}` : null,
128
+ ],
102
129
  });
103
130
  log(chalk.blue(line1));
104
131
  if (line2) {
@@ -108,12 +135,49 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
108
135
  usage,
109
136
  elapsedMs: browserResult.tookMs,
110
137
  runtime: {
138
+ browserTransport: browserResult.browserTransport,
111
139
  chromePid: browserResult.chromePid,
112
140
  chromePort: browserResult.chromePort,
113
141
  chromeHost: browserResult.chromeHost,
142
+ chromeBrowserWSEndpoint: browserResult.chromeBrowserWSEndpoint,
143
+ chromeProfileRoot: browserResult.chromeProfileRoot,
114
144
  userDataDir: browserResult.userDataDir,
145
+ chromeTargetId: browserResult.chromeTargetId,
146
+ tabUrl: browserResult.tabUrl,
147
+ conversationId: browserResult.conversationId,
115
148
  controllerPid: browserResult.controllerPid ?? process.pid,
116
149
  },
150
+ archive: browserResult.archive,
117
151
  answerText,
152
+ artifacts: savedArtifacts,
118
153
  };
119
154
  }
155
+ export async function ensureSessionArtifacts(params) {
156
+ if (!params.sessionId || !params.answerMarkdown.trim()) {
157
+ return params.existingArtifacts;
158
+ }
159
+ let artifacts = params.existingArtifacts;
160
+ const hasReport = artifacts?.some((artifact) => artifact.kind === "deep-research-report");
161
+ if (params.browserConfig.researchMode === "deep" && !hasReport) {
162
+ const report = await saveDeepResearchReportArtifact({
163
+ sessionId: params.sessionId,
164
+ reportMarkdown: params.answerMarkdown,
165
+ conversationUrl: params.conversationUrl,
166
+ logger: params.logger,
167
+ }).catch(() => null);
168
+ artifacts = appendArtifacts(artifacts, [report]);
169
+ }
170
+ const hasTranscript = artifacts?.some((artifact) => artifact.kind === "transcript");
171
+ if (!hasTranscript) {
172
+ const transcript = await saveBrowserTranscriptArtifact({
173
+ sessionId: params.sessionId,
174
+ prompt: params.prompt,
175
+ answerMarkdown: params.answerMarkdown,
176
+ conversationUrl: params.conversationUrl,
177
+ artifacts,
178
+ logger: params.logger,
179
+ }).catch(() => null);
180
+ artifacts = appendArtifacts(artifacts, [transcript]);
181
+ }
182
+ return artifacts;
183
+ }
@@ -0,0 +1,182 @@
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";
5
+ import { delay } from "./utils.js";
6
+ export const DEFAULT_MAX_CONCURRENT_CHATGPT_TABS = 3;
7
+ const REGISTRY_FILENAME = "oracle-tab-leases.json";
8
+ const REGISTRY_LOCK_DIRNAME = "oracle-tab-leases.lock";
9
+ const DEFAULT_POLL_MS = 1000;
10
+ const DEFAULT_STALE_MS = 6 * 60 * 60 * 1000;
11
+ const REGISTRY_LOCK_TIMEOUT_MS = 10_000;
12
+ export function normalizeMaxConcurrentTabs(value) {
13
+ if (value === undefined || value === null) {
14
+ return DEFAULT_MAX_CONCURRENT_CHATGPT_TABS;
15
+ }
16
+ const numeric = typeof value === "string" ? Number.parseInt(value, 10) : Number(value);
17
+ if (!Number.isFinite(numeric) || numeric <= 0) {
18
+ return DEFAULT_MAX_CONCURRENT_CHATGPT_TABS;
19
+ }
20
+ return Math.max(1, Math.trunc(numeric));
21
+ }
22
+ export async function acquireBrowserTabLease(profileDir, options, deps = {}) {
23
+ const maxConcurrentTabs = normalizeMaxConcurrentTabs(options.maxConcurrentTabs);
24
+ const pollMs = Math.max(50, options.pollMs ?? DEFAULT_POLL_MS);
25
+ const timeoutMs = Math.max(0, options.timeoutMs ?? 0);
26
+ const staleMs = Math.max(60_000, options.staleMs ?? DEFAULT_STALE_MS);
27
+ const now = deps.now ?? Date.now;
28
+ const pid = deps.pid ?? process.pid;
29
+ const leaseId = randomUUID();
30
+ const startedAt = now();
31
+ let warned = false;
32
+ let lastHeartbeatAt = 0;
33
+ for (;;) {
34
+ const acquired = await withRegistryLock(profileDir, async () => {
35
+ const registry = await readRegistry(profileDir);
36
+ const active = pruneStaleLeases(registry.leases, {
37
+ nowMs: now(),
38
+ staleMs,
39
+ isProcessAlive: deps.isProcessAlive ?? isProcessAlive,
40
+ });
41
+ if (active.length >= maxConcurrentTabs) {
42
+ if (active.length !== registry.leases.length) {
43
+ await writeRegistry(profileDir, { version: 1, leases: active });
44
+ }
45
+ return null;
46
+ }
47
+ const timestamp = new Date(now()).toISOString();
48
+ const lease = {
49
+ id: leaseId,
50
+ pid,
51
+ sessionId: options.sessionId,
52
+ chromeHost: options.chromeHost,
53
+ chromePort: options.chromePort,
54
+ createdAt: timestamp,
55
+ updatedAt: timestamp,
56
+ };
57
+ await writeRegistry(profileDir, { version: 1, leases: [...active, lease] });
58
+ return lease;
59
+ });
60
+ if (acquired) {
61
+ options.logger?.(`[browser] Acquired ChatGPT browser slot ${leaseId.slice(0, 8)} (${maxConcurrentTabs} max).`);
62
+ return {
63
+ id: leaseId,
64
+ release: async () => releaseBrowserTabLease(profileDir, leaseId, options.logger),
65
+ update: async (patch) => updateBrowserTabLease(profileDir, leaseId, patch),
66
+ };
67
+ }
68
+ const elapsed = now() - startedAt;
69
+ if (!warned || now() - lastHeartbeatAt >= 30_000) {
70
+ options.logger?.(`[browser] Waiting for ChatGPT browser slot (${maxConcurrentTabs} max, ${Math.round(elapsed / 1000)}s elapsed).`);
71
+ warned = true;
72
+ lastHeartbeatAt = now();
73
+ }
74
+ if (timeoutMs > 0 && elapsed >= timeoutMs) {
75
+ throw new Error(`Timed out waiting for ChatGPT browser slot after ${Math.round(elapsed / 1000)}s (${maxConcurrentTabs} max).`);
76
+ }
77
+ await delay(timeoutMs > 0 ? Math.min(pollMs, timeoutMs - elapsed) : pollMs);
78
+ }
79
+ }
80
+ export async function updateBrowserTabLease(profileDir, leaseId, patch) {
81
+ await withRegistryLock(profileDir, async () => {
82
+ const registry = await readRegistry(profileDir);
83
+ const leases = registry.leases.map((lease) => lease.id === leaseId
84
+ ? { ...lease, ...patch, id: lease.id, updatedAt: new Date().toISOString() }
85
+ : lease);
86
+ await writeRegistry(profileDir, { version: 1, leases });
87
+ });
88
+ }
89
+ export async function releaseBrowserTabLease(profileDir, leaseId, logger) {
90
+ await withRegistryLock(profileDir, async () => {
91
+ const registry = await readRegistry(profileDir);
92
+ const leases = registry.leases.filter((lease) => lease.id !== leaseId);
93
+ await writeRegistry(profileDir, { version: 1, leases });
94
+ }).catch(() => undefined);
95
+ logger?.(`[browser] Released ChatGPT browser slot ${leaseId.slice(0, 8)}.`);
96
+ }
97
+ export async function hasOtherActiveBrowserTabLeases(profileDir, leaseId, options = {}) {
98
+ const now = options.now ?? Date.now;
99
+ const staleMs = Math.max(60_000, options.staleMs ?? DEFAULT_STALE_MS);
100
+ return withRegistryLock(profileDir, async () => {
101
+ const registry = await readRegistry(profileDir);
102
+ const active = pruneStaleLeases(registry.leases, {
103
+ nowMs: now(),
104
+ staleMs,
105
+ isProcessAlive: options.isProcessAlive ?? isProcessAlive,
106
+ });
107
+ if (active.length !== registry.leases.length) {
108
+ await writeRegistry(profileDir, { version: 1, leases: active });
109
+ }
110
+ return active.some((lease) => lease.id !== leaseId);
111
+ });
112
+ }
113
+ async function withRegistryLock(profileDir, callback) {
114
+ const lockDir = path.join(profileDir, REGISTRY_LOCK_DIRNAME);
115
+ const startedAt = Date.now();
116
+ for (;;) {
117
+ try {
118
+ await mkdir(lockDir, { recursive: false });
119
+ break;
120
+ }
121
+ catch (error) {
122
+ if (error.code !== "EEXIST") {
123
+ throw error;
124
+ }
125
+ if (Date.now() - startedAt > REGISTRY_LOCK_TIMEOUT_MS) {
126
+ await rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
127
+ continue;
128
+ }
129
+ await delay(50);
130
+ }
131
+ }
132
+ try {
133
+ return await callback();
134
+ }
135
+ finally {
136
+ await rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
137
+ }
138
+ }
139
+ async function readRegistry(profileDir) {
140
+ try {
141
+ const raw = await readFile(registryPath(profileDir), "utf8");
142
+ const parsed = JSON.parse(raw);
143
+ if (!Array.isArray(parsed.leases)) {
144
+ return { version: 1, leases: [] };
145
+ }
146
+ return {
147
+ version: 1,
148
+ leases: parsed.leases.filter(isLeaseRecord),
149
+ };
150
+ }
151
+ catch {
152
+ return { version: 1, leases: [] };
153
+ }
154
+ }
155
+ async function writeRegistry(profileDir, registry) {
156
+ await mkdir(profileDir, { recursive: true });
157
+ await writeFile(registryPath(profileDir), `${JSON.stringify(registry, null, 2)}\n`, "utf8");
158
+ }
159
+ function registryPath(profileDir) {
160
+ return path.join(profileDir, REGISTRY_FILENAME);
161
+ }
162
+ function pruneStaleLeases(leases, options) {
163
+ return leases.filter((lease) => {
164
+ if (!options.isProcessAlive(lease.pid)) {
165
+ return false;
166
+ }
167
+ const updatedAt = Date.parse(lease.updatedAt);
168
+ if (Number.isFinite(updatedAt) && options.nowMs - updatedAt > options.staleMs) {
169
+ return false;
170
+ }
171
+ return true;
172
+ });
173
+ }
174
+ function isLeaseRecord(value) {
175
+ if (!value || typeof value !== "object")
176
+ return false;
177
+ const record = value;
178
+ return (typeof record.id === "string" &&
179
+ typeof record.pid === "number" &&
180
+ typeof record.createdAt === "string" &&
181
+ typeof record.updatedAt === "string");
182
+ }
@@ -10,7 +10,7 @@ export function parseDuration(input, fallback) {
10
10
  if (/^[0-9]+$/.test(lowercase)) {
11
11
  return Number(lowercase);
12
12
  }
13
- const normalized = lowercase.replace(/\s+/g, '');
13
+ const normalized = lowercase.replace(/\s+/g, "");
14
14
  const singleMatch = /^([0-9]+)(ms|s|m|h)$/i.exec(normalized);
15
15
  if (singleMatch && singleMatch[0].length === normalized.length) {
16
16
  const value = Number(singleMatch[1]);
@@ -33,13 +33,13 @@ export function parseDuration(input, fallback) {
33
33
  function convertUnit(value, unitRaw) {
34
34
  const unit = unitRaw?.toLowerCase();
35
35
  switch (unit) {
36
- case 'ms':
36
+ case "ms":
37
37
  return value;
38
- case 's':
38
+ case "s":
39
39
  return value * 1000;
40
- case 'm':
40
+ case "m":
41
41
  return value * 60_000;
42
- case 'h':
42
+ case "h":
43
43
  return value * 3_600_000;
44
44
  default:
45
45
  return value;
@@ -72,11 +72,11 @@ export async function withRetries(task, options = {}) {
72
72
  await delay(delayMs * attempt);
73
73
  }
74
74
  }
75
- throw new Error('withRetries exhausted without result');
75
+ throw new Error("withRetries exhausted without result");
76
76
  }
77
77
  export function formatBytes(size) {
78
78
  if (!Number.isFinite(size) || size < 0) {
79
- return 'n/a';
79
+ return "n/a";
80
80
  }
81
81
  if (size < 1024) {
82
82
  return `${size} B`;
@@ -113,8 +113,8 @@ export function normalizeChatgptUrl(raw, fallback) {
113
113
  export function isTemporaryChatUrl(url) {
114
114
  try {
115
115
  const parsed = new URL(url);
116
- const value = (parsed.searchParams.get('temporary-chat') ?? '').trim().toLowerCase();
117
- return value === 'true' || value === '1' || value === 'yes';
116
+ const value = (parsed.searchParams.get("temporary-chat") ?? "").trim().toLowerCase();
117
+ return value === "true" || value === "1" || value === "yes";
118
118
  }
119
119
  catch {
120
120
  return false;
@@ -1 +1 @@
1
- export { runBrowserMode, CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET, parseDuration, normalizeChatgptUrl, isTemporaryChatUrl, } from './browser/index.js';
1
+ export { runBrowserMode, CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET, parseDuration, normalizeChatgptUrl, isTemporaryChatUrl, } from "./browser/index.js";
@@ -1,8 +1,8 @@
1
- import chalk from 'chalk';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
- import { loadUserConfig } from '../../config.js';
5
- import { resolveRemoteServiceConfig } from '../../remote/remoteServiceConfig.js';
1
+ import chalk from "chalk";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { loadUserConfig } from "../../config.js";
5
+ import { resolveRemoteServiceConfig } from "../../remote/remoteServiceConfig.js";
6
6
  export async function runBridgeClaudeConfig(options) {
7
7
  const { config: userConfig } = await loadUserConfig();
8
8
  const resolved = resolveRemoteServiceConfig({
@@ -12,40 +12,44 @@ export async function runBridgeClaudeConfig(options) {
12
12
  env: process.env,
13
13
  });
14
14
  const snippet = formatClaudeMcpConfig({
15
- oracleHomeDir: process.env.ORACLE_HOME_DIR ?? path.join(os.homedir(), '.oracle-local'),
16
- browserProfileDir: process.env.ORACLE_BROWSER_PROFILE_DIR ??
17
- path.join(os.homedir(), '.oracle-local', 'browser-profile'),
15
+ oracleHomeDir: options.oracleHomeDir ??
16
+ process.env.ORACLE_HOME_DIR ??
17
+ path.join(os.homedir(), options.localBrowser ? ".oracle" : ".oracle-local"),
18
+ browserProfileDir: options.browserProfileDir ??
19
+ process.env.ORACLE_BROWSER_PROFILE_DIR ??
20
+ path.join(os.homedir(), options.localBrowser ? ".oracle" : ".oracle-local", "browser-profile"),
18
21
  remoteHost: resolved.host,
19
22
  remoteToken: resolved.token,
20
23
  includeToken: Boolean(options.printToken),
24
+ localBrowser: Boolean(options.localBrowser),
21
25
  });
22
26
  console.log(snippet);
23
- if (!options.printToken) {
24
- console.log('');
25
- console.log(chalk.dim('Tip: rerun with --print-token to include ORACLE_REMOTE_TOKEN in the snippet.'));
27
+ if (!options.printToken && !options.localBrowser) {
28
+ console.error("");
29
+ console.error(chalk.dim("Tip: rerun with --print-token to include ORACLE_REMOTE_TOKEN in the snippet."));
26
30
  }
27
31
  }
28
- export function formatClaudeMcpConfig({ oracleHomeDir, browserProfileDir, remoteHost, remoteToken, includeToken, }) {
32
+ export function formatClaudeMcpConfig({ oracleHomeDir, browserProfileDir, remoteHost, remoteToken, includeToken, localBrowser = false, }) {
29
33
  const env = {};
30
34
  // biome-ignore lint/complexity/useLiteralKeys: env vars are uppercase and include underscores.
31
- env['ORACLE_ENGINE'] = 'browser';
35
+ env["ORACLE_ENGINE"] = "browser";
32
36
  // biome-ignore lint/complexity/useLiteralKeys: env vars are uppercase and include underscores.
33
- env['ORACLE_HOME_DIR'] = oracleHomeDir;
37
+ env["ORACLE_HOME_DIR"] = oracleHomeDir;
34
38
  // biome-ignore lint/complexity/useLiteralKeys: env vars are uppercase and include underscores.
35
- env['ORACLE_BROWSER_PROFILE_DIR'] = browserProfileDir;
36
- if (remoteHost) {
39
+ env["ORACLE_BROWSER_PROFILE_DIR"] = browserProfileDir;
40
+ if (remoteHost && !localBrowser) {
37
41
  // biome-ignore lint/complexity/useLiteralKeys: env vars are uppercase and include underscores.
38
- env['ORACLE_REMOTE_HOST'] = remoteHost;
42
+ env["ORACLE_REMOTE_HOST"] = remoteHost;
39
43
  // biome-ignore lint/complexity/useLiteralKeys: env vars are uppercase and include underscores.
40
- env['ORACLE_REMOTE_TOKEN'] = includeToken ? remoteToken ?? '<YOUR_TOKEN>' : '<YOUR_TOKEN>';
44
+ env["ORACLE_REMOTE_TOKEN"] = includeToken ? (remoteToken ?? "<YOUR_TOKEN>") : "<YOUR_TOKEN>";
41
45
  }
42
46
  // Claude Code supports project-scoped `.mcp.json` config files:
43
47
  // https://docs.anthropic.com/en/docs/claude-code/mcp
44
48
  return JSON.stringify({
45
49
  mcpServers: {
46
50
  oracle: {
47
- type: 'stdio',
48
- command: 'oracle-mcp',
51
+ type: "stdio",
52
+ command: "oracle-mcp",
49
53
  args: [],
50
54
  env,
51
55
  },
@@ -1,29 +1,33 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- import chalk from 'chalk';
4
- import { configPath as defaultConfigPath } from '../../config.js';
5
- import { parseBridgeConnectionString, readBridgeConnectionArtifact, looksLikePath, } from '../../bridge/connection.js';
6
- import { readUserConfigFile, writeUserConfigFile } from '../../bridge/userConfigFile.js';
7
- import { checkRemoteHealth } from '../../remote/health.js';
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import chalk from "chalk";
4
+ import { configPath as defaultConfigPath } from "../../config.js";
5
+ import { parseBridgeConnectionString, readBridgeConnectionArtifact, looksLikePath, } from "../../bridge/connection.js";
6
+ import { readUserConfigFile, writeUserConfigFile } from "../../bridge/userConfigFile.js";
7
+ import { checkRemoteHealth } from "../../remote/health.js";
8
8
  export async function runBridgeClient(options) {
9
9
  const connectRaw = options.connect?.trim();
10
10
  if (!connectRaw) {
11
- throw new Error('Missing --connect. Provide a connection string or a bridge-connection.json path.');
11
+ throw new Error("Missing --connect. Provide a connection string or a bridge-connection.json path.");
12
12
  }
13
13
  const { remoteHost, remoteToken, tunnel } = await resolveConnection(connectRaw);
14
14
  if (options.test !== false) {
15
- const health = await checkRemoteHealth({ host: remoteHost, token: remoteToken, timeoutMs: 5000 });
15
+ const health = await checkRemoteHealth({
16
+ host: remoteHost,
17
+ token: remoteToken,
18
+ timeoutMs: 5000,
19
+ });
16
20
  if (!health.ok) {
17
- const suffix = health.statusCode ? ` (HTTP ${health.statusCode})` : '';
18
- throw new Error(`Remote service health check failed: ${health.error ?? 'unknown error'}${suffix}`);
21
+ const suffix = health.statusCode ? ` (HTTP ${health.statusCode})` : "";
22
+ throw new Error(`Remote service health check failed: ${health.error ?? "unknown error"}${suffix}`);
19
23
  }
20
- console.log(chalk.green(`Remote service OK (${remoteHost})${health.version ? ` — oracle ${health.version}` : ''}`));
24
+ console.log(chalk.green(`Remote service OK (${remoteHost})${health.version ? ` — oracle ${health.version}` : ""}`));
21
25
  }
22
26
  const configFilePath = options.config?.trim() || defaultConfigPath();
23
27
  if (options.writeConfig !== false) {
24
28
  const { config } = await readUserConfigFile(configFilePath);
25
- const next = { ...config, browser: { ...(config.browser ?? {}) } };
26
- next.browser = { ...(next.browser ?? {}) };
29
+ const next = { ...config, browser: { ...config.browser } };
30
+ next.browser = { ...next.browser };
27
31
  next.browser.remoteHost = remoteHost;
28
32
  next.browser.remoteToken = remoteToken;
29
33
  if (tunnel) {
@@ -38,19 +42,19 @@ export async function runBridgeClient(options) {
38
42
  await writeUserConfigFile(configFilePath, next);
39
43
  console.log(chalk.green(`Wrote remote config to ${configFilePath}`));
40
44
  }
41
- console.log('');
42
- console.log('Next:');
45
+ console.log("");
46
+ console.log("Next:");
43
47
  console.log(chalk.dim(`- oracle --engine browser -p "hello" --file README.md`));
44
48
  if (options.printEnv) {
45
- console.log('');
46
- console.log('# Optional env overrides (paste into your shell):');
49
+ console.log("");
50
+ console.log("# Optional env overrides (paste into your shell):");
47
51
  console.log(`export ORACLE_ENGINE=browser`);
48
52
  console.log(`export ORACLE_REMOTE_HOST=${shellQuote(remoteHost)}`);
49
53
  console.log(`export ORACLE_REMOTE_TOKEN=${shellQuote(remoteToken)}`);
50
54
  }
51
55
  }
52
56
  async function resolveConnection(input) {
53
- if (input.includes('://')) {
57
+ if (input.includes("://")) {
54
58
  return { ...parseBridgeConnectionString(input) };
55
59
  }
56
60
  const resolvedPath = looksLikePath(input) ? path.resolve(process.cwd(), input) : null;
@@ -58,7 +62,11 @@ async function resolveConnection(input) {
58
62
  const stat = await fs.stat(resolvedPath).catch(() => null);
59
63
  if (stat?.isFile()) {
60
64
  const artifact = await readBridgeConnectionArtifact(resolvedPath);
61
- return { remoteHost: artifact.remoteHost, remoteToken: artifact.remoteToken, tunnel: artifact.tunnel };
65
+ return {
66
+ remoteHost: artifact.remoteHost,
67
+ remoteToken: artifact.remoteToken,
68
+ tunnel: artifact.tunnel,
69
+ };
62
70
  }
63
71
  if (stat) {
64
72
  throw new Error(`--connect points to ${resolvedPath}, but it is not a file.`);
@@ -1,6 +1,6 @@
1
- import chalk from 'chalk';
2
- import { loadUserConfig } from '../../config.js';
3
- import { resolveRemoteServiceConfig } from '../../remote/remoteServiceConfig.js';
1
+ import chalk from "chalk";
2
+ import { loadUserConfig } from "../../config.js";
3
+ import { resolveRemoteServiceConfig } from "../../remote/remoteServiceConfig.js";
4
4
  export async function runBridgeCodexConfig(options) {
5
5
  const { config: userConfig } = await loadUserConfig();
6
6
  const resolved = resolveRemoteServiceConfig({
@@ -16,28 +16,28 @@ export async function runBridgeCodexConfig(options) {
16
16
  });
17
17
  console.log(snippet);
18
18
  if (!options.printToken) {
19
- console.log('');
20
- console.log(chalk.dim('Tip: rerun with --print-token to include ORACLE_REMOTE_TOKEN in the snippet.'));
19
+ console.error("");
20
+ console.error(chalk.dim("Tip: rerun with --print-token to include ORACLE_REMOTE_TOKEN in the snippet."));
21
21
  }
22
22
  }
23
23
  export function formatCodexMcpSnippet({ remoteHost, remoteToken, includeToken, }) {
24
- const hostValue = remoteHost ?? '127.0.0.1:9473';
25
- const tokenValue = includeToken ? remoteToken ?? '<YOUR_TOKEN>' : '<YOUR_TOKEN>';
24
+ const hostValue = remoteHost ?? "127.0.0.1:9473";
25
+ const tokenValue = includeToken ? (remoteToken ?? "<YOUR_TOKEN>") : "<YOUR_TOKEN>";
26
26
  return [
27
- '# ~/.codex/config.toml',
28
- '',
29
- '[mcp.servers.oracle]',
27
+ "# ~/.codex/config.toml",
28
+ "",
29
+ "[mcp.servers.oracle]",
30
30
  'command = "oracle-mcp"',
31
- 'args = []',
31
+ "args = []",
32
32
  `env = { ORACLE_ENGINE = "browser", ORACLE_REMOTE_HOST = "${escapeTomlString(hostValue)}", ORACLE_REMOTE_TOKEN = "${escapeTomlString(tokenValue)}" }`,
33
- '',
34
- '# If you prefer npx:',
35
- '# [mcp.servers.oracle]',
33
+ "",
34
+ "# If you prefer npx:",
35
+ "# [mcp.servers.oracle]",
36
36
  '# command = "npx"',
37
37
  '# args = ["-y", "@steipete/oracle", "oracle-mcp"]',
38
38
  `# env = { ORACLE_ENGINE = "browser", ORACLE_REMOTE_HOST = "${escapeTomlString(hostValue)}", ORACLE_REMOTE_TOKEN = "${escapeTomlString(tokenValue)}" }`,
39
- ].join('\n');
39
+ ].join("\n");
40
40
  }
41
41
  function escapeTomlString(value) {
42
- return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
42
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
43
43
  }