@steipete/oracle 1.0.8 → 1.1.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 (58) hide show
  1. package/README.md +3 -0
  2. package/dist/.DS_Store +0 -0
  3. package/dist/bin/oracle-cli.js +9 -3
  4. package/dist/markdansi/types/index.js +4 -0
  5. package/dist/oracle/bin/oracle-cli.js +472 -0
  6. package/dist/oracle/src/browser/actions/assistantResponse.js +471 -0
  7. package/dist/oracle/src/browser/actions/attachments.js +82 -0
  8. package/dist/oracle/src/browser/actions/modelSelection.js +190 -0
  9. package/dist/oracle/src/browser/actions/navigation.js +75 -0
  10. package/dist/oracle/src/browser/actions/promptComposer.js +167 -0
  11. package/dist/oracle/src/browser/chromeLifecycle.js +104 -0
  12. package/dist/oracle/src/browser/config.js +33 -0
  13. package/dist/oracle/src/browser/constants.js +40 -0
  14. package/dist/oracle/src/browser/cookies.js +210 -0
  15. package/dist/oracle/src/browser/domDebug.js +36 -0
  16. package/dist/oracle/src/browser/index.js +331 -0
  17. package/dist/oracle/src/browser/pageActions.js +5 -0
  18. package/dist/oracle/src/browser/prompt.js +88 -0
  19. package/dist/oracle/src/browser/promptSummary.js +20 -0
  20. package/dist/oracle/src/browser/sessionRunner.js +80 -0
  21. package/dist/oracle/src/browser/types.js +1 -0
  22. package/dist/oracle/src/browser/utils.js +62 -0
  23. package/dist/oracle/src/browserMode.js +1 -0
  24. package/dist/oracle/src/cli/browserConfig.js +44 -0
  25. package/dist/oracle/src/cli/dryRun.js +59 -0
  26. package/dist/oracle/src/cli/engine.js +17 -0
  27. package/dist/oracle/src/cli/errorUtils.js +9 -0
  28. package/dist/oracle/src/cli/help.js +70 -0
  29. package/dist/oracle/src/cli/markdownRenderer.js +15 -0
  30. package/dist/oracle/src/cli/options.js +103 -0
  31. package/dist/oracle/src/cli/promptRequirement.js +14 -0
  32. package/dist/oracle/src/cli/rootAlias.js +30 -0
  33. package/dist/oracle/src/cli/sessionCommand.js +77 -0
  34. package/dist/oracle/src/cli/sessionDisplay.js +270 -0
  35. package/dist/oracle/src/cli/sessionRunner.js +94 -0
  36. package/dist/oracle/src/heartbeat.js +43 -0
  37. package/dist/oracle/src/oracle/client.js +48 -0
  38. package/dist/oracle/src/oracle/config.js +29 -0
  39. package/dist/oracle/src/oracle/errors.js +101 -0
  40. package/dist/oracle/src/oracle/files.js +220 -0
  41. package/dist/oracle/src/oracle/format.js +33 -0
  42. package/dist/oracle/src/oracle/fsAdapter.js +7 -0
  43. package/dist/oracle/src/oracle/oscProgress.js +60 -0
  44. package/dist/oracle/src/oracle/request.js +48 -0
  45. package/dist/oracle/src/oracle/run.js +444 -0
  46. package/dist/oracle/src/oracle/tokenStats.js +39 -0
  47. package/dist/oracle/src/oracle/types.js +1 -0
  48. package/dist/oracle/src/oracle.js +9 -0
  49. package/dist/oracle/src/sessionManager.js +205 -0
  50. package/dist/oracle/src/version.js +39 -0
  51. package/dist/src/cli/markdownRenderer.js +18 -0
  52. package/dist/src/cli/rootAlias.js +14 -0
  53. package/dist/src/cli/sessionCommand.js +60 -2
  54. package/dist/src/cli/sessionDisplay.js +129 -4
  55. package/dist/src/oracle/oscProgress.js +60 -0
  56. package/dist/src/oracle/run.js +63 -51
  57. package/dist/src/sessionManager.js +17 -0
  58. package/package.json +14 -22
@@ -0,0 +1,205 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs/promises';
4
+ import { createWriteStream } from 'node:fs';
5
+ const ORACLE_HOME = process.env.ORACLE_HOME_DIR ?? path.join(os.homedir(), '.oracle');
6
+ const SESSIONS_DIR = path.join(ORACLE_HOME, 'sessions');
7
+ const MAX_STATUS_LIMIT = 1000;
8
+ const DEFAULT_SLUG = 'session';
9
+ const MAX_SLUG_WORDS = 5;
10
+ const MIN_CUSTOM_SLUG_WORDS = 3;
11
+ async function ensureDir(dirPath) {
12
+ await fs.mkdir(dirPath, { recursive: true });
13
+ }
14
+ export async function ensureSessionStorage() {
15
+ await ensureDir(SESSIONS_DIR);
16
+ }
17
+ function slugify(text, maxWords = MAX_SLUG_WORDS) {
18
+ const normalized = text?.toLowerCase() ?? '';
19
+ const words = normalized.match(/[a-z0-9]+/g) ?? [];
20
+ const trimmed = words.slice(0, maxWords);
21
+ return trimmed.length > 0 ? trimmed.join('-') : DEFAULT_SLUG;
22
+ }
23
+ function countSlugWords(slug) {
24
+ return slug.split('-').filter(Boolean).length;
25
+ }
26
+ function normalizeCustomSlug(candidate) {
27
+ const slug = slugify(candidate, MAX_SLUG_WORDS);
28
+ const wordCount = countSlugWords(slug);
29
+ if (wordCount < MIN_CUSTOM_SLUG_WORDS || wordCount > MAX_SLUG_WORDS) {
30
+ throw new Error(`Custom slug must include between ${MIN_CUSTOM_SLUG_WORDS} and ${MAX_SLUG_WORDS} words.`);
31
+ }
32
+ return slug;
33
+ }
34
+ export function createSessionId(prompt, customSlug) {
35
+ if (customSlug) {
36
+ return normalizeCustomSlug(customSlug);
37
+ }
38
+ return slugify(prompt);
39
+ }
40
+ function sessionDir(id) {
41
+ return path.join(SESSIONS_DIR, id);
42
+ }
43
+ function metaPath(id) {
44
+ return path.join(sessionDir(id), 'session.json');
45
+ }
46
+ function logPath(id) {
47
+ return path.join(sessionDir(id), 'output.log');
48
+ }
49
+ function requestPath(id) {
50
+ return path.join(sessionDir(id), 'request.json');
51
+ }
52
+ async function fileExists(targetPath) {
53
+ try {
54
+ await fs.access(targetPath);
55
+ return true;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ async function ensureUniqueSessionId(baseSlug) {
62
+ let candidate = baseSlug;
63
+ let suffix = 2;
64
+ while (await fileExists(sessionDir(candidate))) {
65
+ candidate = `${baseSlug}-${suffix}`;
66
+ suffix += 1;
67
+ }
68
+ return candidate;
69
+ }
70
+ export async function initializeSession(options, cwd) {
71
+ await ensureSessionStorage();
72
+ const baseSlug = createSessionId(options.prompt || DEFAULT_SLUG, options.slug);
73
+ const sessionId = await ensureUniqueSessionId(baseSlug);
74
+ const dir = sessionDir(sessionId);
75
+ await ensureDir(dir);
76
+ const mode = options.mode ?? 'api';
77
+ const browserConfig = options.browserConfig;
78
+ const metadata = {
79
+ id: sessionId,
80
+ createdAt: new Date().toISOString(),
81
+ status: 'pending',
82
+ promptPreview: (options.prompt || '').slice(0, 160),
83
+ model: options.model,
84
+ cwd,
85
+ mode,
86
+ browser: browserConfig ? { config: browserConfig } : undefined,
87
+ options: {
88
+ prompt: options.prompt,
89
+ file: options.file ?? [],
90
+ model: options.model,
91
+ maxInput: options.maxInput,
92
+ system: options.system,
93
+ maxOutput: options.maxOutput,
94
+ silent: options.silent,
95
+ filesReport: options.filesReport,
96
+ slug: sessionId,
97
+ mode,
98
+ browserConfig,
99
+ verbose: options.verbose,
100
+ heartbeatIntervalMs: options.heartbeatIntervalMs,
101
+ browserInlineFiles: options.browserInlineFiles,
102
+ background: options.background,
103
+ },
104
+ };
105
+ await fs.writeFile(metaPath(sessionId), JSON.stringify(metadata, null, 2), 'utf8');
106
+ await fs.writeFile(requestPath(sessionId), JSON.stringify(metadata.options, null, 2), 'utf8');
107
+ await fs.writeFile(logPath(sessionId), '', 'utf8');
108
+ return metadata;
109
+ }
110
+ export async function readSessionMetadata(sessionId) {
111
+ try {
112
+ const raw = await fs.readFile(metaPath(sessionId), 'utf8');
113
+ return JSON.parse(raw);
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ }
119
+ export async function updateSessionMetadata(sessionId, updates) {
120
+ const existing = (await readSessionMetadata(sessionId)) ?? { id: sessionId };
121
+ const next = { ...existing, ...updates };
122
+ await fs.writeFile(metaPath(sessionId), JSON.stringify(next, null, 2), 'utf8');
123
+ return next;
124
+ }
125
+ export function createSessionLogWriter(sessionId) {
126
+ const stream = createWriteStream(logPath(sessionId), { flags: 'a' });
127
+ const logLine = (line = '') => {
128
+ stream.write(`${line}\n`);
129
+ };
130
+ const writeChunk = (chunk) => {
131
+ stream.write(chunk);
132
+ return true;
133
+ };
134
+ return { stream, logLine, writeChunk, logPath: logPath(sessionId) };
135
+ }
136
+ export async function listSessionsMetadata() {
137
+ await ensureSessionStorage();
138
+ const entries = await fs.readdir(SESSIONS_DIR).catch(() => []);
139
+ const metas = [];
140
+ for (const entry of entries) {
141
+ const meta = await readSessionMetadata(entry);
142
+ if (meta) {
143
+ metas.push(meta);
144
+ }
145
+ }
146
+ return metas.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
147
+ }
148
+ export function filterSessionsByRange(metas, { hours = 24, includeAll = false, limit = 100 }) {
149
+ const maxLimit = Math.min(limit, MAX_STATUS_LIMIT);
150
+ let filtered = metas;
151
+ if (!includeAll) {
152
+ const cutoff = Date.now() - hours * 60 * 60 * 1000;
153
+ filtered = metas.filter((meta) => new Date(meta.createdAt).getTime() >= cutoff);
154
+ }
155
+ const limited = filtered.slice(0, maxLimit);
156
+ const truncated = filtered.length > maxLimit;
157
+ return { entries: limited, truncated, total: filtered.length };
158
+ }
159
+ export async function readSessionLog(sessionId) {
160
+ try {
161
+ return await fs.readFile(logPath(sessionId), 'utf8');
162
+ }
163
+ catch {
164
+ return '';
165
+ }
166
+ }
167
+ export async function deleteSessionsOlderThan({ hours = 24, includeAll = false, } = {}) {
168
+ await ensureSessionStorage();
169
+ const entries = await fs.readdir(SESSIONS_DIR).catch(() => []);
170
+ if (!entries.length) {
171
+ return { deleted: 0, remaining: 0 };
172
+ }
173
+ const cutoff = includeAll ? Number.NEGATIVE_INFINITY : Date.now() - hours * 60 * 60 * 1000;
174
+ let deleted = 0;
175
+ for (const entry of entries) {
176
+ const dir = sessionDir(entry);
177
+ let createdMs;
178
+ const meta = await readSessionMetadata(entry);
179
+ if (meta?.createdAt) {
180
+ const parsed = Date.parse(meta.createdAt);
181
+ if (!Number.isNaN(parsed)) {
182
+ createdMs = parsed;
183
+ }
184
+ }
185
+ if (createdMs == null) {
186
+ try {
187
+ const stats = await fs.stat(dir);
188
+ createdMs = stats.birthtimeMs || stats.mtimeMs;
189
+ }
190
+ catch {
191
+ continue;
192
+ }
193
+ }
194
+ if (includeAll || (createdMs != null && createdMs < cutoff)) {
195
+ await fs.rm(dir, { recursive: true, force: true });
196
+ deleted += 1;
197
+ }
198
+ }
199
+ const remaining = Math.max(entries.length - deleted, 0);
200
+ return { deleted, remaining };
201
+ }
202
+ export async function wait(ms) {
203
+ return new Promise((resolve) => setTimeout(resolve, ms));
204
+ }
205
+ export { ORACLE_HOME, SESSIONS_DIR, MAX_STATUS_LIMIT };
@@ -0,0 +1,39 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ let cachedVersion = null;
5
+ export function getCliVersion() {
6
+ if (cachedVersion) {
7
+ return cachedVersion;
8
+ }
9
+ cachedVersion = readVersionFromPackage();
10
+ return cachedVersion;
11
+ }
12
+ function readVersionFromPackage() {
13
+ const modulePath = fileURLToPath(import.meta.url);
14
+ let currentDir = path.dirname(modulePath);
15
+ const filesystemRoot = path.parse(currentDir).root;
16
+ // biome-ignore lint/nursery/noUnnecessaryConditions: deliberate sentinel loop to walk up directories
17
+ while (true) {
18
+ const candidate = path.join(currentDir, 'package.json');
19
+ try {
20
+ const raw = readFileSync(candidate, 'utf8');
21
+ const parsed = JSON.parse(raw);
22
+ const version = typeof parsed.version === 'string' && parsed.version.trim().length > 0
23
+ ? parsed.version.trim()
24
+ : '0.0.0';
25
+ return version;
26
+ }
27
+ catch (error) {
28
+ const code = error instanceof Error && 'code' in error ? error.code : undefined;
29
+ if (code && code !== 'ENOENT') {
30
+ break;
31
+ }
32
+ }
33
+ if (currentDir === filesystemRoot) {
34
+ break;
35
+ }
36
+ currentDir = path.dirname(currentDir);
37
+ }
38
+ return '0.0.0';
39
+ }
@@ -0,0 +1,18 @@
1
+ import { render as renderMarkdown } from 'markdansi';
2
+ export function renderMarkdownAnsi(markdown) {
3
+ try {
4
+ const color = Boolean(process.stdout.isTTY);
5
+ const width = process.stdout.columns;
6
+ const hyperlinks = color; // enable OSC 8 only when we have color/TTY
7
+ return renderMarkdown(markdown, {
8
+ color,
9
+ width,
10
+ wrap: true,
11
+ hyperlinks,
12
+ });
13
+ }
14
+ catch {
15
+ // Last-resort fallback: return the raw markdown so we never crash.
16
+ return markdown;
17
+ }
18
+ }
@@ -14,3 +14,17 @@ export async function handleStatusFlag(options, deps = defaultDeps) {
14
14
  await deps.showStatus({ hours: 24, includeAll: false, limit: 100, showExamples: true });
15
15
  return true;
16
16
  }
17
+ const defaultSessionDeps = {
18
+ attachSession,
19
+ };
20
+ /**
21
+ * Hidden root-level alias to attach to a stored session (`--session <id>`).
22
+ * Returns true when the alias was handled so callers can short-circuit.
23
+ */
24
+ export async function handleSessionAlias(options, deps = defaultSessionDeps) {
25
+ if (!options.session) {
26
+ return false;
27
+ }
28
+ await deps.attachSession(options.session);
29
+ return true;
30
+ }
@@ -1,14 +1,25 @@
1
+ import chalk from 'chalk';
1
2
  import { usesDefaultStatusFilters } from './options.js';
2
3
  import { attachSession, showStatus } from './sessionDisplay.js';
3
- import { deleteSessionsOlderThan } from '../sessionManager.js';
4
+ import { deleteSessionsOlderThan, getSessionPaths } from '../sessionManager.js';
4
5
  const defaultDependencies = {
5
6
  showStatus,
6
7
  attachSession,
7
8
  usesDefaultStatusFilters,
8
9
  deleteSessionsOlderThan,
10
+ getSessionPaths,
9
11
  };
12
+ const SESSION_OPTION_KEYS = new Set(['hours', 'limit', 'all', 'clear', 'clean', 'render', 'renderMarkdown', 'path']);
10
13
  export async function handleSessionCommand(sessionId, command, deps = defaultDependencies) {
11
14
  const sessionOptions = command.opts();
15
+ if (sessionOptions.verboseRender) {
16
+ process.env.ORACLE_VERBOSE_RENDER = '1';
17
+ }
18
+ const renderSource = command.getOptionValueSource?.('render');
19
+ const renderMarkdownSource = command.getOptionValueSource?.('renderMarkdown');
20
+ const renderExplicit = renderSource === 'cli' || renderMarkdownSource === 'cli';
21
+ const autoRender = !renderExplicit && process.stdout.isTTY;
22
+ const pathRequested = Boolean(sessionOptions.path);
12
23
  const clearRequested = Boolean(sessionOptions.clear || sessionOptions.clean);
13
24
  if (clearRequested) {
14
25
  if (sessionId) {
@@ -28,6 +39,28 @@ export async function handleSessionCommand(sessionId, command, deps = defaultDep
28
39
  process.exitCode = 1;
29
40
  return;
30
41
  }
42
+ if (pathRequested) {
43
+ if (!sessionId) {
44
+ console.error('The --path flag requires a session ID.');
45
+ process.exitCode = 1;
46
+ return;
47
+ }
48
+ try {
49
+ const paths = await deps.getSessionPaths(sessionId);
50
+ const richTty = Boolean(process.stdout.isTTY && chalk.level > 0);
51
+ const label = (text) => (richTty ? chalk.cyan(text) : text);
52
+ const value = (text) => (richTty ? chalk.dim(text) : text);
53
+ console.log(`${label('Session dir:')} ${value(paths.dir)}`);
54
+ console.log(`${label('Metadata:')} ${value(paths.metadata)}`);
55
+ console.log(`${label('Request:')} ${value(paths.request)}`);
56
+ console.log(`${label('Log:')} ${value(paths.log)}`);
57
+ }
58
+ catch (error) {
59
+ console.error(error instanceof Error ? error.message : String(error));
60
+ process.exitCode = 1;
61
+ }
62
+ return;
63
+ }
31
64
  if (!sessionId) {
32
65
  const showExamples = deps.usesDefaultStatusFilters(command);
33
66
  await deps.showStatus({
@@ -38,7 +71,13 @@ export async function handleSessionCommand(sessionId, command, deps = defaultDep
38
71
  });
39
72
  return;
40
73
  }
41
- await deps.attachSession(sessionId);
74
+ // Surface any root-level flags that were provided but are ignored when attaching to a session.
75
+ const ignoredFlags = listIgnoredFlags(command);
76
+ if (ignoredFlags.length > 0) {
77
+ console.log(`Ignoring flags on session attach: ${ignoredFlags.join(', ')}`);
78
+ }
79
+ const renderMarkdown = Boolean(sessionOptions.render || sessionOptions.renderMarkdown || autoRender);
80
+ await deps.attachSession(sessionId, { renderMarkdown });
42
81
  }
43
82
  export function formatSessionCleanupMessage(result, scope) {
44
83
  const deletedLabel = `${result.deleted} ${result.deleted === 1 ? 'session' : 'sessions'}`;
@@ -46,3 +85,22 @@ export function formatSessionCleanupMessage(result, scope) {
46
85
  const hint = 'Run "oracle session --clear --all" to delete everything.';
47
86
  return `Deleted ${deletedLabel} (${scope}). ${remainingLabel}.\n${hint}`;
48
87
  }
88
+ function listIgnoredFlags(command) {
89
+ const opts = command.optsWithGlobals();
90
+ const ignored = [];
91
+ for (const key of Object.keys(opts)) {
92
+ if (SESSION_OPTION_KEYS.has(key)) {
93
+ continue;
94
+ }
95
+ const source = command.getOptionValueSource?.(key);
96
+ if (source !== 'cli' && source !== 'env') {
97
+ continue;
98
+ }
99
+ const value = opts[key];
100
+ if (value === undefined || value === false || value === null) {
101
+ continue;
102
+ }
103
+ ignored.push(key);
104
+ }
105
+ return ignored;
106
+ }
@@ -1,8 +1,10 @@
1
1
  import chalk from 'chalk';
2
2
  import kleur from 'kleur';
3
3
  import { filterSessionsByRange, listSessionsMetadata, readSessionLog, readSessionMetadata, SESSIONS_DIR, wait, } from '../sessionManager.js';
4
- const isTty = process.stdout.isTTY;
5
- const dim = (text) => (isTty ? kleur.dim(text) : text);
4
+ import { renderMarkdownAnsi } from './markdownRenderer.js';
5
+ const isTty = () => Boolean(process.stdout.isTTY);
6
+ const dim = (text) => (isTty() ? kleur.dim(text) : text);
7
+ const MAX_RENDER_BYTES = 200_000;
6
8
  const CLEANUP_TIP = 'Tip: Run "oracle session --clear --hours 24" to prune cached runs (add --all to wipe everything).';
7
9
  export async function showStatus({ hours, includeAll, limit, showExamples = false }) {
8
10
  const metas = await listSessionsMetadata();
@@ -50,6 +52,8 @@ export async function attachSession(sessionId, options) {
50
52
  return;
51
53
  }
52
54
  const initialStatus = metadata.status;
55
+ const wantsRender = Boolean(options?.renderMarkdown);
56
+ const isVerbose = Boolean(process.env.ORACLE_VERBOSE_RENDER);
53
57
  if (!options?.suppressMetadata) {
54
58
  const reattachLine = buildReattachLine(metadata);
55
59
  if (reattachLine) {
@@ -75,15 +79,96 @@ export async function attachSession(sessionId, options) {
75
79
  if (shouldTrimIntro) {
76
80
  const fullLog = await readSessionLog(sessionId);
77
81
  const trimmed = trimBeforeFirstAnswer(fullLog);
78
- process.stdout.write(trimmed);
82
+ const size = Buffer.byteLength(trimmed, 'utf8');
83
+ const canRender = wantsRender && isTty() && size <= MAX_RENDER_BYTES;
84
+ if (wantsRender && size > MAX_RENDER_BYTES) {
85
+ const msg = `Render skipped (log too large: ${size} bytes > ${MAX_RENDER_BYTES}). Showing raw text.`;
86
+ console.log(dim(msg));
87
+ if (isVerbose) {
88
+ console.log(dim(`Verbose: renderMarkdown=true tty=${isTty()} size=${size}`));
89
+ }
90
+ }
91
+ else if (wantsRender && !isTty()) {
92
+ const msg = 'Render requested but stdout is not a TTY; showing raw text.';
93
+ console.log(dim(msg));
94
+ if (isVerbose) {
95
+ console.log(dim(`Verbose: renderMarkdown=true tty=${isTty()} size=${size}`));
96
+ }
97
+ }
98
+ if (canRender) {
99
+ if (isVerbose) {
100
+ console.log(dim(`Verbose: rendering markdown (size=${size}, tty=${isTty()})`));
101
+ }
102
+ process.stdout.write(renderMarkdownAnsi(trimmed));
103
+ }
104
+ else {
105
+ process.stdout.write(trimmed);
106
+ }
79
107
  return;
80
108
  }
109
+ if (wantsRender) {
110
+ console.log(dim('Render will apply after completion; streaming raw text meanwhile...'));
111
+ if (isVerbose) {
112
+ console.log(dim(`Verbose: streaming phase renderMarkdown=true tty=${isTty()}`));
113
+ }
114
+ }
115
+ const liveRenderState = wantsRender && isTty()
116
+ ? { pending: '', inFence: false, inTable: false, renderedBytes: 0, fallback: false, noticedFallback: false }
117
+ : null;
81
118
  let lastLength = 0;
119
+ const renderLiveChunk = (chunk) => {
120
+ if (!liveRenderState || chunk.length === 0) {
121
+ process.stdout.write(chunk);
122
+ return;
123
+ }
124
+ if (liveRenderState.fallback) {
125
+ process.stdout.write(chunk);
126
+ return;
127
+ }
128
+ liveRenderState.pending += chunk;
129
+ const { chunks, remainder } = extractRenderableChunks(liveRenderState.pending, liveRenderState);
130
+ liveRenderState.pending = remainder;
131
+ for (const candidate of chunks) {
132
+ const projected = liveRenderState.renderedBytes + Buffer.byteLength(candidate, 'utf8');
133
+ if (projected > MAX_RENDER_BYTES) {
134
+ if (!liveRenderState.noticedFallback) {
135
+ console.log(dim(`Render skipped (log too large: > ${MAX_RENDER_BYTES} bytes). Showing raw text.`));
136
+ liveRenderState.noticedFallback = true;
137
+ }
138
+ liveRenderState.fallback = true;
139
+ process.stdout.write(candidate + liveRenderState.pending);
140
+ liveRenderState.pending = '';
141
+ return;
142
+ }
143
+ process.stdout.write(renderMarkdownAnsi(candidate));
144
+ liveRenderState.renderedBytes += Buffer.byteLength(candidate, 'utf8');
145
+ }
146
+ };
147
+ const flushRemainder = () => {
148
+ if (!liveRenderState || liveRenderState.fallback) {
149
+ return;
150
+ }
151
+ if (liveRenderState.pending.length === 0) {
152
+ return;
153
+ }
154
+ const text = liveRenderState.pending;
155
+ liveRenderState.pending = '';
156
+ const projected = liveRenderState.renderedBytes + Buffer.byteLength(text, 'utf8');
157
+ if (projected > MAX_RENDER_BYTES) {
158
+ if (!liveRenderState.noticedFallback) {
159
+ console.log(dim(`Render skipped (log too large: > ${MAX_RENDER_BYTES} bytes). Showing raw text.`));
160
+ }
161
+ process.stdout.write(text);
162
+ liveRenderState.fallback = true;
163
+ return;
164
+ }
165
+ process.stdout.write(renderMarkdownAnsi(text));
166
+ };
82
167
  const printNew = async () => {
83
168
  const text = await readSessionLog(sessionId);
84
169
  const nextChunk = text.slice(lastLength);
85
170
  if (nextChunk.length > 0) {
86
- process.stdout.write(nextChunk);
171
+ renderLiveChunk(nextChunk);
87
172
  lastLength = text.length;
88
173
  }
89
174
  };
@@ -96,6 +181,7 @@ export async function attachSession(sessionId, options) {
96
181
  }
97
182
  if (latest.status === 'completed' || latest.status === 'error') {
98
183
  await printNew();
184
+ flushRemainder();
99
185
  if (!options?.suppressMetadata) {
100
186
  if (latest.status === 'error' && latest.errorMessage) {
101
187
  console.log('\nResult:');
@@ -234,3 +320,42 @@ function printStatusExamples() {
234
320
  console.log(dim(' Attach to a specific running/completed session to stream its output.'));
235
321
  console.log(dim(CLEANUP_TIP));
236
322
  }
323
+ function extractRenderableChunks(text, state) {
324
+ const chunks = [];
325
+ let buffer = '';
326
+ const lines = text.split(/(\n)/);
327
+ for (let i = 0; i < lines.length; i += 1) {
328
+ const segment = lines[i];
329
+ if (segment === '\n') {
330
+ buffer += segment;
331
+ // Detect code fences
332
+ const prev = lines[i - 1] ?? '';
333
+ const fenceMatch = prev.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
334
+ if (!state.inFence && fenceMatch) {
335
+ state.inFence = true;
336
+ state.fenceDelimiter = fenceMatch[2];
337
+ }
338
+ else if (state.inFence && state.fenceDelimiter && prev.startsWith(state.fenceDelimiter)) {
339
+ state.inFence = false;
340
+ state.fenceDelimiter = undefined;
341
+ }
342
+ const trimmed = prev.trim();
343
+ if (!state.inFence) {
344
+ if (!state.inTable && trimmed.startsWith('|') && trimmed.includes('|')) {
345
+ state.inTable = true;
346
+ }
347
+ if (state.inTable && trimmed === '') {
348
+ state.inTable = false;
349
+ }
350
+ }
351
+ const safeBreak = !state.inFence && !state.inTable && trimmed === '';
352
+ if (safeBreak) {
353
+ chunks.push(buffer);
354
+ buffer = '';
355
+ }
356
+ continue;
357
+ }
358
+ buffer += segment;
359
+ }
360
+ return { chunks, remainder: buffer };
361
+ }
@@ -0,0 +1,60 @@
1
+ import process from 'node:process';
2
+ const OSC = '\u001b]9;4;';
3
+ const ST = '\u001b\\';
4
+ function sanitizeLabel(label) {
5
+ const withoutEscape = label.split('\u001b').join('');
6
+ const withoutBellAndSt = withoutEscape.replaceAll('\u0007', '').replaceAll('\u009c', '');
7
+ return withoutBellAndSt.replaceAll(']', '').trim();
8
+ }
9
+ export function supportsOscProgress(env = process.env, isTty = process.stdout.isTTY) {
10
+ if (!isTty) {
11
+ return false;
12
+ }
13
+ if (env.ORACLE_NO_OSC_PROGRESS === '1') {
14
+ return false;
15
+ }
16
+ if (env.ORACLE_FORCE_OSC_PROGRESS === '1') {
17
+ return true;
18
+ }
19
+ const termProgram = (env.TERM_PROGRAM ?? '').toLowerCase();
20
+ if (termProgram.includes('ghostty')) {
21
+ return true;
22
+ }
23
+ if (termProgram.includes('wezterm')) {
24
+ return true;
25
+ }
26
+ if (env.WT_SESSION) {
27
+ return true; // Windows Terminal exposes this
28
+ }
29
+ return false;
30
+ }
31
+ export function startOscProgress(options = {}) {
32
+ const { label = 'Waiting for OpenAI', targetMs = 10 * 60_000, write = (text) => process.stdout.write(text) } = options;
33
+ if (!supportsOscProgress(options.env, options.isTty)) {
34
+ return () => { };
35
+ }
36
+ const cleanLabel = sanitizeLabel(label);
37
+ const target = Math.max(targetMs, 1_000);
38
+ const send = (state, percent) => {
39
+ const clamped = Math.max(0, Math.min(100, Math.round(percent)));
40
+ write(`${OSC}${state};${clamped};${cleanLabel}${ST}`);
41
+ };
42
+ const startedAt = Date.now();
43
+ send(1, 0); // activate progress bar
44
+ const timer = setInterval(() => {
45
+ const elapsed = Date.now() - startedAt;
46
+ const percent = Math.min(99, (elapsed / target) * 100);
47
+ send(1, percent);
48
+ }, 900);
49
+ timer.unref?.();
50
+ let stopped = false;
51
+ return () => {
52
+ // biome-ignore lint/nursery/noUnnecessaryConditions: multiple callers may try to stop
53
+ if (stopped) {
54
+ return;
55
+ }
56
+ stopped = true;
57
+ clearInterval(timer);
58
+ send(0, 0); // clear the progress bar
59
+ };
60
+ }