@steipete/oracle 1.0.8 → 1.2.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 (106) hide show
  1. package/README.md +32 -4
  2. package/assets-oracle-icon.png +0 -0
  3. package/dist/bin/oracle-cli.js +178 -21
  4. package/dist/bin/oracle-mcp.js +6 -0
  5. package/dist/markdansi/types/index.js +4 -0
  6. package/dist/oracle/bin/oracle-cli.js +472 -0
  7. package/dist/oracle/src/browser/actions/assistantResponse.js +471 -0
  8. package/dist/oracle/src/browser/actions/attachments.js +82 -0
  9. package/dist/oracle/src/browser/actions/modelSelection.js +190 -0
  10. package/dist/oracle/src/browser/actions/navigation.js +75 -0
  11. package/dist/oracle/src/browser/actions/promptComposer.js +167 -0
  12. package/dist/oracle/src/browser/chromeLifecycle.js +104 -0
  13. package/dist/oracle/src/browser/config.js +33 -0
  14. package/dist/oracle/src/browser/constants.js +40 -0
  15. package/dist/oracle/src/browser/cookies.js +210 -0
  16. package/dist/oracle/src/browser/domDebug.js +36 -0
  17. package/dist/oracle/src/browser/index.js +331 -0
  18. package/dist/oracle/src/browser/pageActions.js +5 -0
  19. package/dist/oracle/src/browser/prompt.js +88 -0
  20. package/dist/oracle/src/browser/promptSummary.js +20 -0
  21. package/dist/oracle/src/browser/sessionRunner.js +80 -0
  22. package/dist/oracle/src/browser/types.js +1 -0
  23. package/dist/oracle/src/browser/utils.js +62 -0
  24. package/dist/oracle/src/browserMode.js +1 -0
  25. package/dist/oracle/src/cli/browserConfig.js +44 -0
  26. package/dist/oracle/src/cli/dryRun.js +59 -0
  27. package/dist/oracle/src/cli/engine.js +17 -0
  28. package/dist/oracle/src/cli/errorUtils.js +9 -0
  29. package/dist/oracle/src/cli/help.js +70 -0
  30. package/dist/oracle/src/cli/markdownRenderer.js +15 -0
  31. package/dist/oracle/src/cli/options.js +103 -0
  32. package/dist/oracle/src/cli/promptRequirement.js +14 -0
  33. package/dist/oracle/src/cli/rootAlias.js +30 -0
  34. package/dist/oracle/src/cli/sessionCommand.js +77 -0
  35. package/dist/oracle/src/cli/sessionDisplay.js +270 -0
  36. package/dist/oracle/src/cli/sessionRunner.js +94 -0
  37. package/dist/oracle/src/heartbeat.js +43 -0
  38. package/dist/oracle/src/oracle/client.js +48 -0
  39. package/dist/oracle/src/oracle/config.js +29 -0
  40. package/dist/oracle/src/oracle/errors.js +101 -0
  41. package/dist/oracle/src/oracle/files.js +220 -0
  42. package/dist/oracle/src/oracle/format.js +33 -0
  43. package/dist/oracle/src/oracle/fsAdapter.js +7 -0
  44. package/dist/oracle/src/oracle/oscProgress.js +60 -0
  45. package/dist/oracle/src/oracle/request.js +48 -0
  46. package/dist/oracle/src/oracle/run.js +444 -0
  47. package/dist/oracle/src/oracle/tokenStats.js +39 -0
  48. package/dist/oracle/src/oracle/types.js +1 -0
  49. package/dist/oracle/src/oracle.js +9 -0
  50. package/dist/oracle/src/sessionManager.js +205 -0
  51. package/dist/oracle/src/version.js +39 -0
  52. package/dist/src/browser/actions/modelSelection.js +117 -29
  53. package/dist/src/browser/cookies.js +1 -1
  54. package/dist/src/browser/index.js +2 -1
  55. package/dist/src/browser/prompt.js +6 -5
  56. package/dist/src/browser/sessionRunner.js +4 -2
  57. package/dist/src/cli/dryRun.js +41 -5
  58. package/dist/src/cli/engine.js +7 -0
  59. package/dist/src/cli/help.js +1 -1
  60. package/dist/src/cli/hiddenAliases.js +17 -0
  61. package/dist/src/cli/markdownRenderer.js +97 -0
  62. package/dist/src/cli/notifier.js +223 -0
  63. package/dist/src/cli/promptRequirement.js +3 -0
  64. package/dist/src/cli/rootAlias.js +14 -0
  65. package/dist/src/cli/runOptions.js +29 -0
  66. package/dist/src/cli/sessionCommand.js +60 -2
  67. package/dist/src/cli/sessionDisplay.js +222 -10
  68. package/dist/src/cli/sessionRunner.js +21 -2
  69. package/dist/src/cli/tui/index.js +436 -0
  70. package/dist/src/config.js +27 -0
  71. package/dist/src/mcp/server.js +36 -0
  72. package/dist/src/mcp/tools/consult.js +158 -0
  73. package/dist/src/mcp/tools/sessionResources.js +64 -0
  74. package/dist/src/mcp/tools/sessions.js +106 -0
  75. package/dist/src/mcp/types.js +17 -0
  76. package/dist/src/mcp/utils.js +24 -0
  77. package/dist/src/oracle/files.js +143 -6
  78. package/dist/src/oracle/oscProgress.js +60 -0
  79. package/dist/src/oracle/run.js +104 -71
  80. package/dist/src/oracle/tokenEstimate.js +34 -0
  81. package/dist/src/sessionManager.js +65 -3
  82. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  83. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  84. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  85. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  86. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  87. package/dist/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  88. package/dist/vendor/oracle-notifier/README.md +24 -0
  89. package/dist/vendor/oracle-notifier/build-notifier.sh +93 -0
  90. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  91. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  92. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  93. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  94. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  95. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.swift +45 -0
  96. package/dist/vendor/oracle-notifier/oracle-notifier/README.md +24 -0
  97. package/dist/vendor/oracle-notifier/oracle-notifier/build-notifier.sh +93 -0
  98. package/package.json +27 -9
  99. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  100. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  101. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  102. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  103. package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  104. package/vendor/oracle-notifier/OracleNotifier.swift +45 -0
  105. package/vendor/oracle-notifier/README.md +24 -0
  106. package/vendor/oracle-notifier/build-notifier.sh +93 -0
@@ -8,11 +8,13 @@ import { APIConnectionError, APIConnectionTimeoutError } from 'openai';
8
8
  import { DEFAULT_SYSTEM_PROMPT, MODEL_CONFIGS, TOKENIZER_OPTIONS } from './config.js';
9
9
  import { readFiles } from './files.js';
10
10
  import { buildPrompt, buildRequestBody } from './request.js';
11
+ import { estimateRequestTokens } from './tokenEstimate.js';
11
12
  import { formatElapsed, formatUSD } from './format.js';
12
13
  import { getFileTokenStats, printFileTokenStats } from './tokenStats.js';
13
14
  import { OracleResponseError, OracleTransportError, PromptValidationError, describeTransportError, toTransportError, } from './errors.js';
14
15
  import { createDefaultClientFactory } from './client.js';
15
16
  import { startHeartbeat } from '../heartbeat.js';
17
+ import { startOscProgress } from './oscProgress.js';
16
18
  import { getCliVersion } from '../version.js';
17
19
  import { createFsAdapter } from './fsAdapter.js';
18
20
  const isTty = process.stdout.isTTY;
@@ -84,19 +86,23 @@ export async function runOracle(options, deps = {}) {
84
86
  logVerbose(`Attached files use ${totalFileTokens.toLocaleString()} tokens`);
85
87
  const systemPrompt = options.system?.trim() || DEFAULT_SYSTEM_PROMPT;
86
88
  const promptWithFiles = buildPrompt(options.prompt, files, cwd);
87
- const tokenizerInput = [
88
- { role: 'system', content: systemPrompt },
89
- { role: 'user', content: promptWithFiles },
90
- ];
91
- const estimatedInputTokens = modelConfig.tokenizer(tokenizerInput, TOKENIZER_OPTIONS);
92
- logVerbose(`Estimated tokens (prompt + files): ${estimatedInputTokens.toLocaleString()}`);
93
89
  const fileCount = files.length;
94
90
  const cliVersion = getCliVersion();
95
91
  const richTty = process.stdout.isTTY && chalk.level > 0;
96
92
  const headerModelLabel = richTty ? chalk.cyan(modelConfig.model) : modelConfig.model;
93
+ const requestBody = buildRequestBody({
94
+ modelConfig,
95
+ systemPrompt,
96
+ userPrompt: promptWithFiles,
97
+ searchEnabled,
98
+ maxOutputTokens: options.maxOutput,
99
+ background: useBackground,
100
+ storeResponse: useBackground,
101
+ });
102
+ const estimatedInputTokens = estimateRequestTokens(requestBody, modelConfig);
97
103
  const tokenLabel = richTty ? chalk.green(estimatedInputTokens.toLocaleString()) : estimatedInputTokens.toLocaleString();
98
104
  const fileLabel = richTty ? chalk.magenta(fileCount.toString()) : fileCount.toString();
99
- const headerLine = `Oracle (${cliVersion}) consulting ${headerModelLabel}'s crystal ball with ${tokenLabel} tokens and ${fileLabel} files...`;
105
+ const headerLine = `oracle (${cliVersion}) consulting ${headerModelLabel}'s crystal ball with ${tokenLabel} tokens and ${fileLabel} files...`;
100
106
  const shouldReportFiles = (options.filesReport || fileTokenInfo.totalTokens > inputTokenBudget) && fileTokenInfo.stats.length > 0;
101
107
  if (!isPreview) {
102
108
  log(headerLine);
@@ -111,15 +117,7 @@ export async function runOracle(options, deps = {}) {
111
117
  if (estimatedInputTokens > inputTokenBudget) {
112
118
  throw new PromptValidationError(`Input too large (${estimatedInputTokens.toLocaleString()} tokens). Limit is ${inputTokenBudget.toLocaleString()} tokens.`, { estimatedInputTokens, inputTokenBudget });
113
119
  }
114
- const requestBody = buildRequestBody({
115
- modelConfig,
116
- systemPrompt,
117
- userPrompt: promptWithFiles,
118
- searchEnabled,
119
- maxOutputTokens: options.maxOutput,
120
- background: useBackground,
121
- storeResponse: useBackground,
122
- });
120
+ logVerbose(`Estimated tokens (request body): ${estimatedInputTokens.toLocaleString()}`);
123
121
  if (isPreview && previewMode) {
124
122
  if (previewMode === 'json' || previewMode === 'full') {
125
123
  log('Request JSON');
@@ -142,6 +140,11 @@ export async function runOracle(options, deps = {}) {
142
140
  }
143
141
  const openAiClient = client ?? clientFactory(apiKey);
144
142
  logVerbose('Dispatching request to OpenAI Responses API...');
143
+ const stopOscProgress = startOscProgress({
144
+ label: useBackground ? 'Waiting for OpenAI (background)' : 'Waiting for OpenAI',
145
+ targetMs: useBackground ? BACKGROUND_MAX_WAIT_MS : 10 * 60_000,
146
+ write,
147
+ });
145
148
  const runStart = now();
146
149
  let response = null;
147
150
  let elapsedMs = 0;
@@ -154,74 +157,98 @@ export async function runOracle(options, deps = {}) {
154
157
  answerHeaderPrinted = true;
155
158
  }
156
159
  };
157
- if (useBackground) {
158
- response = await executeBackgroundResponse({
159
- client: openAiClient,
160
- requestBody,
161
- log,
162
- wait,
163
- heartbeatIntervalMs: options.heartbeatIntervalMs,
164
- now,
165
- });
166
- elapsedMs = now() - runStart;
167
- }
168
- else {
169
- const stream = await openAiClient.responses.stream(requestBody);
170
- let heartbeatActive = false;
171
- let stopHeartbeat = null;
172
- const stopHeartbeatNow = () => {
173
- if (!heartbeatActive) {
174
- return;
175
- }
176
- heartbeatActive = false;
177
- stopHeartbeat?.();
178
- stopHeartbeat = null;
179
- };
180
- if (options.heartbeatIntervalMs && options.heartbeatIntervalMs > 0) {
181
- heartbeatActive = true;
182
- stopHeartbeat = startHeartbeat({
183
- intervalMs: options.heartbeatIntervalMs,
184
- log: (message) => log(message),
185
- isActive: () => heartbeatActive,
186
- makeMessage: (elapsedMs) => {
187
- const elapsedText = formatElapsed(elapsedMs);
188
- return `API connection active — ${elapsedText} elapsed. Expect up to ~10 min before GPT-5 responds.`;
189
- },
160
+ try {
161
+ if (useBackground) {
162
+ response = await executeBackgroundResponse({
163
+ client: openAiClient,
164
+ requestBody,
165
+ log,
166
+ wait,
167
+ heartbeatIntervalMs: options.heartbeatIntervalMs,
168
+ now,
190
169
  });
170
+ elapsedMs = now() - runStart;
191
171
  }
192
- try {
193
- for await (const event of stream) {
194
- if (event.type === 'response.output_text.delta') {
195
- stopHeartbeatNow();
196
- sawTextDelta = true;
197
- ensureAnswerHeader();
198
- if (!options.silent && typeof event.delta === 'string') {
199
- write(event.delta);
172
+ else {
173
+ const stream = await openAiClient.responses.stream(requestBody);
174
+ let heartbeatActive = false;
175
+ let stopHeartbeat = null;
176
+ const stopHeartbeatNow = () => {
177
+ if (!heartbeatActive) {
178
+ return;
179
+ }
180
+ heartbeatActive = false;
181
+ stopHeartbeat?.();
182
+ stopHeartbeat = null;
183
+ };
184
+ if (options.heartbeatIntervalMs && options.heartbeatIntervalMs > 0) {
185
+ heartbeatActive = true;
186
+ stopHeartbeat = startHeartbeat({
187
+ intervalMs: options.heartbeatIntervalMs,
188
+ log: (message) => log(message),
189
+ isActive: () => heartbeatActive,
190
+ makeMessage: (elapsedMs) => {
191
+ const elapsedText = formatElapsed(elapsedMs);
192
+ return `API connection active — ${elapsedText} elapsed. Expect up to ~10 min before GPT-5 responds.`;
193
+ },
194
+ });
195
+ }
196
+ try {
197
+ for await (const event of stream) {
198
+ if (event.type === 'response.output_text.delta') {
199
+ stopOscProgress();
200
+ stopHeartbeatNow();
201
+ sawTextDelta = true;
202
+ ensureAnswerHeader();
203
+ if (!options.silent && typeof event.delta === 'string') {
204
+ write(event.delta);
205
+ }
200
206
  }
201
207
  }
202
208
  }
203
- }
204
- catch (streamError) {
205
- if (typeof stream.abort === 'function') {
206
- stream.abort();
209
+ catch (streamError) {
210
+ if (typeof stream.abort === 'function') {
211
+ stream.abort();
212
+ }
213
+ stopHeartbeatNow();
214
+ const transportError = toTransportError(streamError);
215
+ log(chalk.yellow(describeTransportError(transportError)));
216
+ throw transportError;
207
217
  }
218
+ response = await stream.finalResponse();
208
219
  stopHeartbeatNow();
209
- const transportError = toTransportError(streamError);
210
- log(chalk.yellow(describeTransportError(transportError)));
211
- throw transportError;
220
+ elapsedMs = now() - runStart;
212
221
  }
213
- response = await stream.finalResponse();
214
- stopHeartbeatNow();
215
- elapsedMs = now() - runStart;
222
+ }
223
+ finally {
224
+ stopOscProgress();
216
225
  }
217
226
  if (!response) {
218
227
  throw new Error('OpenAI did not return a response.');
219
228
  }
220
229
  logVerbose(`Response status: ${response.status ?? 'completed'}`);
221
230
  if (response.status && response.status !== 'completed') {
222
- const detail = response.error?.message || response.incomplete_details?.reason || response.status;
223
- log(chalk.yellow(`OpenAI ended the run early (status=${response.status}${response.incomplete_details?.reason ? `, reason=${response.incomplete_details.reason}` : ''}).`));
224
- throw new OracleResponseError(`Response did not complete: ${detail}`, response);
231
+ // OpenAI can reply `in_progress` even after the stream closes; give it a brief grace poll.
232
+ if (response.id && response.status === 'in_progress') {
233
+ const polishingStart = now();
234
+ const pollIntervalMs = 2_000;
235
+ const maxWaitMs = 60_000;
236
+ log(chalk.dim('Response still in_progress; polling until completion...'));
237
+ // Short polling loop — we don't want to hang forever, just catch late finalization.
238
+ while (now() - polishingStart < maxWaitMs) {
239
+ await wait(pollIntervalMs);
240
+ const refreshed = await openAiClient.responses.retrieve(response.id);
241
+ if (refreshed.status === 'completed') {
242
+ response = refreshed;
243
+ break;
244
+ }
245
+ }
246
+ }
247
+ if (response.status !== 'completed') {
248
+ const detail = response.error?.message || response.incomplete_details?.reason || response.status;
249
+ log(chalk.yellow(`OpenAI ended the run early (status=${response.status}${response.incomplete_details?.reason ? `, reason=${response.incomplete_details.reason}` : ''}).`));
250
+ throw new OracleResponseError(`Response did not complete: ${detail}`, response);
251
+ }
225
252
  }
226
253
  const answerText = extractTextOutput(response);
227
254
  if (!options.silent) {
@@ -250,6 +277,12 @@ export async function runOracle(options, deps = {}) {
250
277
  .map((value, index) => formatTokenValue(value, usage, index))
251
278
  .join('/');
252
279
  statsParts.push(`tok(i/o/r/t)=${tokensDisplay}`);
280
+ const actualInput = usage.input_tokens;
281
+ if (actualInput !== undefined) {
282
+ const delta = actualInput - estimatedInputTokens;
283
+ const deltaText = delta === 0 ? '' : delta > 0 ? ` (+${delta.toLocaleString()})` : ` (${delta.toLocaleString()})`;
284
+ statsParts.push(`est→actual=${estimatedInputTokens.toLocaleString()}→${actualInput.toLocaleString()}${deltaText}`);
285
+ }
253
286
  if (!searchEnabled) {
254
287
  statsParts.push('search=off');
255
288
  }
@@ -260,7 +293,7 @@ export async function runOracle(options, deps = {}) {
260
293
  return {
261
294
  mode: 'live',
262
295
  response,
263
- usage: { inputTokens, outputTokens, reasoningTokens, totalTokens },
296
+ usage: { inputTokens, outputTokens, reasoningTokens, totalTokens, cost },
264
297
  elapsedMs,
265
298
  };
266
299
  }
@@ -0,0 +1,34 @@
1
+ import { TOKENIZER_OPTIONS } from './config.js';
2
+ /**
3
+ * Estimate input tokens from the full request body instead of just system/user text.
4
+ * This is a conservative approximation: we tokenize the key textual fields and add a fixed buffer
5
+ * to cover structural JSON overhead and server-side wrappers (tools/reasoning/background/store).
6
+ */
7
+ export function estimateRequestTokens(requestBody, modelConfig, bufferTokens = 200) {
8
+ const parts = [];
9
+ if (requestBody.instructions) {
10
+ parts.push(requestBody.instructions);
11
+ }
12
+ for (const turn of requestBody.input ?? []) {
13
+ for (const content of turn.content ?? []) {
14
+ if (typeof content.text === 'string') {
15
+ parts.push(content.text);
16
+ }
17
+ }
18
+ }
19
+ if (requestBody.tools && requestBody.tools.length > 0) {
20
+ parts.push(JSON.stringify(requestBody.tools));
21
+ }
22
+ if (requestBody.reasoning) {
23
+ parts.push(JSON.stringify(requestBody.reasoning));
24
+ }
25
+ if (requestBody.background) {
26
+ parts.push('background:true');
27
+ }
28
+ if (requestBody.store) {
29
+ parts.push('store:true');
30
+ }
31
+ const concatenated = parts.join('\n');
32
+ const baseEstimate = modelConfig.tokenizer(concatenated, TOKENIZER_OPTIONS);
33
+ return baseEstimate + bufferTokens;
34
+ }
@@ -5,6 +5,7 @@ import { createWriteStream } from 'node:fs';
5
5
  const ORACLE_HOME = process.env.ORACLE_HOME_DIR ?? path.join(os.homedir(), '.oracle');
6
6
  const SESSIONS_DIR = path.join(ORACLE_HOME, 'sessions');
7
7
  const MAX_STATUS_LIMIT = 1000;
8
+ const ZOMBIE_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes
8
9
  const DEFAULT_SLUG = 'session';
9
10
  const MAX_SLUG_WORDS = 5;
10
11
  const MIN_CUSTOM_SLUG_WORDS = 3;
@@ -67,7 +68,7 @@ async function ensureUniqueSessionId(baseSlug) {
67
68
  }
68
69
  return candidate;
69
70
  }
70
- export async function initializeSession(options, cwd) {
71
+ export async function initializeSession(options, cwd, notifications) {
71
72
  await ensureSessionStorage();
72
73
  const baseSlug = createSessionId(options.prompt || DEFAULT_SLUG, options.slug);
73
74
  const sessionId = await ensureUniqueSessionId(baseSlug);
@@ -84,6 +85,7 @@ export async function initializeSession(options, cwd) {
84
85
  cwd,
85
86
  mode,
86
87
  browser: browserConfig ? { config: browserConfig } : undefined,
88
+ notifications,
87
89
  options: {
88
90
  prompt: options.prompt,
89
91
  file: options.file ?? [],
@@ -99,7 +101,9 @@ export async function initializeSession(options, cwd) {
99
101
  verbose: options.verbose,
100
102
  heartbeatIntervalMs: options.heartbeatIntervalMs,
101
103
  browserInlineFiles: options.browserInlineFiles,
104
+ browserBundleFiles: options.browserBundleFiles,
102
105
  background: options.background,
106
+ search: options.search,
103
107
  },
104
108
  };
105
109
  await fs.writeFile(metaPath(sessionId), JSON.stringify(metadata, null, 2), 'utf8');
@@ -110,7 +114,8 @@ export async function initializeSession(options, cwd) {
110
114
  export async function readSessionMetadata(sessionId) {
111
115
  try {
112
116
  const raw = await fs.readFile(metaPath(sessionId), 'utf8');
113
- return JSON.parse(raw);
117
+ const parsed = JSON.parse(raw);
118
+ return await markZombie(parsed, { persist: false }); // transient check; do not touch disk on single read
114
119
  }
115
120
  catch {
116
121
  return null;
@@ -138,8 +143,9 @@ export async function listSessionsMetadata() {
138
143
  const entries = await fs.readdir(SESSIONS_DIR).catch(() => []);
139
144
  const metas = [];
140
145
  for (const entry of entries) {
141
- const meta = await readSessionMetadata(entry);
146
+ let meta = await readSessionMetadata(entry);
142
147
  if (meta) {
148
+ meta = await markZombie(meta, { persist: true }); // keep stored metadata consistent with zombie detection
143
149
  metas.push(meta);
144
150
  }
145
151
  }
@@ -164,6 +170,15 @@ export async function readSessionLog(sessionId) {
164
170
  return '';
165
171
  }
166
172
  }
173
+ export async function readSessionRequest(sessionId) {
174
+ try {
175
+ const raw = await fs.readFile(requestPath(sessionId), 'utf8');
176
+ return JSON.parse(raw);
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ }
167
182
  export async function deleteSessionsOlderThan({ hours = 24, includeAll = false, } = {}) {
168
183
  await ensureSessionStorage();
169
184
  const entries = await fs.readdir(SESSIONS_DIR).catch(() => []);
@@ -203,3 +218,50 @@ export async function wait(ms) {
203
218
  return new Promise((resolve) => setTimeout(resolve, ms));
204
219
  }
205
220
  export { ORACLE_HOME, SESSIONS_DIR, MAX_STATUS_LIMIT };
221
+ export { ZOMBIE_MAX_AGE_MS };
222
+ export async function getSessionPaths(sessionId) {
223
+ const dir = sessionDir(sessionId);
224
+ const metadata = metaPath(sessionId);
225
+ const log = logPath(sessionId);
226
+ const request = requestPath(sessionId);
227
+ const required = [metadata, log, request];
228
+ const missing = [];
229
+ for (const file of required) {
230
+ if (!(await fileExists(file))) {
231
+ missing.push(path.basename(file));
232
+ }
233
+ }
234
+ if (missing.length > 0) {
235
+ throw new Error(`Session "${sessionId}" is missing: ${missing.join(', ')}`);
236
+ }
237
+ return { dir, metadata, log, request };
238
+ }
239
+ async function markZombie(meta, { persist }) {
240
+ if (!isZombie(meta)) {
241
+ return meta;
242
+ }
243
+ const updated = {
244
+ ...meta,
245
+ status: 'error',
246
+ errorMessage: 'Session marked as zombie (>30m stale)',
247
+ completedAt: new Date().toISOString(),
248
+ };
249
+ if (persist) {
250
+ await fs.writeFile(metaPath(meta.id), JSON.stringify(updated, null, 2), 'utf8');
251
+ }
252
+ return updated;
253
+ }
254
+ function isZombie(meta) {
255
+ if (meta.status !== 'running') {
256
+ return false;
257
+ }
258
+ const reference = meta.startedAt ?? meta.createdAt;
259
+ if (!reference) {
260
+ return false;
261
+ }
262
+ const startedMs = Date.parse(reference);
263
+ if (Number.isNaN(startedMs)) {
264
+ return false;
265
+ }
266
+ return Date.now() - startedMs > ZOMBIE_MAX_AGE_MS;
267
+ }
@@ -0,0 +1,20 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleIdentifier</key>
6
+ <string>com.steipete.oracle.notifier</string>
7
+ <key>CFBundleName</key>
8
+ <string>OracleNotifier</string>
9
+ <key>CFBundleDisplayName</key>
10
+ <string>Oracle Notifier</string>
11
+ <key>CFBundleExecutable</key>
12
+ <string>OracleNotifier</string>
13
+ <key>CFBundleIconFile</key>
14
+ <string>OracleIcon</string>
15
+ <key>CFBundlePackageType</key>
16
+ <string>APPL</string>
17
+ <key>LSMinimumSystemVersion</key>
18
+ <string>13.0</string>
19
+ </dict>
20
+ </plist>
@@ -0,0 +1,128 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>files</key>
6
+ <dict>
7
+ <key>Resources/OracleIcon.icns</key>
8
+ <data>
9
+ edUHAMetayIv3xtc3Vb92VXRLfM=
10
+ </data>
11
+ </dict>
12
+ <key>files2</key>
13
+ <dict>
14
+ <key>Resources/OracleIcon.icns</key>
15
+ <dict>
16
+ <key>hash2</key>
17
+ <data>
18
+ AVPJK/6w6IOsDLmZTW4hL+Za+/4wHMxZiIp0t6m3NRA=
19
+ </data>
20
+ </dict>
21
+ </dict>
22
+ <key>rules</key>
23
+ <dict>
24
+ <key>^Resources/</key>
25
+ <true/>
26
+ <key>^Resources/.*\.lproj/</key>
27
+ <dict>
28
+ <key>optional</key>
29
+ <true/>
30
+ <key>weight</key>
31
+ <real>1000</real>
32
+ </dict>
33
+ <key>^Resources/.*\.lproj/locversion.plist$</key>
34
+ <dict>
35
+ <key>omit</key>
36
+ <true/>
37
+ <key>weight</key>
38
+ <real>1100</real>
39
+ </dict>
40
+ <key>^Resources/Base\.lproj/</key>
41
+ <dict>
42
+ <key>weight</key>
43
+ <real>1010</real>
44
+ </dict>
45
+ <key>^version.plist$</key>
46
+ <true/>
47
+ </dict>
48
+ <key>rules2</key>
49
+ <dict>
50
+ <key>.*\.dSYM($|/)</key>
51
+ <dict>
52
+ <key>weight</key>
53
+ <real>11</real>
54
+ </dict>
55
+ <key>^(.*/)?\.DS_Store$</key>
56
+ <dict>
57
+ <key>omit</key>
58
+ <true/>
59
+ <key>weight</key>
60
+ <real>2000</real>
61
+ </dict>
62
+ <key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
63
+ <dict>
64
+ <key>nested</key>
65
+ <true/>
66
+ <key>weight</key>
67
+ <real>10</real>
68
+ </dict>
69
+ <key>^.*</key>
70
+ <true/>
71
+ <key>^Info\.plist$</key>
72
+ <dict>
73
+ <key>omit</key>
74
+ <true/>
75
+ <key>weight</key>
76
+ <real>20</real>
77
+ </dict>
78
+ <key>^PkgInfo$</key>
79
+ <dict>
80
+ <key>omit</key>
81
+ <true/>
82
+ <key>weight</key>
83
+ <real>20</real>
84
+ </dict>
85
+ <key>^Resources/</key>
86
+ <dict>
87
+ <key>weight</key>
88
+ <real>20</real>
89
+ </dict>
90
+ <key>^Resources/.*\.lproj/</key>
91
+ <dict>
92
+ <key>optional</key>
93
+ <true/>
94
+ <key>weight</key>
95
+ <real>1000</real>
96
+ </dict>
97
+ <key>^Resources/.*\.lproj/locversion.plist$</key>
98
+ <dict>
99
+ <key>omit</key>
100
+ <true/>
101
+ <key>weight</key>
102
+ <real>1100</real>
103
+ </dict>
104
+ <key>^Resources/Base\.lproj/</key>
105
+ <dict>
106
+ <key>weight</key>
107
+ <real>1010</real>
108
+ </dict>
109
+ <key>^[^/]+$</key>
110
+ <dict>
111
+ <key>nested</key>
112
+ <true/>
113
+ <key>weight</key>
114
+ <real>10</real>
115
+ </dict>
116
+ <key>^embedded\.provisionprofile$</key>
117
+ <dict>
118
+ <key>weight</key>
119
+ <real>20</real>
120
+ </dict>
121
+ <key>^version\.plist$</key>
122
+ <dict>
123
+ <key>weight</key>
124
+ <real>20</real>
125
+ </dict>
126
+ </dict>
127
+ </dict>
128
+ </plist>
@@ -0,0 +1,45 @@
1
+ import Foundation
2
+ import UserNotifications
3
+
4
+ let args = CommandLine.arguments
5
+ // Usage: OracleNotifier <title> <message> [soundName]
6
+ if args.count < 3 {
7
+ fputs("usage: OracleNotifier <title> <message> [soundName]\n", stderr)
8
+ exit(1)
9
+ }
10
+ let title = args[1]
11
+ let message = args[2]
12
+ let soundName = args.count >= 4 ? args[3] : "Glass"
13
+
14
+ let center = UNUserNotificationCenter.current()
15
+ let group = DispatchGroup()
16
+ group.enter()
17
+ center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
18
+ if let error = error {
19
+ fputs("auth error: \(error)\n", stderr)
20
+ group.leave()
21
+ return
22
+ }
23
+ if !granted {
24
+ fputs("authorization not granted\n", stderr)
25
+ group.leave()
26
+ return
27
+ }
28
+ let content = UNMutableNotificationContent()
29
+ content.title = title
30
+ content.body = message
31
+ if !soundName.isEmpty {
32
+ content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: soundName))
33
+ } else {
34
+ content.sound = UNNotificationSound.default
35
+ }
36
+ let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
37
+ center.add(request) { addError in
38
+ if let addError = addError {
39
+ fputs("add error: \(addError)\n", stderr)
40
+ }
41
+ group.leave()
42
+ }
43
+ }
44
+ _ = group.wait(timeout: .now() + 2)
45
+ RunLoop.current.run(until: Date().addingTimeInterval(1))
@@ -0,0 +1,24 @@
1
+ # Oracle Notifier helper (macOS, arm64)
2
+
3
+ Builds a tiny signed helper app for macOS notifications with the Oracle icon.
4
+
5
+ ## Build
6
+
7
+ ```bash
8
+ cd vendor/oracle-notifier
9
+ # Optional: notarize by setting App Store Connect key credentials
10
+ export APP_STORE_CONNECT_API_KEY_P8="$(cat AuthKey_XXXXXX.p8)" # with literal newlines or \n escaped
11
+ export APP_STORE_CONNECT_KEY_ID=XXXXXX
12
+ export APP_STORE_CONNECT_ISSUER_ID=YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY
13
+ ./build-notifier.sh
14
+ ```
15
+
16
+ - Requires Xcode command line tools (swiftc) and a macOS Developer ID certificate. Without a valid cert, the build fails (no ad-hoc fallback).
17
+ - If `APP_STORE_CONNECT_*` vars are set, the script notarizes and staples the ticket.
18
+ - Output: `OracleNotifier.app` (arm64 only), bundled with `OracleIcon.icns`.
19
+
20
+ ## Usage
21
+ The CLI prefers this helper on macOS; if it fails or is missing, it falls back to toasted-notifier/terminal-notifier.
22
+
23
+ ## Permissions
24
+ After first run, allow notifications for “Oracle Notifier” in System Settings → Notifications.