golem-kit 0.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.
package/src/cli.ts ADDED
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env tsx
2
+ import { startDevServer } from './dev-server.ts';
3
+ import { buildBrowser } from './browser-build.ts';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { dirname, resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const help = `Golem — local project CLI
10
+
11
+ Usage: ./golem <command>
12
+
13
+ help Show every command (also the default).
14
+ init Create a minimal app in the current directory.
15
+ dev Serve the browser shell at http://127.0.0.1:3000/.
16
+ Stop with Ctrl+C. Uses the local Codex runtime from the browser.
17
+ build Build the browser shell into dist/.
18
+ doctor Report local shell and backend readiness.
19
+
20
+ Requires Node.js >=22.18.0. Commands accept no additional arguments.
21
+ Exit codes: 0 success/clean shutdown, 1 unavailable or failed, 2 invalid usage.
22
+ `;
23
+
24
+ const [command = 'help', ...args] = process.argv.slice(2);
25
+
26
+ if (args.length || !['help', 'init', 'dev', 'build', 'doctor'].includes(command)) {
27
+ console.error('Invalid command or arguments. Run ./golem help.');
28
+ process.exitCode = 2;
29
+ } else {
30
+ switch (command) {
31
+ case 'help':
32
+ console.log(help);
33
+ break;
34
+ case 'init':
35
+ try {
36
+ initProject();
37
+ console.log('Initialized Golem app. Run ./golem help.');
38
+ } catch (error) {
39
+ console.error(`Cannot initialize Golem app: ${error instanceof Error ? error.message : String(error)}`);
40
+ process.exitCode = 1;
41
+ }
42
+ break;
43
+ case 'doctor':
44
+ console.log(`Golem shell readiness (Node ${process.version})
45
+ Ready: local CLI, HTTP shell, golem-ui browser build and Codex session seam.
46
+ Claude integration: not yet connected.
47
+ Not implemented: Claude integration.
48
+ No network exposure is enabled; the dev server binds to loopback.`);
49
+ break;
50
+ case 'build':
51
+ try {
52
+ await buildBrowser();
53
+ } catch (error) {
54
+ console.error(`Cannot build Golem browser shell: ${error instanceof Error ? error.message : String(error)}`);
55
+ process.exitCode = 1;
56
+ }
57
+ break;
58
+ case 'dev':
59
+ try {
60
+ const server = await startDevServer();
61
+ const stop = () => {
62
+ server.close((error) => {
63
+ process.removeListener('SIGINT', stop);
64
+ process.removeListener('SIGTERM', stop);
65
+ if (error) {
66
+ console.error(error.message);
67
+ process.exitCode = 1;
68
+ } else {
69
+ console.log('Golem dev server stopped.');
70
+ }
71
+ });
72
+ server.closeAllConnections();
73
+ };
74
+ process.once('SIGINT', stop);
75
+ process.once('SIGTERM', stop);
76
+ console.log('Golem shell: http://127.0.0.1:3000/ (Ctrl+C to stop)');
77
+ } catch (error) {
78
+ console.error(`Cannot start Golem dev server: ${error instanceof Error ? error.message : String(error)}`);
79
+ process.exitCode = 1;
80
+ }
81
+ }
82
+ }
83
+
84
+ function initProject(): void {
85
+ const root = resolve(process.cwd());
86
+ const files = ['golem.config.ts', 'src/app.tsx', 'docs/domain.md', 'golem'];
87
+ const existing = files.filter((file) => existsSync(resolve(root, file)));
88
+ if (existing.length) throw new Error(`refusing to overwrite existing files: ${existing.join(', ')}`);
89
+ const packagePath = resolve(root, 'package.json');
90
+ const packageExisted = existsSync(packagePath);
91
+ if (existsSync(packagePath)) {
92
+ const current = JSON.parse(readFileSync(packagePath, 'utf8')) as { dependencies?: Record<string, string> };
93
+ if (!current.dependencies?.['golem-kit']) throw new Error('refusing to overwrite existing package.json');
94
+ }
95
+ const frameworkRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
96
+ const framework = JSON.parse(readFileSync(resolve(frameworkRoot, 'package.json'), 'utf8')) as { version: string };
97
+ mkdirSync(resolve(root, 'src'), { recursive: true });
98
+ mkdirSync(resolve(root, 'docs'), { recursive: true });
99
+ if (!existsSync(packagePath)) {
100
+ writeFileSync(packagePath, JSON.stringify({
101
+ name: 'golem-app', private: true, type: 'module', packageManager: 'pnpm@10.28.2',
102
+ engines: { node: '>=22.18.0', pnpm: '10.28.2' },
103
+ ...(process.env.GOLEM_KIT_TARBALL ? {} : { dependencies: { 'golem-kit': framework.version } }),
104
+ }, null, 2) + '\n');
105
+ }
106
+ writeFileSync(resolve(root, 'golem.config.ts'), "export default { title: 'Golem' }\n");
107
+ writeFileSync(resolve(root, 'src/app.tsx'), `export default function App() {
108
+ return (
109
+ <section className="flex h-full min-h-64 items-center justify-center bg-neutral-50 p-6 text-center">
110
+ <div>
111
+ <h1 className="text-lg font-semibold">Welcome to Golem</h1>
112
+ <p className="mt-2 text-sm text-neutral-500">Edit src/app.tsx to build your app.</p>
113
+ </div>
114
+ </section>
115
+ )
116
+ }
117
+ `);
118
+ writeFileSync(resolve(root, 'docs/domain.md'), '# Golem app\n\nA minimal editable app entrypoint.\n');
119
+ const ignorePath = resolve(root, '.gitignore');
120
+ const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, 'utf8') : '';
121
+ if (!ignore.split(/\r?\n/).includes('.golem/')) writeFileSync(ignorePath, `${ignore}${ignore && !ignore.endsWith('\n') ? '\n' : ''}.golem/\n`);
122
+ if (!packageExisted) {
123
+ const packageSpec = process.env.GOLEM_KIT_TARBALL ?? `golem-kit@${framework.version}`;
124
+ execFileSync('pnpm', ['add', '--save-exact', packageSpec], { cwd: root, stdio: 'inherit' });
125
+ }
126
+ writeFileSync(resolve(root, 'golem'), '#!/bin/sh\nset -eu\ncd -- "$(dirname -- "$0")"\nif [ -n "${GOLEM_SOURCE:-}" ]; then\n exec node "$GOLEM_SOURCE/src/cli.ts" "$@"\nfi\nexec node_modules/.bin/golem-kit "$@"\n', { mode: 0o755 });
127
+ }
@@ -0,0 +1,190 @@
1
+ import { createServer, type Server } from 'node:http';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { extname, join, normalize, resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { buildBrowser, rebuild } from './browser-build.ts';
6
+ import { discoverAgents, runtimeState } from './runtime/discovery.ts';
7
+ import { CodexBackend, type SandboxMode } from './runtime/codex.ts';
8
+ import { SessionManager, type Session, type SessionBackend } from './runtime/session.ts';
9
+ import { ConversationState } from './runtime/state.ts';
10
+
11
+ const appRoot = resolve(process.cwd());
12
+ const root = pathToFileURL(`${process.cwd()}/dist/`);
13
+ const types: Record<string, string> = {
14
+ '.html': 'text/html; charset=utf-8',
15
+ '.js': 'text/javascript; charset=utf-8',
16
+ '.css': 'text/css; charset=utf-8',
17
+ '.svg': 'image/svg+xml',
18
+ };
19
+
20
+ // The CLI owns logging and signals; this server only serves the built browser shell.
21
+ export async function startDevServer(
22
+ port = 3000,
23
+ createBackend: (mode: SandboxMode, threadId?: string) => SessionBackend = (mode, threadId) => new CodexBackend(appRoot, mode, 'codex', [], threadId),
24
+ stateDirectory = join(appRoot, '.golem'),
25
+ ): Promise<Server> {
26
+ await buildBrowser();
27
+ const state = new ConversationState(stateDirectory);
28
+ const sessions = new SessionManager((snapshots) => state.save(snapshots));
29
+ sessions.restore(await state.load(), (snapshot) => createBackend(snapshot.buildMode ? 'danger-full-access' : 'read-only', snapshot.threadId));
30
+ const server = createServer((request, response) => {
31
+ void handleRequest(request, response, sessions, port, createBackend).catch((error) => {
32
+ if (!response.headersSent) json(response, 400, { error: error instanceof Error ? error.message : 'Malformed request' });
33
+ else response.destroy();
34
+ });
35
+ });
36
+ server.once('close', () => { void sessions.disposeAll().then(() => sessions.flushAll()) });
37
+ return new Promise((resolve, reject) => {
38
+ server.once('error', reject);
39
+ server.listen(port, '127.0.0.1', () => resolve(server));
40
+ });
41
+ }
42
+
43
+ async function handleRequest(
44
+ request: import('node:http').IncomingMessage,
45
+ response: import('node:http').ServerResponse,
46
+ sessions: SessionManager,
47
+ port: number,
48
+ createBackend: (mode: SandboxMode, threadId?: string) => SessionBackend,
49
+ ): Promise<void> {
50
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1');
51
+ decodeURIComponent(url.pathname);
52
+ if (url.pathname.startsWith('/api/')) {
53
+ await handleApi(request, response, url, sessions, port, createBackend);
54
+ return;
55
+ }
56
+ let pathname: string;
57
+ try {
58
+ pathname = decodeURIComponent(new URL(request.url ?? '/', 'http://127.0.0.1').pathname);
59
+ } catch {
60
+ response.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
61
+ response.end('Malformed URL\n');
62
+ return;
63
+ }
64
+ const relative = pathname === '/' ? 'index.html' : pathname.slice(1);
65
+ const normalized = normalize(join('.', relative));
66
+ const file = new URL(normalized, root);
67
+ if (!file.pathname.startsWith(root.pathname)) {
68
+ response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
69
+ response.end('Not found\n');
70
+ return;
71
+ }
72
+ try {
73
+ const body = await readFile(file);
74
+ response.writeHead(200, { 'Content-Type': types[extname(relative)] ?? 'application/octet-stream' });
75
+ response.end(body);
76
+ } catch {
77
+ if (extname(relative)) {
78
+ response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
79
+ response.end('Not found\n');
80
+ return;
81
+ }
82
+ try {
83
+ const body = await readFile(new URL('index.html', root));
84
+ response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
85
+ response.end(body);
86
+ } catch {
87
+ response.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
88
+ response.end('Run `./golem build` before starting the dev server.\n');
89
+ }
90
+ }
91
+ }
92
+
93
+ function json(response: import('node:http').ServerResponse, status: number, body: unknown): void {
94
+ response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
95
+ response.end(JSON.stringify(body));
96
+ }
97
+
98
+ async function body(request: import('node:http').IncomingMessage): Promise<unknown> {
99
+ let text = '';
100
+ for await (const chunk of request) {
101
+ text += String(chunk);
102
+ if (text.length > 100_000) throw new Error('Request body is too large');
103
+ }
104
+ try { return JSON.parse(text || '{}'); } catch { throw new Error('Request body must be valid JSON'); }
105
+ }
106
+
107
+ /** Fires once after a successful build-mode turn; never blocks the turn's own response. */
108
+ function triggerRebuild(session: Session): void {
109
+ void rebuild().then(
110
+ () => session.notifyRebuilt(),
111
+ (error) => session.notifyBuildFailed(error instanceof Error ? error.message : String(error)),
112
+ );
113
+ }
114
+
115
+ function mutationAllowed(request: import('node:http').IncomingMessage, port: number): boolean {
116
+ const origin = request.headers.origin;
117
+ return !origin || origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`;
118
+ }
119
+
120
+ async function handleApi(
121
+ request: import('node:http').IncomingMessage,
122
+ response: import('node:http').ServerResponse,
123
+ url: URL,
124
+ sessions: SessionManager,
125
+ port: number,
126
+ createBackend: (mode: SandboxMode) => SessionBackend,
127
+ ): Promise<void> {
128
+ if (request.method === 'GET' && url.pathname === '/api/runtime') {
129
+ const discoveries = await discoverAgents();
130
+ json(response, 200, { discoveries, state: runtimeState(discoveries) });
131
+ return;
132
+ }
133
+ if (request.method === 'POST' && url.pathname === '/api/sessions') {
134
+ if (!mutationAllowed(request, port)) return json(response, 403, { error: 'Cross-origin mutations are not allowed' });
135
+ try {
136
+ const input = await body(request) as { backend?: string; intent?: string };
137
+ if (input.backend !== 'codex') return json(response, 400, { error: 'Only the connected Codex backend can start a session' });
138
+ // Server-owned: only an explicit build intent grants filesystem access. Omitted intent stays read-only.
139
+ // danger-full-access, not app-root-confined — see CodexBackend's doc comment for why.
140
+ const buildMode = input.intent === 'build';
141
+ const session = await sessions.start('codex', createBackend(buildMode ? 'danger-full-access' : 'read-only'), buildMode);
142
+ json(response, 201, { id: session.id, backend: session.backend, status: session.status });
143
+ } catch (error) {
144
+ const message = error instanceof Error ? error.message : String(error);
145
+ json(response, message.startsWith('Request body') ? 400 : 503, { error: message });
146
+ }
147
+ return;
148
+ }
149
+ const match = url.pathname.match(/^\/api\/sessions\/([^/]+)(?:\/(history|events|interrupt))?$/);
150
+ if (!match) return json(response, 404, { error: 'Unknown API route' });
151
+ const session = sessions.get(match[1]);
152
+ if (!session) return json(response, 404, { error: 'Unknown session' });
153
+ if (request.method === 'GET' && match[2] === 'history') {
154
+ json(response, 200, { events: session.history, status: session.status, backend: session.backend });
155
+ return;
156
+ }
157
+ if (request.method === 'GET' && match[2] === 'events') {
158
+ const after = Number(url.searchParams.get('after') ?? request.headers['last-event-id'] ?? '-1');
159
+ if (!Number.isInteger(after)) return json(response, 400, { error: 'after must be an integer sequence' });
160
+ response.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
161
+ response.flushHeaders();
162
+ const write = (event: { sequence: number }) => response.write(`id: ${event.sequence}\ndata: ${JSON.stringify(event)}\n\n`);
163
+ const unsubscribe = session.subscribeFrom(after, write);
164
+ request.on('close', unsubscribe);
165
+ return;
166
+ }
167
+ if (request.method === 'POST' && !match[2]) {
168
+ if (!mutationAllowed(request, port)) return json(response, 403, { error: 'Cross-origin mutations are not allowed' });
169
+ try {
170
+ const input = await body(request) as { text?: unknown };
171
+ if (typeof input.text !== 'string' || !input.text.trim()) return json(response, 400, { error: 'text must be a non-empty string' });
172
+ await session.send(input.text);
173
+ await session.flush();
174
+ json(response, 202, { status: session.status });
175
+ if (session.buildMode) triggerRebuild(session);
176
+ } catch (error) {
177
+ const message = error instanceof Error ? error.message : String(error);
178
+ json(response, message.startsWith('Request body') ? 400 : 409, { error: message, status: session.status });
179
+ }
180
+ return;
181
+ }
182
+ if (request.method === 'POST' && match[2] === 'interrupt') {
183
+ if (!mutationAllowed(request, port)) return json(response, 403, { error: 'Cross-origin mutations are not allowed' });
184
+ await session.interrupt();
185
+ await session.flush();
186
+ json(response, 200, { status: session.status });
187
+ return;
188
+ }
189
+ json(response, 404, { error: 'Unknown API route' });
190
+ }
@@ -0,0 +1,119 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process'
2
+ import type { BackendEvent, SessionBackend } from './session.ts'
3
+
4
+ type JsonEvent = { type?: string; thread_id?: string; item?: { type?: string; text?: string }; error?: string; message?: string }
5
+ export type SandboxMode = 'read-only' | 'danger-full-access'
6
+
7
+ /**
8
+ * One native `codex exec --json` process per turn; the thread id preserves continuity.
9
+ * `mode` is chosen by the caller (the dev server, from the session's explicit build intent),
10
+ * never defaulted here: an explicit build intent selects `danger-full-access`, a supported
11
+ * `-s`/`--sandbox` value (`codex exec --help`) that runs with the account's own ordinary
12
+ * filesystem permissions — not the `--dangerously-bypass-approvals-and-sandbox` omnibus flag,
13
+ * which is never used here; every other session stays `read-only`.
14
+ *
15
+ * `cwd`/`-C` are pinned to the resolved app root so Codex resolves relative paths correctly —
16
+ * not as a security boundary. A build-mode session can write anywhere this account can.
17
+ */
18
+ export class CodexBackend implements SessionBackend {
19
+ private emit!: (event: BackendEvent) => void
20
+ private nativeThreadId: string | undefined
21
+ private child: ChildProcess | undefined
22
+ private request: Promise<void> | undefined
23
+ private interrupted = false
24
+ private childClosed: Promise<void> | undefined
25
+ private readonly cwd: string
26
+ private readonly mode: SandboxMode
27
+ private readonly executable: string
28
+ private readonly prefixArgs: string[]
29
+
30
+ constructor(cwd: string, mode: SandboxMode, executable = 'codex', prefixArgs: string[] = [], threadId?: string) {
31
+ this.cwd = cwd
32
+ this.mode = mode
33
+ this.executable = executable
34
+ this.prefixArgs = prefixArgs
35
+ this.nativeThreadId = threadId
36
+ }
37
+
38
+ async start(emit: (event: BackendEvent) => void): Promise<void> { this.emit = emit }
39
+
40
+ threadId(): string | undefined { return this.nativeThreadId }
41
+
42
+ send(text: string): Promise<void> {
43
+ if (this.request) return Promise.reject(new Error('Codex is already handling a request'))
44
+ const args = this.nativeThreadId
45
+ ? [...this.prefixArgs, 'exec', 'resume', this.nativeThreadId, '--json', '-c', `sandbox_mode="${this.mode}"`, '--skip-git-repo-check', text]
46
+ : [...this.prefixArgs, 'exec', '--json', '-s', this.mode, '-C', this.cwd, '--skip-git-repo-check', text]
47
+ this.request = new Promise((resolve, reject) => {
48
+ this.interrupted = false
49
+ const child = spawn(this.executable, args, { cwd: this.cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, detached: process.platform !== 'win32' })
50
+ this.child = child
51
+ this.childClosed = new Promise((resolve) => child.once('close', () => resolve()))
52
+ let stderr = ''
53
+ let output = ''
54
+ let settled = false
55
+ const fail = (message: string) => {
56
+ if (settled) return
57
+ settled = true
58
+ this.emit({ type: 'error', message })
59
+ reject(new Error(message))
60
+ void this.stopChild(child)
61
+ }
62
+ child.stderr.setEncoding('utf8').on('data', (chunk) => { stderr += chunk })
63
+ child.stdout.setEncoding('utf8').on('data', (chunk) => {
64
+ output += String(chunk)
65
+ const lines = output.split('\n')
66
+ output = lines.pop() ?? ''
67
+ for (const line of lines.filter(Boolean)) {
68
+ let event: JsonEvent
69
+ try { event = JSON.parse(line) } catch { continue }
70
+ if (event.type === 'thread.started' && event.thread_id) this.nativeThreadId = event.thread_id
71
+ if (event.type === 'item.completed' && event.item?.type === 'agent_message' && event.item.text) {
72
+ this.emit({ type: 'message', text: event.item.text })
73
+ }
74
+ if (event.type === 'error' || event.type === 'turn.failed') fail(event.error ?? event.message ?? 'Codex request failed')
75
+ }
76
+ })
77
+ child.once('error', (error) => fail(error.message))
78
+ child.once('close', (code, signal) => {
79
+ this.child = undefined
80
+ this.childClosed = undefined
81
+ this.request = undefined
82
+ if (settled) return
83
+ if (code === 0 || this.interrupted) { settled = true; resolve(); return }
84
+ fail(stderr.trim() || (signal ? `Codex terminated by ${signal}` : `Codex exited with code ${code}`))
85
+ })
86
+ })
87
+ return this.request
88
+ }
89
+
90
+ async interrupt(): Promise<void> {
91
+ if (!this.child) return
92
+ this.interrupted = true
93
+ await this.stopChild(this.child)
94
+ }
95
+
96
+ async shutdown(): Promise<void> {
97
+ if (this.child) await this.stopChild(this.child)
98
+ if (this.request) await this.request.catch(() => {})
99
+ this.child = undefined
100
+ }
101
+
102
+ private async stopChild(child: ChildProcess): Promise<void> {
103
+ const closed = this.childClosed ?? new Promise<void>((resolve) => child.once('close', () => resolve()))
104
+ this.signal(child, 'SIGTERM')
105
+ // Always escalate the request group: its leader may exit after TERM while a child survives.
106
+ await new Promise<void>((resolve) => setTimeout(resolve, 250))
107
+ this.signal(child, 'SIGKILL')
108
+ await Promise.race([closed, new Promise<void>((resolve) => setTimeout(resolve, 750))])
109
+ }
110
+
111
+ private signal(child: ChildProcess, signal: NodeJS.Signals): void {
112
+ try {
113
+ if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal)
114
+ else child.kill(signal)
115
+ } catch (error) {
116
+ if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error
117
+ }
118
+ }
119
+ }
@@ -0,0 +1,92 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ export type AgentName = 'claude' | 'codex'
4
+ export type ProbeStatus = 'available' | 'missing' | 'failed'
5
+
6
+ export type AgentDiscovery = {
7
+ agent: AgentName
8
+ executable: string
9
+ status: ProbeStatus
10
+ runnable?: boolean
11
+ detail?: string
12
+ }
13
+
14
+ export type Probe = (executable: string, args: string[], timeoutMs: number) => Promise<ProbeResult>
15
+
16
+ export type ProbeResult = {
17
+ status: 'available' | 'missing' | 'failed'
18
+ detail?: string
19
+ }
20
+
21
+ const executables: Record<AgentName, string> = { claude: 'claude', codex: 'codex' }
22
+
23
+ export function probeExecutable(executable: string, args = ['--version'], timeoutMs = 2_000): Promise<ProbeResult> {
24
+ return new Promise((resolve) => {
25
+ const child = spawn(executable, args, { stdio: 'ignore', windowsHide: true })
26
+ let settled = false
27
+ let timedOut = false
28
+ let timeoutTimer: NodeJS.Timeout
29
+ let killTimer: NodeJS.Timeout | undefined
30
+ let settleTimer: NodeJS.Timeout | undefined
31
+ const finish = (result: ProbeResult) => {
32
+ if (settled) return
33
+ settled = true
34
+ clearTimeout(timeoutTimer)
35
+ if (killTimer) clearTimeout(killTimer)
36
+ if (settleTimer) clearTimeout(settleTimer)
37
+ resolve(result)
38
+ }
39
+ timeoutTimer = setTimeout(() => {
40
+ timedOut = true
41
+ child.kill('SIGTERM')
42
+ killTimer = setTimeout(() => {
43
+ child.kill('SIGKILL')
44
+ settleTimer = setTimeout(() => finish({ status: 'failed', detail: 'timed out' }), 500)
45
+ }, 100)
46
+ }, timeoutMs)
47
+ child.once('error', (error: NodeJS.ErrnoException) => {
48
+ finish(error.code === 'ENOENT'
49
+ ? { status: 'missing' }
50
+ : { status: 'failed', detail: error.message })
51
+ })
52
+ child.once('exit', (code, signal) => {
53
+ finish(timedOut
54
+ ? { status: 'failed', detail: 'timed out' }
55
+ : code === 0
56
+ ? { status: 'available' }
57
+ : { status: 'failed', detail: signal ? `terminated by ${signal}` : `exited with code ${code}` })
58
+ })
59
+ })
60
+ }
61
+
62
+ export async function discoverAgents(
63
+ probe: Probe = probeExecutable,
64
+ timeoutMs = 2_000,
65
+ ): Promise<AgentDiscovery[]> {
66
+ return Promise.all((Object.entries(executables) as [AgentName, string][]).map(async ([agent, executable]) => ({
67
+ agent,
68
+ executable,
69
+ ...(probe === probeExecutable ? { runnable: agent === 'codex' } : {}),
70
+ ...(await probe(executable, ['--version'], timeoutMs)),
71
+ })))
72
+ }
73
+
74
+ export type RuntimeState =
75
+ | { kind: 'setup'; explanation: string }
76
+ | { kind: 'ready'; backend: AgentName }
77
+ | { kind: 'choice-required'; available: AgentName[]; explanation: string }
78
+
79
+ export function runtimeState(discoveries: AgentDiscovery[]): RuntimeState {
80
+ const available = discoveries
81
+ .filter(({ status, runnable = true }) => status === 'available' && runnable)
82
+ .map(({ agent }) => agent)
83
+ if (available.length === 0) {
84
+ return { kind: 'setup', explanation: 'Install Codex to start an agent session; Claude is not connected yet.' }
85
+ }
86
+ if (available.length === 1) return { kind: 'ready', backend: available[0] }
87
+ return {
88
+ kind: 'choice-required',
89
+ available,
90
+ explanation: 'Both Claude Code and Codex are available; choose one before starting a session.',
91
+ }
92
+ }
@@ -0,0 +1,2 @@
1
+ export * from './discovery.ts'
2
+ export * from './session.ts'