ReviewOS

also looking at this

stacks/uplink

fix(app): a fresh install now either works or says why

#8
Merged glennmichael123 wants to merge fix/setup-blockers into main
16 files +512 -75
app/Commands/Uplink.tsmodified+26-11
Changes to app/Commands/Uplink.ts
@@ -30,7 +30,7 @@ async function openMessages(config: ReturnType<typeof loadConfig>, appDir: strin
3030 catch (error) {
3131 const message = error instanceof MessagesAccessError ? error.message : String(error)
3232 console.error(`[uplink] ${message} Retrying in ${RETRY_OPEN_MS / 1000}s.`)
33 await writeHeartbeat(appDir, { pid: process.pid, startedAt, lastPollAt: null, lastError: message, allowed: config.allowed, own: [], active: [] })
33 await writeHeartbeat(appDir, { pid: process.pid, startedAt, lastPollAt: null, lastError: message, automation: null, lastSendError: null, allowed: config.allowed, own: [], active: [] })
3434 await Bun.sleep(RETRY_OPEN_MS)
3535 }
3636 }
@@ -73,24 +73,39 @@ export default defineCommand((cli) => {
7373 keepMessagesOpen()
7474
7575 const messages = await openMessages(config, appDir, startedAt)
76 const sender = new AppleScriptSender()
7677 const uplink = new Uplink({
7778 config,
7879 messages,
79 sender: new AppleScriptSender(),
80 sender,
8081 engine: selectedEngine(config),
8182 store: new ModelStore(`${appDir}/storage/uplink/cursor`),
8283 })
8384 await uplink.start()
8485
85 const beat = (): Promise<number> => writeHeartbeat(appDir, {
86 pid: process.pid,
87 startedAt,
88 lastPollAt: uplink.lastPollAt,
89 lastError: uplink.lastError,
90 allowed: uplink.allowedHandles,
91 own: uplink.ownHandles,
92 active: uplink.activeRuns,
93 })
86 // The Automation grant is per app, so only this process can prove that
87 // Uplink.app may drive Messages. Ask once at start, and again whenever a
88 // send has failed, so the doctor and the dashboard report the subject
89 // that actually sends rather than whichever terminal ran them.
90 let automation = await sender.canSend()
91 if (!automation.ok)
92 console.error(`[uplink] cannot send through Messages: ${automation.detail}`)
93
94 const beat = async (): Promise<number> => {
95 if (uplink.lastSendError && automation.ok)
96 automation = await sender.canSend()
97 return writeHeartbeat(appDir, {
98 pid: process.pid,
99 startedAt,
100 lastPollAt: uplink.lastPollAt,
101 lastError: uplink.lastError,
102 automation: { ok: automation.ok, detail: automation.detail },
103 lastSendError: uplink.lastSendError,
104 allowed: uplink.allowedHandles,
105 own: uplink.ownHandles,
106 active: uplink.activeRuns,
107 })
108 }
94109 // .env is read once, at start. Editing it (a new token, say) used to do
95110 // nothing until someone remembered uplink:restart, and every text in
96111 // between failed. Under launchd, which restarts us, pick it up on our
app/Desktop/launcher.tsmodified+8-0
Changes to app/Desktop/launcher.ts
@@ -16,9 +16,17 @@ import { dirname, join } from 'node:path'
1616import process from 'node:process'
1717import { format } from 'node:util'
1818import pkg from '../../package.json'
19import { agentPath } from '../Uplink/agent-path'
1920import { acquireSingleInstance, startDesktopAgent } from '../Uplink/desktop-agent'
2021import { LOG_PATH } from '../Uplink/settings'
2122
23// Launched from Finder or from its own LaunchAgent, this process gets the bare
24// launchd PATH, and everything below it inherits that: the engines, their
25// probes, and osascript. Widen it once, here, before anything spawns. Without
26// this a `claude` installed by bun, npm or nvm is invisible to the app on a Mac
27// where it works in a terminal.
28process.env.PATH = agentPath()
29
2230// Launched from Finder or at login there is no terminal, so keep a log where
2331// Console.app looks for one.
2432if (!process.stdout.isTTY) {
app/Uplink/agent-path.tsadded+39-0
Changes to app/Uplink/agent-path.ts
@@ -0,0 +1,39 @@
1import { homedir } from 'node:os'
2import { join } from 'node:path'
3import process from 'node:process'
4
5/**
6 * The PATH an Uplink process needs.
7 *
8 * launchd hands agents a bare PATH, and Uplink shells out to `claude`, `codex`,
9 * `git` and `osascript`. The from-source launcher has always built this list;
10 * the downloadable app did not, so a `claude` installed by bun, npm or nvm was
11 * invisible to it on a Mac where it works perfectly in a terminal. Both use
12 * this now, so the two cannot drift.
13 *
14 * The user's own locations come first: Uplink's bundled tools must not shadow
15 * the CLI a person deliberately installed. Whatever PATH already exists is kept
16 * last, so a real environment (a terminal, a debugging session) still wins over
17 * nothing.
18 */
19export function agentPath(options: { appDir?: string, env?: Record<string, string | undefined> } = {}): string {
20 const env = options.env ?? process.env
21 const home = env.HOME ?? homedir()
22
23 return [
24 // Where the installers put the agent CLIs.
25 join(home, '.local', 'bin'),
26 join(home, '.bun', 'bin'),
27 join(home, '.npm-global', 'bin'),
28 join(home, '.claude', 'local'),
29 '/opt/homebrew/bin',
30 '/usr/local/bin',
31 // The from-source install's own pinned toolchain, after the user's.
32 options.appDir ? join(options.appDir, 'pantry', '.bin') : '',
33 '/usr/bin',
34 '/bin',
35 '/usr/sbin',
36 '/sbin',
37 env.PATH ?? '',
38 ].filter(Boolean).join(':')
39}
app/Uplink/codex-engine.tsmodified+16-7
Changes to app/Uplink/codex-engine.ts
@@ -1,11 +1,11 @@
11import type { Subprocess } from 'bun'
2import type { Engine, EngineProbe, EngineRequest, EngineResult, EngineRun } from './engine'
2import type { Engine, EngineInstall, EngineProbe, EngineRequest, EngineResult, EngineRun } from './engine'
33import { randomUUID } from 'node:crypto'
44import { rmSync } from 'node:fs'
55import { tmpdir } from 'node:os'
66import { join } from 'node:path'
77import process from 'node:process'
8import { PROBE_TIMEOUT_MS } from './engine'
8import { binaryExists, PROBE_TIMEOUT_MS } from './engine'
99import { truncate } from './format'
1010
1111/**
@@ -60,6 +60,10 @@ export class CodexEngine implements Engine {
6060 readonly id = 'codex' as const
6161 readonly label = 'Codex'
6262 readonly authFailureHint = 'run "codex login" on the Mac (or "codex login --device-auth" over SSH)'
63 readonly install: EngineInstall = {
64 command: 'bun install -g @openai/codex',
65 url: 'https://developers.openai.com/codex/cli',
66 }
6367
6468 constructor(private readonly options: CodexEngineOptions) {}
6569
@@ -69,6 +73,9 @@ export class CodexEngine implements Engine {
6973 * can be checked as often as the UI likes.
7074 */
7175 async probe(): Promise<EngineProbe> {
76 if (!binaryExists(this.options.bin))
77 return { ok: false, reason: 'missing', detail: 'Codex is not installed on this Mac.' }
78
7279 try {
7380 const proc = Bun.spawn([this.options.bin, 'login', 'status'], {
7481 cwd: tmpdir(),
@@ -81,13 +88,15 @@ export class CodexEngine implements Engine {
8188 const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()])
8289 const exitCode = await proc.exited
8390 clearTimeout(timer)
91 // `codex login status` prints to stderr, not stdout, and exits 0 when
92 // signed in.
8493 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 }
94 if (exitCode === 0)
95 return { ok: true, reason: 'ok', detail: said }
96 return { ok: false, reason: 'signed-out', detail: said === 'Codex said nothing' ? 'Not signed in yet.' : said }
8897 }
89 catch {
90 return { ok: false, detail: `Could not run ${this.options.bin}. Is Codex installed?` }
98 catch (error) {
99 return { ok: false, reason: 'failed', detail: `Could not run ${this.options.bin}: ${error instanceof Error ? error.message : String(error)}` }
91100 }
92101 }
93102
app/Uplink/config.tsmodified+4-0
Changes to app/Uplink/config.ts
@@ -109,6 +109,10 @@ function findClaude(): string {
109109 for (const candidate of [
110110 join(homedir(), '.local', 'bin', 'claude'),
111111 join(homedir(), '.claude', 'local', 'claude'),
112 // `bun install -g` and an npm global prefix, both of which the PATH the
113 // launchers build also covers.
114 join(homedir(), '.bun', 'bin', 'claude'),
115 join(homedir(), '.npm-global', 'bin', 'claude'),
112116 '/opt/homebrew/bin/claude',
113117 '/usr/local/bin/claude',
114118 ]) {
app/Uplink/desktop-agent.tsmodified+76-4
Changes to app/Uplink/desktop-agent.ts
@@ -1,6 +1,8 @@
11/* eslint-disable no-console */
22import type { Server } from 'bun'
33import type { UplinkConfig } from './config'
4import type { EngineId, EngineInstall, EngineProbe, ProbeReason } from './engine'
5import type { AutomationState } from './sender'
46import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
57import { homedir } from 'node:os'
68import { join } from 'node:path'
@@ -41,7 +43,25 @@ const LOCK_PATH = join(DATA_DIR, 'uplink.pid')
4143const LAUNCH_AGENT = join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`)
4244const RETRY_OPEN_MS = 5_000
4345
44type EngineState = { ok: boolean, detail: string, checkedAt: number }
46type EngineState = EngineProbe & { checkedAt: number }
47
48/**
49 * One row of the popover's setup list. Declared rather than inferred, because
50 * the entries genuinely differ (only an engine row carries `install`) and an
51 * inferred union puts the shared fields out of reach.
52 */
53interface PopoverCheck {
54 id: string
55 name: string
56 ok: boolean
57 detail: string
58 /** Shown, but not counted towards readiness. */
59 informational: boolean
60 engineId?: EngineId
61 label?: string
62 reason?: ProbeReason
63 install?: EngineInstall
64}
4565
4666export interface DesktopAgent {
4767 port: number
@@ -142,6 +162,7 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
142162 if (settings.openAtLogin && (!existsSync(LAUNCH_AGENT) || !readFileSync(LAUNCH_AGENT, 'utf8').includes(`<string>${process.execPath}</string>`)))
143163 setOpenAtLogin(true)
144164
165 const sender = new AppleScriptSender()
145166 const store = new SqliteStore(DATABASE_PATH)
146167 const startedAt = Date.now()
147168 let messages: MessagesDb | null = null
@@ -150,6 +171,17 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
150171 let knownAllowed: string[] = []
151172 const engines = new Map<string, EngineState>()
152173 let checkingEngines = false
174 let automation: AutomationState | null = null
175
176 /**
177 * Whether macOS will let this app drive Messages. This process is Uplink.app,
178 * so unlike `buddy uplink:doctor` under Terminal it is the right subject to
179 * ask. The first call raises the prompt, which is why setup calls it rather
180 * than leaving it to the first real reply.
181 */
182 const checkAutomation = async (): Promise<void> => {
183 automation = await sender.canSend()
184 }
153185
154186 /**
155187 * Probe every engine, not only the selected one: the popover shows the other
@@ -180,7 +212,7 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
180212 messagesError = error instanceof MessagesAccessError ? 'Uplink needs Full Disk Access to read Messages.' : String(error)
181213 return
182214 }
183 uplink = new Uplink({ config, messages, sender: new AppleScriptSender(), engine: selectedEngine(config), store })
215 uplink = new Uplink({ config, messages, sender, engine: selectedEngine(config), store })
184216 await uplink.start()
185217 }
186218
@@ -200,10 +232,16 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
200232 const keepMessagesOpen = setInterval(() => {
201233 if (uplink && Bun.spawnSync(['pgrep', '-x', 'Messages']).exitCode !== 0)
202234 Bun.spawnSync(['open', '-g', '-a', 'Messages'])
235 // Re-ask while the answer is anything but yes. A refusal only changes in
236 // System Settings, and a timeout from a Messages that was still starting
237 // should not leave the app stuck in setup for ever.
238 if (!automation?.ok)
239 void checkAutomation()
203240 }, 15_000)
204241
205242 await startWatching()
206243 void checkEngines()
244 void checkAutomation()
207245
208246 const status = () => {
209247 const recent = store.recentRuns(8).map(run => ({
@@ -223,7 +261,7 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
223261 if (uplink)
224262 knownAllowed = uplink.allowedHandles
225263 const allowed = uplink ? knownAllowed : settings.allowed.length > 0 ? settings.allowed : knownAllowed
226 const checks = [
264 const checks: PopoverCheck[] = [
227265 {
228266 id: 'fda',
229267 name: 'Read Messages',
@@ -234,13 +272,19 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
234272 ...allEngines(config).map((engine) => {
235273 const state = engines.get(engine.id)
236274 const selected = engine.id === config.engine
275 const reason = state?.reason ?? 'failed'
276 const missing = reason === 'missing'
237277 return {
238278 // `engine` is the selected one, and the popover draws its sign-in
239279 // step. The other is listed so switching is discoverable.
240280 id: selected ? 'engine' : `engine:${engine.id}`,
241 name: selected ? `Sign in to ${engine.label}` : engine.label,
281 // A missing CLI is not a sign-in problem, and calling it one sends
282 // the person to run a command that does not exist either.
283 name: missing ? `Install ${engine.label}` : selected ? `Sign in to ${engine.label}` : engine.label,
242284 engineId: engine.id,
243285 label: engine.label,
286 reason,
287 install: engine.install,
244288 ok: state?.ok ?? false,
245289 detail: checkingEngines && !state ? 'Checking' : (state?.detail ?? 'Not checked yet'),
246290 informational: !selected,
@@ -253,6 +297,16 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
253297 detail: allowed.length > 0 ? allowed.join(', ') : 'No handles yet. Sign in to Messages, or add your number.',
254298 informational: false,
255299 },
300 {
301 // The outbound half of the chain. Without this the app reported itself
302 // ready while every reply was refused, so a first text produced a run
303 // marked done and a phone that heard nothing.
304 id: 'automation',
305 name: 'Send replies',
306 ok: automation?.ok ?? false,
307 detail: uplink?.lastSendError ?? automation?.detail ?? 'Not checked yet',
308 informational: false,
309 },
256310 ]
257311 // An engine the person has not selected must not hold the app in setup.
258312 const ready = checks.every(check => check.ok || check.informational)
@@ -305,6 +359,24 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
305359 Bun.spawnSync(['open', 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles'])
306360 return json({ ok: true })
307361
362 case '/api/automation/test':
363 await checkAutomation()
364 return json(automation)
365
366 case '/api/open/automation': {
367 Bun.spawnSync(['open', 'x-apple.systempreferences:com.apple.preference.security?Privacy_Automation'])
368 return json({ ok: true })
369 }
370
371 case '/api/open/install-docs': {
372 // The selected engine's own documentation, so the link cannot drift
373 // from the command shown beside it.
374 const { url } = allEngines(config).find(engine => engine.id === config.engine)?.install ?? { url: '' }
375 if (url)
376 Bun.spawnSync(['open', url])
377 return json({ ok: Boolean(url), url })
378 }
379
308380 case '/api/open/terminal': {
309381 // Both sign-ins need a real terminal for their browser round trip.
310382 const command = config.engine === 'codex' ? 'codex login' : 'claude setup-token'
app/Uplink/doctor.tsmodified+50-12
Changes to app/Uplink/doctor.ts
@@ -1,5 +1,5 @@
11import type { UplinkConfig } from './config'
2import type { Engine } from './engine'
2import type { Engine, ProbeReason } from './engine'
33import { existsSync } from 'node:fs'
44import { join } from 'node:path'
55import { allEngines } from './engines'
@@ -35,6 +35,10 @@ export interface Heartbeat {
3535 startedAt: number
3636 lastPollAt: number | null
3737 lastError: string | null
38 /** Whether the watcher itself can drive Messages. Null before it has asked. */
39 automation: { ok: boolean, detail: string } | null
40 /** Why the last reply could not be delivered, if one could not. */
41 lastSendError: string | null
3842 allowed: string[]
3943 own: string[]
4044 active: Array<{ prompt: string, startedAt: number, lastActivity: string | null, queued: number }>
@@ -61,14 +65,23 @@ export async function readHeartbeat(appDir: string): Promise<Heartbeat | null> {
6165const HEARTBEAT_STALE_MS = 2 * 60_000
6266
6367/**
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.
68 * What to do about an engine that cannot answer, which depends on why.
69 *
70 * A missing CLI used to be reported inside the sign-in step, so the person was
71 * told to run a command that does not exist either. These are three problems
72 * with three different next steps.
6773 */
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'
74function engineFix(engine: Engine, reason: ProbeReason): string {
75 if (reason === 'missing')
76 return `Install it: ${engine.install.command} (see ${engine.install.url}), then ./buddy uplink:restart`
77
78 if (reason === 'signed-out') {
79 return engine.id === 'claude'
80 ? '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'
81 : 'Run codex login (or codex login --device-auth if you are on SSH), then ./buddy uplink:restart'
82 }
83
84 return `${engine.label} is installed and signed in, but did not answer. The detail above is what it said.`
7285}
7386
7487export async function runChecks(config: UplinkConfig, appDir: string): Promise<Check[]> {
@@ -119,16 +132,20 @@ export async function runChecks(config: UplinkConfig, appDir: string): Promise<C
119132 for (const engine of allEngines(config)) {
120133 const selected = engine.id === config.engine
121134 const probe = await engine.probe()
135 const name = probe.reason === 'missing'
136 ? `${engine.label} (not installed)`
137 : selected ? `${engine.label} account` : `${engine.label} (available, not selected)`
138
122139 checks.push({
123 name: selected ? `${engine.label} account` : `${engine.label} (available, not selected)`,
140 name,
124141 ok: probe.ok,
125142 detail: probe.detail,
126143 informational: !selected,
127144 fix: probe.ok
128145 ? undefined
129146 : selected
130 ? engineFix(engine)
131 : `Optional. To use it, set UPLINK_ENGINE=${engine.id} in .env, then: ${engineFix(engine)}`,
147 ? engineFix(engine, probe.reason)
148 : `Optional. To use it, set UPLINK_ENGINE=${engine.id} in .env, then: ${engineFix(engine, probe.reason)}`,
132149 })
133150 }
134151
@@ -137,7 +154,28 @@ export async function runChecks(config: UplinkConfig, appDir: string): Promise<C
137154 ? { name: 'Messages app', ok: true, detail: 'running' }
138155 : { name: 'Messages app', ok: false, detail: 'not running', fix: 'Open Messages and sign in with your Apple ID' })
139156
140 checks.push({ name: 'Sending replies', ok: true, detail: 'macOS asks once, on the first reply, to let Uplink control Messages - click Allow' })
157 // The Automation grant is per app: the watcher inside Uplink.app and this
158 // command under Terminal are different subjects. Only the watcher's answer is
159 // about the thing that will actually send, so ask the heartbeat rather than
160 // measuring this process and reporting it as Uplink's.
161 if (fresh && heartbeat.automation) {
162 checks.push(heartbeat.automation.ok
163 ? { name: 'Sending replies', ok: true, detail: heartbeat.automation.detail }
164 : {
165 name: 'Sending replies',
166 ok: false,
167 detail: heartbeat.lastSendError ?? heartbeat.automation.detail,
168 fix: `Allow ${paths.bundle} to control Messages in System Settings > Privacy & Security > Automation, then ./buddy uplink:restart. macOS asks once; if it was declined, the switch is in that pane.`,
169 })
170 }
171 else {
172 checks.push({
173 name: 'Sending replies',
174 ok: false,
175 detail: 'not checked: the watcher is not running, and only it can prove Uplink.app may control Messages',
176 fix: 'Start it with ./buddy uplink:install, then run this again',
177 })
178 }
141179
142180 return checks
143181}
app/Uplink/engine.tsmodified+43-4
Changes to app/Uplink/engine.ts
@@ -53,12 +53,34 @@ export function isEngineId(value: string): value is EngineId {
5353 return (ENGINE_IDS as readonly string[]).includes(value)
5454}
5555
56/**
57 * Why an engine cannot answer. These are three different problems with three
58 * different next steps, and setup used to collapse them into one: a missing
59 * binary was reported inside the "Sign in" step, which sends the person to run
60 * a command that does not exist either.
61 */
62export type ProbeReason = 'ok' | 'missing' | 'signed-out' | 'failed'
63
5664/** Whether a CLI can answer right now, for the doctor and the menubar. */
5765export interface EngineProbe {
5866 ok: boolean
67 reason: ProbeReason
5968 detail: string
6069}
6170
71/** Where to get an engine that is not installed. */
72export interface EngineInstall {
73 command: string
74 url: string
75}
76
77/** True when `bin` is something this machine can actually run. */
78export function binaryExists(bin: string): boolean {
79 // An absolute path from findClaude/findCodex, or a bare name to resolve
80 // against PATH, which the launchers widen before anything spawns.
81 return bin.includes('/') ? Bun.file(bin).size > 0 : Bun.which(bin) !== null
82}
83
6284export interface Engine {
6385 readonly id: EngineId
6486 /** How the engine is named to a person: "Claude Code", "Codex". */
@@ -68,6 +90,8 @@ export interface Engine {
6890 * from the Mac, so this has to name the CLI and the exact command.
6991 */
7092 readonly authFailureHint: string
93 /** How to install this CLI, for a Mac that does not have it. */
94 readonly install: EngineInstall
7195 run: (request: EngineRequest) => EngineRun
7296 /**
7397 * Can this CLI answer right now. Separate from `run` because setup has to
@@ -95,6 +119,10 @@ export class ClaudeEngine implements Engine {
95119 readonly id = 'claude' as const
96120 readonly label = 'Claude Code'
97121 readonly authFailureHint = 'run "claude setup-token" on the Mac and give Uplink the token'
122 readonly install: EngineInstall = {
123 command: 'bun install -g @anthropic-ai/claude-code',
124 url: 'https://docs.claude.com/en/docs/claude-code',
125 }
98126
99127 constructor(private readonly options: ClaudeEngineOptions) {}
100128
@@ -104,6 +132,11 @@ export class ClaudeEngine implements Engine {
104132 * rejected with a 401 on first use.
105133 */
106134 async probe(): Promise<EngineProbe> {
135 // Asked before spawning, because a missing binary and a logged-out one are
136 // different problems and Bun throws rather than exiting 127 for the former.
137 if (!binaryExists(this.options.bin))
138 return { ok: false, reason: 'missing', detail: 'Claude Code is not installed on this Mac.' }
139
107140 try {
108141 const proc = Bun.spawn([this.options.bin, '-p', 'Reply with exactly: ok', '--model', 'haiku', '--output-format', 'json'], {
109142 cwd: tmpdir(),
@@ -119,12 +152,18 @@ export class ClaudeEngine implements Engine {
119152 const result = JSON.parse(text) as { is_error?: boolean, result?: string }
120153 if (result.is_error) {
121154 const said = truncate(result.result ?? 'Claude did not answer', 140)
122 return { ok: false, detail: AUTH_FAILURE.test(said) ? 'Not signed in yet.' : said }
155 return AUTH_FAILURE.test(said)
156 ? { ok: false, reason: 'signed-out', detail: 'Not signed in yet.' }
157 : { ok: false, reason: 'failed', detail: said }
158 }
159 return {
160 ok: true,
161 reason: 'ok',
162 detail: this.env().CLAUDE_CODE_OAUTH_TOKEN ? 'Signed in with your token' : 'Signed in through the Claude CLI',
123163 }
124 return { ok: true, detail: this.env().CLAUDE_CODE_OAUTH_TOKEN ? 'Signed in with your token' : 'Signed in through the Claude CLI' }
125164 }
126 catch {
127 return { ok: false, detail: `Could not run ${this.options.bin}. Is Claude Code installed?` }
165 catch (error) {
166 return { ok: false, reason: 'failed', detail: `Could not run ${this.options.bin}: ${error instanceof Error ? error.message : String(error)}` }
128167 }
129168 }
130169
app/Uplink/launcher.tsmodified+4-14
Changes to app/Uplink/launcher.ts
@@ -16,25 +16,15 @@
1616 */
1717import { join } from 'node:path'
1818import process from 'node:process'
19import { agentPath } from './agent-path'
1920
2021declare const UPLINK_APP_DIR: string
2122
2223const appDir = process.env.UPLINK_APP_DIR || UPLINK_APP_DIR
23const home = process.env.HOME ?? ''
2424
25// launchd hands agents a bare PATH; the watcher shells out to claude, git and osascript.
26const PATH = [
27 join(appDir, 'pantry', '.bin'),
28 join(home, '.local', 'bin'),
29 join(home, '.bun', 'bin'),
30 '/opt/homebrew/bin',
31 '/usr/local/bin',
32 '/usr/bin',
33 '/bin',
34 '/usr/sbin',
35 '/sbin',
36 process.env.PATH ?? '',
37].filter(Boolean).join(':')
25// launchd hands agents a bare PATH; the watcher shells out to claude, codex,
26// git and osascript.
27const PATH = agentPath({ appDir })
3828
3929const watcher = Bun.spawn([join(appDir, 'buddy'), 'uplink:watch'], {
4030 cwd: appDir,
app/Uplink/menubar.htmlmodified+58-2
Changes to app/Uplink/menubar.html
@@ -204,6 +204,18 @@
204204 <label for="engine" class="grow secondary">Answer texts with</label>
205205 <span id="engine-home"><select id="engine"></select></span>
206206 </div>
207 <!-- A missing CLI is not a sign-in problem, so its instructions
208 replace the sign-in ones rather than sitting above them. -->
209 <div id="engine-missing" hidden>
210 <p><strong data-missing-label></strong> is not installed on this Mac. Uplink runs it for you, but it cannot install it.</p>
211 <input id="install-command" readonly spellcheck="false" aria-label="Install command">
212 <div class="buttons">
213 <button data-action="copy-install">Copy</button>
214 <button data-action="open-install-docs">Docs</button>
215 <span class="grow"></span>
216 <button class="default" data-action="test-engine">Check Again</button>
217 </div>
218 </div>
207219 <!-- Each CLI signs in its own way, so only the selected one's
208220 instructions are shown. -->
209221 <div data-engine-only="claude">
@@ -234,6 +246,21 @@
234246 </div>
235247 <div class="step-body" data-body id="handles-home"></div>
236248 </li>
249 <li class="step" id="step-automation">
250 <span class="mark" data-mark>4</span>
251 <div>
252 <div class="step-title">Let Uplink send replies</div>
253 <div class="step-detail" data-detail></div>
254 </div>
255 <div class="step-body" data-body>
256 <p>macOS asks once for permission to control Messages. Do it now, at the Mac: nobody can answer that prompt from a phone, and until it is answered a reply has nowhere to go.</p>
257 <div class="buttons">
258 <button data-action="open-automation">Open Privacy Settings</button>
259 <span class="grow"></span>
260 <button class="default" data-action="test-automation">Ask Now</button>
261 </div>
262 </div>
263 </li>
237264 </ol>
238265 </section>
239266
@@ -314,7 +341,7 @@
314341 }
315342
316343 function renderSteps(status, byId) {
317 const steps = [['#step-fda', byId.fda], ['#step-engine', byId.engine], ['#step-handles', byId.handles]]
344 const steps = [['#step-fda', byId.fda], ['#step-engine', byId.engine], ['#step-handles', byId.handles], ['#step-automation', byId.automation]]
318345 const firstTodo = steps.findIndex(([, check]) => !check.ok)
319346 steps.forEach(([id, check], index) => {
320347 const el = $(id)
@@ -347,8 +374,17 @@
347374 if (document.activeElement !== picker)
348375 picker.value = selected
349376
377 // Signing in is not possible until the CLI exists, so its instructions
378 // stand in for the sign-in ones.
379 const missing = check?.reason === 'missing'
380 $('#engine-missing').hidden = !missing
381 if (missing) {
382 $('[data-missing-label]').textContent = check?.label || selected
383 $('#install-command').value = check?.install?.command || ''
384 }
385
350386 for (const node of document.querySelectorAll('[data-engine-only]'))
351 node.hidden = node.dataset.engineOnly !== selected
387 node.hidden = missing || node.dataset.engineOnly !== selected
352388 }
353389
354390 function whoText(status) {
@@ -529,6 +565,26 @@
529565 await api('/api/engine/test', {})
530566 await refresh()
531567 },
568 'copy-install': async () => {
569 const field = $('#install-command')
570 field.select()
571 // The webview can refuse the async clipboard API, so leave the command
572 // selected for the person to copy either way.
573 try {
574 await navigator.clipboard.writeText(field.value)
575 }
576 catch {}
577 },
578 'open-install-docs': () => api('/api/open/install-docs', {}),
579 // Raises the macOS prompt when it has never been answered, and re-reads
580 // the grant when it has.
581 'test-automation': async () => {
582 const detail = $('#step-automation [data-detail]')
583 detail.textContent = 'Checking'
584 await api('/api/automation/test', {})
585 await refresh()
586 },
587 'open-automation': () => api('/api/open/automation', {}),
532588 'save-token': async () => {
533589 const error = $('#token-error')
534590 error.hidden = true
app/Uplink/sender.tsmodified+63-0
Changes to app/Uplink/sender.ts
@@ -15,8 +15,27 @@ export interface ReplyTarget {
1515 service: string
1616}
1717
18/**
19 * Whether macOS will let this process drive Messages.
20 *
21 * The grant is per app and per process identity: the watcher inside Uplink.app
22 * and `buddy uplink:doctor` under Terminal are two different subjects, so a
23 * result is only ever about the process that asked.
24 */
25export interface AutomationState {
26 ok: boolean
27 reason: 'ok' | 'denied' | 'failed'
28 detail: string
29}
30
1831export interface Sender {
1932 send: (target: ReplyTarget, text: string) => Promise<void>
33 /**
34 * Can this process send at all. The first call raises the macOS Automation
35 * prompt, which is the point of having it: it has to happen while someone is
36 * at the Mac, not on the first real reply to a phone that then hears nothing.
37 */
38 canSend: () => Promise<AutomationState>
2039}
2140
2241const SCRIPT = `
@@ -40,7 +59,51 @@ on run argv
4059end run
4160`
4261
62/**
63 * Reads from Messages and sends nothing, so it can be run during setup and on
64 * demand. Counting accounts needs exactly the Automation permission a real
65 * reply needs.
66 */
67const PROBE_SCRIPT = 'tell application "Messages" to count of accounts'
68
69/** macOS error -1743 is "not authorized to send Apple events". */
70const NOT_AUTHORIZED = /-1743|not authori[sz]ed|not allowed to send|doesn't have permission/i
71
72/** -1712 is an AppleEvent timeout, which a busy or starting Messages produces. */
73const TIMED_OUT = /-1712|timed out/i
74
75/** Long enough for someone to read the macOS prompt and click Allow. */
76const PROBE_TIMEOUT_MS = 60_000
77
4378export class AppleScriptSender implements Sender {
79 async canSend(): Promise<AutomationState> {
80 try {
81 const proc = Bun.spawn(['osascript', '-e', PROBE_SCRIPT], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
82 // The prompt itself blocks the script until someone answers it, and a
83 // busy or starting Messages can time out on its own, so this waits
84 // generously rather than reporting a refusal that has not happened.
85 const timer = setTimeout(() => proc.kill(), PROBE_TIMEOUT_MS)
86 const [code, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()])
87 clearTimeout(timer)
88
89 if (code === 0)
90 return { ok: true, reason: 'ok', detail: 'Uplink can send through Messages' }
91
92 const said = stderr.trim()
93 if (NOT_AUTHORIZED.test(said))
94 return { ok: false, reason: 'denied', detail: 'macOS has not been allowed to let Uplink control Messages' }
95
96 // A timeout is not a refusal. Say so, because the two want different
97 // things from the person reading it.
98 return TIMED_OUT.test(said)
99 ? { ok: false, reason: 'failed', detail: 'Messages did not answer in time. It may still be starting up.' }
100 : { ok: false, reason: 'failed', detail: said || `osascript exited ${code}` }
101 }
102 catch (error) {
103 return { ok: false, reason: 'failed', detail: error instanceof Error ? error.message : String(error) }
104 }
105 }
106
44107 async send(target: ReplyTarget, text: string): Promise<void> {
45108 const proc = Bun.spawn(
46109 ['osascript', '-e', SCRIPT, text, target.chatGuid, target.handle, target.service === 'SMS' ? 'SMS' : 'iMessage'],
app/Uplink/uplink.tsmodified+42-14
Changes to app/Uplink/uplink.ts
@@ -89,6 +89,8 @@ export class Uplink {
8989 private timer: ReturnType<typeof setTimeout> | null = null
9090 lastPollAt: number | null = null
9191 lastError: string | null = null
92 /** Why the last reply could not be delivered, for the heartbeat and the popover. */
93 lastSendError: string | null = null
9294 private ticking = false
9395 private stopped = false
9496
@@ -408,7 +410,16 @@ export class Uplink {
408410 const text = result.authFailure
409411 ? `I cannot reach ${engine.label}: it is not signed in on this Mac. Someone there needs to ${engine.authFailureHint}.`
410412 : result.ok ? result.text : `That failed: ${result.text}`
411 await this.reply(active.target, text, { keepRest: true })
413
414 // A run whose answer never left the Mac is not done, whatever the agent
415 // did. Recording it as done is how a first install reports itself healthy
416 // while the phone hears nothing.
417 if (!await this.reply(active.target, text, { keepRest: true })) {
418 await this.deps.store.updateRun(active.runId, {
419 status: 'failed',
420 error: `The answer could not be delivered through Messages: ${this.lastSendError ?? 'unknown error'}`,
421 })
422 }
412423 }
413424
414425 const next = state.queue.shift()
@@ -416,19 +427,28 @@ export class Uplink {
416427 await this.begin(state, next)
417428 }
418429
430 /**
431 * Answers a control word. `reply` reports whether it delivered, which
432 * `finish` acts on for a real answer; a control reply is said and forgotten,
433 * so the result is deliberately dropped here.
434 */
419435 private async control(command: ControlCommand, target: ReplyTarget): Promise<void> {
420436 const state = this.chat(target.chatGuid)
421437
422438 switch (command) {
423439 case 'help':
424 return this.reply(target, HELP_TEXT)
440 await this.reply(target, HELP_TEXT)
441 return
425442
426443 case 'ping':
427 return this.reply(target, `pong (up ${formatDuration(this.now() - this.startedAt)})`)
444 await this.reply(target, `pong (up ${formatDuration(this.now() - this.startedAt)})`)
445 return
428446
429447 case 'status': {
430 if (!state.active)
431 return this.reply(target, 'Idle. Text me a question or a task.')
448 if (!state.active) {
449 await this.reply(target, 'Idle. Text me a question or a task.')
450 return
451 }
432452 const active = state.active
433453 const lines = [
434454 `Working ${formatDuration(this.now() - active.startedAt)} on: ${truncate(active.prompt, 80)}`,
@@ -436,23 +456,28 @@ export class Uplink {
436456 ]
437457 if (state.queue.length > 0)
438458 lines.push(`${state.queue.length} queued.`)
439 return this.reply(target, lines.join('\n'))
459 await this.reply(target, lines.join('\n'))
460 return
440461 }
441462
442463 case 'stop':
443 return this.reply(target, await this.stopThread(state, 'Stopped') ?? 'Nothing running.')
464 await this.reply(target, await this.stopThread(state, 'Stopped') ?? 'Nothing running.')
465 return
444466
445467 case 'new': {
446468 const conversation = await this.conversation(target)
447469 await this.deps.store.saveConversation({ ...conversation, sessionId: null, cwd: null, moreText: null })
448 return this.reply(target, 'Fresh start. Your next text begins a new conversation.')
470 await this.reply(target, 'Fresh start. Your next text begins a new conversation.')
471 return
449472 }
450473
451474 case 'more': {
452475 const conversation = await this.conversation(target)
453 if (!conversation.moreText)
454 return this.reply(target, 'Nothing more to send.')
455 return this.reply(target, conversation.moreText, { keepRest: true })
476 if (!conversation.moreText) {
477 await this.reply(target, 'Nothing more to send.')
478 return
479 }
480 await this.reply(target, conversation.moreText, { keepRest: true })
456481 }
457482 }
458483 }
@@ -463,7 +488,7 @@ export class Uplink {
463488 * and easy to lose track of. Short status replies never touch the stored
464489 * conversation, so one sent mid-run cannot race the run's own save.
465490 */
466 async reply(target: ReplyTarget, text: string, options: { keepRest?: boolean } = {}): Promise<void> {
491 async reply(target: ReplyTarget, text: string, options: { keepRest?: boolean } = {}): Promise<boolean> {
467492 const parts = chunk(toPlainText(text), this.config.maxChars - this.config.replyPrefix.length)
468493 const now = parts.slice(0, this.config.maxParts)
469494 const later = parts.slice(this.config.maxParts)
@@ -482,10 +507,13 @@ export class Uplink {
482507 await this.deps.sender.send(target, body)
483508 }
484509 catch (error) {
485 this.log.error(`Reply to ${target.handle} failed: ${error instanceof Error ? error.message : String(error)}`)
486 return
510 this.lastSendError = error instanceof Error ? error.message : String(error)
511 this.log.error(`Reply to ${target.handle} failed: ${this.lastSendError}`)
512 return false
487513 }
488514 }
515 this.lastSendError = null
516 return true
489517 }
490518}
491519
tests/unit/uplink/agent-path.test.tsadded+44-0
Changes to tests/unit/uplink/agent-path.test.ts
@@ -0,0 +1,44 @@
1import { describe, expect, it } from 'bun:test'
2import { agentPath } from '../../../app/Uplink/agent-path'
3
4const env = { HOME: '/Users/someone', PATH: '/usr/bin:/bin' }
5
6describe('agentPath', () => {
7 it('covers every place the agent CLIs are installed', () => {
8 const entries = agentPath({ env }).split(':')
9
10 // The list the launchers used to disagree about. `~/.bun/bin` is the gap
11 // that made a bun-installed claude invisible to the downloadable app.
12 for (const expected of [
13 '/Users/someone/.local/bin',
14 '/Users/someone/.bun/bin',
15 '/Users/someone/.npm-global/bin',
16 '/Users/someone/.claude/local',
17 '/opt/homebrew/bin',
18 '/usr/local/bin',
19 ])
20 expect(entries).toContain(expected)
21 })
22
23 it("puts the user's own locations before Uplink's bundled toolchain", () => {
24 const entries = agentPath({ appDir: '/app', env }).split(':')
25 expect(entries).toContain('/app/pantry/.bin')
26 // Uplink's pinned bun must not shadow the CLI a person installed themselves.
27 expect(entries.indexOf('/Users/someone/.bun/bin')).toBeLessThan(entries.indexOf('/app/pantry/.bin'))
28 })
29
30 it('keeps any existing PATH, last, so a real environment still wins', () => {
31 const entries = agentPath({ env }).split(':')
32 expect(entries.at(-1)).toBe('/bin')
33 expect(entries.at(-2)).toBe('/usr/bin')
34 })
35
36 it('omits the app directory when there is none, rather than emitting an empty entry', () => {
37 expect(agentPath({ env })).not.toContain('::')
38 expect(agentPath({ env }).split(':').filter(Boolean).length).toBe(agentPath({ env }).split(':').length)
39 })
40
41 it('falls back to the real home when the environment has none', () => {
42 expect(agentPath({ env: { PATH: '/usr/bin' } })).toContain('/.bun/bin')
43 })
44})
tests/unit/uplink/codex-engine.test.tsmodified+7-3
Changes to tests/unit/uplink/codex-engine.test.ts
@@ -210,7 +210,7 @@ describe('CodexEngine', () => {
210210 // The real CLI prints this on stderr, not stdout.
211211 writeFileSync(path, '#!/bin/sh\necho "Logged in using ChatGPT" >&2\nexit 0\n')
212212 chmodSync(path, 0o755)
213 expect(await engine(path).probe()).toEqual({ ok: true, detail: 'Logged in using ChatGPT' })
213 expect(await engine(path).probe()).toEqual({ ok: true, reason: 'ok', detail: 'Logged in using ChatGPT' })
214214 })
215215
216216 it('is not signed in when it exits non-zero', async () => {
@@ -219,13 +219,17 @@ describe('CodexEngine', () => {
219219 chmodSync(path, 0o755)
220220 const probe = await engine(path).probe()
221221 expect(probe.ok).toBe(false)
222 expect(probe.reason).toBe('signed-out')
222223 expect(probe.detail).toContain('Not logged in')
223224 })
224225
225 it('says so when the binary is missing', async () => {
226 it('reports a missing binary as missing, not as signed out', async () => {
227 // These are different problems with different next steps, and setup used
228 // to collapse them into one.
226229 const probe = await engine(join(dir, 'not-installed')).probe()
227230 expect(probe.ok).toBe(false)
228 expect(probe.detail).toContain('Is Codex installed?')
231 expect(probe.reason).toBe('missing')
232 expect(probe.detail).toContain('not installed')
229233 })
230234 })
231235})
tests/unit/uplink/fixtures.tsmodified+15-4
Changes to tests/unit/uplink/fixtures.ts
@@ -1,5 +1,5 @@
1import type { Engine, EngineEvent, EngineRequest, EngineResult, EngineRun } from '../../../app/Uplink/engine'
2import type { ReplyTarget, Sender } from '../../../app/Uplink/sender'
1import type { Engine, EngineEvent, EngineProbe, EngineRequest, EngineResult, EngineRun } from '../../../app/Uplink/engine'
2import type { AutomationState, ReplyTarget, Sender } from '../../../app/Uplink/sender'
33import { Database } from 'bun:sqlite'
44import { mkdtempSync } from 'node:fs'
55import { tmpdir } from 'node:os'
@@ -119,10 +119,20 @@ export class FakeChatDb {
119119
120120export class FakeSender implements Sender {
121121 sent: Array<{ target: ReplyTarget, text: string }> = []
122 /** Set to make every send fail, the way a refused Automation grant does. */
123 refuse: string | null = null
124 automation: AutomationState = { ok: true, reason: 'ok', detail: 'Uplink can send through Messages' }
125
122126 /** Mirror each send into chat.db, the way Messages records it. */
123127 constructor(private readonly db?: FakeChatDb) {}
124128
129 async canSend(): Promise<AutomationState> {
130 return this.automation
131 }
132
125133 async send(target: ReplyTarget, text: string): Promise<void> {
134 if (this.refuse)
135 throw new Error(this.refuse)
126136 this.sent.push({ target, text })
127137 this.db?.add({ chat: target.handle, text, fromMe: true })
128138 }
@@ -148,10 +158,11 @@ export class FakeEngine implements Engine {
148158 readonly id = 'claude' as const
149159 readonly label = 'Claude Code'
150160 readonly authFailureHint = 'run "claude setup-token" on the Mac and give Uplink the token'
161 readonly install = { command: 'bun install -g @anthropic-ai/claude-code', url: 'https://docs.claude.com/en/docs/claude-code' }
151162
152 probeResult: { ok: boolean, detail: string } = { ok: true, detail: 'Signed in' }
163 probeResult: EngineProbe = { ok: true, reason: 'ok', detail: 'Signed in' }
153164
154 async probe(): Promise<{ ok: boolean, detail: string }> {
165 async probe(): Promise<EngineProbe> {
155166 this.probes += 1
156167 return this.probeResult
157168 }
tests/unit/uplink/uplink.test.tsmodified+17-0
Changes to tests/unit/uplink/uplink.test.ts
@@ -214,6 +214,23 @@ describe('Uplink', () => {
214214 expect(h.engine.runs[1].request.sessionId).toBeNull()
215215 })
216216
217 it('does not record a run as done when the reply could not be delivered', async () => {
218 const h = await harness()
219 // What a refused Messages Automation grant looks like from here. Without
220 // this the run was recorded done, the dashboard showed an answer, and the
221 // phone heard nothing at all.
222 h.sender.refuse = 'Messages refused to send (osascript exit 1): not authorized to send Apple events'
223 h.fake.add({ chat: ME, text: 'hello', fromMe: true })
224 await h.uplink.tick()
225 h.engine.finish(0, 'Here are the scores')
226 await settle()
227
228 expect(h.sender.texts()).toEqual([])
229 expect(h.store.runs.get(1)?.status).toBe('failed')
230 expect(h.store.runs.get(1)?.error).toContain('could not be delivered')
231 expect(h.uplink.lastSendError).toContain('not authorized')
232 })
233
217234 it('tells the user which CLI is not logged in, and how to fix it', async () => {
218235 const h = await harness()
219236 h.fake.add({ chat: ME, text: 'hello', fromMe: true })