@firefunc-agent/runner 0.5.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.
package/dist/job.js ADDED
@@ -0,0 +1,391 @@
1
+ import { mkdtempSync, rmSync, existsSync, mkdirSync, writeFileSync, copyFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join, basename, dirname } from 'node:path';
4
+ import { exec, execOut, spawnBackground, killProcessTree } from './exec.js';
5
+ import { configPath } from './config.js';
6
+ import { createKeyedMutex } from './locks.js';
7
+ import { getAdapter, stripForeignEngineCreds } from './engines/index.js';
8
+ function log(msg) {
9
+ console.log(`[firefunc-runner] ${new Date().toISOString()} ${msg}`);
10
+ }
11
+ const gitMutex = createKeyedMutex();
12
+ function saveSessionLog(runId, stdout, stderr) {
13
+ try {
14
+ const dir = join(dirname(configPath()), 'logs');
15
+ mkdirSync(dir, { recursive: true });
16
+ const p = join(dir, `${runId}.log`);
17
+ writeFileSync(p, `=== agent stdout ===\n${stdout}\n\n=== agent stderr ===\n${stderr}\n`);
18
+ return p;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ const EXT_FOR_MIME = {
25
+ 'image/png': '.png',
26
+ 'image/jpeg': '.jpg',
27
+ 'image/gif': '.gif',
28
+ 'image/webp': '.webp',
29
+ 'image/bmp': '.bmp',
30
+ };
31
+ function safeAttachmentName(filename, i, mime) {
32
+ const base = basename(filename ?? '').replace(/[^a-zA-Z0-9._-]/g, '_');
33
+ if (base && /\.[a-zA-Z0-9]+$/.test(base))
34
+ return base;
35
+ const ext = (mime && EXT_FOR_MIME[mime]) || '.png';
36
+ return `${base || `attachment-${i + 1}`}${ext}`;
37
+ }
38
+ export function resolveRepoPath(repo, cfg) {
39
+ if (!repo)
40
+ return null;
41
+ const want = repo.toLowerCase();
42
+ if (cfg.repos) {
43
+ for (const [k, v] of Object.entries(cfg.repos)) {
44
+ if (k.toLowerCase() === want && existsSync(v))
45
+ return v;
46
+ }
47
+ }
48
+ if (cfg.reposDir) {
49
+ const guess = join(cfg.reposDir, basename(repo));
50
+ if (existsSync(guess))
51
+ return guess;
52
+ }
53
+ return null;
54
+ }
55
+ function installCmdFor(dir) {
56
+ if (existsSync(join(dir, 'pnpm-lock.yaml')))
57
+ return 'pnpm install --frozen-lockfile';
58
+ if (existsSync(join(dir, 'yarn.lock')))
59
+ return 'yarn install --frozen-lockfile';
60
+ if (existsSync(join(dir, 'package-lock.json')))
61
+ return 'npm ci';
62
+ return 'npm install';
63
+ }
64
+ async function autoInstallDeps(worktree, env, timeoutMs) {
65
+ const dirs = existsSync(join(worktree, 'package.json'))
66
+ ? ['']
67
+ : ['backend', 'web', 'frontend', 'server', 'client', 'api'];
68
+ let installed = 0;
69
+ for (const d of dirs) {
70
+ const dir = d ? join(worktree, d) : worktree;
71
+ if (!existsSync(join(dir, 'package.json')))
72
+ continue;
73
+ const cmd = installCmdFor(dir);
74
+ log(`auto-install: ${cmd} (in ${d || '.'})`);
75
+ const r = await exec(cmd, [], { cwd: dir, env, timeoutMs, shell: true });
76
+ if (r.code !== 0) {
77
+ log(`auto-install failed (exit ${r.code}) in ${d || '.'} — continuing without deps: ${r.stderr.slice(0, 200)}`);
78
+ }
79
+ else {
80
+ installed++;
81
+ }
82
+ }
83
+ if (!installed && dirs[0] !== '')
84
+ log('auto-install: no package.json found — skipping');
85
+ }
86
+ export async function runJob(job, cfg, api, viewer, opts) {
87
+ const report = async (payload) => {
88
+ try {
89
+ await api.result(job.runId, payload);
90
+ }
91
+ catch (err) {
92
+ log(`failed to report result for ${job.runId}: ${err.message}`);
93
+ }
94
+ };
95
+ const fail = (error) => {
96
+ log(`job ${job.runId} FAILED — ${error.replace(/\s+/g, ' ').slice(0, 300)}`);
97
+ return report({ status: 'failed', error });
98
+ };
99
+ const repoPath = resolveRepoPath(job.repo, cfg);
100
+ if (!repoPath) {
101
+ log(`no local checkout for ${job.repo} — set it in config.repos or reposDir`);
102
+ return report({
103
+ status: 'failed',
104
+ error: `Runner has no local checkout configured for ${job.repo}.`,
105
+ });
106
+ }
107
+ const adapter = getAdapter(job.engine ?? cfg.engine);
108
+ const branch = job.branch ?? `${adapter.branchPrefix}/firefunc-${job.runId.slice(0, 8)}`;
109
+ const timeoutMs = (cfg.jobTimeoutMinutes ?? 20) * 60_000;
110
+ let worktree = null;
111
+ let attachDir = null;
112
+ const bgProcs = [];
113
+ viewer?.begin(job.runId, {
114
+ title: job.title ?? job.externalId ?? undefined,
115
+ repo: job.repo ?? undefined,
116
+ });
117
+ try {
118
+ await api.event(job.runId, 'cloning', { repo: job.repo, branch });
119
+ const prep = await gitMutex.run(repoPath, async () => {
120
+ await exec('git', ['-C', repoPath, 'fetch', 'origin', '--quiet'], { timeoutMs: 120_000 });
121
+ let b = cfg.baseBranch ?? '';
122
+ if (!b) {
123
+ const head = await execOut('git', ['-C', repoPath, 'symbolic-ref', 'refs/remotes/origin/HEAD'], repoPath);
124
+ b = head ? head.replace('refs/remotes/origin/', '') : 'main';
125
+ }
126
+ await exec('git', ['-C', repoPath, 'worktree', 'prune']).catch(() => { });
127
+ const wt = mkdtempSync(join(tmpdir(), 'firefunc-'));
128
+ const result = await exec('git', ['-C', repoPath, 'worktree', 'add', '--force', '-B', branch, wt, `origin/${b}`], { timeoutMs: 120_000 });
129
+ return { base: b, wt, add: result };
130
+ });
131
+ const base = prep.base;
132
+ worktree = prep.wt;
133
+ if (prep.add.code !== 0) {
134
+ return fail(`git worktree add failed: ${prep.add.stderr.slice(0, 400)}`);
135
+ }
136
+ const attachPaths = [];
137
+ if (job.attachments?.length) {
138
+ attachDir = mkdtempSync(join(tmpdir(), 'firefunc-attach-'));
139
+ mkdirSync(attachDir, { recursive: true });
140
+ job.attachments.forEach((att, i) => {
141
+ if (!att?.dataB64)
142
+ return;
143
+ try {
144
+ const p = join(attachDir, safeAttachmentName(att.filename, i, att.contentType));
145
+ writeFileSync(p, Buffer.from(att.dataB64, 'base64'));
146
+ attachPaths.push(p);
147
+ }
148
+ catch {
149
+ }
150
+ });
151
+ if (attachPaths.length)
152
+ log(`wrote ${attachPaths.length} attachment(s) for ${job.runId}`);
153
+ }
154
+ const attachNote = attachPaths.length
155
+ ? `The reporter attached ${attachPaths.length} file(s). Read EACH one with the Read tool before forming your fix — they may be screenshots, designs, logs, stack traces, or repro data that show the bug or the intended result:\n${attachPaths
156
+ .map((p) => `- ${p}`)
157
+ .join('\n')}\n\n`
158
+ : '';
159
+ const runEnv = {
160
+ ...cfg.claudeEnv,
161
+ ...(cfg.claudeCodeOAuthToken ? { CLAUDE_CODE_OAUTH_TOKEN: cfg.claudeCodeOAuthToken } : {}),
162
+ ...(opts?.devServerPort ? { PORT: String(opts.devServerPort) } : {}),
163
+ };
164
+ if (opts?.devServerPort && cfg.backgroundCommands?.length) {
165
+ log(`session dev-server PORT=${opts.devServerPort} (parallel-safe)`);
166
+ }
167
+ if (cfg.envFile) {
168
+ try {
169
+ copyFileSync(cfg.envFile, join(worktree, '.env'));
170
+ log(`copied ${cfg.envFile} → worktree/.env`);
171
+ }
172
+ catch (e) {
173
+ log(`envFile copy failed (${cfg.envFile}): ${e.message}`);
174
+ }
175
+ }
176
+ const setupMs = (cfg.setupTimeoutMinutes ?? cfg.jobTimeoutMinutes ?? 20) * 60_000;
177
+ if (cfg.setupCommands?.length) {
178
+ for (const cmd of cfg.setupCommands) {
179
+ log(`setup: ${cmd}`);
180
+ const r = await exec(cmd, [], {
181
+ cwd: worktree,
182
+ env: runEnv,
183
+ timeoutMs: setupMs,
184
+ shell: true,
185
+ });
186
+ if (r.code !== 0) {
187
+ return fail(`Setup command failed (exit ${r.code}): ${cmd}\n${r.stderr.slice(0, 400)}`);
188
+ }
189
+ }
190
+ }
191
+ else if (cfg.autoInstall ?? true) {
192
+ await autoInstallDeps(worktree, runEnv, setupMs);
193
+ }
194
+ for (const cmd of cfg.backgroundCommands ?? []) {
195
+ log(`starting background: ${cmd}`);
196
+ bgProcs.push(spawnBackground(cmd, { cwd: worktree, env: runEnv }));
197
+ }
198
+ await api.event(job.runId, 'fixing', { title: job.title });
199
+ const mode = job.permissionMode ?? cfg.permissionMode ?? 'bypassPermissions';
200
+ const model = job.model ?? cfg.model ?? null;
201
+ const effort = job.effort ?? cfg.effort ?? null;
202
+ const ultracode = effort === 'ultracode';
203
+ const observeNote = 'Dev/observation tooling MAY be available here: the app may be runnable (setup ran), a dev server may be up, and AWS read creds / a staging URL / a test account may be in your environment. PREFER reproducing the reported symptom LIVE (run the app, curl the API, drive the UI) before reasoning statically, and re-observe after your change to confirm the fix. Treat any cloud/staging access as READ-ONLY.\n\n';
204
+ const prompt = observeNote + attachNote + (ultracode ? `ultracode\n\n${job.prompt}` : job.prompt);
205
+ const tools = cfg.allowedTools ??
206
+ 'Read Edit Write Bash Glob Grep WebFetch WebSearch Task TodoWrite NotebookEdit';
207
+ const isWin = process.platform === 'win32';
208
+ let inv;
209
+ try {
210
+ inv = adapter.buildInvocation({
211
+ prompt,
212
+ model,
213
+ effort,
214
+ ultracode,
215
+ permissionMode: mode,
216
+ allowedTools: tools,
217
+ maxBudgetUsd: cfg.maxBudgetUsd,
218
+ attachDir: attachDir ?? undefined,
219
+ attachPaths,
220
+ isWin,
221
+ runEnv: stripForeignEngineCreds(runEnv, adapter.id),
222
+ cfg,
223
+ });
224
+ }
225
+ catch (e) {
226
+ return fail(`${adapter.displayName} cannot run on this machine: ${e.message}`);
227
+ }
228
+ const engineName = adapter.displayName;
229
+ log(`running ${inv.bin} (${engineName}) on ${job.repo} (${branch}) — model=${model ?? 'default'} effort=${effort ?? 'default'} mode=${mode}`);
230
+ log(`${engineName} auth: ${inv.authLogLine}`);
231
+ const printer = cfg.quiet ? undefined : adapter.makeStreamPrinter();
232
+ const onStdout = printer || viewer
233
+ ? (chunk) => {
234
+ printer?.(chunk);
235
+ viewer?.push(job.runId, chunk);
236
+ }
237
+ : undefined;
238
+ if (printer) {
239
+ log(`── live ${engineName} session (shown here only — never sent to FireFunc) ──`);
240
+ }
241
+ const heartbeat = setInterval(() => void api.event(job.runId, 'heartbeat'), 5 * 60_000);
242
+ const engineRun = await exec(inv.bin, inv.args, {
243
+ cwd: worktree,
244
+ timeoutMs,
245
+ env: inv.env,
246
+ input: inv.stdinInput,
247
+ shell: inv.shell,
248
+ onStdout,
249
+ }).finally(() => clearInterval(heartbeat));
250
+ viewer?.end(job.runId, engineRun.code === 0 ? 'done' : 'failed');
251
+ if (printer)
252
+ log(`── end ${engineName} session ──`);
253
+ const sessionLogPath = saveSessionLog(job.runId, engineRun.stdout, engineRun.stderr);
254
+ const summary = adapter.summarize(engineRun.stdout);
255
+ if (sessionLogPath)
256
+ log(`${engineName} session saved → ${sessionLogPath}`);
257
+ if (summary)
258
+ log(`${engineName} said: ${summary.replace(/\s+/g, ' ').slice(0, 500)}`);
259
+ if (engineRun.code === 124) {
260
+ return fail(`${engineName} timed out after ${cfg.jobTimeoutMinutes ?? 20} min.`);
261
+ }
262
+ const scanHints = () => {
263
+ const authMsg = adapter.authFailureHint(`${summary}\n${engineRun.stderr}`);
264
+ if (authMsg) {
265
+ log(`auth/billing failure for ${job.runId}: ${authMsg}`);
266
+ return { status: 'failed', error: authMsg };
267
+ }
268
+ const rateMsg = adapter.rateLimitHint(`${summary}\n${engineRun.stderr}`);
269
+ if (rateMsg) {
270
+ log(`rate limit for ${job.runId}: ${rateMsg}`);
271
+ return { status: 'failed', error: rateMsg };
272
+ }
273
+ return null;
274
+ };
275
+ if (engineRun.code !== 0) {
276
+ const hinted = scanHints();
277
+ if (hinted)
278
+ return report(hinted);
279
+ return fail(summary
280
+ ? `${engineName} exited abnormally (code ${engineRun.code}). Last message: ${summary.replace(/\s+/g, ' ').slice(0, 500)}`
281
+ : `${engineName} exited abnormally (code ${engineRun.code}). ${engineRun.stderr.slice(0, 300)}`);
282
+ }
283
+ const title = job.title ? `fix: ${job.title}` : `fix: ${job.externalId ?? 'FireFunc auto-fix'}`;
284
+ await exec('git', ['-C', worktree, 'add', '-A']);
285
+ const vsBase = await exec('git', [
286
+ '-C',
287
+ worktree,
288
+ 'diff',
289
+ '--cached',
290
+ '--quiet',
291
+ `origin/${base}`,
292
+ ]);
293
+ if (vsBase.code === 0) {
294
+ const hinted = scanHints();
295
+ if (hinted)
296
+ return report(hinted);
297
+ log(`no changes produced for ${job.runId} — reporting no_pr`);
298
+ return report({
299
+ status: 'no_pr',
300
+ error: summary
301
+ ? `No code change made. ${engineName}: ${summary.replace(/\s+/g, ' ').slice(0, 800)}`
302
+ : `${engineName} made no changes (could not reproduce or fix).`,
303
+ });
304
+ }
305
+ const uncommitted = await exec('git', ['-C', worktree, 'diff', '--cached', '--quiet', 'HEAD']);
306
+ if (uncommitted.code !== 0) {
307
+ await exec('git', ['-C', worktree, 'commit', '-m', title, '--quiet']);
308
+ }
309
+ else {
310
+ log(`agent already committed its changes for ${job.runId} — reconciling to a PR`);
311
+ }
312
+ await api.event(job.runId, 'pushing', { branch });
313
+ const push = await exec('git', ['-C', worktree, 'push', '--force-with-lease', 'origin', `${branch}:${branch}`], { timeoutMs: 120_000 });
314
+ if (push.code !== 0) {
315
+ return fail(`git push failed: ${push.stderr.slice(0, 400)}`);
316
+ }
317
+ const body = `Automated fix by FireFunc (self-hosted ${engineName} runner) for ${job.externalId ?? 'a reported bug'}.\n\nReview before merging.`;
318
+ const create = await exec('gh', [
319
+ 'pr',
320
+ 'create',
321
+ '--draft',
322
+ '--head',
323
+ branch,
324
+ '--base',
325
+ base,
326
+ '--title',
327
+ title,
328
+ '--body',
329
+ body,
330
+ ], { cwd: worktree, timeoutMs: 60_000 });
331
+ const wt = worktree;
332
+ const viewPr = async (b) => {
333
+ const out = await execOut('gh', ['pr', 'view', b, '--json', 'url,number'], wt);
334
+ try {
335
+ const j = JSON.parse(out);
336
+ if (typeof j.url === 'string' && /\/pull\/\d+/.test(j.url) && Number.isFinite(j.number)) {
337
+ return { url: j.url, number: Number(j.number) };
338
+ }
339
+ }
340
+ catch {
341
+ }
342
+ return null;
343
+ };
344
+ const headBranch = (await execOut('git', ['rev-parse', '--abbrev-ref', 'HEAD'], wt)) || branch;
345
+ let resolved = null;
346
+ for (const b of [...new Set([headBranch, branch].filter(Boolean))]) {
347
+ resolved = await viewPr(b);
348
+ if (resolved)
349
+ break;
350
+ }
351
+ if (!resolved) {
352
+ const m = `${create.stdout}\n${create.stderr}`.match(/https?:\/\/\S*\/pull\/(\d+)/);
353
+ if (m)
354
+ resolved = { url: m[0], number: Number(m[1]) };
355
+ }
356
+ const url = resolved?.url ?? '';
357
+ const number = resolved?.number;
358
+ await api.event(job.runId, 'pr_opened', { url, branch });
359
+ log(`opened PR for ${job.runId}: ${url || '(url unknown)'}`);
360
+ return report({
361
+ status: 'pr_opened',
362
+ pr: { repo: job.repo ?? undefined, number, url: url || undefined, branch, summary: title },
363
+ });
364
+ }
365
+ catch (err) {
366
+ log(`job ${job.runId} errored: ${err.message}`);
367
+ return report({ status: 'failed', error: err.message.slice(0, 400) });
368
+ }
369
+ finally {
370
+ viewer?.end(job.runId, 'failed');
371
+ await Promise.all(bgProcs.map((p) => killProcessTree(p).catch(() => { })));
372
+ if (worktree) {
373
+ await gitMutex
374
+ .run(repoPath, () => exec('git', ['-C', repoPath, 'worktree', 'remove', '--force', worktree]))
375
+ .catch(() => { });
376
+ try {
377
+ rmSync(worktree, { recursive: true, force: true });
378
+ }
379
+ catch {
380
+ }
381
+ }
382
+ if (attachDir) {
383
+ try {
384
+ rmSync(attachDir, { recursive: true, force: true });
385
+ }
386
+ catch {
387
+ }
388
+ }
389
+ }
390
+ }
391
+ //# sourceMappingURL=job.js.map
@@ -0,0 +1,4 @@
1
+ export type KeyedMutex = {
2
+ run<T>(key: string, fn: () => Promise<T>): Promise<T>;
3
+ };
4
+ export declare function createKeyedMutex(): KeyedMutex;
package/dist/locks.js ADDED
@@ -0,0 +1,13 @@
1
+ const noop = () => { };
2
+ export function createKeyedMutex() {
3
+ const tails = new Map();
4
+ return {
5
+ run(key, fn) {
6
+ const prev = tails.get(key) ?? Promise.resolve();
7
+ const result = prev.then(fn, fn);
8
+ tails.set(key, result.then(noop, noop));
9
+ return result;
10
+ },
11
+ };
12
+ }
13
+ //# sourceMappingURL=locks.js.map
package/dist/pool.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ export type JobPoolDeps<J> = {
2
+ maxParallel: number | (() => number);
3
+ stopping: () => boolean;
4
+ claim: () => Promise<J | null>;
5
+ run: (job: J) => Promise<void>;
6
+ onClaimError?: (err: unknown) => Promise<'fatal' | 'retry'>;
7
+ onStart?: (job: J, inFlight: number) => void;
8
+ idleDelayMs?: number;
9
+ maxIdleDelayMs?: number;
10
+ sleep?: (ms: number) => Promise<void>;
11
+ };
12
+ export declare function runJobPool<J>(deps: JobPoolDeps<J>): Promise<void>;
package/dist/pool.js ADDED
@@ -0,0 +1,47 @@
1
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
2
+ export async function runJobPool(deps) {
3
+ const sleep = deps.sleep ?? defaultSleep;
4
+ const baseIdleMs = deps.idleDelayMs ?? 800;
5
+ const maxIdleMs = Math.max(baseIdleMs, deps.maxIdleDelayMs ?? 8_000);
6
+ let emptyClaims = 0;
7
+ const idleDelay = () => Math.min(maxIdleMs, baseIdleMs * 2 ** Math.min(emptyClaims, 10));
8
+ const cap = () => Math.max(1, Math.floor(typeof deps.maxParallel === 'function' ? deps.maxParallel() : deps.maxParallel));
9
+ const active = new Set();
10
+ while (!deps.stopping()) {
11
+ if (active.size >= cap()) {
12
+ await Promise.race(active);
13
+ continue;
14
+ }
15
+ let job;
16
+ try {
17
+ job = await deps.claim();
18
+ }
19
+ catch (err) {
20
+ if (!deps.onClaimError)
21
+ throw err;
22
+ if ((await deps.onClaimError(err)) === 'fatal')
23
+ break;
24
+ continue;
25
+ }
26
+ if (job == null) {
27
+ const delay = idleDelay();
28
+ emptyClaims += 1;
29
+ if (active.size > 0)
30
+ await Promise.race([...active, sleep(delay)]);
31
+ else
32
+ await sleep(delay);
33
+ continue;
34
+ }
35
+ emptyClaims = 0;
36
+ if (deps.stopping())
37
+ break;
38
+ deps.onStart?.(job, active.size + 1);
39
+ const p = deps.run(job).finally(() => {
40
+ active.delete(p);
41
+ });
42
+ active.add(p);
43
+ }
44
+ if (active.size > 0)
45
+ await Promise.allSettled(active);
46
+ }
47
+ //# sourceMappingURL=pool.js.map
@@ -0,0 +1,12 @@
1
+ export type SessionEvent = {
2
+ kind: 'text';
3
+ text: string;
4
+ } | {
5
+ kind: 'tool';
6
+ name: string;
7
+ arg?: string;
8
+ } | {
9
+ kind: 'result';
10
+ text: string;
11
+ };
12
+ export declare function makeStreamJsonParser(onEvent: (ev: SessionEvent) => void): (chunk: string) => void;
@@ -0,0 +1,42 @@
1
+ export function makeStreamJsonParser(onEvent) {
2
+ let buf = '';
3
+ return (chunk) => {
4
+ buf += chunk;
5
+ let nl = buf.indexOf('\n');
6
+ while (nl >= 0) {
7
+ const line = buf.slice(0, nl).trim();
8
+ buf = buf.slice(nl + 1);
9
+ nl = buf.indexOf('\n');
10
+ if (!line)
11
+ continue;
12
+ let ev;
13
+ try {
14
+ ev = JSON.parse(line);
15
+ }
16
+ catch {
17
+ continue;
18
+ }
19
+ if (ev.type === 'assistant' && ev.message?.content) {
20
+ for (const c of ev.message.content) {
21
+ if (c.type === 'text' && c.text?.trim()) {
22
+ onEvent({ kind: 'text', text: c.text });
23
+ }
24
+ else if (c.type === 'tool_use' && c.name) {
25
+ const inp = c.input ?? {};
26
+ const arg = (inp.file_path ??
27
+ inp.command ??
28
+ inp.pattern ??
29
+ inp.path ??
30
+ inp.url ??
31
+ '');
32
+ onEvent(arg ? { kind: 'tool', name: c.name, arg } : { kind: 'tool', name: c.name });
33
+ }
34
+ }
35
+ }
36
+ else if (ev.type === 'result' && typeof ev.result === 'string' && ev.result.trim()) {
37
+ onEvent({ kind: 'result', text: ev.result });
38
+ }
39
+ }
40
+ };
41
+ }
42
+ //# sourceMappingURL=session-stream.js.map
@@ -0,0 +1,16 @@
1
+ export type SessionStatus = 'running' | 'done' | 'failed';
2
+ export type SessionViewer = {
3
+ url: string | null;
4
+ begin: (runId: string, meta: {
5
+ title?: string;
6
+ repo?: string;
7
+ }) => void;
8
+ push: (runId: string, chunk: string) => void;
9
+ end: (runId: string, status: Exclude<SessionStatus, 'running'>) => void;
10
+ close: () => void;
11
+ };
12
+ export declare function createSessionViewer(opts: {
13
+ port?: number;
14
+ onListen?: (url: string) => void;
15
+ onError?: (msg: string) => void;
16
+ }): SessionViewer;