ReviewOS

also looking at this

stacks/uplink

feat(engine): answer texts with Codex as well as Claude Code

#7
Merged glennmichael123 wants to merge feat/codex-engine into main
18 files +1080 -142

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

app/Uplink/codex-engine.tsadded+378-0
Changes to app/Uplink/codex-engine.ts
@@ -0,0 +1,378 @@
1import type { Subprocess } from 'bun'
2import type { Engine, EngineProbe, EngineRequest, EngineResult, EngineRun } from './engine'
3import { randomUUID } from 'node:crypto'
4import { rmSync } from 'node:fs'
5import { tmpdir } from 'node:os'
6import { join } from 'node:path'
7import process from 'node:process'
8import { PROBE_TIMEOUT_MS } from './engine'
9import { truncate } from './format'
10
11/**
12 * Runs a prompt through the OpenAI Codex CLI in its non-interactive mode
13 * (`codex exec`).
14 *
15 * Same reasoning as the Claude engine: going through the CLI uses whatever
16 * account it is logged into - a ChatGPT plan via `codex login`, no API key
17 * needed - and brings Codex's own toolset and each repository's AGENTS.md.
18 *
19 * Four things about this CLI differ from Claude Code and shape everything
20 * below. They were confirmed against a real run, not read off documentation.
21 *
22 * 1. Resuming is a subcommand, `codex exec resume <id> <prompt>`, not a flag.
23 * It accepts neither `-C/--cd` nor `-s/--sandbox`, so a resumed run takes
24 * its working directory from the recorded session. Session lookup is
25 * filtered by cwd, so the process still has to be spawned in the right
26 * directory.
27 * 2. There is no `--append-system-prompt`. Uplink's instructions are prepended
28 * to the prompt instead, which works on every version.
29 * 3. Codex refuses to run outside a git repository without
30 * `--skip-git-repo-check`, and Uplink's default working directory is the
31 * home directory. The flag is mandatory, not optional.
32 * 4. The JSONL flag is spelled `--json` on some versions and
33 * `--experimental-json` on others, and without it the CLI prints prose
34 * there is nothing to parse. It is resolved against the installed binary
35 * rather than hardcoded.
36 */
37
38export interface CodexEngineOptions {
39 bin: string
40 model?: string | null
41 /**
42 * How much the agent may do without asking. `bypass` is the equivalent of
43 * Claude Code's `bypassPermissions`, which is what Uplink has always used:
44 * the point of the product is that nobody is at the Mac to approve anything.
45 */
46 permission: CodexPermission
47 systemPrompt: string
48 timeoutMs: number
49 env?: Record<string, string | undefined>
50}
51
52export type CodexPermission = 'bypass' | 'danger-full-access' | 'workspace-write' | 'read-only'
53
54export const CODEX_PERMISSIONS: readonly CodexPermission[] = ['bypass', 'danger-full-access', 'workspace-write', 'read-only']
55
56/** A 401 from the backend, which is what a signed-out CLI produces. */
57const UNAUTHORIZED = /\b401\b|unauthorized|missing bearer|not logged in|no codex credentials/i
58
59export class CodexEngine implements Engine {
60 readonly id = 'codex' as const
61 readonly label = 'Codex'
62 readonly authFailureHint = 'run "codex login" on the Mac (or "codex login --device-auth" over SSH)'
63
64 constructor(private readonly options: CodexEngineOptions) {}
65
66 /**
67 * `codex login status` exits 0 and says which account when signed in. Unlike
68 * Claude Code, proof of life costs nothing here: no turn is spent, so this
69 * can be checked as often as the UI likes.
70 */
71 async probe(): Promise<EngineProbe> {
72 try {
73 const proc = Bun.spawn([this.options.bin, 'login', 'status'], {
74 cwd: tmpdir(),
75 env: this.env(),
76 stdin: 'ignore',
77 stdout: 'pipe',
78 stderr: 'pipe',
79 })
80 const timer = setTimeout(() => proc.kill(), PROBE_TIMEOUT_MS)
81 const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()])
82 const exitCode = await proc.exited
83 clearTimeout(timer)
84 const said = truncate((out.trim() || err.trim()) || 'Codex said nothing', 140)
85 return exitCode === 0
86 ? { ok: true, detail: said }
87 : { ok: false, detail: said === 'Codex said nothing' ? 'Not signed in yet.' : said }
88 }
89 catch {
90 return { ok: false, detail: `Could not run ${this.options.bin}. Is Codex installed?` }
91 }
92 }
93
94 /**
95 * Which spelling of the JSONL flag this binary offers, asked once per binary
96 * per process. The two are not interchangeable and passing the wrong one
97 * fails silently: the CLI prints prose instead of events and the run looks
98 * empty rather than broken. `--help` is inert, so asking is cheap and safe.
99 */
100 resolveJsonFlag(): Promise<string> {
101 return resolveJsonFlag(this.options.bin, this.env())
102 }
103
104 args(request: EngineRequest, jsonFlag: string, lastMessageFile: string): string[] {
105 const prompt = `${this.options.systemPrompt}\n\n---\n\n${request.prompt}`
106
107 // `codex exec resume` accepts a much shorter flag list than `codex exec`:
108 // `--sandbox` and `--cd` are both rejected with "unexpected argument" and
109 // exit 2. So the sandbox is set through `-c sandbox_mode=`, which both
110 // forms accept, and only a fresh run gets `--cd`.
111 const permission = this.options.permission === 'bypass'
112 ? ['--dangerously-bypass-approvals-and-sandbox']
113 : ['--config', `sandbox_mode="${this.options.permission}"`]
114
115 const args = request.sessionId
116 ? ['exec', 'resume', request.sessionId]
117 : ['exec']
118
119 args.push(jsonFlag, '--skip-git-repo-check', '--output-last-message', lastMessageFile)
120 // Resume has no --cd: it takes the working directory from the recorded
121 // session, which is why the process is spawned in `request.cwd` regardless.
122 if (!request.sessionId)
123 args.push('--cd', request.cwd)
124 args.push(...permission)
125 if (this.options.model)
126 args.push('--model', this.options.model)
127 args.push(prompt)
128 return args
129 }
130
131 run(request: EngineRequest): EngineRun {
132 const started = Date.now()
133 let cancelled = false
134 let timedOut = false
135 const lastMessageFile = join(tmpdir(), `uplink-codex-${randomUUID()}.txt`)
136
137 let proc: Subprocess<'ignore', 'pipe', 'pipe'> | null = null
138 const killTree = (): void => {
139 if (!proc)
140 return
141 try {
142 process.kill(-proc.pid, 'SIGTERM')
143 }
144 catch {
145 proc.kill('SIGTERM')
146 }
147 }
148
149 let timer: ReturnType<typeof setTimeout> | null = null
150
151 const done = (async (): Promise<EngineResult> => {
152 const jsonFlag = await this.resolveJsonFlag()
153
154 proc = Bun.spawn([this.options.bin, ...this.args(request, jsonFlag, lastMessageFile)], {
155 cwd: request.cwd,
156 stdin: 'ignore',
157 stdout: 'pipe',
158 stderr: 'pipe',
159 env: this.env(),
160 // Its own process group, so stopping a run also stops what the agent
161 // started. Killing only `codex` leaves those holding stdout open and
162 // the run never ends.
163 detached: true,
164 })
165
166 // Cancelling before the process existed still has to take effect.
167 if (cancelled) {
168 killTree()
169 return failure('Stopped.', request.sessionId ?? null, Date.now() - started)
170 }
171
172 timer = setTimeout(() => {
173 timedOut = true
174 killTree()
175 }, this.options.timeoutMs)
176
177 let sessionId = request.sessionId ?? null
178 let lastText = ''
179 let turns = 0
180 let failed: string | null = null
181
182 const handle = (line: string): void => {
183 const event = parseLine(line)
184 if (!event)
185 return
186 switch (event.type) {
187 case 'thread.started': {
188 const id = typeof event.thread_id === 'string' ? event.thread_id : null
189 if (id && id !== sessionId) {
190 sessionId = id
191 request.onEvent?.({ kind: 'session', sessionId: id })
192 }
193 break
194 }
195 case 'item.completed': {
196 const item = event.item as Record<string, any> | undefined
197 if (!item)
198 break
199 if (item.type === 'agent_message') {
200 const text = String(item.text ?? '')
201 if (text.trim()) {
202 lastText = text
203 request.onEvent?.({ kind: 'text', text })
204 }
205 }
206 // `reasoning` is the model thinking aloud, not an action worth
207 // texting as progress. Everything else is something it did.
208 else if (item.type !== 'reasoning') {
209 request.onEvent?.({ kind: 'tool', name: String(item.type ?? 'tool'), summary: summarizeItem(item) })
210 }
211 break
212 }
213 case 'turn.completed':
214 turns += 1
215 break
216 case 'turn.failed':
217 failed = errorText(event) ?? 'the turn failed'
218 break
219 }
220 }
221
222 const stderr = new Response(proc.stderr).text()
223 const decoder = new TextDecoder()
224 let buffered = ''
225 for await (const bytes of proc.stdout) {
226 buffered += decoder.decode(bytes, { stream: true })
227 let newline = buffered.indexOf('\n')
228 while (newline !== -1) {
229 handle(buffered.slice(0, newline))
230 buffered = buffered.slice(newline + 1)
231 newline = buffered.indexOf('\n')
232 }
233 }
234 if (buffered.trim())
235 handle(buffered)
236
237 const exitCode = await proc.exited
238 if (timer)
239 clearTimeout(timer)
240 const errorOutput = (await stderr).trim()
241 const durationMs = Date.now() - started
242
243 // The last message is written to a file as well as streamed, so a stream
244 // this version spells differently still yields the answer.
245 const fromFile = await readAndRemove(lastMessageFile)
246
247 if (cancelled)
248 return failure('Stopped.', sessionId, durationMs)
249 if (timedOut)
250 return failure(`Timed out after ${Math.round(this.options.timeoutMs / 60000)} minutes.`, sessionId, durationMs)
251
252 const text = (fromFile || lastText).trim()
253
254 if (exitCode === 0 && !failed) {
255 return {
256 ok: true,
257 text: text || 'Done.',
258 sessionId,
259 // Codex reports tokens, never a cost. A figure invented from a price
260 // table would read as measured, the way Claude's genuinely is.
261 costCents: null,
262 durationMs,
263 turns: turns || null,
264 authFailure: false,
265 }
266 }
267
268 const reason = failed ?? errorOutput ?? ''
269 const said = truncate(reason || text || `codex exited with code ${exitCode}`, 400)
270 return { ...failure(said, sessionId, durationMs), authFailure: await this.looksUnauthenticated(said) }
271 })()
272
273 return {
274 done,
275 cancel: () => {
276 cancelled = true
277 killTree()
278 },
279 }
280 }
281
282 /**
283 * Asked rather than pattern-matched where possible. A regex over failure text
284 * is how Claude's engine has to do it, but Codex will answer the question
285 * directly, and a wrong answer here sends someone to the wrong fix.
286 */
287 private async looksUnauthenticated(said: string): Promise<boolean> {
288 if (UNAUTHORIZED.test(said))
289 return true
290 const probe = await this.probe()
291 return !probe.ok
292 }
293
294 private env(): Record<string, string | undefined> {
295 return { ...process.env, ...this.options.env }
296 }
297}
298
299/** One answer per binary, for the life of the process. */
300const jsonFlags = new Map<string, Promise<string>>()
301
302export function resolveJsonFlag(bin: string, env: Record<string, string | undefined>): Promise<string> {
303 const cached = jsonFlags.get(bin)
304 if (cached)
305 return cached
306 const resolved = (async (): Promise<string> => {
307 try {
308 const proc = Bun.spawn([bin, 'exec', '--help'], { stdin: 'ignore', stdout: 'pipe', stderr: 'ignore', env })
309 const help = await new Response(proc.stdout).text()
310 await proc.exited
311 // Prefer the plain spelling when a version offers both.
312 if (/(^|\s)--json(\s|$)/m.test(help))
313 return '--json'
314 return /--experimental-json/.test(help) ? '--experimental-json' : '--json'
315 }
316 catch {
317 return '--json'
318 }
319 })()
320 jsonFlags.set(bin, resolved)
321 return resolved
322}
323
324/** Forget the cached answers. For tests, which use a different stub per case. */
325export function clearJsonFlagCache(): void {
326 jsonFlags.clear()
327}
328
329function failure(text: string, sessionId: string | null, durationMs: number): EngineResult {
330 return { ok: false, text, sessionId, costCents: null, durationMs, turns: null, authFailure: false }
331}
332
333function parseLine(line: string): Record<string, any> | null {
334 const trimmed = line.trim()
335 if (!trimmed.startsWith('{'))
336 return null
337 try {
338 return JSON.parse(trimmed)
339 }
340 catch {
341 return null
342 }
343}
344
345function errorText(event: Record<string, any>): string | null {
346 const error = event.error ?? event.message
347 if (typeof error === 'string')
348 return error
349 if (error && typeof error.message === 'string')
350 return error.message
351 return null
352}
353
354/**
355 * One human-readable line for something the agent did. The item kinds beyond
356 * `agent_message` and `reasoning` are not a closed set across versions, so this
357 * reads whichever descriptive field is present rather than switching on a name.
358 */
359export function summarizeItem(item: Record<string, any>): string {
360 const name = String(item.type ?? 'tool')
361 const detail = item.command ?? item.path ?? item.query ?? item.url ?? item.name ?? item.text ?? ''
362 const shown = typeof detail === 'string'
363 ? detail.replace(/^\/Users\/[^/]+/, '~')
364 : Array.isArray(detail) ? detail.join(' ') : ''
365 return shown ? `${name}: ${truncate(shown, 80)}` : name
366}
367
368async function readAndRemove(path: string): Promise<string> {
369 try {
370 const file = Bun.file(path)
371 const text = await file.exists() ? await file.text() : ''
372 rmSync(path, { force: true })
373 return text
374 }
375 catch {
376 return ''
377 }
378}
app/Uplink/config.tsmodified+54-2
Changes to app/Uplink/config.ts
@@ -1,6 +1,10 @@
1import type { CodexPermission } from './codex-engine'
2import type { EngineId } from './engine'
13import { homedir } from 'node:os'
24import { join } from 'node:path'
35import process from 'node:process'
6import { CODEX_PERMISSIONS } from './codex-engine'
7import { isEngineId } from './engine'
48import { parseHandleList } from './handles'
59import { DEFAULT_MESSAGES_DB } from './messages-db'
610
@@ -23,9 +27,16 @@ export interface UplinkConfig {
2327 /** A thread idle longer than this starts a fresh agent session. */
2428 sessionIdleMs: number
2529 workdir: string
30 /** Which agent CLI answers a text, unless a thread says otherwise. */
31 engine: EngineId
2632 claudeBin: string
27 model: string | null
33 claudeModel: string | null
34 /** How much Claude Code may do without asking. */
2835 permissionMode: string
36 codexBin: string
37 codexModel: string | null
38 /** How much Codex may do without asking. The two CLIs spell this differently. */
39 codexPermission: CodexPermission
2940 timeoutMs: number
3041}
3142
@@ -62,13 +73,31 @@ export function loadConfig(env: Env = process.env): UplinkConfig {
6273 progressEveryMs: int(env, 'UPLINK_PROGRESS_EVERY_MS', 10 * MINUTE),
6374 sessionIdleMs: int(env, 'UPLINK_SESSION_IDLE_MS', 6 * 60 * MINUTE),
6475 workdir: expandHome(str(env, 'UPLINK_WORKDIR', homedir())),
76 engine: engineId(env.UPLINK_ENGINE),
6577 claudeBin: expandHome(str(env, 'UPLINK_CLAUDE_BIN', findClaude())),
66 model: env.UPLINK_MODEL || null,
78 // UPLINK_MODEL predates the second engine and meant Claude's model alias,
79 // so an existing .env keeps working.
80 claudeModel: env.UPLINK_CLAUDE_MODEL || env.UPLINK_MODEL || null,
6781 permissionMode: str(env, 'UPLINK_PERMISSION_MODE', 'bypassPermissions'),
82 codexBin: expandHome(str(env, 'UPLINK_CODEX_BIN', findCodex())),
83 codexModel: env.UPLINK_CODEX_MODEL || null,
84 codexPermission: codexPermission(env.UPLINK_CODEX_PERMISSION),
6885 timeoutMs: int(env, 'UPLINK_TIMEOUT_MS', 90 * MINUTE),
6986 }
7087}
7188
89function engineId(value: string | undefined): EngineId {
90 return value && isEngineId(value) ? value : 'claude'
91}
92
93function codexPermission(value: string | undefined): CodexPermission {
94 return value && (CODEX_PERMISSIONS as readonly string[]).includes(value)
95 ? value as CodexPermission
96 // Claude Code runs with bypassPermissions, because nobody is at the Mac to
97 // approve anything. Codex's equivalent is the same trade, spelled its way.
98 : 'bypass'
99}
100
72101/**
73102 * launchd starts agents with a bare PATH, so `claude` from an interactive
74103 * shell is not necessarily findable. Check where the installers put it.
@@ -89,6 +118,29 @@ function findClaude(): string {
89118 return 'claude'
90119}
91120
121/**
122 * The same bare-PATH problem as findClaude, plus one path that is easy to miss:
123 * the ChatGPT desktop app bundles its own codex binary and does not put it on
124 * PATH. On a Mac with ChatGPT installed that is the working Codex, so a search
125 * that skips it reports Codex missing on a machine that has it.
126 */
127function findCodex(): string {
128 const fromPath = Bun.which('codex')
129 if (fromPath)
130 return fromPath
131 for (const candidate of [
132 '/Applications/ChatGPT.app/Contents/Resources/codex',
133 join(homedir(), '.local', 'bin', 'codex'),
134 join(homedir(), '.bun', 'bin', 'codex'),
135 '/opt/homebrew/bin/codex',
136 '/usr/local/bin/codex',
137 ]) {
138 if (Bun.file(candidate).size > 0)
139 return candidate
140 }
141 return 'codex'
142}
143
92144export function systemPrompt(config: UplinkConfig): string {
93145 return [
94146 'You are Uplink, reached by text message. The user is away from this Mac and texting from a phone,',
app/Uplink/engine.tsmodified+81-5
Changes to app/Uplink/engine.ts
@@ -1,4 +1,5 @@
11import type { Subprocess } from 'bun'
2import { tmpdir } from 'node:os'
23import process from 'node:process'
34import { truncate } from './format'
45
@@ -43,8 +44,37 @@ export interface EngineRun {
4344 cancel: () => void
4445}
4546
47/** Which agent CLI answers a text. */
48export type EngineId = 'claude' | 'codex'
49
50export const ENGINE_IDS: readonly EngineId[] = ['claude', 'codex']
51
52export function isEngineId(value: string): value is EngineId {
53 return (ENGINE_IDS as readonly string[]).includes(value)
54}
55
56/** Whether a CLI can answer right now, for the doctor and the menubar. */
57export interface EngineProbe {
58 ok: boolean
59 detail: string
60}
61
4662export interface Engine {
63 readonly id: EngineId
64 /** How the engine is named to a person: "Claude Code", "Codex". */
65 readonly label: string
66 /**
67 * What to text back when a run failed on authentication. The person is away
68 * from the Mac, so this has to name the CLI and the exact command.
69 */
70 readonly authFailureHint: string
4771 run: (request: EngineRequest) => EngineRun
72 /**
73 * Can this CLI answer right now. Separate from `run` because setup has to
74 * answer it before any text arrives, and because the two CLIs disagree on
75 * what proof of life costs: Claude spends a turn, Codex does not.
76 */
77 probe: () => Promise<EngineProbe>
4878}
4979
5080export interface ClaudeEngineOptions {
@@ -58,9 +88,59 @@ export interface ClaudeEngineOptions {
5888
5989const AUTH_FAILURE = /failed to authenticate|oauth|invalid api key|please run \/login|not logged in|credit balance/i
6090
91/** A probe that hangs is worse than one that fails: setup shows "Checking" for ever. */
92export const PROBE_TIMEOUT_MS = 60_000
93
6194export class ClaudeEngine implements Engine {
95 readonly id = 'claude' as const
96 readonly label = 'Claude Code'
97 readonly authFailureHint = 'run "claude setup-token" on the Mac and give Uplink the token'
98
6299 constructor(private readonly options: ClaudeEngineOptions) {}
63100
101 /**
102 * One real turn, on the cheapest model. Only asking "is a token set" passed a
103 * token truncated at the terminal's 80-column wrap: set, well-formed, and
104 * rejected with a 401 on first use.
105 */
106 async probe(): Promise<EngineProbe> {
107 try {
108 const proc = Bun.spawn([this.options.bin, '-p', 'Reply with exactly: ok', '--model', 'haiku', '--output-format', 'json'], {
109 cwd: tmpdir(),
110 env: this.env(),
111 stdin: 'ignore',
112 stdout: 'pipe',
113 stderr: 'pipe',
114 })
115 const timer = setTimeout(() => proc.kill(), PROBE_TIMEOUT_MS)
116 const text = await new Response(proc.stdout).text()
117 clearTimeout(timer)
118 await proc.exited
119 const result = JSON.parse(text) as { is_error?: boolean, result?: string }
120 if (result.is_error) {
121 const said = truncate(result.result ?? 'Claude did not answer', 140)
122 return { ok: false, detail: AUTH_FAILURE.test(said) ? 'Not signed in yet.' : said }
123 }
124 return { ok: true, detail: this.env().CLAUDE_CODE_OAUTH_TOKEN ? 'Signed in with your token' : 'Signed in through the Claude CLI' }
125 }
126 catch {
127 return { ok: false, detail: `Could not run ${this.options.bin}. Is Claude Code installed?` }
128 }
129 }
130
131 /**
132 * A .env copied from .env.example carries `CLAUDE_CODE_OAUTH_TOKEN=`; an
133 * empty token must not stand in for the CLI's own login. The probe needs the
134 * same treatment as a run, or a fresh install reports a failure a real run
135 * would not have.
136 */
137 private env(): Record<string, string | undefined> {
138 const env: Record<string, string | undefined> = { ...process.env, ...this.options.env }
139 if (!env.CLAUDE_CODE_OAUTH_TOKEN)
140 delete env.CLAUDE_CODE_OAUTH_TOKEN
141 return env
142 }
143
64144 args(request: EngineRequest): string[] {
65145 const args = [
66146 '-p',
@@ -85,11 +165,7 @@ export class ClaudeEngine implements Engine {
85165 let cancelled = false
86166 let timedOut = false
87167
88 const env: Record<string, string | undefined> = { ...process.env, ...this.options.env }
89 // A .env copied from .env.example carries `CLAUDE_CODE_OAUTH_TOKEN=`; an
90 // empty token must not stand in for the CLI's own login.
91 if (!env.CLAUDE_CODE_OAUTH_TOKEN)
92 delete env.CLAUDE_CODE_OAUTH_TOKEN
168 const env = this.env()
93169
94170 const proc: Subprocess<'ignore', 'pipe', 'pipe'> = Bun.spawn([this.options.bin, ...this.args(request)], {
95171 cwd: request.cwd,
app/Uplink/engines.tsadded+47-0
Changes to app/Uplink/engines.ts
@@ -0,0 +1,47 @@
1import type { UplinkConfig } from './config'
2import type { Engine, EngineId } from './engine'
3import { CodexEngine } from './codex-engine'
4import { systemPrompt } from './config'
5import { ClaudeEngine } from './engine'
6import { ENGINE_IDS } from './engine'
7
8/**
9 * Builds engines from config, so the watcher, the app and the doctor all select
10 * one the same way. Both construction sites used to say `new ClaudeEngine(...)`
11 * with their own copy of the options, which is two places to forget.
12 */
13
14export function engineFor(id: EngineId, config: UplinkConfig): Engine {
15 const prompt = systemPrompt(config)
16 if (id === 'codex') {
17 return new CodexEngine({
18 bin: config.codexBin,
19 model: config.codexModel,
20 permission: config.codexPermission,
21 systemPrompt: prompt,
22 timeoutMs: config.timeoutMs,
23 })
24 }
25 return new ClaudeEngine({
26 bin: config.claudeBin,
27 model: config.claudeModel,
28 permissionMode: config.permissionMode,
29 systemPrompt: prompt,
30 timeoutMs: config.timeoutMs,
31 })
32}
33
34/** The configured engine. */
35export function selectedEngine(config: UplinkConfig): Engine {
36 return engineFor(config.engine, config)
37}
38
39/**
40 * Every engine, selected one first. The doctor and the menubar report on all of
41 * them: an engine the person has not set up is information, not a failure, and
42 * seeing that the other one is available is how they learn they can switch.
43 */
44export function allEngines(config: UplinkConfig): Engine[] {
45 const ordered = [config.engine, ...ENGINE_IDS.filter(id => id !== config.engine)]
46 return ordered.map(id => engineFor(id, config))
47}
app/Uplink/settings.tsmodified+15-2
Changes to app/Uplink/settings.ts
@@ -1,6 +1,8 @@
1import type { EngineId } from './engine'
12import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
23import { homedir } from 'node:os'
34import { join } from 'node:path'
5import { isEngineId } from './engine'
46
57/**
68 * Settings for the downloadable app, which has no .env: a JSON file for
@@ -17,12 +19,16 @@ export const DATABASE_PATH = join(DATA_DIR, 'uplink.sqlite')
1719export const LOG_PATH = join(homedir(), 'Library', 'Logs', 'Uplink.log')
1820
1921const KEYCHAIN_SERVICE = 'com.stacksjs.uplink'
22// Claude Code's token. Codex keeps its own credentials in CODEX_HOME via
23// `codex login`, so there is nothing of its to store here.
2024const KEYCHAIN_ACCOUNT = 'claude-oauth-token'
2125
2226export interface Settings {
2327 /** Handles allowed to command Uplink. Empty: this Mac's own handles. */
2428 allowed: string[]
25 /** Model alias for runs, or null for the CLI's default. */
29 /** Which agent CLI answers a text. */
30 engine: EngineId
31 /** Claude's model alias for runs, or null for the CLI's default. */
2632 model: string | null
2733 /** Where a run starts when a text names no path. */
2834 workdir: string
@@ -34,6 +40,7 @@ export interface Settings {
3440
3541export const DEFAULT_SETTINGS: Settings = {
3642 allowed: [],
43 engine: 'claude',
3744 model: null,
3845 workdir: homedir(),
3946 openAtLogin: true,
@@ -45,7 +52,12 @@ export function readSettings(path: string = SETTINGS_PATH): Settings {
4552 return { ...DEFAULT_SETTINGS }
4653 try {
4754 const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<Settings>
48 return { ...DEFAULT_SETTINGS, ...parsed }
55 const settings = { ...DEFAULT_SETTINGS, ...parsed }
56 // A hand-edited or downgraded file can name an engine this build does not
57 // have. Falling back beats refusing to start.
58 if (!isEngineId(String(settings.engine)))
59 settings.engine = DEFAULT_SETTINGS.engine
60 return settings
4961 }
5062 catch {
5163 // A hand-edited file with a typo must not stop the app from starting.
@@ -65,6 +77,7 @@ export function writeSettings(settings: Settings, path: string = SETTINGS_PATH):
6577export function settingsEnv(settings: Settings, env: Record<string, string | undefined> = process.env): Record<string, string | undefined> {
6678 return {
6779 UPLINK_ALLOWED: settings.allowed.join(','),
80 UPLINK_ENGINE: settings.engine,
6881 UPLINK_MODEL: settings.model ?? '',
6982 UPLINK_WORKDIR: settings.workdir,
7083 ...Object.fromEntries(Object.entries(env).filter(([key]) => key.startsWith('UPLINK_'))),
tests/unit/uplink/codex-engine.test.tsadded+230-0
Changes to tests/unit/uplink/codex-engine.test.ts
@@ -0,0 +1,230 @@
1import type { EngineEvent } from '../../../app/Uplink/engine'
2import { afterAll, beforeEach, describe, expect, it } from 'bun:test'
3import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { clearJsonFlagCache, CodexEngine, summarizeItem } from '../../../app/Uplink/codex-engine'
7
8const dir = mkdtempSync(join(tmpdir(), 'uplink-codex-'))
9afterAll(() => rmSync(dir, { recursive: true, force: true }))
10beforeEach(() => clearJsonFlagCache())
11
12let counter = 0
13
14/**
15 * A stand-in `codex` printing the JSONL that `codex exec --json` really emits,
16 * captured from a run of codex-cli 0.155: a thread.started carrying the id, a
17 * turn.started, one item.completed per thing the agent did, and a
18 * turn.completed carrying token usage and no cost.
19 *
20 * It also answers `exec --help`, because the engine asks the binary which
21 * spelling of the JSONL flag it offers before using it.
22 */
23function stubCodex(options: { events?: object[], exit?: number, stderr?: string, jsonFlag?: string, lastMessage?: string } = {}): string {
24 const path = join(dir, `codex-${counter += 1}`)
25 const events = options.events ?? [
26 { type: 'thread.started', thread_id: 'th-9' },
27 { type: 'turn.started' },
28 { type: 'item.completed', item: { id: 'item_0', type: 'command_execution', command: 'git log --oneline -3', exit_code: 0 } },
29 { type: 'item.completed', item: { id: 'item_1', type: 'agent_message', text: 'Chiefs 24-21' } },
30 { type: 'turn.completed', usage: { input_tokens: 15119, cached_input_tokens: 12032, cache_write_input_tokens: 0, output_tokens: 5, reasoning_output_tokens: 0 } },
31 ]
32 const lines = events.map(event => `echo '${JSON.stringify(event)}'`).join('\n')
33 // `-o <file>` is the last-message file; the real CLI writes it on success only.
34 const writeLast = options.lastMessage === undefined
35 ? `for a in "$@"; do if [ "$prev" = "--output-last-message" ]; then printf '%s' "${options.lastMessage ?? 'Chiefs 24-21'}" > "$a"; fi; prev="$a"; done`
36 : `for a in "$@"; do if [ "$prev" = "--output-last-message" ]; then printf '%s' "${options.lastMessage}" > "$a"; fi; prev="$a"; done`
37
38 writeFileSync(path, `#!/bin/sh
39if [ "$1" = "exec" ] && [ "$2" = "--help" ]; then
40 echo "Usage: codex exec [OPTIONS] [PROMPT]"
41 echo " ${options.jsonFlag ?? '--json'}"
42 echo " Print events to stdout as JSONL"
43 exit 0
44fi
45printf '%s\\n' "$@" > "${path}.args"
46pwd > "${path}.cwd"
47prev=""
48${writeLast}
49${lines}
50${options.stderr ? `echo "${options.stderr}" >&2` : ''}
51exit ${options.exit ?? 0}
52`)
53 chmodSync(path, 0o755)
54 return path
55}
56
57function engine(bin: string, overrides: Partial<{ model: string | null, permission: 'bypass' | 'workspace-write', timeoutMs: number }> = {}): CodexEngine {
58 return new CodexEngine({
59 bin,
60 model: overrides.model ?? null,
61 permission: overrides.permission ?? 'bypass',
62 systemPrompt: 'Answer in plain text.',
63 timeoutMs: overrides.timeoutMs ?? 10_000,
64 })
65}
66
67function argsOf(bin: string): string[] {
68 return readFileSync(`${bin}.args`, 'utf8').trim().split('\n')
69}
70
71describe('CodexEngine', () => {
72 it('parses the event stream into a result and events', async () => {
73 const bin = stubCodex()
74 const events: EngineEvent[] = []
75 const result = await engine(bin).run({ prompt: 'scores?', cwd: dir, onEvent: e => events.push(e) }).done
76
77 expect(result.ok).toBe(true)
78 expect(result.text).toBe('Chiefs 24-21')
79 expect(result.sessionId).toBe('th-9')
80 // Codex reports tokens and no dollar figure, so a cost here would be invented.
81 expect(result.costCents).toBeNull()
82 expect(result.turns).toBe(1)
83 expect(result.authFailure).toBe(false)
84
85 expect(events).toContainEqual({ kind: 'session', sessionId: 'th-9' })
86 expect(events).toContainEqual({ kind: 'text', text: 'Chiefs 24-21' })
87 expect(events).toContainEqual({ kind: 'tool', name: 'command_execution', summary: 'command_execution: git log --oneline -3' })
88 })
89
90 it('always passes --skip-git-repo-check, because the default workdir is not a repo', async () => {
91 const bin = stubCodex()
92 await engine(bin).run({ prompt: 'x', cwd: dir }).done
93 expect(argsOf(bin)).toContain('--skip-git-repo-check')
94 })
95
96 it('starts a fresh run with exec, --cd and the bypass flag', async () => {
97 const bin = stubCodex()
98 await engine(bin).run({ prompt: 'scores?', cwd: dir }).done
99 const args = argsOf(bin)
100
101 expect(args[0]).toBe('exec')
102 expect(args).toContain('--json')
103 expect(args.slice(args.indexOf('--cd'), args.indexOf('--cd') + 2)).toEqual(['--cd', dir])
104 expect(args).toContain('--dangerously-bypass-approvals-and-sandbox')
105 // No --append-system-prompt exists, so the instructions ride on the prompt.
106 expect(args[args.length - 1]).toContain('Answer in plain text.')
107 expect(args[args.length - 1]).toContain('scores?')
108 expect(readFileSync(`${bin}.cwd`, 'utf8').trim()).toEndWith(dir.replace(/^\/private/, ''))
109 })
110
111 it('resumes through the subcommand, with the session id positional', async () => {
112 const bin = stubCodex()
113 await engine(bin).run({ prompt: 'who won?', cwd: dir, sessionId: 'th-7' }).done
114 const args = argsOf(bin)
115
116 expect(args.slice(0, 3)).toEqual(['exec', 'resume', 'th-7'])
117 // `resume` rejects both of these with "unexpected argument" and exit 2.
118 expect(args).not.toContain('--cd')
119 expect(args).not.toContain('--sandbox')
120 })
121
122 it('sets a non-bypass sandbox through -c, which resume accepts', async () => {
123 const bin = stubCodex()
124 await engine(bin, { permission: 'workspace-write' }).run({ prompt: 'x', cwd: dir, sessionId: 'th-7' }).done
125 const args = argsOf(bin)
126
127 expect(args).not.toContain('--sandbox')
128 expect(args.slice(args.indexOf('--config'), args.indexOf('--config') + 2))
129 .toEqual(['--config', 'sandbox_mode="workspace-write"'])
130 })
131
132 it('passes the model only when one is set', async () => {
133 const withModel = stubCodex()
134 await engine(withModel, { model: 'gpt-5-codex' }).run({ prompt: 'x', cwd: dir }).done
135 const args = argsOf(withModel)
136 expect(args.slice(args.indexOf('--model'), args.indexOf('--model') + 2)).toEqual(['--model', 'gpt-5-codex'])
137
138 clearJsonFlagCache()
139 const without = stubCodex()
140 await engine(without).run({ prompt: 'x', cwd: dir }).done
141 expect(argsOf(without)).not.toContain('--model')
142 })
143
144 it('uses the version spelling of the JSONL flag that the binary offers', async () => {
145 const bin = stubCodex({ jsonFlag: '--experimental-json' })
146 await engine(bin).run({ prompt: 'x', cwd: dir }).done
147 const args = argsOf(bin)
148 expect(args).toContain('--experimental-json')
149 expect(args).not.toContain('--json')
150 })
151
152 it('falls back to the streamed message when the last-message file is absent', async () => {
153 // The real CLI does not write that file when a turn fails, so the stream has
154 // to be the source of truth.
155 const bin = stubCodex({ lastMessage: '' })
156 const result = await engine(bin).run({ prompt: 'x', cwd: dir }).done
157 expect(result.text).toBe('Chiefs 24-21')
158 })
159
160 it('reports a turn.failed as a failure', async () => {
161 const bin = stubCodex({
162 events: [
163 { type: 'thread.started', thread_id: 'th-9' },
164 { type: 'turn.failed', error: { message: 'context window exceeded' } },
165 ],
166 exit: 1,
167 lastMessage: '',
168 })
169 const result = await engine(bin).run({ prompt: 'x', cwd: dir }).done
170 expect(result.ok).toBe(false)
171 expect(result.text).toContain('context window exceeded')
172 expect(result.sessionId).toBe('th-9')
173 })
174
175 it('flags a signed-out CLI from the failure text', async () => {
176 const bin = stubCodex({
177 events: [],
178 exit: 1,
179 stderr: 'unexpected status 401 Unauthorized: Missing bearer or basic authentication in header',
180 lastMessage: '',
181 })
182 const result = await engine(bin).run({ prompt: 'x', cwd: dir }).done
183 expect(result.ok).toBe(false)
184 expect(result.authFailure).toBe(true)
185 })
186
187 it('cancels a run', async () => {
188 const bin = stubCodex()
189 const run = engine(bin).run({ prompt: 'x', cwd: dir })
190 run.cancel()
191 const result = await run.done
192 expect(result.ok).toBe(false)
193 expect(result.text).toBe('Stopped.')
194 })
195
196 describe('probe', () => {
197 it('is signed in when codex login status exits 0', async () => {
198 const path = join(dir, `codex-login-ok-${counter += 1}`)
199 // The real CLI prints this on stderr, not stdout.
200 writeFileSync(path, '#!/bin/sh\necho "Logged in using ChatGPT" >&2\nexit 0\n')
201 chmodSync(path, 0o755)
202 expect(await engine(path).probe()).toEqual({ ok: true, detail: 'Logged in using ChatGPT' })
203 })
204
205 it('is not signed in when it exits non-zero', async () => {
206 const path = join(dir, `codex-login-bad-${counter += 1}`)
207 writeFileSync(path, '#!/bin/sh\necho "Not logged in" >&2\nexit 1\n')
208 chmodSync(path, 0o755)
209 const probe = await engine(path).probe()
210 expect(probe.ok).toBe(false)
211 expect(probe.detail).toContain('Not logged in')
212 })
213
214 it('says so when the binary is missing', async () => {
215 const probe = await engine(join(dir, 'not-installed')).probe()
216 expect(probe.ok).toBe(false)
217 expect(probe.detail).toContain('Is Codex installed?')
218 })
219 })
220})
221
222describe('summarizeItem', () => {
223 it('reads whichever descriptive field an item carries', () => {
224 expect(summarizeItem({ type: 'command_execution', command: 'bun test' })).toBe('command_execution: bun test')
225 expect(summarizeItem({ type: 'web_search', query: 'nfl scores' })).toBe('web_search: nfl scores')
226 expect(summarizeItem({ type: 'file_change', path: '/Users/someone/Code/app.ts' })).toBe('file_change: ~/Code/app.ts')
227 // Unknown kinds still produce a line, because the set is not closed.
228 expect(summarizeItem({ type: 'something_new' })).toBe('something_new')
229 })
230})
tests/unit/uplink/fixtures.tsmodified+14-0
Changes to tests/unit/uplink/fixtures.ts
@@ -141,6 +141,20 @@ interface PendingRun {
141141/** An engine whose runs finish when the test says so. */
142142export class FakeEngine implements Engine {
143143 runs: PendingRun[] = []
144 probes = 0
145
146 // Identity matching the real Claude engine, so a test that asserts on a
147 // reply naming the CLI is asserting the text a user would actually get.
148 readonly id = 'claude' as const
149 readonly label = 'Claude Code'
150 readonly authFailureHint = 'run "claude setup-token" on the Mac and give Uplink the token'
151
152 probeResult: { ok: boolean, detail: string } = { ok: true, detail: 'Signed in' }
153
154 async probe(): Promise<{ ok: boolean, detail: string }> {
155 this.probes += 1
156 return this.probeResult
157 }
144158
145159 run(request: EngineRequest): EngineRun {
146160 let resolve!: (result: EngineResult) => void
tests/unit/uplink/sqlite-store.test.tsmodified+12-2
Changes to tests/unit/uplink/sqlite-store.test.ts
@@ -75,8 +75,18 @@ describe('settings', () => {
7575 })
7676
7777 it('feeds loadConfig without any .env', () => {
78 const config = loadConfig(settingsEnv({ allowed: ['(555) 000-1111'], model: 'opus', workdir: '/tmp', openAtLogin: true, paused: false }, {}))
79 expect(config).toMatchObject({ allowed: ['+15550001111'], model: 'opus', workdir: '/tmp' })
78 const config = loadConfig(settingsEnv({ allowed: ['(555) 000-1111'], engine: 'claude', model: 'opus', workdir: '/tmp', openAtLogin: true, paused: false }, {}))
79 expect(config).toMatchObject({ allowed: ['+15550001111'], engine: 'claude', claudeModel: 'opus', workdir: '/tmp' })
80 })
81
82 it('carries the selected engine through to config', () => {
83 const settings = { allowed: [], engine: 'codex' as const, model: null, workdir: '/tmp', openAtLogin: true, paused: false }
84 expect(loadConfig(settingsEnv(settings, {})).engine).toBe('codex')
85 // A settings.json naming an engine this build does not have must not stop
86 // the app from starting.
87 const path = join(tempDir(), 'settings.json')
88 writeSettings({ ...settings, engine: 'gemini' as never }, path)
89 expect(readSettings(path).engine).toBe('claude')
8090 })
8191
8292 it('cleans a token pasted across a line wrap', () => {