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
.env.examplemodified+19-1
Changes to .env.example
@@ -138,14 +138,32 @@ TS_CLOUD_UI_PASSWORD=
138138# forward this process cannot see, or 0 to keep a public box off public DNS.
139139# TS_CLOUD_SSH_PUBLISH_DNS=
140140
141# Uplink - text this Mac, get Claude. See README.md.
141# Uplink - text this Mac, get an agent. See README.md.
142142# Who may command it. Empty = this Mac's own Messages handles only ("text myself").
143143# Anyone listed can make an agent act on this Mac, so list only yourself.
144144UPLINK_ALLOWED=
145# Which agent CLI answers: claude or codex. Empty = claude.
146UPLINK_ENGINE=
147# --- Claude Code ---
145148# From `claude setup-token` (your Claude Max login, good for a year). Encrypt it: ./buddy env:set
146149CLAUDE_CODE_OAUTH_TOKEN=
147150# Optional: model alias for runs (sonnet, opus, haiku). Empty = the CLI's default.
151# UPLINK_MODEL is the older name for this and still works.
152UPLINK_CLAUDE_MODEL=
148153UPLINK_MODEL=
154# Optional: where the claude binary is, if it is not on PATH.
155UPLINK_CLAUDE_BIN=
156# --- Codex ---
157# Sign in with `codex login` (or `codex login --device-auth` over SSH). Codex keeps the
158# credential itself, under CODEX_HOME, so there is no token to put here.
159# Optional: where the codex binary is. Uplink also looks inside ChatGPT.app, which bundles one.
160UPLINK_CODEX_BIN=
161# Optional: model the agent should use. Empty = the CLI's default.
162UPLINK_CODEX_MODEL=
163# How much Codex may do without asking. `bypass` matches what Uplink asks of Claude Code:
164# no approval prompts, no sandbox, because nobody is at the Mac to answer one.
165# Also: danger-full-access, workspace-write, read-only.
166UPLINK_CODEX_PERMISSION=bypass
149167# Where runs start when a text names no path.
150168UPLINK_WORKDIR=~
151169# Replies longer than UPLINK_MAX_CHARS are split; past UPLINK_MAX_PARTS the rest waits for "more".
app/Commands/Uplink.tsmodified+18-18
Changes to app/Commands/Uplink.ts
@@ -1,9 +1,11 @@
1import type { EngineId } from '../Uplink/engine'
12import { copyFileSync, existsSync, watch } from 'node:fs'
23import process from 'node:process'
34import { defineCommand } from '@stacksjs/cli'
4import { loadConfig, systemPrompt } from '../Uplink/config'
5import { runChecks, writeHeartbeat } from '../Uplink/doctor'
6import { ClaudeEngine } from '../Uplink/engine'
5import { loadConfig } from '../Uplink/config'
6import { checksPass, runChecks, writeHeartbeat } from '../Uplink/doctor'
7import { ENGINE_IDS, isEngineId } from '../Uplink/engine'
8import { engineFor, selectedEngine } from '../Uplink/engines'
79import { formatDuration, toPlainText } from '../Uplink/format'
810import { MessagesAccessError, MessagesDb } from '../Uplink/messages-db'
911import { ModelStore } from '../Uplink/model-store'
@@ -18,16 +20,6 @@ import { detectWorkdir } from '../Uplink/workdir'
1820const HEARTBEAT_MS = 15_000
1921const RETRY_OPEN_MS = 30_000
2022
21function engineFor(config: ReturnType<typeof loadConfig>): ClaudeEngine {
22 return new ClaudeEngine({
23 bin: config.claudeBin,
24 model: config.model,
25 permissionMode: config.permissionMode,
26 systemPrompt: systemPrompt(config),
27 timeoutMs: config.timeoutMs,
28 })
29}
30
3123async function openMessages(config: ReturnType<typeof loadConfig>, appDir: string, startedAt: number): Promise<MessagesDb> {
3224 // Without Full Disk Access this fails. Under launchd, exiting would only
3325 // restart-loop, so wait for the grant instead and say why in the heartbeat.
@@ -85,7 +77,7 @@ export default defineCommand((cli) => {
8577 config,
8678 messages,
8779 sender: new AppleScriptSender(),
88 engine: engineFor(config),
80 engine: selectedEngine(config),
8981 store: new ModelStore(`${appDir}/storage/uplink/cursor`),
9082 })
9183 await uplink.start()
@@ -134,11 +126,19 @@ export default defineCommand((cli) => {
134126 cli
135127 .command('uplink:ask <prompt>', 'Run one prompt through the agent exactly as a text would, and print the reply')
136128 .option('--cwd <dir>', 'Working directory (default: detected from the prompt)')
137 .action(async (prompt: string, options: { cwd?: string }) => {
129 .option('--engine <name>', 'claude or codex (default: UPLINK_ENGINE)')
130 .action(async (prompt: string, options: { cwd?: string, engine?: string }) => {
138131 const config = loadConfig()
139132 const cwd = options.cwd ?? detectWorkdir(prompt, config.workdir)
140 console.log(`[uplink] running in ${cwd}`)
141 const run = engineFor(config).run({
133 // Naming an engine here is how the other one gets tried without editing
134 // .env, which is the whole point of this command.
135 if (options.engine && !isEngineId(options.engine)) {
136 console.error(`[uplink] unknown engine "${options.engine}". Use ${ENGINE_IDS.join(' or ')}.`)
137 process.exit(1)
138 }
139 const engine = options.engine ? engineFor(options.engine as EngineId, config) : selectedEngine(config)
140 console.log(`[uplink] running in ${cwd} on ${engine.label}`)
141 const run = engine.run({
142142 prompt,
143143 cwd,
144144 onEvent: (event) => {
@@ -161,7 +161,7 @@ export default defineCommand((cli) => {
161161 if (!check.ok && check.fix)
162162 console.log(` → ${check.fix}`)
163163 }
164 process.exit(checks.every(c => c.ok) ? 0 : 1)
164 process.exit(checksPass(checks) ? 0 : 1)
165165 })
166166
167167 cli
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/dashboard.tsmodified+2-2
Changes to app/Uplink/dashboard.ts
@@ -3,7 +3,7 @@ import { homedir } from 'node:os'
33import process from 'node:process'
44import Run from '../Models/Run'
55import { loadConfig } from './config'
6import { readHeartbeat, runChecks } from './doctor'
6import { checksPass, readHeartbeat, runChecks } from './doctor'
77import { formatDuration, truncate } from './format'
88
99/**
@@ -62,7 +62,7 @@ export async function loadDashboard(appDir: string = process.cwd()): Promise<Das
6262 ])
6363
6464 const live = isLive(heartbeat)
65 const ready = checks.every(check => check.ok)
65 const ready = checksPass(checks)
6666 const runs = rows.map((row): RunRow => ({
6767 id: row.id,
6868 prompt: truncate(row.prompt ?? '', 140),
app/Uplink/desktop-agent.tsmodified+70-61
Changes to app/Uplink/desktop-agent.ts
@@ -2,11 +2,12 @@
22import type { Server } from 'bun'
33import type { UplinkConfig } from './config'
44import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
5import { homedir, tmpdir } from 'node:os'
5import { homedir } from 'node:os'
66import { join } from 'node:path'
77import process from 'node:process'
8import { loadConfig, systemPrompt } from './config'
9import { ClaudeEngine } from './engine'
8import { loadConfig } from './config'
9import { isEngineId } from './engine'
10import { allEngines, selectedEngine } from './engines'
1011import { formatDuration, truncate } from './format'
1112import { MessagesAccessError, MessagesDb } from './messages-db'
1213import { AppleScriptSender } from './sender'
@@ -40,7 +41,7 @@ const LOCK_PATH = join(DATA_DIR, 'uplink.pid')
4041const LAUNCH_AGENT = join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`)
4142const RETRY_OPEN_MS = 5_000
4243
43type ClaudeState = { ok: boolean, detail: string, checkedAt: number } | null
44type EngineState = { ok: boolean, detail: string, checkedAt: number }
4445
4546export interface DesktopAgent {
4647 port: number
@@ -124,29 +125,6 @@ export function setOpenAtLogin(enabled: boolean): void {
124125`)
125126}
126127
127async function probeClaude(bin: string, token: string | null): Promise<ClaudeState> {
128 const env: Record<string, string | undefined> = { ...process.env }
129 if (token)
130 env.CLAUDE_CODE_OAUTH_TOKEN = token
131 else
132 delete env.CLAUDE_CODE_OAUTH_TOKEN
133 try {
134 const proc = Bun.spawn([bin, '-p', 'Reply with exactly: ok', '--model', 'haiku', '--output-format', 'json'], { cwd: tmpdir(), env, stdout: 'pipe', stderr: 'pipe' })
135 const timer = setTimeout(() => proc.kill(), 60_000)
136 const text = await new Response(proc.stdout).text()
137 clearTimeout(timer)
138 await proc.exited
139 const result = JSON.parse(text) as { is_error?: boolean, result?: string }
140 if (result.is_error && !token && /authenticate|oauth|log ?in/i.test(result.result ?? ''))
141 return { ok: false, detail: 'Not signed in yet.', checkedAt: Date.now() }
142 return result.is_error
143 ? { ok: false, detail: truncate(result.result ?? 'Claude did not answer', 140), checkedAt: Date.now() }
144 : { ok: true, detail: token ? 'Signed in with your token' : 'Signed in through the Claude CLI', checkedAt: Date.now() }
145 }
146 catch {
147 return { ok: false, detail: `Could not run ${bin}. Is Claude Code installed?`, checkedAt: Date.now() }
148 }
149}
150128
151129export async function startDesktopAgent(options: { version: string }): Promise<DesktopAgent> {
152130 mkdirSync(DATA_DIR, { recursive: true })
@@ -169,23 +147,25 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
169147 let messages: MessagesDb | null = null
170148 let messagesError: string | null = null
171149 let uplink: Uplink | null = null
172 let claude: ClaudeState = null
173 let checkingClaude = false
174
175 const engine = (): ClaudeEngine => new ClaudeEngine({
176 bin: config.claudeBin,
177 model: config.model,
178 permissionMode: config.permissionMode,
179 systemPrompt: systemPrompt(config),
180 timeoutMs: config.timeoutMs,
181 })
182
183 const checkClaude = async (): Promise<void> => {
184 if (checkingClaude)
150 const engines = new Map<string, EngineState>()
151 let checkingEngines = false
152
153 /**
154 * Probe every engine, not only the selected one: the popover shows the other
155 * as available rather than as a failure, which is how a person discovers they
156 * can switch.
157 */
158 const checkEngines = async (): Promise<void> => {
159 if (checkingEngines)
185160 return
186 checkingClaude = true
187 claude = await probeClaude(config.claudeBin, token)
188 checkingClaude = false
161 checkingEngines = true
162 try {
163 for (const engine of allEngines(config))
164 engines.set(engine.id, { ...await engine.probe(), checkedAt: Date.now() })
165 }
166 finally {
167 checkingEngines = false
168 }
189169 }
190170
191171 const startWatching = async (): Promise<void> => {
@@ -199,7 +179,7 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
199179 messagesError = error instanceof MessagesAccessError ? 'Uplink needs Full Disk Access to read Messages.' : String(error)
200180 return
201181 }
202 uplink = new Uplink({ config, messages, sender: new AppleScriptSender(), engine: engine(), store })
182 uplink = new Uplink({ config, messages, sender: new AppleScriptSender(), engine: selectedEngine(config), store })
203183 await uplink.start()
204184 }
205185
@@ -222,7 +202,7 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
222202 }, 15_000)
223203
224204 await startWatching()
225 void checkClaude()
205 void checkEngines()
226206
227207 const status = () => {
228208 const recent = store.recentRuns(8).map(run => ({
@@ -244,21 +224,33 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
244224 name: 'Read Messages',
245225 ok: messages !== null,
246226 detail: messages ? 'Full Disk Access granted' : (messagesError ?? 'Waiting for Full Disk Access'),
227 informational: false,
247228 },
248 {
249 id: 'claude',
250 name: 'Sign in to Claude',
251 ok: claude?.ok ?? false,
252 detail: checkingClaude ? 'Checking' : (claude?.detail ?? 'Not checked yet'),
253 },
229 ...allEngines(config).map((engine) => {
230 const state = engines.get(engine.id)
231 const selected = engine.id === config.engine
232 return {
233 // `engine` is the selected one, and the popover draws its sign-in
234 // step. The other is listed so switching is discoverable.
235 id: selected ? 'engine' : `engine:${engine.id}`,
236 name: selected ? `Sign in to ${engine.label}` : engine.label,
237 engineId: engine.id,
238 label: engine.label,
239 ok: state?.ok ?? false,
240 detail: checkingEngines && !state ? 'Checking' : (state?.detail ?? 'Not checked yet'),
241 informational: !selected,
242 }
243 }),
254244 {
255245 id: 'handles',
256246 name: 'Who can text it',
257247 ok: allowed.length > 0,
258248 detail: allowed.length > 0 ? allowed.join(', ') : 'No handles yet. Sign in to Messages, or add your number.',
249 informational: false,
259250 },
260251 ]
261 const ready = checks.every(check => check.ok)
252 // An engine the person has not selected must not hold the app in setup.
253 const ready = checks.every(check => check.ok || check.informational)
262254 const state = settings.paused ? 'paused' : !ready ? 'setup' : active.length > 0 ? 'working' : 'listening'
263255 return {
264256 state,
@@ -267,11 +259,18 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
267259 checks,
268260 active,
269261 recent,
270 settings: { allowed: settings.allowed, openAtLogin: settings.openAtLogin, paused: settings.paused, model: settings.model },
262 settings: { allowed: settings.allowed, engine: settings.engine, openAtLogin: settings.openAtLogin, paused: settings.paused, model: settings.model },
263 engines: allEngines(config).map(engine => ({ id: engine.id, label: engine.label })),
271264 lastError: uplink?.lastError ?? null,
272265 }
273266 }
274267
268 /** The selected engine's probe, which is what a sign-in button wants back. */
269 const engineResult = (): { ok: boolean, detail: string } => {
270 const state = engines.get(config.engine)
271 return { ok: state?.ok ?? false, detail: state?.detail ?? 'Not checked yet' }
272 }
273
275274 const json = (body: unknown, status = 200): Response => Response.json(body, { status, headers: { 'cache-control': 'no-store' } })
276275
277276 const server = Bun.serve({
@@ -301,10 +300,12 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
301300 Bun.spawnSync(['open', 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles'])
302301 return json({ ok: true })
303302
304 case '/api/open/terminal':
305 // `claude setup-token` needs a real terminal for its browser sign-in.
306 Bun.spawnSync(['osascript', '-e', 'tell application "Terminal" to do script "claude setup-token"', '-e', 'tell application "Terminal" to activate'])
307 return json({ ok: true })
303 case '/api/open/terminal': {
304 // Both sign-ins need a real terminal for their browser round trip.
305 const command = config.engine === 'codex' ? 'codex login' : 'claude setup-token'
306 Bun.spawnSync(['osascript', '-e', `tell application "Terminal" to do script "${command}"`, '-e', 'tell application "Terminal" to activate'])
307 return json({ ok: true, command })
308 }
308309
309310 case '/api/token': {
310311 const cleaned = cleanToken(String(body.token ?? ''))
@@ -313,17 +314,22 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
313314 writeToken(cleaned)
314315 token = cleaned
315316 process.env.CLAUDE_CODE_OAUTH_TOKEN = cleaned
316 await checkClaude()
317 return json({ ok: claude?.ok ?? false, detail: claude?.detail })
317 await checkEngines()
318 return json(engineResult())
318319 }
319320
321 // `/api/claude/test` is the old name for this and still answers, because
322 // a popover cached from an older build would otherwise have a dead button.
320323 case '/api/claude/test':
321 await checkClaude()
322 return json({ ok: claude?.ok ?? false, detail: claude?.detail })
324 case '/api/engine/test':
325 await checkEngines()
326 return json(engineResult())
323327
324328 case '/api/settings': {
325329 if (Array.isArray(body.allowed))
326330 settings.allowed = body.allowed.map(String).map((s: string) => s.trim()).filter(Boolean)
331 if (typeof body.engine === 'string' && isEngineId(body.engine))
332 settings.engine = body.engine
327333 if (typeof body.paused === 'boolean')
328334 settings.paused = body.paused
329335 if (typeof body.openAtLogin === 'boolean') {
@@ -334,6 +340,9 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
334340 settings = readSettings()
335341 config = loadConfig(settingsEnv(settings))
336342 await restartWatching()
343 // A switched engine has not been probed under the new selection yet,
344 // and the popover reads the answer straight back.
345 await checkEngines()
337346 return json(status())
338347 }
339348
app/Uplink/doctor.tsmodified+38-34
Changes to app/Uplink/doctor.ts
@@ -1,9 +1,9 @@
11import type { UplinkConfig } from './config'
2import type { Engine } from './engine'
23import { existsSync } from 'node:fs'
34import { join } from 'node:path'
4import { tmpdir } from 'node:os'
5import process from 'node:process'
6import { formatDuration, truncate } from './format'
5import { allEngines } from './engines'
6import { formatDuration } from './format'
77import { MessagesDb } from './messages-db'
88import { servicePaths, serviceState } from './service'
99
@@ -18,6 +18,16 @@ export interface Check {
1818 ok: boolean
1919 detail: string
2020 fix?: string
21 /**
22 * Shown, but not counted towards whether Uplink is ready. An engine the
23 * person has not selected belongs here: it is information, not a problem.
24 */
25 informational?: boolean
26}
27
28/** Whether every check that counts passed. */
29export function checksPass(checks: Check[]): boolean {
30 return checks.every(check => check.ok || check.informational)
2131}
2232
2333export interface Heartbeat {
@@ -51,34 +61,14 @@ export async function readHeartbeat(appDir: string): Promise<Heartbeat | null> {
5161const HEARTBEAT_STALE_MS = 2 * 60_000
5262
5363/**
54 * Whether the claude CLI can actually answer, checked with one real turn.
55 *
56 * Only asking "is a token set" passed a token truncated at the terminal's
57 * 80-column wrap: set, well-formed, and rejected with a 401 on first use.
64 * What to do about an engine that cannot answer. The engine's own
65 * `authFailureHint` is written for a text message, so it names the CLI and the
66 * command but not the surrounding `.env` step this install needs.
5867 */
59async function claudeAuth(bin: string): Promise<{ ok: boolean, detail: string }> {
60 try {
61 const proc = Bun.spawn([bin, '-p', 'Reply with exactly: ok', '--model', 'haiku', '--output-format', 'json'], {
62 stdout: 'pipe',
63 stderr: 'pipe',
64 cwd: tmpdir(),
65 })
66 const timer = setTimeout(() => proc.kill(), 60_000)
67 const text = await new Response(proc.stdout).text()
68 clearTimeout(timer)
69 await proc.exited
70 const result = JSON.parse(text) as { is_error?: boolean, result?: string }
71 const via = process.env.CLAUDE_CODE_OAUTH_TOKEN ? 'CLAUDE_CODE_OAUTH_TOKEN' : 'the CLI login'
72 if (!result.is_error)
73 return { ok: true, detail: `answers (via ${via})` }
74 const hint = process.env.CLAUDE_CODE_OAUTH_TOKEN
75 ? ` - the token is ${process.env.CLAUDE_CODE_OAUTH_TOKEN.length} characters; a paste cut at a line wrap is the usual cause`
76 : ''
77 return { ok: false, detail: `${truncate(result.result ?? 'no answer', 120)}${hint}` }
78 }
79 catch (error) {
80 return { ok: false, detail: `could not run ${bin}: ${error instanceof Error ? error.message : String(error)}` }
81 }
68function engineFix(engine: Engine): string {
69 return engine.id === 'claude'
70 ? 'Run claude setup-token, copy the WHOLE token (it can wrap onto a second line), then ./buddy env:set CLAUDE_CODE_OAUTH_TOKEN <token> and ./buddy uplink:restart'
71 : 'Run codex login (or codex login --device-auth if you are on SSH), then ./buddy uplink:restart'
8272}
8373
8474export async function runChecks(config: UplinkConfig, appDir: string): Promise<Check[]> {
@@ -123,10 +113,24 @@ export async function runChecks(config: UplinkConfig, appDir: string): Promise<C
123113 ? { name: 'Who can text it', ok: true, detail: config.allowed.length > 0 ? `UPLINK_ALLOWED: ${allowed.join(', ')}` : `your own handles: ${allowed.join(', ')}` }
124114 : { name: 'Who can text it', ok: false, detail: 'nobody yet', fix: 'Set UPLINK_ALLOWED in .env to your phone number (and/or Apple ID email)' })
125115
126 const auth = await claudeAuth(config.claudeBin)
127 checks.push(auth.ok
128 ? { name: 'Claude account', ok: true, detail: auth.detail }
129 : { name: 'Claude account', ok: false, detail: auth.detail, fix: 'Run claude setup-token, copy the WHOLE token (it can wrap onto a second line), then ./buddy env:set CLAUDE_CODE_OAUTH_TOKEN <token> and ./buddy uplink:restart' })
116 // One check per engine. Only the selected one is allowed to fail the doctor:
117 // a person using Codex has done nothing wrong by never signing in to Claude,
118 // and seeing the other engine listed is how they learn they can switch.
119 for (const engine of allEngines(config)) {
120 const selected = engine.id === config.engine
121 const probe = await engine.probe()
122 checks.push({
123 name: selected ? `${engine.label} account` : `${engine.label} (available, not selected)`,
124 ok: probe.ok,
125 detail: probe.detail,
126 informational: !selected,
127 fix: probe.ok
128 ? undefined
129 : selected
130 ? engineFix(engine)
131 : `Optional. To use it, set UPLINK_ENGINE=${engine.id} in .env, then: ${engineFix(engine)}`,
132 })
133 }
130134
131135 const messages = Bun.spawnSync(['pgrep', '-x', 'Messages'])
132136 checks.push(messages.exitCode === 0
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+46-0
Changes to app/Uplink/engines.ts
@@ -0,0 +1,46 @@
1import type { UplinkConfig } from './config'
2import type { Engine, EngineId } from './engine'
3import { CodexEngine } from './codex-engine'
4import { systemPrompt } from './config'
5import { ClaudeEngine, ENGINE_IDS } from './engine'
6
7/**
8 * Builds engines from config, so the watcher, the app and the doctor all select
9 * one the same way. Both construction sites used to say `new ClaudeEngine(...)`
10 * with their own copy of the options, which is two places to forget.
11 */
12
13export function engineFor(id: EngineId, config: UplinkConfig): Engine {
14 const prompt = systemPrompt(config)
15 if (id === 'codex') {
16 return new CodexEngine({
17 bin: config.codexBin,
18 model: config.codexModel,
19 permission: config.codexPermission,
20 systemPrompt: prompt,
21 timeoutMs: config.timeoutMs,
22 })
23 }
24 return new ClaudeEngine({
25 bin: config.claudeBin,
26 model: config.claudeModel,
27 permissionMode: config.permissionMode,
28 systemPrompt: prompt,
29 timeoutMs: config.timeoutMs,
30 })
31}
32
33/** The configured engine. */
34export function selectedEngine(config: UplinkConfig): Engine {
35 return engineFor(config.engine, config)
36}
37
38/**
39 * Every engine, selected one first. The doctor and the menubar report on all of
40 * them: an engine the person has not set up is information, not a failure, and
41 * seeing that the other one is available is how they learn they can switch.
42 */
43export function allEngines(config: UplinkConfig): Engine[] {
44 const ordered = [config.engine, ...ENGINE_IDS.filter(id => id !== config.engine)]
45 return ordered.map(id => engineFor(id, config))
46}
app/Uplink/filter.tsmodified+20-0
Changes to app/Uplink/filter.ts
@@ -9,6 +9,7 @@ import { CHAT_STYLE_DIRECT } from './messages-db'
99 * rule is an allowlist, never a denylist, and every check fails closed.
1010 *
1111 * A message is a command when ALL of these hold:
12 * - it arrived over iMessage, never SMS (see below),
1213 * - it is in a one-to-one chat (never a group: anyone could be added to one),
1314 * - that chat is with an allowed handle,
1415 * - it was sent BY that handle - or, in a chat with one of this Mac's own
@@ -33,7 +34,26 @@ export type Verdict =
3334 | { accept: true, text: string }
3435 | { accept: false, reason: string }
3536
37/**
38 * Only iMessage carries an identity worth trusting.
39 *
40 * With Text Message Forwarding on, which is the default once an iPhone is
41 * paired and is the setup this product assumes, forwarded SMS lands in chat.db
42 * as a direct chat whose identifier is the sender's number. SMS sender IDs are
43 * spoofable through commodity bulk-SMS gateways; iMessage sender IDs are not.
44 * Without this check, anyone who knows the owner's phone number could send one
45 * SMS that passes every other test here and reaches an agent running with
46 * approvals disabled in the owner's home directory.
47 *
48 * iMessage is also the only service that reaches this Mac over satellite, which
49 * is what Uplink is for, so nothing real is lost.
50 */
51const COMMAND_SERVICE = 'iMessage'
52
3653export function classify(message: IncomingMessage, ctx: FilterContext): Verdict {
54 if (message.service !== COMMAND_SERVICE)
55 return { accept: false, reason: `not ${COMMAND_SERVICE}` }
56
3757 if (message.chatStyle !== CHAT_STYLE_DIRECT)
3858 return { accept: false, reason: 'group chat' }
3959
app/Uplink/menubar.htmlmodified+53-13
Changes to app/Uplink/menubar.html
@@ -46,8 +46,8 @@
4646 button.primary { background: var(--accent); border-color: var(--accent); color: #18181b; }
4747 button:active { transform: translateY(1px); }
4848 button:disabled { opacity: .5; cursor: default; }
49 input, textarea { width: 100%; font: 12px/1.4 ui-monospace, SFMono-Regular, monospace; color: var(--ink); background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 7px 9px; -webkit-user-select: text; user-select: text; }
50 input:focus, textarea:focus, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
49 input, textarea, select { width: 100%; font: 12px/1.4 ui-monospace, SFMono-Regular, monospace; color: var(--ink); background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 7px 9px; -webkit-user-select: text; user-select: text; }
50 input:focus, textarea:focus, select:focus, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
5151 label { display: block; margin: 10px 0 4px; font-weight: 600; font-size: 12px; }
5252 .help { margin: 4px 0 0; color: var(--muted); font-size: 11.5px; }
5353 .error { margin: 6px 0 0; color: #dc2626; font-size: 12px; }
@@ -86,18 +86,28 @@
8686 <p class="help" data-when-todo>Switch on Uplink in the list. It notices within a few seconds.</p>
8787 </div>
8888 </div>
89 <div class="card step" id="step-claude">
89 <div class="card step" id="step-engine">
9090 <span class="mark" data-mark>2</span>
9191 <div>
92 <strong>Sign in to Claude</strong>
92 <strong data-engine-name>Sign in</strong>
9393 <p data-detail></p>
9494 <div data-when-todo>
95 <p class="help">Uplink uses your Claude plan through Claude Code. Run <code>claude setup-token</code>, sign in, and paste the token it prints.</p>
96 <div class="actions"><button data-action="open-terminal">Run it in Terminal</button></div>
97 <label for="token">Token</label>
98 <textarea id="token" rows="3" placeholder="sk-ant-oat01-..." spellcheck="false" autocomplete="off"></textarea>
99 <div class="actions"><button class="primary" data-action="save-token">Save to Keychain</button><button data-action="test-claude">Check again</button></div>
100 <p class="error" id="token-error" hidden></p>
95 <label for="engine">Answer texts with</label>
96 <select id="engine"></select>
97 <!-- Each CLI signs in its own way, so only the selected one's
98 instructions are shown. -->
99 <div data-engine-only="claude">
100 <p class="help">Uplink uses your Claude plan through Claude Code. Run <code>claude setup-token</code>, sign in, and paste the token it prints.</p>
101 <div class="actions"><button data-action="open-terminal">Run it in Terminal</button></div>
102 <label for="token">Token</label>
103 <textarea id="token" rows="3" placeholder="sk-ant-oat01-..." spellcheck="false" autocomplete="off"></textarea>
104 <div class="actions"><button class="primary" data-action="save-token">Save to Keychain</button><button data-action="test-engine">Check again</button></div>
105 <p class="error" id="token-error" hidden></p>
106 </div>
107 <div data-engine-only="codex">
108 <p class="help">Uplink uses your ChatGPT plan through Codex. Run <code>codex login</code> and sign in; Codex keeps the credential itself, so there is no token to paste here.</p>
109 <div class="actions"><button data-action="open-terminal">Run it in Terminal</button><button class="primary" data-action="test-engine">Check again</button></div>
110 </div>
101111 </div>
102112 </div>
103113 </div>
@@ -166,6 +176,32 @@
166176 node.hidden = check.ok
167177 }
168178
179 /**
180 * The sign-in step follows whichever engine is selected: its name, its
181 * instructions, and nothing belonging to the other one.
182 */
183 function renderEngine(status, check) {
184 const selected = status.settings.engine
185 const label = check?.label || selected
186 $('[data-engine-name]').textContent = `Sign in to ${label}`
187
188 const picker = $('#engine')
189 const options = (status.engines || []).map(engine => engine.id).join(',')
190 if (picker.dataset.options !== options) {
191 picker.replaceChildren(...(status.engines || []).map((engine) => {
192 const option = document.createElement('option')
193 option.value = engine.id
194 option.textContent = engine.label
195 return option
196 }))
197 picker.dataset.options = options
198 }
199 picker.value = selected
200
201 for (const node of document.querySelectorAll('[data-engine-only]'))
202 node.hidden = node.dataset.engineOnly !== selected
203 }
204
169205 function render(status) {
170206 current = status
171207 $('#dot').className = `dot ${status.state}`
@@ -177,8 +213,9 @@
177213 const byId = Object.fromEntries(status.checks.map(check => [check.id, check]))
178214 $('#setup').hidden = status.state !== 'setup'
179215 renderStep('#step-fda', byId.fda)
180 renderStep('#step-claude', byId.claude)
216 renderStep('#step-engine', byId.engine)
181217 renderStep('#step-handles', byId.handles)
218 renderEngine(status, byId.engine)
182219 if (!allowedEdited)
183220 $('#allowed').value = status.settings.allowed.join('\n')
184221
@@ -229,7 +266,7 @@
229266 { type: 'separator' },
230267 { label: 'Open Uplink', action: 'open' },
231268 { label: status.settings.paused ? 'Resume' : 'Pause', action: 'toggle-pause' },
232 { label: 'Check Claude sign-in', action: 'test-claude' },
269 { label: `Check ${status.settings.engine === 'codex' ? 'Codex' : 'Claude'} sign-in`, action: 'test-engine' },
233270 { type: 'separator' },
234271 { label: 'Quit Uplink', action: 'quit', shortcut: 'q' },
235272 ]
@@ -273,7 +310,7 @@
273310 'open': () => show(),
274311 'open-fda': () => api('/api/open/full-disk-access', {}),
275312 'open-terminal': () => api('/api/open/terminal', {}),
276 'test-claude': () => api('/api/claude/test', {}).then(refresh),
313 'test-engine': () => api('/api/engine/test', {}).then(refresh),
277314 'save-token': async () => {
278315 const error = $('#token-error')
279316 error.hidden = true
@@ -303,6 +340,9 @@
303340 Promise.resolve(actions[button.dataset.action]?.()).finally(() => { button.disabled = false })
304341 })
305342 $('#allowed').addEventListener('input', () => { allowedEdited = true })
343 // Switching engines restarts the watcher and re-probes, so the answer comes
344 // back in the same response.
345 $('#engine').addEventListener('change', async event => render(await api('/api/settings', { engine: event.target.value })))
306346 $('#login').addEventListener('change', async event => render(await api('/api/settings', { openAtLogin: event.target.checked })))
307347
308348 if (craft?.tray) {
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_'))),
app/Uplink/uplink.tsmodified+5-1
Changes to app/Uplink/uplink.ts
@@ -366,8 +366,12 @@ export class Uplink {
366366 })
367367
368368 if (!active.stoppedByUser) {
369 // Naming the wrong CLI, or a .env the downloadable app does not have,
370 // sends someone at the Mac to fix something that is not broken. The
371 // engine carries its own remedy.
372 const engine = this.deps.engine
369373 const text = result.authFailure
370 ? 'I cannot reach Claude: the claude CLI on the Mac is not logged in. Someone at the Mac needs to run "claude setup-token" and put the token in Uplink\'s .env.'
374 ? `I cannot reach ${engine.label}: it is not signed in on this Mac. Someone there needs to ${engine.authFailureHint}.`
371375 : result.ok ? result.text : `That failed: ${result.text}`
372376 await this.reply(active.target, text, { keepRest: true })
373377 }
tests/unit/uplink/codex-engine.test.tsadded+241-0
Changes to tests/unit/uplink/codex-engine.test.ts
@@ -0,0 +1,241 @@
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
67/**
68 * The flags, one per line. The prompt is deliberately excluded: it carries the
69 * system prompt and is many lines long, so it would otherwise be scattered
70 * across this array. Use `rawArgsOf` to assert on it.
71 */
72function argsOf(bin: string): string[] {
73 return rawArgsOf(bin).split('\n').filter(line => line.startsWith('-') || !line.includes(' '))
74}
75
76function rawArgsOf(bin: string): string {
77 return readFileSync(`${bin}.args`, 'utf8').trim()
78}
79
80describe('CodexEngine', () => {
81 it('parses the event stream into a result and events', async () => {
82 const bin = stubCodex()
83 const events: EngineEvent[] = []
84 const result = await engine(bin).run({ prompt: 'scores?', cwd: dir, onEvent: e => events.push(e) }).done
85
86 expect(result.ok).toBe(true)
87 expect(result.text).toBe('Chiefs 24-21')
88 expect(result.sessionId).toBe('th-9')
89 // Codex reports tokens and no dollar figure, so a cost here would be invented.
90 expect(result.costCents).toBeNull()
91 expect(result.turns).toBe(1)
92 expect(result.authFailure).toBe(false)
93
94 expect(events).toContainEqual({ kind: 'session', sessionId: 'th-9' })
95 expect(events).toContainEqual({ kind: 'text', text: 'Chiefs 24-21' })
96 expect(events).toContainEqual({ kind: 'tool', name: 'command_execution', summary: 'command_execution: git log --oneline -3' })
97 })
98
99 it('always passes --skip-git-repo-check, because the default workdir is not a repo', async () => {
100 const bin = stubCodex()
101 await engine(bin).run({ prompt: 'x', cwd: dir }).done
102 expect(argsOf(bin)).toContain('--skip-git-repo-check')
103 })
104
105 it('starts a fresh run with exec, --cd and the bypass flag', async () => {
106 const bin = stubCodex()
107 await engine(bin).run({ prompt: 'scores?', cwd: dir }).done
108 const args = argsOf(bin)
109
110 expect(args[0]).toBe('exec')
111 expect(args).toContain('--json')
112 expect(args.slice(args.indexOf('--cd'), args.indexOf('--cd') + 2)).toEqual(['--cd', dir])
113 expect(args).toContain('--dangerously-bypass-approvals-and-sandbox')
114 // No --append-system-prompt exists, so the instructions ride on the prompt,
115 // which is why the prompt is many lines and the last argv line is not it.
116 const raw = rawArgsOf(bin)
117 expect(raw).toContain('Answer in plain text.')
118 expect(raw).toEndWith('scores?')
119 expect(readFileSync(`${bin}.cwd`, 'utf8').trim()).toEndWith(dir.replace(/^\/private/, ''))
120 })
121
122 it('resumes through the subcommand, with the session id positional', async () => {
123 const bin = stubCodex()
124 await engine(bin).run({ prompt: 'who won?', cwd: dir, sessionId: 'th-7' }).done
125 const args = argsOf(bin)
126
127 expect(args.slice(0, 3)).toEqual(['exec', 'resume', 'th-7'])
128 // `resume` rejects both of these with "unexpected argument" and exit 2.
129 expect(args).not.toContain('--cd')
130 expect(args).not.toContain('--sandbox')
131 })
132
133 it('sets a non-bypass sandbox through -c, which resume accepts', async () => {
134 const bin = stubCodex()
135 await engine(bin, { permission: 'workspace-write' }).run({ prompt: 'x', cwd: dir, sessionId: 'th-7' }).done
136 const args = argsOf(bin)
137
138 expect(args).not.toContain('--sandbox')
139 expect(args.slice(args.indexOf('--config'), args.indexOf('--config') + 2))
140 .toEqual(['--config', 'sandbox_mode="workspace-write"'])
141 })
142
143 it('passes the model only when one is set', async () => {
144 const withModel = stubCodex()
145 await engine(withModel, { model: 'gpt-5-codex' }).run({ prompt: 'x', cwd: dir }).done
146 const args = argsOf(withModel)
147 expect(args.slice(args.indexOf('--model'), args.indexOf('--model') + 2)).toEqual(['--model', 'gpt-5-codex'])
148
149 clearJsonFlagCache()
150 const without = stubCodex()
151 await engine(without).run({ prompt: 'x', cwd: dir }).done
152 expect(argsOf(without)).not.toContain('--model')
153 })
154
155 it('uses the version spelling of the JSONL flag that the binary offers', async () => {
156 const bin = stubCodex({ jsonFlag: '--experimental-json' })
157 await engine(bin).run({ prompt: 'x', cwd: dir }).done
158 const args = argsOf(bin)
159 expect(args).toContain('--experimental-json')
160 expect(args).not.toContain('--json')
161 })
162
163 it('falls back to the streamed message when the last-message file is absent', async () => {
164 // The real CLI does not write that file when a turn fails, so the stream has
165 // to be the source of truth.
166 const bin = stubCodex({ lastMessage: '' })
167 const result = await engine(bin).run({ prompt: 'x', cwd: dir }).done
168 expect(result.text).toBe('Chiefs 24-21')
169 })
170
171 it('reports a turn.failed as a failure', async () => {
172 const bin = stubCodex({
173 events: [
174 { type: 'thread.started', thread_id: 'th-9' },
175 { type: 'turn.failed', error: { message: 'context window exceeded' } },
176 ],
177 exit: 1,
178 lastMessage: '',
179 })
180 const result = await engine(bin).run({ prompt: 'x', cwd: dir }).done
181 expect(result.ok).toBe(false)
182 expect(result.text).toContain('context window exceeded')
183 expect(result.sessionId).toBe('th-9')
184 })
185
186 it('flags a signed-out CLI from the failure text', async () => {
187 const bin = stubCodex({
188 events: [],
189 exit: 1,
190 stderr: 'unexpected status 401 Unauthorized: Missing bearer or basic authentication in header',
191 lastMessage: '',
192 })
193 const result = await engine(bin).run({ prompt: 'x', cwd: dir }).done
194 expect(result.ok).toBe(false)
195 expect(result.authFailure).toBe(true)
196 })
197
198 it('cancels a run', async () => {
199 const bin = stubCodex()
200 const run = engine(bin).run({ prompt: 'x', cwd: dir })
201 run.cancel()
202 const result = await run.done
203 expect(result.ok).toBe(false)
204 expect(result.text).toBe('Stopped.')
205 })
206
207 describe('probe', () => {
208 it('is signed in when codex login status exits 0', async () => {
209 const path = join(dir, `codex-login-ok-${counter += 1}`)
210 // The real CLI prints this on stderr, not stdout.
211 writeFileSync(path, '#!/bin/sh\necho "Logged in using ChatGPT" >&2\nexit 0\n')
212 chmodSync(path, 0o755)
213 expect(await engine(path).probe()).toEqual({ ok: true, detail: 'Logged in using ChatGPT' })
214 })
215
216 it('is not signed in when it exits non-zero', async () => {
217 const path = join(dir, `codex-login-bad-${counter += 1}`)
218 writeFileSync(path, '#!/bin/sh\necho "Not logged in" >&2\nexit 1\n')
219 chmodSync(path, 0o755)
220 const probe = await engine(path).probe()
221 expect(probe.ok).toBe(false)
222 expect(probe.detail).toContain('Not logged in')
223 })
224
225 it('says so when the binary is missing', async () => {
226 const probe = await engine(join(dir, 'not-installed')).probe()
227 expect(probe.ok).toBe(false)
228 expect(probe.detail).toContain('Is Codex installed?')
229 })
230 })
231})
232
233describe('summarizeItem', () => {
234 it('reads whichever descriptive field an item carries', () => {
235 expect(summarizeItem({ type: 'command_execution', command: 'bun test' })).toBe('command_execution: bun test')
236 expect(summarizeItem({ type: 'web_search', query: 'nfl scores' })).toBe('web_search: nfl scores')
237 expect(summarizeItem({ type: 'file_change', path: '/Users/someone/Code/app.ts' })).toBe('file_change: ~/Code/app.ts')
238 // Unknown kinds still produce a line, because the set is not closed.
239 expect(summarizeItem({ type: 'something_new' })).toBe('something_new')
240 })
241})
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/messages.test.tsmodified+10-0
Changes to tests/unit/uplink/messages.test.ts
@@ -107,8 +107,18 @@ describe('classify', () => {
107107 expect(classify({ ...base, chatIdentifier: ME, chatGuid: `iMessage;-;${ME}`, sender: FRIEND }, selfCtx)).toEqual({ accept: false, reason: 'sender mismatch' })
108108 })
109109
110 it('refuses SMS even from an allowed handle, because sender IDs are spoofable', () => {
111 // With Text Message Forwarding on, a forwarded SMS is a direct chat whose
112 // identifier is the sender's number, so every other check here passes.
113 expect(classify({ ...base, service: 'SMS' }, ctx)).toEqual({ accept: false, reason: 'not iMessage' })
114 expect(classify({ ...base, service: 'SMS', chatIdentifier: ME, chatGuid: `SMS;-;${ME}`, isFromMe: true, sender: null }, ctx))
115 .toEqual({ accept: false, reason: 'not iMessage' })
116 })
117
110118 it('fails closed on everything else', () => {
111119 const rejected: Array<[Partial<IncomingMessage>, string]> = [
120 [{ service: 'SMS' }, 'not iMessage'],
121 [{ service: 'RCS' }, 'not iMessage'],
112122 [{ chatIdentifier: '+15559999999', sender: '+15559999999' }, 'chat not allowed'],
113123 [{ chatStyle: 43 }, 'group chat'],
114124 [{ isFromMe: true, sender: null }, 'outgoing message'],
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', () => {
tests/unit/uplink/uplink.test.tsmodified+4-1
Changes to tests/unit/uplink/uplink.test.ts
@@ -190,12 +190,15 @@ describe('Uplink', () => {
190190 expect(h.engine.runs[1].request.sessionId).toBeNull()
191191 })
192192
193 it('tells the user when the claude CLI is not logged in', async () => {
193 it('tells the user which CLI is not logged in, and how to fix it', async () => {
194194 const h = await harness()
195195 h.fake.add({ chat: ME, text: 'hello', fromMe: true })
196196 await h.uplink.tick()
197197 h.engine.finish(0, 'Failed to authenticate', { ok: false, authFailure: true })
198198 await settle()
199 // The remedy comes from the engine, so a Codex install is told about Codex
200 // rather than about a claude token and a .env the app does not have.
201 expect(h.sender.texts()[0]).toContain('Claude Code')
199202 expect(h.sender.texts()[0]).toContain('claude setup-token')
200203 expect(h.store.runs.get(1)?.status).toBe('failed')
201204 })