From bc30cfb7cbaad8ff6a97a84495bd75e098ae74bd Mon Sep 17 00:00:00 2001 From: Cui Chen Date: Mon, 9 Nov 2026 17:08:18 +0800 Subject: [PATCH 1/9] Read one task and one message per agent event; skip unchanged state polls Each streamed agent event looked up its task by reading and parsing every task, archived ones included, and each delta or tool message re-read the whole conversation to find one item. Tasks are now found by thread through an index on threadId and messages by their primary key. /api/state answers with an ETag, and the app sends the tag of the state it shows: an unchanged poll gets an empty 304 instead of every task again to download, parse and compare (182 KB on a 50-task workspace, every 1-2.5 s). --- src/server/app.ts | 20 +++++++++++++------- src/server/store.ts | 16 ++++++++++++++-- src/server/workspace.ts | 16 ++++++++-------- src/web/api.ts | 10 ++++++++++ src/web/main.tsx | 12 +++++++++--- tests/app.test.ts | 19 +++++++++++++++++++ tests/refresh.test.ts | 21 +++++++++++++++++++++ 7 files changed, 94 insertions(+), 20 deletions(-) diff --git a/src/server/app.ts b/src/server/app.ts index 8ea5d738..a27b11ac 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -6,7 +6,7 @@ import rateLimit from '@fastify/rate-limit'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { registerFrontend } from './frontend.ts'; import type { Store } from './store.ts'; import type { Workspace } from './workspace.ts'; @@ -114,8 +114,8 @@ export async function buildApp(options: AppOptions) { const resource = request.routeOptions.config.resource, params = request.params as Record; if (!resource) return; const id = 'project' in resource ? (resource.from === 'body' ? request.body as Record : params)?.[resource.project] - : 'task' in resource ? store.tasks().find(task => task.id === params[resource.task])?.projectId - : store.tasks().find(task => task.id === workspace.requests.get(params[resource.request])?.taskId)?.projectId; + : 'task' in resource ? store.taskProject(params[resource.task]) + : store.taskProject(workspace.requests.get(params[resource.request])?.taskId || ''); if (id) projectRequests.set(request, workspace.enterProjectRequest(id)); }); app.addHook('onSend', async (request, _reply, payload) => { projectRequests.get(request)?.(); return payload; }); @@ -212,11 +212,17 @@ export async function buildApp(options: AppOptions) { app.post('/api/updates/channel', request => options.updates!.setChannel((request.body as any)?.channel)); app.post('/api/updates/install', request => options.updates!.install((request.body as any)?.version)); } - app.get('/api/state', async () => { + app.get('/api/state', async (request, reply) => { await options.prepareDemo?.(); // Check the backend environment (Linux when using WSL), again on each poll // so installing Git unlocks version management without a page reload. - return { ...workspace.snapshot(), gitAvailable: Boolean(findExecutable('git')), csrf: options.csrf }; + const body = JSON.stringify({ ...workspace.snapshot(), gitAvailable: Boolean(findExecutable('git')), csrf: options.csrf }); + // The app polls every second or two and most polls find nothing new: those + // get an empty 304 instead of every task again to download and parse. + const tag = `"${createHash('sha1').update(body).digest('base64url')}"`; + reply.header('ETag', tag).header('Cache-Control', 'no-cache'); + if (request.headers['if-none-match']?.replace(/^W\//, '') === tag) return reply.code(304).send(); + return reply.type('application/json; charset=utf-8').send(body); }); // Quick notes for the magic pen. None saved means the presets. app.get('/api/quick-notes', () => ({ quickNotes: store.preference('quick-notes') || null })); @@ -298,7 +304,7 @@ export async function buildApp(options: AppOptions) { const annotations: string[] | undefined = b.annotations, groupInstructions = parseGroupInstructions(b.groupInstructions); const id = string(b.id, 'Task ID', 64), projectId = string(b.projectId, 'Paper ID', 64); // A skill submission uses the same task endpoint and retry identity as editing. - const existing = store.tasks().find(task => task.id === id); + const existing = store.findTask(id); if (existing) { if (existing.projectId !== projectId) throw new Error('Invalid task ID.'); return existing; } // AI review compiles the task's own copy of the manuscript, so it does not wait for the preview. const reviewing = Boolean(skill && findSkill(store.project(projectId).path, skill).output === 'annotations'); @@ -572,7 +578,7 @@ export async function buildApp(options: AppOptions) { app.post<{ Params: { id: string } }>('/api/projects/:id/annotations/send', project(), async request => { const project = store.project(request.params.id), b = request.body as any, id = string(b.id, 'Task ID', 64); // A retried send returns its task; an ID from another paper is refused. - const existing = store.tasks().find(task => task.id === id); + const existing = store.findTask(id); if (existing) { if (existing.projectId !== project.id) throw new Error('Invalid task ID.'); return existing; } if (!Array.isArray(b.annotations) || !b.annotations.length || b.annotations.some((id: unknown) => typeof id !== 'string' || !id || id.length > 64)) throw new Error('Select valid PDF notes for this task.'); const notes = [...new Set(b.annotations)].map(id => store.annotation(project.id, id)); diff --git a/src/server/store.ts b/src/server/store.ts index ae5e8ca9..4446adf2 100644 --- a/src/server/store.ts +++ b/src/server/store.ts @@ -23,7 +23,8 @@ export class Store extends EventEmitter { CREATE TABLE IF NOT EXISTS attachments(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, data TEXT NOT NULL); CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, data TEXT NOT NULL); CREATE INDEX IF NOT EXISTS task_items ON items(task_id,created); - CREATE INDEX IF NOT EXISTS task_runs ON runs(task_id,status);`); + CREATE INDEX IF NOT EXISTS task_runs ON runs(task_id,status); + CREATE INDEX IF NOT EXISTS task_threads ON tasks(json_extract(data,'$.threadId'));`); // Keep the host's settings when upgrading a formerly shared workspace. // Papers, tasks, notes and historical attribution stay intact; credentials // and invitations are removed together so they cannot be reused. @@ -40,7 +41,16 @@ export class Store extends EventEmitter { tasks() { return this.rows('tasks'); } runs() { return this.rows('runs'); } project(id: string) { const v = this.projects().find(p => p.id === id); if (!v) throw new Error('Paper project not found.'); return v; } - task(id: string) { const row = this.db.prepare('SELECT data FROM tasks WHERE id=?').get(id) as { data: string } | undefined; if (!row) throw new Error('Task not found.'); return JSON.parse(row.data) as Task; } + task(id: string) { const task = this.findTask(id); if (!task) throw new Error('Task not found.'); return task; } + // Agents stream several events a second, each naming its thread; reading + // every task (archived ones too) for each of them grew with the history. + findTask(id: string) { const row = this.db.prepare('SELECT data FROM tasks WHERE id=?').get(id) as { data: string } | undefined; return row ? JSON.parse(row.data) as Task : undefined; } + taskProject(id: string) { return (this.db.prepare('SELECT project_id FROM tasks WHERE id=?').get(id) as { project_id: string } | undefined)?.project_id; } + taskByThread(threadId: unknown) { + if (typeof threadId !== 'string' || !threadId) return undefined; + const row = this.db.prepare("SELECT data FROM tasks WHERE json_extract(data,'$.threadId')=? ORDER BY rowid LIMIT 1").get(threadId) as { data: string } | undefined; + return row ? JSON.parse(row.data) as Task : undefined; + } projectByPath(p: string) { return this.projects().find(project => project.path === p); } saveProject(p: Project) { this.db.prepare('INSERT INTO projects VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET path=excluded.path,data=excluded.data').run(p.id, p.path, JSON.stringify(p)); this.emit('change'); } removeProject(id: string) { @@ -67,6 +77,8 @@ export class Store extends EventEmitter { saveRun(r: Run) { this.db.prepare('INSERT INTO runs VALUES(?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET status=excluded.status,data=excluded.data').run(r.id, r.taskId, r.requestKey, r.status, JSON.stringify(r)); this.emit('change'); } runByKey(key: string) { const row = this.db.prepare('SELECT data FROM runs WHERE request_key=?').get(key) as { data: string } | undefined; return row ? JSON.parse(row.data) as Run : undefined; } items(taskId: string) { return (this.db.prepare('SELECT data FROM items WHERE task_id=? ORDER BY created,rowid').all(taskId) as { data: string }[]).map(r => JSON.parse(r.data) as TimelineItem); } + // One message of a conversation that can hold megabytes of command output. + item(taskId: string, id: string) { const row = this.db.prepare('SELECT data FROM items WHERE id=? AND task_id=?').get(id, taskId) as { data: string } | undefined; return row ? JSON.parse(row.data) as TimelineItem : undefined; } // A running task's view reads its timeline every second, and a command's // output alone can be 40 KB, so a view that has the timeline asks only for // the items written since its revision. Revisions start over with the diff --git a/src/server/workspace.ts b/src/server/workspace.ts index 5a8a0e90..62ca6927 100644 --- a/src/server/workspace.ts +++ b/src/server/workspace.ts @@ -314,7 +314,7 @@ export class Workspace extends EventEmitter { if (!/^[\w-]{16,64}$/.test(input.id)) throw new Error('Invalid task ID.'); if (this.store.taskDeleted(input.id)) throw new Error('This task was deleted. Create a new task instead.'); // A retried request returns its task; an ID from another paper is refused. - const existing = this.store.tasks().find(t => t.id === input.id); + const existing = this.store.findTask(input.id); if (existing) { if (existing.projectId !== input.projectId || (existing.mode === 'review') !== Boolean(input.prepareReview)) throw new Error('Invalid task ID.'); return existing; } this.validateTaskPermissions(input.permissions ?? 'workspace', input.agent || 'codex', Boolean(input.prepareReview)); if (input.prepareReview && (input.annotations?.length || input.attachments?.length)) throw new Error('AI review uses the manuscript PDF. Start a separate editing task to send notes or attachments.'); @@ -578,7 +578,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Use file tools for manuscript edits. S } private appendDelta(task: Task, params: any, type: string) { const id = `${task.id}:${params.turnId}:${params.itemId}`; - const item = this.liveItems.get(id) || this.store.items(task.id).find(item => item.id === id) || { id, taskId: task.id, type, text: '', createdAt: Date.now() }; + const item = this.liveItems.get(id) || this.store.item(task.id, id) || { id, taskId: task.id, type, text: '', createdAt: Date.now() }; if (type === 'command') item.detail = ((item.detail || '') + (params.delta || '')).slice(-40000); else { if (type === 'assistant' && this.active.get(task.id)?.noteOptionsRequested) { @@ -607,7 +607,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Use file tools for manuscript edits. S this.usageTimers.set(agent, setTimeout(() => { this.usageTimers.delete(agent); void this.readUsage(agent); }, 1500)); return; } - const task = this.store.tasks().find(t => t.threadId === p.threadId); + const task = this.store.taskByThread(p.threadId); if (!task || task.status === 'deleting') return; const eventTurn = p.turnId || p.turn?.id; if (eventTurn && task.turnId && eventTurn !== task.turnId) return; @@ -617,7 +617,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Use file tools for manuscript edits. S const item = p.item, id = `${task.id}:${p.turnId}:${item.id}`; if (['userMessage', 'reasoning'].includes(item.type)) return; this.flush(); - const previous = this.store.items(task.id).find(i => i.id === id); + const previous = this.store.item(task.id, id); const entry: TimelineItem = { id, taskId: task.id, createdAt: previous?.createdAt || Date.now(), type: item.type, text: item.text || item.command || '', status: item.status || (method === 'item/completed' ? 'completed' : 'inProgress') }; if (item.type === 'agentMessage') entry.type = task.mode === 'review' ? 'reviewOutput' : 'assistant'; if (item.type === 'agentMessage' && this.active.get(task.id)?.noteOptionsRequested) { @@ -675,7 +675,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Use file tools for manuscript edits. S } private serverRequest(message: any, agent: AgentId = 'codex') { if (this.closing) return; - const task = this.store.tasks().find(t => t.threadId === message.params?.threadId), runtime = this.agents[agent]; + const task = this.store.taskByThread(message.params?.threadId), runtime = this.agents[agent]; const allowed = ['item/commandExecution/requestApproval', 'item/fileChange/requestApproval', 'item/permissions/requestApproval', 'item/tool/requestUserInput', 'item/tool/call']; if (!task || this.agentOf(task) !== agent || !this.active.has(task.id) || !allowed.includes(message.method)) { runtime.reject(message.id, 'This request is not supported or its turn is no longer active.'); return; } if (message.params?.turnId && task.turnId && message.params.turnId !== task.turnId) { runtime.reject(message.id, 'This turn is no longer active.'); return; } @@ -1114,12 +1114,12 @@ ${this.agentOf(task) === 'antigravity' ? 'Use file tools for manuscript edits. S this.store.updateTask(taskId, { titleGeneration: 'pending' }); const written = this.oneOff(id, { cwd: this.store.project(task.projectId).path, sandbox: 'read-only', instructions: taskCaptionInstructions, prompt, model, effort: model?.efforts[0], timeout: 45000, what: 'generate a task title' }).then(reply => { if (this.closing) return null; - const current = this.store.tasks().find(item => item.id === taskId); + const current = this.store.findTask(taskId); if (!current || current.status === 'deleting' || (current.titleRevision || 0) !== revision) return current || null; return this.store.updateTask(taskId, { title: taskCaptionFrom(reply), titleSource: 'generated', titleRevision: revision + 1, titleGeneration: null }); }).catch(error => { if (!this.closing) { - const current = this.store.tasks().find(item => item.id === taskId); + const current = this.store.findTask(taskId); if (current && current.status !== 'deleting' && (current.titleRevision || 0) === revision) this.store.updateTask(taskId, { titleGeneration: 'failed' }); } throw error; @@ -1333,7 +1333,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Use file tools for manuscript edits. S async deleteTask(taskId: string): Promise<{ ok: true }> { if (this.preparing.has(taskId)) throw new Error('Wait for task creation to finish before deleting it.'); if (this.deletions.has(taskId)) throw new Error('Deletion is already in progress. Wait for it to finish.'); - const task = this.store.tasks().find(t => t.id === taskId); + const task = this.store.findTask(taskId); if (!task) return { ok: true }; if (!['idle', 'interrupted', 'reviewing', 'failed', 'deleting'].includes(task.status) || this.active.has(taskId) || this.publishing.has(taskId) || this.reviewWrites.has(taskId) || this.commentWrites.has(taskId) diff --git a/src/web/api.ts b/src/web/api.ts index 23d8651d..70f962ce 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -15,3 +15,13 @@ export async function requestApi(route: string, token: string, body?: u if (!response.ok) throw new Error(value.error ? errorText(value.error) : t('Request failed. Please try again.')); return value; } + +// The state the app polls. Sending the tag of the state it shows lets the +// server answer an unchanged one with an empty 304, returned here as null. +export async function requestState(token: string, tag: string, signal?: AbortSignal): Promise<{ value: T | null; tag: string }> { + const response = await fetch('/api/state', { headers: { 'X-Papilot-Token': token, ...(tag ? { 'If-None-Match': tag } : {}) }, cache: 'no-store', signal }); + if (response.status === 304) return { value: null, tag }; + const value = await response.json(); + if (!response.ok) throw new Error(value.error ? errorText(value.error) : t('Request failed. Please try again.')); + return { value, tag: response.headers.get('ETag') || '' }; +} diff --git a/src/web/main.tsx b/src/web/main.tsx index d5989d7e..dfc196cc 100644 --- a/src/web/main.tsx +++ b/src/web/main.tsx @@ -1,4 +1,4 @@ -import { requestApi } from './api.ts'; +import { requestApi, requestState } from './api.ts'; import { isPlayground, pdfAsset, wordAsset, requestPlaygroundDownload } from './playground-mode.ts'; import { canonicalGroup, groupsWithoutInstructions, noteGroups, type NoteGroup } from '../note-groups.ts'; import { GroupCard, GroupInstruction } from './note-groups.tsx'; @@ -1044,9 +1044,11 @@ function App() { const timelineCache = useRef(new Map()); const timelineRevisions = useRef(new Map()); const snapshotText = useRef(''); + // The server's tag for snapshotText; sent only while that text is what the app shows. + const snapshotTag = useRef(''); const composers = useRef(new Map()); const answerDrafts = useRef(new Map>()); - const [snapshotRequest] = useState(() => new ReadRequest()); + const [snapshotRequest] = useState(() => new ReadRequest<{ value: (Snapshot & { csrf: string }) | null; tag: string }>()); const [timelineRequest] = useState(() => new ReadRequest<{ items: TimelineItem[]; revision?: string; partial?: boolean }>()); const [online, setOnline] = useState(true), [error, setError] = useState(''); const [connectionError, setConnectionError] = useState(''); @@ -1213,9 +1215,13 @@ function App() { const refresh = useCallback(async (fresh = false) => { try { if (fresh) snapshotRequest.cancel(); - await snapshotRequest.run('state', signal => api('/state', undefined, signal), next => { + const load = (signal: AbortSignal) => import.meta.env.MODE === 'playground' ? api('/state', undefined, signal).then(value => ({ value, tag: '' })) + : requestState(token, snapshotText.current && snapshotRef.current ? snapshotTag.current : '', signal); + await snapshotRequest.run('state', load, ({ value: next, tag }) => { // Most polls find nothing new; an identical snapshot leaves the app as it is. + if (!next) { setOnline(true); setConnectionError(''); return; } const text = JSON.stringify(next); + snapshotTag.current = tag; if (text === snapshotText.current && snapshotRef.current) { setOnline(true); setConnectionError(''); return; } snapshotText.current = text; const selection = selectedRef.current, previous = snapshotRef.current; diff --git a/tests/app.test.ts b/tests/app.test.ts index df30a3db..9bb58db5 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -203,6 +203,25 @@ test('paper path and removal require CSRF and an idle paper', async () => { } finally { await f.close(); } }); +test('an unchanged workspace state is answered with an empty 304', async () => { + const f = await fixture(); + try { + const first = await f.local.inject({ url: '/api/state', headers: LOCAL }); + const tag = String(first.headers.etag); + assert.match(tag, /^"[\w-]+"$/); + assert.equal(first.json().csrf, 'local-csrf'); + const same = await f.local.inject({ url: '/api/state', headers: { ...LOCAL, 'if-none-match': tag } }); + assert.equal(same.statusCode, 304); + assert.equal(same.body, ''); + assert.equal((await f.local.inject({ url: '/api/state', headers: { ...LOCAL, 'if-none-match': 'W/' + tag } })).statusCode, 304, 'a proxy may weaken the tag'); + f.store.updateTask('grant-task', { title: 'Renamed' }); + const changed = await f.local.inject({ url: '/api/state', headers: { ...LOCAL, 'if-none-match': tag } }); + assert.equal(changed.statusCode, 200); + assert.notEqual(changed.headers.etag, tag); + assert.equal(changed.json().tasks.find((task: any) => task.id === 'grant-task').title, 'Renamed'); + } finally { await f.close(); } +}); + test('LaTeX installation information is read-only', async () => { const f = await fixture(); try { diff --git a/tests/refresh.test.ts b/tests/refresh.test.ts index 38d92ce4..83bf2207 100644 --- a/tests/refresh.test.ts +++ b/tests/refresh.test.ts @@ -166,3 +166,24 @@ for (const createdAt of [1, 2]) test(`a repeated Claude block stays visible whil refresh(); } finally { store.close(); } }); + +test('agent events find their task and message without reading the whole history', () => { + const store = new Store(':memory:'); + const task = (id: string, threadId: string | null) => store.saveTask({ id, projectId: 'paper', name: id, title: id, instructions: '', worktree: '', threadId, turnId: null, model: null, status: 'reviewing', createdAt: 1 } as unknown as Parameters[0]); + task('old', 'thread-a'); task('none', null); task('new', 'thread-b'); task('again', 'thread-a'); + assert.equal(store.taskByThread('thread-b')?.id, 'new'); + assert.equal(store.taskByThread('thread-a')?.id, 'old', 'the first task with the thread, as before'); + for (const missing of [undefined, null, '', 'thread-c', 7]) assert.equal(store.taskByThread(missing), undefined, `${String(missing)} names no task`); + store.updateTask('old', { threadId: 'thread-c' }); + assert.equal(store.taskByThread('thread-a')?.id, 'again', 'a rebound thread follows the saved task'); + assert.equal(store.taskByThread('thread-c')?.id, 'old'); + store.deleteTask('again'); + assert.equal(store.taskByThread('thread-a'), undefined); + const plan = store.db.prepare("EXPLAIN QUERY PLAN SELECT data FROM tasks WHERE json_extract(data,'$.threadId')=? ORDER BY rowid LIMIT 1").all('thread-a') as { detail: string }[]; + assert.ok(plan.some(row => row.detail.includes('task_threads')), 'the thread lookup uses its index'); + assert.equal(store.findTask('new')?.id, 'new'); assert.equal(store.findTask('gone'), undefined); + assert.equal(store.taskProject('new'), 'paper'); assert.equal(store.taskProject('gone'), undefined); + store.saveItem({ id: 'new:1:a', taskId: 'new', type: 'command', text: 'ls', createdAt: 5, detail: 'x'.repeat(40000) }); + assert.equal(store.item('new', 'new:1:a')?.createdAt, 5); + assert.equal(store.item('old', 'new:1:a'), undefined, 'messages stay with their task'); +}); From 35579cf1accfd83c0fcf488f98d03bc978bea2a9 Mon Sep 17 00:00:00 2001 From: Cui Chen Date: Mon, 9 Nov 2026 17:08:19 +0800 Subject: [PATCH 2/9] Use the computer's Codex in the desktop app and stop bundling it Codex was the largest part of the installer (338 MB unpacked) and its version was part of the shell fingerprint, so every Codex update meant a new installer. The desktop app now uses the first codex on PATH that runs, like the other agents; Settings > Runtime > Agents says when none is found or none runs. The npm command keeps installing its tested Codex. The review engine sits inside the server folder, so its copies of packages the server already has in the same version (mupdf, esbuild, fastify, React) are removed when packaging; Node resolves them from the server's node_modules, also through a layer's links. The macOS arm64 disk image goes from 337 MiB (0.9.4) to 184.5 MiB. --- CHANGELOG.md | 5 ++++ bin/agent-paths.d.mts | 1 + bin/agent-paths.mjs | 10 +++++++ bin/doctor.mjs | 27 +++++++++++------ desktop/README.md | 2 +- docs/guide.md | 8 ++--- docs/windows.md | 8 ++--- i18n/state/de.json | 10 ------- i18n/state/es.json | 10 ------- i18n/state/fr.json | 10 ------- i18n/state/it.json | 10 ------- i18n/state/ja.json | 10 ------- i18n/state/ko.json | 10 ------- i18n/state/pt.json | 10 ------- i18n/state/sv.json | 10 ------- i18n/state/zh-Hant.json | 10 ------- scripts/check-desktop.mjs | 3 +- scripts/desktop-layer.d.mts | 1 + scripts/desktop-layer.mjs | 34 ++++++++++++++++++++-- scripts/desktop.mjs | 9 +++--- src/server/codex-binary.ts | 27 +++++++++++++++++ src/server/codex.ts | 10 +++++-- src/server/paths.ts | 2 -- src/web/agent-setup.ts | 3 +- src/web/locales/de.json | 3 +- src/web/locales/errors/de.json | 4 ++- src/web/locales/errors/es.json | 4 ++- src/web/locales/errors/fr.json | 4 ++- src/web/locales/errors/it.json | 4 ++- src/web/locales/errors/ja.json | 4 ++- src/web/locales/errors/ko.json | 4 ++- src/web/locales/errors/pt.json | 4 ++- src/web/locales/errors/sv.json | 4 ++- src/web/locales/errors/zh-CN.json | 4 ++- src/web/locales/errors/zh-Hant.json | 4 ++- src/web/locales/es.json | 3 +- src/web/locales/fr.json | 3 +- src/web/locales/it.json | 3 +- src/web/locales/ja.json | 3 +- src/web/locales/ko.json | 3 +- src/web/locales/pt.json | 3 +- src/web/locales/sv.json | 3 +- src/web/locales/zh-Hant.json | 3 +- src/web/runtime-agents.tsx | 4 +-- src/web/zh-CN.ts | 3 +- tests/codex-binary.test.ts | 45 +++++++++++++++++++++++++++++ tests/desktop-layer.test.ts | 25 ++++++++++++++-- tests/doctor.test.ts | 6 ++++ 48 files changed, 233 insertions(+), 157 deletions(-) create mode 100644 src/server/codex-binary.ts create mode 100644 tests/codex-binary.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5443e4cd..8b638b75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ Notable changes to Papilot, newest first. The format follows [Keep a Changelog]( ## [Unreleased] +### Changed + +- **A much smaller desktop installer.** The desktop app no longer includes Codex: it uses the Codex installed on the computer, and **Settings → Runtime → Agents** says when none is found or none runs, so you can install it and select **Reconnect**. The review engine also no longer carries its own copy of dependencies the app already has. +- **Quicker with a long history.** Each streamed agent message updates one task and one message instead of reading every task and the whole conversation again, and the app's state poll gets an empty answer when nothing changed instead of every task again. + ## [0.9.4] - 2026-11-09 Review edits by PDF note, compare prepared alternatives in context, and choose how many alternatives future requests should prepare. diff --git a/bin/agent-paths.d.mts b/bin/agent-paths.d.mts index c81eb0e4..c3fd75fa 100644 --- a/bin/agent-paths.d.mts +++ b/bin/agent-paths.d.mts @@ -5,6 +5,7 @@ export function tarCommand(): string; export function agentDirs(env?: NodeJS.ProcessEnv): string[]; export function agentPath(env?: NodeJS.ProcessEnv): string; export function findExecutable(command: string, env?: NodeJS.ProcessEnv): string | null; +export function findExecutables(command: string, env?: NodeJS.ProcessEnv, extra?: string[]): string[]; export function which(command: string, env?: NodeJS.ProcessEnv): string | null; export function spawnCommand(command: string, args: string[], options?: SpawnOptions): ChildProcess; export function spawnSyncCommand(command: string, args: string[], options?: SpawnSyncOptions): import('node:child_process').SpawnSyncReturns; diff --git a/bin/agent-paths.mjs b/bin/agent-paths.mjs index 780c0b82..7c7fa502 100644 --- a/bin/agent-paths.mjs +++ b/bin/agent-paths.mjs @@ -67,6 +67,16 @@ export function findExecutable(command, env = process.env) { } return null; } +// Every executable PATH offers for a command, in order, each installation once +// (links to the same file count once). An early one may be broken. +export function findExecutables(command, env = process.env, extra = []) { + const seen = new Set(); + return [...agentPath(env).split(path.delimiter), ...extra].filter(Boolean).map(dir => findExecutable(path.join(dir.replace(/^"|"$/g, ''), command), env)).filter(file => { + if (!file) return false; + let real = file; try { real = fs.realpathSync(file); } catch { /* compared as it is */ } + return !seen.has(real) && Boolean(seen.add(real)); + }); +} // The executable PATH resolves a command to, as a shell would find it. This is // the cross-platform `which`: Windows needs PATHEXT, POSIX needs the execute bit. export function which(command, env = process.env) { diff --git a/bin/doctor.mjs b/bin/doctor.mjs index 31e8d88b..5efa69c7 100644 --- a/bin/doctor.mjs +++ b/bin/doctor.mjs @@ -1,4 +1,4 @@ -import { findExecutable, killTree, spawnCommand } from './agent-paths.mjs'; +import { findExecutable, findExecutables, killTree, spawnCommand } from './agent-paths.mjs'; // Deliberately uses only Node built-ins: diagnostics must work before npm setup. import fs from 'node:fs'; import os from 'node:os'; @@ -129,27 +129,36 @@ export async function doctor(args) { check('tex-engines', availableEngines.length > 0, 'error', availableEngines.length ? `TeX engines: ${availableEngines.join(', ')}.` : 'No working pdflatex, xelatex or lualatex was found.', 'Install a full TeX distribution and add its binaries to PATH.'); async function codexCheck() { - const minimum = pkg.devDependencies['@openai/codex']; + // The desktop app uses whichever installed Codex runs; the npm command + // installs the version it was tested with when the installed one is older. + const desktop = process.env.PAPILOT_DESKTOP === '1'; + const minimum = desktop ? null : pkg.devDependencies['@openai/codex']; const configured = process.env.PAPILOT_CODEX_BIN; - const candidates = configured ? [configured] : ['codex', path.join(data, 'agents/node_modules/.bin/codex')]; - let found = false; + // Like the server: the desktop app tries every codex on PATH, the npm command the first. + const managed = path.join(data, 'agents/node_modules/.bin'); + const candidates = configured ? [configured] : desktop ? findExecutables('codex', process.env, [managed]) : ['codex', path.join(managed, 'codex')]; + const found = []; for (const candidate of candidates) { const file = agentExecutable(candidate); if (!file) continue; - found = true; const result = await command(file, ['--version']); const version = result.stdout.match(/\d+\.\d+\.\d+/)?.[0]; - if (!result.ok || !version || !atLeast(version, minimum)) { + found.push(file); + if (!result.ok || (minimum && (!version || !atLeast(version, minimum)))) { if (!configured) continue; - check('agent.codex', false, 'warn', `Configured Codex ${result.ok ? 'is older than the supported version or its version is unknown' : result.reason}.`, `Set PAPILOT_CODEX_BIN to Codex ${minimum} or newer.`); + check('agent.codex', false, 'warn', `Configured Codex ${result.ok ? 'is older than the supported version or its version is unknown' : result.reason}.`, minimum ? `Set PAPILOT_CODEX_BIN to Codex ${minimum} or newer.` : 'Correct PAPILOT_CODEX_BIN.'); return false; } const login = await command(file, ['login', 'status']); const ready = login.ok && /^\s*logged in\b/im.test(login.stdout + '\n' + login.stderr); - check('agent.codex', ready, 'warn', `Codex ${version} (${file}): ${ready ? 'signed in' : login.reason || 'login could not be confirmed'}.`, 'Run papilot codex login, then rerun doctor.'); + check('agent.codex', ready, 'warn', `${version ? `Codex ${version}` : 'Codex'} (${file}): ${ready ? 'signed in' : login.reason || 'login could not be confirmed'}.`, desktop ? 'Run codex login, then select Reconnect.' : 'Run papilot codex login, then rerun doctor.'); return ready; } - check('agent.codex', false, 'warn', configured ? 'PAPILOT_CODEX_BIN does not point to an executable.' : found ? `No installed Codex meets version ${minimum}.` : 'Codex is not installed.', process.env.PAPILOT_INSTALL_METHOD === 'nix' ? 'Install a supported Codex separately with Nix or set PAPILOT_CODEX_BIN to an existing installation.' : configured ? 'Correct PAPILOT_CODEX_BIN or unset it to use automatic installation.' : 'Run papilot codex login; it installs the supported Codex when needed.'); + check('agent.codex', false, 'warn', configured ? 'PAPILOT_CODEX_BIN does not point to an executable.' : !found.length ? 'Codex is not installed.' : desktop ? `Codex could not be run (${found.join(', ')}).` : `No installed Codex meets version ${minimum}.`, + process.env.PAPILOT_INSTALL_METHOD === 'nix' ? 'Install a supported Codex separately with Nix or set PAPILOT_CODEX_BIN to an existing installation.' + : configured ? `Correct PAPILOT_CODEX_BIN${desktop ? '' : ' or unset it to use automatic installation'}.` + : desktop ? `${found.length ? 'Reinstall' : 'Install'} Codex (npm install -g @openai/codex), then select Reconnect.` + : 'Run papilot codex login; it installs the supported Codex when needed.'); return false; } async function claudeCheck() { diff --git a/desktop/README.md b/desktop/README.md index 9bb941e9..64a6f76c 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -4,7 +4,7 @@ The desktop app reuses the existing React + TypeScript + Vite interface. Electro ## Build and run -Use macOS, Linux or Windows and an official Node.js distribution, version 22.22 or newer. The build bundles the Node and npm used by the build command. It includes the tested Codex version; Git, LaTeX and other agent CLIs remain host tools. +Use macOS, Linux or Windows and an official Node.js distribution, version 22.22 or newer. The build bundles the Node and npm used by the build command. Codex is not included: like Git, LaTeX and the other agent CLIs it is a host tool. ```sh npm ci diff --git a/docs/guide.md b/docs/guide.md index 8ab69fe4..de1c2d8a 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -49,11 +49,11 @@ Each paper in that menu has a **pencil** button that opens its name and reposito To try a sample first, run `papilot demo` (`npm run demo` in a source checkout). It copies the example paper in `examples/papilot-paper` (this README written as a 26-page LaTeX paper in fifteen chapter files, with a usage chapter, formal models of review rounds, conflicts, the PDF view's geometry, queueing and Git, theorems with proofs, an algorithm, TikZ and pgfplots figures, tables and a BibTeX bibliography, so there is plenty to review) into a separate Git repository, `example-paper` in the data folder, and registers it as **Papilot paper** without changing an existing manuscript. Start an editing task from the interface when you are ready to use your chosen agent. -Papilot uses the `codex` on your `PATH` when it is version 0.164.0 or newer; otherwise the `papilot` command installs that version into `agents` in the data folder on its first start. Either way Codex uses the host's normal Codex login and configuration. To sign in, run `papilot codex login` on the host. No API key is stored by Papilot. Model selection defaults to the host's configured Codex model. +Papilot uses the `codex` on your `PATH` when it is version 0.164.0 or newer; otherwise the `papilot` command installs that version into `agents` in the data folder on its first start. The desktop app does not include or install Codex: it uses the first Codex installed on the computer that runs. **Settings → Runtime → Agents** says when none is found or none runs; install or reinstall Codex, then select **Reconnect**. Either way Codex uses the host's normal Codex login and configuration. To sign in, run `papilot codex login` on the host, or `codex login` with the desktop app. No API key is stored by Papilot. Model selection defaults to the host's configured Codex model. ### Agents -Each task runs on one agent and model, chosen from the model menu below the prompt. The menu lists every available agent's models, grouped by agent with your recent choices first; type to search all of them, or use the arrow keys and Enter. Reasoning effort and fast mode for the chosen model sit below the list. Papilot remembers the agent, and each agent's model and reasoning effort, for the next task. Papilot looks for every agent when it starts and whenever you select **Reconnect**, and offers only the ones it finds on this host: an installed CLI, or the API key an SDK-based agent needs. Codex is always offered, because Papilot installs it. An agent that is found but not signed in is listed as unavailable and says why. **Settings → Runtime → Agents** shows every supported agent and its live availability, including agents that are not set up. Select an agent card to see its account, model names, setup instructions, installation checks, and usage limits when the agent reports them. Model names appear as compact labels, with the default marked and longer catalogs expandable. When setup is not detected, the card explains the server-side detection result, offers steps for that adapter, and provides Reconnect to refresh availability. Changes to environment variables require restarting Papilot; a different terminal or desktop-app login may not configure the running server. Details open within Settings; quotas are hidden while the connection is unavailable. The **LaTeX** page automatically identifies the active distribution (such as MacTeX, TeX Live, or MiKTeX), version, and installation location on the host. +Each task runs on one agent and model, chosen from the model menu below the prompt. The menu lists every available agent's models, grouped by agent with your recent choices first; type to search all of them, or use the arrow keys and Enter. Reasoning effort and fast mode for the chosen model sit below the list. Papilot remembers the agent, and each agent's model and reasoning effort, for the next task. Papilot looks for every agent when it starts and whenever you select **Reconnect**, and offers only the ones it finds on this host: an installed CLI, or the API key an SDK-based agent needs. The `papilot` command always offers Codex, because it installs it; the desktop app offers it when it finds one. An agent that is found but not signed in is listed as unavailable and says why. **Settings → Runtime → Agents** shows every supported agent and its live availability, including agents that are not set up. Select an agent card to see its account, model names, setup instructions, installation checks, and usage limits when the agent reports them. Model names appear as compact labels, with the default marked and longer catalogs expandable. When setup is not detected, the card explains the server-side detection result, offers steps for that adapter, and provides Reconnect to refresh availability. Changes to environment variables require restarting Papilot; a different terminal or desktop-app login may not configure the running server. Details open within Settings; quotas are hidden while the connection is unavailable. The **LaTeX** page automatically identifies the active distribution (such as MacTeX, TeX Live, or MiKTeX), version, and installation location on the host. CLI agents, including Claude when Claude Code is installed, use their own login and configuration on this host. The SDK-based agents (Cursor, GLM, and Claude without Claude Code) take their key from Papilot's environment and receive it only for their own service. Papilot never stores a credential. Every agent edits only the task's worktree and leaves compiling and publishing the review to Papilot. @@ -291,7 +291,7 @@ The workspace uses short, non-overlapping polling on HTTP/1 connections so multi ## Updates -**Desktop app:** Settings → Updates offers the same **Stable** and **Dev (GitHub main)** channels. Stable follows published releases with an installer for your OS and CPU architecture; Dev follows a nightly build of main. When the new version was built for the installer you have, **Update now** downloads only Papilot's own code (about 5 MB), checks that it starts and restarts when tasks are idle, as the web server does. When it needs a newer Electron, Node, Codex or dependencies, the update links to the installer for your system instead. Papers and settings stay in place either way, and desktop update status is separate from the web server's. See [Papilot Desktop](../desktop/README.md). +**Desktop app:** Settings → Updates offers the same **Stable** and **Dev (GitHub main)** channels. Stable follows published releases with an installer for your OS and CPU architecture; Dev follows a nightly build of main. When the new version was built for the installer you have, **Update now** downloads only Papilot's own code (about 5 MB), checks that it starts and restarts when tasks are idle, as the web server does. When it needs a newer Electron, Node or dependencies, the update links to the installer for your system instead. Papers and settings stay in place either way, and desktop update status is separate from the web server's. See [Papilot Desktop](../desktop/README.md). **Web server (npm or source checkout):** A **New version** badge appears beside **Settings** when npm has a newer stable Papilot release. Papilot checks at startup and every six hours. Click the badge, then **Update to …**; the panel shows installation, waiting, restart and failure states. @@ -309,7 +309,7 @@ If installation or its initial startup check fails, the existing server keeps ru | --- | --- | --- | | `PAPILOT_PORT` | `7274` | Local HTTP port; the server binds to `127.0.0.1`. | | `PAPILOT_DATA` | `~/.papilot` for the `papilot` command, `.papilot` in a source checkout | SQLite state and generated example project. | -| `PAPILOT_CODEX_BIN` | `codex` on `PATH` (0.164.0 or newer), else one installed into the data folder | Codex executable used by the backend. | +| `PAPILOT_CODEX_BIN` | `papilot`: `codex` on `PATH` (0.164.0 or newer), else one installed into the data folder. Desktop app: the first `codex` on `PATH` that runs | Codex executable used by the backend. | | `PAPILOT_SERVICE_PATH` | Unset | Absolute tool directories to prioritize in the macOS service; retained across service reinstalls. | | `PAPILOT_CLAUDE_BIN` | `claude` on `PATH` | Claude Code CLI used for Claude tasks; when found, Claude runs `claude -p` on its login instead of the Agent SDK. | | `ANTHROPIC_API_KEY` | Unset | Anthropic API key for Claude tasks (the Agent SDK does not accept a Claude subscription login). | diff --git a/docs/windows.md b/docs/windows.md index 68f32dd8..2a805338 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -1,6 +1,6 @@ # Windows setup -Windows support is available from Papilot 0.9.1. Download the Windows x64 installer (`Papilot-VERSION-win-x64.exe`) or portable app (`Papilot-VERSION-win-x64-portable.exe`) from [GitHub Releases](https://github.com/PapilotAI/papilot/releases/latest). The installer lets you choose an installation folder and adds Start menu and desktop shortcuts; the portable app runs without installation. Both include Node.js, npm and Codex. Windows installers are unsigned. The desktop workflow builds and tests Windows x64 alongside macOS and Linux. +Windows support is available from Papilot 0.9.1. Download the Windows x64 installer (`Papilot-VERSION-win-x64.exe`) or portable app (`Papilot-VERSION-win-x64-portable.exe`) from [GitHub Releases](https://github.com/PapilotAI/papilot/releases/latest). The installer lets you choose an installation folder and adds Start menu and desktop shortcuts; the portable app runs without installation. Both include Node.js and npm; install Codex yourself. Windows installers are unsigned. The desktop workflow builds and tests Windows x64 alongside macOS and Linux. ## Choose an environment @@ -9,7 +9,7 @@ The Windows desktop app recommends **WSL** on first launch. The setup screen det - **Windows** uses the Windows installations of Git, TeX and agents, and stores app data under `%APPDATA%\Papilot`. - **WSL** keeps the same Windows window but runs Papilot's backend, Git, TeX and agents inside the selected Linux distribution. Agent sign-ins stay in that distribution. App data is under `~/.local/share/papilot-desktop/data`, separate from Windows and from other distributions. -If WSL is missing, the setup screen offers a copyable `wsl --install -d Ubuntu` command and Microsoft's installation guide. Run it in an administrator PowerShell, restart if requested, then open Ubuntu to finish its first setup. Inside that distribution, install Linux Node.js LTS (22.22+), npm, Git and TeX Live. Windows tools do not count as Linux dependencies. Choose the distribution and click **Prepare WSL**. This installs Papilot's Linux dependencies and bundled Codex in its own user directory, without using Windows `node_modules`. The first setup needs access to your npm registry from WSL; subsequent launches use the prepared copy. Failed or canceled setup can be retried, with details available in an expandable log. +If WSL is missing, the setup screen offers a copyable `wsl --install -d Ubuntu` command and Microsoft's installation guide. Run it in an administrator PowerShell, restart if requested, then open Ubuntu to finish its first setup. Inside that distribution, install Linux Node.js LTS (22.22+), npm, Git and TeX Live. Windows tools do not count as Linux dependencies. Choose the distribution and click **Prepare WSL**. This installs Papilot's Linux dependencies in its own user directory, without using Windows `node_modules`. Install Codex inside the distribution too (`npm install -g @openai/codex`); a Windows Codex does not count. The first setup needs access to your npm registry from WSL; subsequent launches use the prepared copy. Failed or canceled setup can be retried, with details available in an expandable log. The Windows runtime already includes Node.js and npm. Version Management is disabled when Git is missing in the selected backend environment, with a link to installation instructions. Normal workspace refreshes detect Git after installation and enable the entry; restoring a saved page cannot bypass the check. The example PDF can be read without Git or TeX, including after a restart. Installing Git initializes version management for the same example project while preserving its source edits and notes. An edited source is never marked as matching the bundled PDF; compiling edits requires Git and TeX. WSL preparation requires Linux Node.js and npm. Tool detection checks the TeX commands (`latexmk`, `synctex` and a LaTeX engine); a paper may need additional TeX packages. Agent sign-in is checked separately inside the workspace. @@ -36,7 +36,7 @@ npm.cmd run desktop:build npm.cmd run desktop:build -- --dir ``` -The installer is `release/desktop/Papilot-VERSION-win-x64.exe`; the portable executable is `release/desktop/Papilot-VERSION-win-x64-portable.exe`, and the unpacked executable is `release/desktop/win-unpacked/Papilot.exe`. Build on Windows with a Node runtime matching the target CPU. Native release CI currently targets x64. The installer bundles Node, npm and Codex; Git, TeX and other agent CLIs are host tools. Unsigned development installers do not have a trusted publisher signature. +The installer is `release/desktop/Papilot-VERSION-win-x64.exe`; the portable executable is `release/desktop/Papilot-VERSION-win-x64-portable.exe`, and the unpacked executable is `release/desktop/win-unpacked/Papilot.exe`. Build on Windows with a Node runtime matching the target CPU. Native release CI currently targets x64. The installer bundles Node and npm; Git, TeX, Codex and other agent CLIs are host tools. Unsigned development installers do not have a trusted publisher signature. For the browser/server version from this checkout: @@ -102,6 +102,6 @@ Initial validation used the 0.9.0 source base (`64afda0`) with these Windows cha The integration based on PR #72 (`4896713`), including the changes from main (`5ae19cb`), also built the NSIS installer, portable executable and update layer on Windows. Its packaged app passed native launch, environment selection, WSL compilation, update activation and rollback checks. -After independent WSL device sign-in, the integrated app completed two real Codex editing turns in an isolated Linux paper with Chinese characters, spaces and `&` in its path. Restarting the Windows app resumed the same task and thread. Both rounds produced readable PDFs with the expected text, and the original manuscript stayed unchanged. Both turns inherited `auto_review` with `on-request` and the workspace-write sandbox; there were no manual approval requests. A separate harmless elevated print command also passed automatic review. These live checks used Papilot's bundled Codex, not a mock agent. +After independent WSL device sign-in, the integrated app completed two real Codex editing turns in an isolated Linux paper with Chinese characters, spaces and `&` in its path. Restarting the Windows app resumed the same task and thread. Both rounds produced readable PDFs with the expected text, and the original manuscript stayed unchanged. Both turns inherited `auto_review` with `on-request` and the workspace-write sandbox; there were no manual approval requests. A separate harmless elevated print command also passed automatic review. These live checks used a real Codex, not a mock agent. Agent authentication and provider API calls require the user's account and a working connection to the provider; these are separate from the offline launch and compilation checks. ARM64 and UNC network-share compilation have not been validated on hardware. diff --git a/i18n/state/de.json b/i18n/state/de.json index 00ffc8af..b0d690d9 100644 --- a/i18n/state/de.json +++ b/i18n/state/de.json @@ -3634,11 +3634,6 @@ "textHash": "04bd4c1de5a4a38dbef1fa4be3b50de789974755b46c384d2186b889ee8f7c6a", "model": "gemini-4-argon-medium" }, - "Run papilot codex login on the host. It installs a supported Codex CLI when needed.": { - "revision": "01e58d85719447befcd53216c800d19996f83f593bd78f8382ab9ee55d1f6584", - "textHash": "2c929736ebabf924fe5d9f6f6a24181817307b6156454b8afc75df9cb847100c", - "model": "gemini-4-argon-medium" - }, "Run tool": { "revision": "9c759451cca299f2f828fb8f24c6cf270453d9e217dc331a26c274e33b866cb1", "textHash": "8f70809d600855bc407dad8f33881cc7ab032e8a71c2ee60524673bf16a6b99a", @@ -4749,11 +4744,6 @@ "textHash": "14a8bf232725e5125083ae8d7841bb6f71912c648e4396bcc6827dc02c582232", "model": "gemini-4-argon-medium" }, - "Uses a compatible local Codex CLI. Papilot installs its own copy when needed.": { - "revision": "9d94ae47d2206dc71f6f0b85ebd56ef739919474587156d6bfd062572c2fd250", - "textHash": "5d3c96b50bff9baab170ed2a0f6d90701dc4769c2c438b3a3a10f6a4e5fca19b", - "model": "gemini-4-argon-medium" - }, "Uses the Cursor SDK with CURSOR_API_KEY. Papilot installs the SDK on first use.": { "revision": "be17dc7a93ea6e4f9f3a11e796877782837c0ed6e573e1ba55df75fed93ef427", "textHash": "eced74fbc3be9b35aa9912c438ccbe8420fadde7823d542161f791bb418d5f0f", diff --git a/i18n/state/es.json b/i18n/state/es.json index d7279835..ccbaea26 100644 --- a/i18n/state/es.json +++ b/i18n/state/es.json @@ -3634,11 +3634,6 @@ "textHash": "65bd1ca18aaa07fe42682e3bfc33175064d594cfe774106575ed2ce666679af0", "model": "gemini-4-argon-medium" }, - "Run papilot codex login on the host. It installs a supported Codex CLI when needed.": { - "revision": "c23dac563f110ea0f3ce439c1b31dac5b84bd3ad51d30549066d576679433394", - "textHash": "f855786a4a69283134ea80c6d8b79c74b9e7ee47b4bc310a1c2d037092887dec", - "model": "gemini-4-argon-medium" - }, "Run tool": { "revision": "eeb9c522ec0d4d0bc16e66716dc18a88d1c9e86e26867f0b491a98ea4db69848", "textHash": "35cb23579c29519a9190cc29452ddd172b4fa748dd318f9b9228d7d047c53318", @@ -4749,11 +4744,6 @@ "textHash": "b8af4e4ab548dd8966907f66d8c16eae215a457f1b6f7197bfdde8aab00ecaa0", "model": "gemini-4-argon-medium" }, - "Uses a compatible local Codex CLI. Papilot installs its own copy when needed.": { - "revision": "68a85370565f291c310c7bfcfef1f4e66181a28b8cfb5d9290fac9808982d9ac", - "textHash": "40e9095e8530a35885cce2dcd704e69d3fe0d8597a3e9057e92bac49d2442d25", - "model": "gemini-4-argon-medium" - }, "Uses the Cursor SDK with CURSOR_API_KEY. Papilot installs the SDK on first use.": { "revision": "030b77c2ccc4bdbdb028d343c37f4df313e6bf0a5de5049b1e6b3f30036a2be6", "textHash": "2c5cd55e1667454b238d1f7ecc5c61f05e60645dc83d0b6c90c93ddddd008684", diff --git a/i18n/state/fr.json b/i18n/state/fr.json index 42bc8e5f..6e48eca8 100644 --- a/i18n/state/fr.json +++ b/i18n/state/fr.json @@ -3634,11 +3634,6 @@ "textHash": "c54bee7b61a8467653ebc586bcd6bdd228d7b30d5d4a9e2b51060fbf8b5df7b1", "model": "gemini-4-argon-medium" }, - "Run papilot codex login on the host. It installs a supported Codex CLI when needed.": { - "revision": "bcb0efc997e8b3668eadc2651b7e13f6ba5dfa133405aab9d5d83f3468f54c21", - "textHash": "ddc9b3905209573709ce76b8729d80613d42c9375bc7c8797d1bf49f0715cb0e", - "model": "gemini-4-argon-medium" - }, "Run tool": { "revision": "562244f280dec96020a593c60ef3a5231b15cae7bc156479fbe655b183266f61", "textHash": "9cfa0a5f55fee4f91eefb4594b955d70674d361cc42e403a1c6bdc9adf5c0221", @@ -4749,11 +4744,6 @@ "textHash": "e57c0b406968fd926274e16fd42541587473205ee0612c966eb8c50acf0673a8", "model": "gemini-4-argon-medium" }, - "Uses a compatible local Codex CLI. Papilot installs its own copy when needed.": { - "revision": "73b36bd7d5bb023baaed463b22ac3810d0698002be5926463a0d9c5cb6dc5601", - "textHash": "8833e92b09d39efe1fb4b7c49c563994242a3f0889e4e40f97269df1e591db70", - "model": "gemini-4-argon-medium" - }, "Uses the Cursor SDK with CURSOR_API_KEY. Papilot installs the SDK on first use.": { "revision": "f0591224f1fef689938a6bd8192420bb43264d96df96ecb753af7c2745bd63d5", "textHash": "cda82f56d87ab2dfe56d04059a3ec1fe38c2dfbb68c9d84fbac235e44444026b", diff --git a/i18n/state/it.json b/i18n/state/it.json index 497f3827..c7ae12ea 100644 --- a/i18n/state/it.json +++ b/i18n/state/it.json @@ -3634,11 +3634,6 @@ "textHash": "469d34183788cd8bfe6c7438f46751cef3a16fe7f6dd43668b87f7ae705fa7bf", "model": "gemini-4-argon-medium" }, - "Run papilot codex login on the host. It installs a supported Codex CLI when needed.": { - "revision": "bbadfd4f996acfeb2639cee4815bc5d93425032cfe7fc3f6ba0c7aa7917d10ab", - "textHash": "0ea5d199da1c73e781496bec49b1147fa63d9cb84459d69fb59549da92e7098d", - "model": "gemini-4-argon-medium" - }, "Run tool": { "revision": "72b897c73e46010971badb37e6e8ad5455840c6a2c035b30bcbe506af8967733", "textHash": "f56371c38c8285548b8ce967cb5c33d04d8de668a8426303ef12a16c06fb24f4", @@ -4749,11 +4744,6 @@ "textHash": "30c1f5d4c702b462275bcea97f411d191bb1796045ad5c8ecaa703b80ac23aa6", "model": "gemini-4-argon-medium" }, - "Uses a compatible local Codex CLI. Papilot installs its own copy when needed.": { - "revision": "e8309d3bc134f3ad8fe47fb01917bcf2d4ff109f6d917e8a575ea84b1acb2d60", - "textHash": "223cf7748dcc1bff5deb47affda6ef2711da258608c33d325530bd5135c369b4", - "model": "gemini-4-argon-medium" - }, "Uses the Cursor SDK with CURSOR_API_KEY. Papilot installs the SDK on first use.": { "revision": "847e5387b6028d0c04d08baa939c7f23585a2cca83c69b7dc0e48b2ba9cee5db", "textHash": "82957f981299e59a91632dd05e4fa093ba2152b7cadd103316325cdf7d8a25b7", diff --git a/i18n/state/ja.json b/i18n/state/ja.json index 6756c6b8..83179abd 100644 --- a/i18n/state/ja.json +++ b/i18n/state/ja.json @@ -3634,11 +3634,6 @@ "textHash": "5f8bcc83562982752d14b1f6a378ea8557fd68030c5944c904ee20ec900694f1", "model": "gemini-4-argon-medium" }, - "Run papilot codex login on the host. It installs a supported Codex CLI when needed.": { - "revision": "e9cca4139700f7b085cb5f245e1694e0c2ba54c76a10127a0c9ea9672d3d5238", - "textHash": "9af72e1202f875efef0f209ce1212b713a190b90b32be426dd0bf05c14f34b90", - "model": "gemini-4-argon-medium" - }, "Run tool": { "revision": "f2d73703efea2259d1b3791ac5a672cb788c3e6b4c7b33788a90bac1dcaabddf", "textHash": "ec96feff625b9f9edda70dbb442f405b1c8b8997afdd05c2e2d75a7be7fb8493", @@ -4749,11 +4744,6 @@ "textHash": "6cc8d555c663419a688b3b35284eff122199c8c4be605cdd68eba468bf1c2afd", "model": "gemini-4-argon-medium" }, - "Uses a compatible local Codex CLI. Papilot installs its own copy when needed.": { - "revision": "6ec919458038322da49fa0901a79dd08aa1bb7bab5f2e8c97eb359eda954de11", - "textHash": "01e57f418eb4bc36bee40ae552da6e97084f9ef64f39369a6a8e2f72a33cb2fd", - "model": "gemini-4-argon-medium" - }, "Uses the Cursor SDK with CURSOR_API_KEY. Papilot installs the SDK on first use.": { "revision": "d39e5c529d2db2e4b14b6a6ad660f78f7d7ba91e16c45434420732d926f5c8b7", "textHash": "b3b931eb41c2772dba8051fa31ca0d4f9ed904a5b8f27a6808feb38f34881398", diff --git a/i18n/state/ko.json b/i18n/state/ko.json index 31cdde86..0c0bfc96 100644 --- a/i18n/state/ko.json +++ b/i18n/state/ko.json @@ -3634,11 +3634,6 @@ "textHash": "c83b6949db234f1841ea128556637244c4a86746567576855af98b32dcfdaea4", "model": "gemini-4-argon-medium" }, - "Run papilot codex login on the host. It installs a supported Codex CLI when needed.": { - "revision": "ba3ef48ec2bee4447dd7196461d968d89740cb1fa3cfbb322c66edc53a3d4932", - "textHash": "f55da1a8a98f2acbe1fb26f971afb6dd06b3f4a32ae610cbb703159fed37643e", - "model": "gemini-4-argon-medium" - }, "Run tool": { "revision": "28c7995b4be7d785ec8da7fc7ef3958881e3d0e55a6a5501d218a019973849b8", "textHash": "7caccacd2324ae454883d5b05c02ba8d0a275a0894ff8cd3bb56189ae02fb93e", @@ -4749,11 +4744,6 @@ "textHash": "5434a4cde60257c42ab9a1845bfad03373acd49fd80b23c20ba7b31ac89e58ec", "model": "gemini-4-argon-medium" }, - "Uses a compatible local Codex CLI. Papilot installs its own copy when needed.": { - "revision": "8d8102999898c25ea79f18e0e7ac461de4fee2d5d5f59229c3c85c26e03e1848", - "textHash": "b105944fcdc39053610750217d8a72407dd4718bd3ac2b88b56228b91a70394c", - "model": "gemini-4-argon-medium" - }, "Uses the Cursor SDK with CURSOR_API_KEY. Papilot installs the SDK on first use.": { "revision": "7b63334ded76a4b965ef0fe8568356c518272688c8237ee28678024d347515b4", "textHash": "c7e21162f0e2aada16736f9a9574d62cd04ccdd336a50780561bfd1aebe2f792", diff --git a/i18n/state/pt.json b/i18n/state/pt.json index ef9a7caa..89be1dbc 100644 --- a/i18n/state/pt.json +++ b/i18n/state/pt.json @@ -3634,11 +3634,6 @@ "textHash": "b176c1ef5b17c792383c42a9bdbab3ce6df80b175b916a30e5a86c2c511ab79c", "model": "gemini-4-argon-medium" }, - "Run papilot codex login on the host. It installs a supported Codex CLI when needed.": { - "revision": "08db2c4342dd469eda046cd8f3aee85e799031b02c60a6fd86ea54583c8404b1", - "textHash": "98ab07642b91418ca7a0e0c1f50e585a9e959c0b06f46a8b5712cbf41cad7cd5", - "model": "gemini-4-argon-medium" - }, "Run tool": { "revision": "17339476e835eeb6a6d4768d94d479dc2da27de66c845c0ff4944f4d0733ab3d", "textHash": "5c1e98b79e46e620944eba0b850c4f06e65aff786fd0512dd34febe3fe9fa580", @@ -4749,11 +4744,6 @@ "textHash": "f010e7f22b19032a23e0036728c9baa73c4fed3014b7e561d9601a7754481964", "model": "gemini-4-argon-medium" }, - "Uses a compatible local Codex CLI. Papilot installs its own copy when needed.": { - "revision": "c74d2cd6740cc4bd7cc06f4413628966db693355658781ff3aec50a6eea75b2e", - "textHash": "0689a8ac89831bf98ded8411b6bee2f588f221acb97c54c6149905b99979ced3", - "model": "gemini-4-argon-medium" - }, "Uses the Cursor SDK with CURSOR_API_KEY. Papilot installs the SDK on first use.": { "revision": "b78a71b74e9fd64e1d8ef992bcd788a8bcee6b44497a87fc5da3d3725019be99", "textHash": "06386a0e6e7f7881d21b6f8a51348f9dcb84336263da4d5ec66415862600ddb3", diff --git a/i18n/state/sv.json b/i18n/state/sv.json index ba829335..7bffe511 100644 --- a/i18n/state/sv.json +++ b/i18n/state/sv.json @@ -3634,11 +3634,6 @@ "textHash": "114996931f9e747a71a2a3e280508ee615356bfcd6a96bca3c343805f4127aba", "model": "gemini-4-argon-medium" }, - "Run papilot codex login on the host. It installs a supported Codex CLI when needed.": { - "revision": "7447f709a72f4c986089fa8ac6bec369897e7d9c286b9fe76015149add31444f", - "textHash": "2a9d1d4ea11ad6580ba9dbc53392b070ded6bc57ab617aeef79c56f2f132eb3c", - "model": "gemini-4-argon-medium" - }, "Run tool": { "revision": "6abd33be8e31038cee6ea5b02410420f7c65798c70905d9a0d1776ade906ac74", "textHash": "527ce7f2463103394cf7701fc1ef24727505fef974e671f16ff00cd4621a016e", @@ -4749,11 +4744,6 @@ "textHash": "081190a9f2cb0efb09674fc1047b25abbef0a7b79c2a87ebfd37f11f57963bb7", "model": "gemini-4-argon-medium" }, - "Uses a compatible local Codex CLI. Papilot installs its own copy when needed.": { - "revision": "341473c118ef599e1475c998f7442826b8d9760d9a44828a9938cacefc44a474", - "textHash": "0e5dee7f414825291a2ed7e15fc2ada5a37ccac17c71154756264958c9fba4ee", - "model": "gemini-4-argon-medium" - }, "Uses the Cursor SDK with CURSOR_API_KEY. Papilot installs the SDK on first use.": { "revision": "bfa36ad51a8e58bbe541f05aed5acb2240dc078ce4b31812c4923e5db62e87ff", "textHash": "0c2489b76494bd2abac772a22e093b39cc8de38699e00f22385e7cebdd6f9c61", diff --git a/i18n/state/zh-Hant.json b/i18n/state/zh-Hant.json index c0dbbe03..211394d6 100644 --- a/i18n/state/zh-Hant.json +++ b/i18n/state/zh-Hant.json @@ -3634,11 +3634,6 @@ "textHash": "2fb18a455e427842c5b799a24cebab72a42b6fa2444f6b769e218fdb69b84712", "model": "gemini-4-argon-medium" }, - "Run papilot codex login on the host. It installs a supported Codex CLI when needed.": { - "revision": "fcd1a48f49ffc18d8f5f62e51c64601d0ed1f6d3208fd007ee9e53ad98637d43", - "textHash": "9e52dd65252982f78c1bd91f1959aa31248479003ae1153deb5488ad236401d3", - "model": "gemini-4-argon-medium" - }, "Run tool": { "revision": "cafafc1994775d4daa81228b0f1217da417566a4f830181bb74a6c86bf71f783", "textHash": "f81c160ab853d896b719b24a7243580f49e5196460ecb5b5119a31da923f873b", @@ -4749,11 +4744,6 @@ "textHash": "3385e4a8c2f7aeb6d65600902b0944df3dcdbf3565f00baea0000aef7a2a77ff", "model": "gemini-4-argon-medium" }, - "Uses a compatible local Codex CLI. Papilot installs its own copy when needed.": { - "revision": "e013d91e1ea58aefb0b607bafb57ed5be9f8b8c3704517353c8f225f42ed532d", - "textHash": "c48fdfd68984f69772607cfe804f4e1e393febbcffa2ff7b77576fcbf71c5d6d", - "model": "gemini-4-argon-medium" - }, "Uses the Cursor SDK with CURSOR_API_KEY. Papilot installs the SDK on first use.": { "revision": "5ac0f22bd13a02d07eee1b2de061f341eae26eee994bd5509611a20b594f54e0", "textHash": "57fd58d5247526e96eb9e4c526f88ed2bc392f4189fc7ab730004059e44d8069", diff --git a/scripts/check-desktop.mjs b/scripts/check-desktop.mjs index df115d00..637f1d7b 100644 --- a/scripts/check-desktop.mjs +++ b/scripts/check-desktop.mjs @@ -34,7 +34,8 @@ if (packaged) { const node = path.join(resources, 'runtime/bin', process.platform === 'win32' ? 'node.exe' : 'node'), server = path.join(resources, 'server'); assert.match(execFileSync(node, ['--version'], { encoding: 'utf8' }), /^v\d+\./); assert.match(execFileSync(node, [path.join(resources, 'runtime/lib/node_modules/npm/bin/npm-cli.js'), '--version'], { encoding: 'utf8' }), /^\d+\./); - assert.match(execFileSync(node, [path.join(server, 'node_modules/@openai/codex/bin/codex.js'), '--version'], { encoding: 'utf8' }), /codex-cli/); + // Codex is a host tool; the installer does not carry it. + assert.ok(!fs.existsSync(path.join(server, 'node_modules/@openai/codex')), 'the installer does not bundle Codex'); const help = execFileSync(node, ['--import', pathToFileURL(path.join(server, 'node_modules/tsx/dist/loader.mjs')).href, path.join(server, 'engine/src/cli.ts'), '--help'], { encoding: 'utf8', cwd: data, env }); assert.match(help, /Usage:/); } diff --git a/scripts/desktop-layer.d.mts b/scripts/desktop-layer.d.mts index b129de14..f7f1bcc6 100644 --- a/scripts/desktop-layer.d.mts +++ b/scripts/desktop-layer.d.mts @@ -6,3 +6,4 @@ export function layerName(version: string): string; export function buildLayer(root?: string, output?: string, revision?: string): { format: number; version: string; fingerprint: string; layer: { name: string; size: number; sha256: string } }; export const INSTALLER_SUFFIXES: string[]; export function reusableInstallers(previous: any, assets: string[], fingerprint: string): string[] | null; +export function dedupeEngine(server: string): number; diff --git a/scripts/desktop-layer.mjs b/scripts/desktop-layer.mjs index 9f998e89..cd82d98e 100644 --- a/scripts/desktop-layer.mjs +++ b/scripts/desktop-layer.mjs @@ -1,5 +1,5 @@ import { npmCli } from '../engine/src/core/platform.mjs'; -// A desktop installer is a shell (Electron, Node, Codex and the production +// A desktop installer is a shell (Electron, Node and the production // dependencies) around Papilot's own code. Updates download only that code, a // few megabytes, when it was built for an identical shell. Anything else needs // a new installer. @@ -35,12 +35,42 @@ export function shellFingerprint(root = process.cwd(), node = process.versions.n .map(file => [path.relative(root, file).split(path.sep).join('/'), hash(file)]).sort(([a], [b]) => a.localeCompare(b)); const inputs = { format: LAYER_FORMAT, node: node.split('.')[0], - electron: pkg.devDependencies?.electron, builder: pkg.devDependencies?.['electron-builder'], codex: pkg.devDependencies?.['@openai/codex'], + // Codex is a host tool, not part of the installer. + electron: pkg.devDependencies?.electron, builder: pkg.devDependencies?.['electron-builder'], dependencies: production(path.join(root, 'package-lock.json')), engine: production(path.join(root, 'engine/package-lock.json')), shell, }; return createHash('sha256').update(JSON.stringify(inputs)).digest('hex'); } +// The review engine sits inside the server folder, so Node resolves a package +// it lacks from the server's node_modules (also through a layer's links, see +// linkBundle). Its own copies of the same versions (mupdf, esbuild, fastify, +// React…) only made the installer larger. +export function dedupeEngine(server) { + const outer = path.join(server, 'node_modules'), inner = path.join(server, 'engine/node_modules'); + const version = folder => { try { return JSON.parse(fs.readFileSync(path.join(folder, 'package.json'), 'utf8')).version || null; } catch { return null; } }; + const names = fs.readdirSync(inner).filter(name => !name.startsWith('.')) + .flatMap(name => name.startsWith('@') ? fs.readdirSync(path.join(inner, name)).map(child => `${name}/${child}`) : [name]); + let bytes = 0; + const size = file => { const stat = fs.lstatSync(file); return stat.isDirectory() ? fs.readdirSync(file).reduce((sum, name) => sum + size(path.join(file, name)), 0) : stat.size; }; + for (const name of names) { + const own = version(path.join(inner, name)); + if (!own || own !== version(path.join(outer, name))) continue; + bytes += size(path.join(inner, name)); + fs.rmSync(path.join(inner, name), { recursive: true, force: true }); + } + for (const name of fs.readdirSync(inner)) if (name.startsWith('@') && !fs.readdirSync(path.join(inner, name)).length) fs.rmdirSync(path.join(inner, name)); + // Executables of removed packages: links on macOS and Linux, shims on Windows. + const bin = path.join(inner, '.bin'); + if (fs.existsSync(bin)) for (const name of fs.readdirSync(bin)) { + const file = path.join(bin, name); + const targets = fs.lstatSync(file).isSymbolicLink() ? [path.resolve(bin, fs.readlinkSync(file))] + : [...fs.readFileSync(file, 'utf8').matchAll(/\.\.[\\/]((?:@[^\\/"']+[\\/])?[^\\/"']+)[\\/]/g)].map(match => path.join(inner, match[1])); + if (targets.some(target => !fs.existsSync(target))) fs.rmSync(file, { force: true }); + } + return bytes; +} + // Nightly dev builds carry the commit, like the web server's GitHub dev channel. export function desktopVersion(version, revision = process.env.PAPILOT_DESKTOP_REVISION) { if (!revision) return version; diff --git a/scripts/desktop.mjs b/scripts/desktop.mjs index ac43cc03..342e5813 100644 --- a/scripts/desktop.mjs +++ b/scripts/desktop.mjs @@ -4,7 +4,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module'; import { spawnSync, spawn } from 'node:child_process'; -import { buildLayer, desktopVersion, shellFingerprint } from './desktop-layer.mjs'; +import { buildLayer, dedupeEngine, desktopVersion, shellFingerprint } from './desktop-layer.mjs'; import { tarCommand } from '../bin/agent-paths.mjs'; import { buildWindowsIcon } from './build-windows-icon.mjs'; @@ -78,13 +78,12 @@ if (process.argv[2] === 'layer') { const serverPackage = JSON.parse(fs.readFileSync('.desktop/server/package.json', 'utf8')); // A main build reports the same dev version as its update layer. serverPackage.version = desktopVersion(pkg.version); - // The CLI installs this tested Codex on first run; desktop carries it so - // opening the app for the first time works without an npm download. - serverPackage.dependencies['@openai/codex'] = pkg.devDependencies['@openai/codex']; - delete serverPackage.devDependencies['@openai/codex']; + // Codex is not bundled: the server uses the Codex installed on the computer + // (src/server/codex-binary.ts). fs.writeFileSync('.desktop/server/package.json', JSON.stringify(serverPackage, null, 2) + '\n'); runNpm(['install', '--omit=dev', '--no-audit', '--no-fund'], path.join(root, '.desktop/server')); runNpm(['ci', '--omit=dev', '--no-audit', '--no-fund'], path.join(root, '.desktop/server/engine')); + console.log(`Removed ${(dedupeEngine(path.join(root, '.desktop/server')) / 1048576).toFixed(1)} MB of engine dependencies the server already has.`); if (process.platform === 'win32') { // Code and lockfiles only. Linux installs its own native dependencies; // Windows node_modules and Windows Node must never run inside WSL. diff --git a/src/server/codex-binary.ts b/src/server/codex-binary.ts new file mode 100644 index 00000000..2049a266 --- /dev/null +++ b/src/server/codex-binary.ts @@ -0,0 +1,27 @@ +import path from 'node:path'; +import { execCommand, findExecutables } from '../../bin/agent-paths.mjs'; +import { DATA } from './paths.ts'; + +// Papilot uses the Codex installed on this computer, like every other agent. +// The desktop installer does not carry Codex: it was the largest part of one, +// and every Codex update meant downloading the whole app again. + +// Every codex on PATH, in order (an early one may be a broken installation +// while a later one works), then the copy the npm command installs into the +// data folder (`papilot codex login`). +export function codexCandidates(env: NodeJS.ProcessEnv = process.env, data = DATA) { + return env.PAPILOT_CODEX_BIN ? [env.PAPILOT_CODEX_BIN] : findExecutables('codex', env, [path.join(data, 'agents', 'node_modules', '.bin')]); +} + +/** The first Codex that runs. */ +export async function codexBinary(env: NodeJS.ProcessEnv = process.env, data = DATA): Promise { + // The npm command has already chosen its Codex. + if (env.PAPILOT_CODEX_BIN) return env.PAPILOT_CODEX_BIN; + const candidates = codexCandidates(env, data); + for (const file of candidates) { + try { await execCommand(file, ['--version'], { env, timeout: 15000 }); return file; } catch { /* a broken installation; try the next */ } + } + if (!candidates.length) throw new Error('Codex was not found on this computer. Install Codex, then select Reconnect.'); + console.error(`No installed Codex could be run: ${candidates.join(', ')}.`); + throw new Error('Codex is installed but could not be run. Reinstall Codex, then select Reconnect.'); +} diff --git a/src/server/codex.ts b/src/server/codex.ts index c383b05c..c6d9732b 100644 --- a/src/server/codex.ts +++ b/src/server/codex.ts @@ -3,7 +3,8 @@ import { type ChildProcess } from 'node:child_process'; import { createInterface } from 'node:readline'; import { EventEmitter } from 'node:events'; import type { TaskPermissions, TaskPermissionOption } from '../shared.ts'; -import { CODEX, ROOT, VERSION } from './paths.ts'; +import { ROOT, VERSION } from './paths.ts'; +import { codexBinary, codexCandidates } from './codex-binary.ts'; import { taskAccessInstructions } from './task-permissions.ts'; export class RpcResponseError extends Error {} @@ -41,7 +42,9 @@ export class Codex extends EventEmitter { private starting: Promise | null = null; ready = false; stderr = ''; - constructor(private binary = CODEX, private args = ['app-server', '--listen', 'stdio://'], private env: NodeJS.ProcessEnv = process.env) { super(); } + // Without a binary of its own, this host's Codex is used (see codex-binary.ts). + constructor(private binary?: string, private args = ['app-server', '--listen', 'stdio://'], private env: NodeJS.ProcessEnv = process.env) { super(); } + detect() { return Boolean(this.binary || codexCandidates(this.env).length); } start(): Promise { if (this.ready) return Promise.resolve(); if (this.starting) return this.starting; @@ -50,7 +53,8 @@ export class Codex extends EventEmitter { } private async launch() { this.stderr = ''; - const child = spawnCommand(this.binary, this.args, { cwd: ROOT, env: { ...this.env, PATH: agentPath(this.env) }, stdio: ['pipe', 'pipe', 'pipe'] }); + const binary = this.binary || await codexBinary(this.env); + const child = spawnCommand(binary, this.args, { cwd: ROOT, env: { ...this.env, PATH: agentPath(this.env) }, stdio: ['pipe', 'pipe', 'pipe'] }); this.child = child; const lines = createInterface({ input: child.stdout! }); lines.on('line', line => { try { this.receive(JSON.parse(line)); } catch { /* non-protocol stdout is ignored */ } }); diff --git a/src/server/paths.ts b/src/server/paths.ts index 8ed5af30..fd12faa3 100644 --- a/src/server/paths.ts +++ b/src/server/paths.ts @@ -1,4 +1,3 @@ -import { findExecutable } from '../../bin/agent-paths.mjs'; import path from 'node:path'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; @@ -9,4 +8,3 @@ export const VERSION: string = JSON.parse(fs.readFileSync(path.join(ROOT, 'packa export const PORT = Number(process.env.PAPILOT_PORT || 7274); export const PUBLIC_ORIGIN = process.env.PAPILOT_PUBLIC_ORIGIN || ''; export const OWNER = process.env.PAPILOT_TAILSCALE_LOGIN || ''; -export const CODEX = process.env.PAPILOT_CODEX_BIN || findExecutable('codex') || [path.join(ROOT, 'node_modules/.bin/codex'), path.join(DATA, 'agents/node_modules/.bin/codex')].find(file => fs.existsSync(file)) || 'codex'; diff --git a/src/web/agent-setup.ts b/src/web/agent-setup.ts index 2aece6dd..40e34f17 100644 --- a/src/web/agent-setup.ts +++ b/src/web/agent-setup.ts @@ -28,8 +28,7 @@ export function agentSetup(id: AgentId) { ? t('The last connection attempt found neither Claude Code nor supported API credentials in the Papilot server environment.') : t('The last connection attempt could not find a runnable {command} CLI in the Papilot server environment. It may still be installed in another location or for another user.', { command }), steps: [ - id === 'codex' ? t('Run papilot codex login on the host. It installs a supported Codex CLI when needed.') - : t('Install {command} on the host running Papilot, then complete its login or provider setup below.', { command }), + t('Install {command} on the host running Papilot, then complete its login or provider setup below.', { command }), t('If it is already installed, set {variable} to its executable path in the environment that starts Papilot, then restart Papilot.', { variable }), ...(id === 'claude' ? [t('For the SDK instead, start Papilot with ANTHROPIC_API_KEY or a supported cloud provider configured. Restart Papilot after changing its environment.')] : []), ], diff --git a/src/web/locales/de.json b/src/web/locales/de.json index 886fcd40..9acace16 100644 --- a/src/web/locales/de.json +++ b/src/web/locales/de.json @@ -777,7 +777,6 @@ "Revise for reviewer comments and draft a point-by-point reply": "Anhand von Gutachterkommentaren überarbeiten und eine Punkt-für-Punkt-Antwort entwerfen", "Ruler": "Lineal", "Run command": "Befehl ausführen", - "Run papilot codex login on the host. It installs a supported Codex CLI when needed.": "Führen Sie papilot codex login auf dem Host aus. Bei Bedarf wird eine unterstützte Codex-CLI installiert.", "Run tool": "Tool ausführen", "Running": "Wird ausgeführt", "Runt": "Kurze Endzeile", @@ -1019,7 +1018,7 @@ "Uses Kimi Code CLI and its saved login through ACP.": "Verwendet die Kimi Code CLI und deren gespeicherte Anmeldung über ACP.", "Uses OpenCode with the providers and models you have connected.": "Verwendet OpenCode mit den von Ihnen verbundenen Anbietern und Modellen.", "Uses Qwen Code and its saved login through ACP.": "Verwendet Qwen Code und dessen gespeicherte Anmeldung über ACP.", - "Uses a compatible local Codex CLI. Papilot installs its own copy when needed.": "Verwendet eine kompatible lokale Codex CLI. Papilot installiert bei Bedarf eine eigene Kopie.", + "Uses the local Codex CLI and its saved login.": "Verwendet die lokale Codex CLI und deren gespeicherte Anmeldung.", "Uses the Cursor SDK with CURSOR_API_KEY. Papilot installs the SDK on first use.": "Verwendet das Cursor SDK mit CURSOR_API_KEY. Papilot installiert das SDK bei der ersten Verwendung.", "Uses the local Antigravity CLI and its saved Google login.": "Verwendet die lokale Antigravity CLI und deren gespeicherte Google-Anmeldung.", "Verified": "Verifiziert", diff --git a/src/web/locales/errors/de.json b/src/web/locales/errors/de.json index 32f99df4..84de07cd 100644 --- a/src/web/locales/errors/de.json +++ b/src/web/locales/errors/de.json @@ -264,5 +264,7 @@ "The option changes source outside this note.": "Die Option ändert Quelltext außerhalb dieser Anmerkung.", "The option has overlapping patches.": "Die Option enthält überlappende Änderungen.", "Invalid review action.": "Ungültige Prüfaktion.", - "Send noteAlternativeRange as few, balanced or many.": "Setze noteAlternativeRange auf few, balanced oder many." + "Send noteAlternativeRange as few, balanced or many.": "Setze noteAlternativeRange auf few, balanced oder many.", + "Codex was not found on this computer. Install Codex, then select Reconnect.": "Codex wurde auf diesem Computer nicht gefunden. Installieren Sie Codex und wählen Sie dann „Neu verbinden“.", + "Codex is installed but could not be run. Reinstall Codex, then select Reconnect.": "Codex ist installiert, konnte aber nicht ausgeführt werden. Installieren Sie Codex neu und wählen Sie dann „Neu verbinden“." } diff --git a/src/web/locales/errors/es.json b/src/web/locales/errors/es.json index a3c15cfd..83f1d909 100644 --- a/src/web/locales/errors/es.json +++ b/src/web/locales/errors/es.json @@ -264,5 +264,7 @@ "The option changes source outside this note.": "La opción modifica código fuente fuera de esta anotación.", "The option has overlapping patches.": "La opción contiene cambios superpuestos.", "Invalid review action.": "Acción de revisión no válida.", - "Send noteAlternativeRange as few, balanced or many.": "Establece noteAlternativeRange en few, balanced o many." + "Send noteAlternativeRange as few, balanced or many.": "Establece noteAlternativeRange en few, balanced o many.", + "Codex was not found on this computer. Install Codex, then select Reconnect.": "No se encontró Codex en este equipo. Instala Codex y luego selecciona Reconectar.", + "Codex is installed but could not be run. Reinstall Codex, then select Reconnect.": "Codex está instalado, pero no se pudo ejecutar. Reinstala Codex y luego selecciona Reconectar." } diff --git a/src/web/locales/errors/fr.json b/src/web/locales/errors/fr.json index 269ec34f..ea494df6 100644 --- a/src/web/locales/errors/fr.json +++ b/src/web/locales/errors/fr.json @@ -264,5 +264,7 @@ "The option changes source outside this note.": "Cette option modifie du code source hors de cette annotation.", "The option has overlapping patches.": "Cette option comporte des modifications qui se chevauchent.", "Invalid review action.": "Action de révision non valide.", - "Send noteAlternativeRange as few, balanced or many.": "Définissez noteAlternativeRange sur few, balanced ou many." + "Send noteAlternativeRange as few, balanced or many.": "Définissez noteAlternativeRange sur few, balanced ou many.", + "Codex was not found on this computer. Install Codex, then select Reconnect.": "Codex est introuvable sur cet ordinateur. Installez Codex, puis sélectionnez Reconnecter.", + "Codex is installed but could not be run. Reinstall Codex, then select Reconnect.": "Codex est installé, mais n’a pas pu être exécuté. Réinstallez Codex, puis sélectionnez Reconnecter." } diff --git a/src/web/locales/errors/it.json b/src/web/locales/errors/it.json index 54ab86e8..fa7b5dba 100644 --- a/src/web/locales/errors/it.json +++ b/src/web/locales/errors/it.json @@ -264,5 +264,7 @@ "The option changes source outside this note.": "L’opzione modifica il sorgente al di fuori di questa annotazione.", "The option has overlapping patches.": "L’opzione contiene modifiche sovrapposte.", "Invalid review action.": "Azione di revisione non valida.", - "Send noteAlternativeRange as few, balanced or many.": "Imposta noteAlternativeRange su few, balanced o many." + "Send noteAlternativeRange as few, balanced or many.": "Imposta noteAlternativeRange su few, balanced o many.", + "Codex was not found on this computer. Install Codex, then select Reconnect.": "Codex non è stato trovato su questo computer. Installa Codex, quindi seleziona Riconnetti.", + "Codex is installed but could not be run. Reinstall Codex, then select Reconnect.": "Codex è installato, ma non è stato possibile avviarlo. Reinstalla Codex, quindi seleziona Riconnetti." } diff --git a/src/web/locales/errors/ja.json b/src/web/locales/errors/ja.json index 218f6ba4..ec0c6dab 100644 --- a/src/web/locales/errors/ja.json +++ b/src/web/locales/errors/ja.json @@ -264,5 +264,7 @@ "The option changes source outside this note.": "この案はこの注釈の範囲外のソースを変更します。", "The option has overlapping patches.": "この案には重複する変更が含まれています。", "Invalid review action.": "無効なレビュー操作です。", - "Send noteAlternativeRange as few, balanced or many.": "noteAlternativeRange を few、balanced、many のいずれかに設定してください。" + "Send noteAlternativeRange as few, balanced or many.": "noteAlternativeRange を few、balanced、many のいずれかに設定してください。", + "Codex was not found on this computer. Install Codex, then select Reconnect.": "このコンピュータで Codex が見つかりませんでした。Codex をインストールしてから「再接続」を選択してください。", + "Codex is installed but could not be run. Reinstall Codex, then select Reconnect.": "Codex はインストールされていますが、実行できませんでした。Codex を再インストールしてから「再接続」を選択してください。" } diff --git a/src/web/locales/errors/ko.json b/src/web/locales/errors/ko.json index d33e746b..e86b6fe4 100644 --- a/src/web/locales/errors/ko.json +++ b/src/web/locales/errors/ko.json @@ -264,5 +264,7 @@ "The option changes source outside this note.": "이 방안은 해당 주석 범위 밖의 소스를 변경합니다.", "The option has overlapping patches.": "이 방안에는 겹치는 수정이 포함되어 있습니다.", "Invalid review action.": "유효하지 않은 검토 작업입니다.", - "Send noteAlternativeRange as few, balanced or many.": "noteAlternativeRange를 few, balanced 또는 many로 설정하세요." + "Send noteAlternativeRange as few, balanced or many.": "noteAlternativeRange를 few, balanced 또는 many로 설정하세요.", + "Codex was not found on this computer. Install Codex, then select Reconnect.": "이 컴퓨터에서 Codex를 찾을 수 없습니다. Codex를 설치한 후 다시 연결을 선택하세요.", + "Codex is installed but could not be run. Reinstall Codex, then select Reconnect.": "Codex가 설치되어 있지만 실행할 수 없습니다. Codex를 다시 설치한 후 다시 연결을 선택하세요." } diff --git a/src/web/locales/errors/pt.json b/src/web/locales/errors/pt.json index 8a8f936a..d2006c24 100644 --- a/src/web/locales/errors/pt.json +++ b/src/web/locales/errors/pt.json @@ -264,5 +264,7 @@ "The option changes source outside this note.": "A opção altera código-fonte fora desta anotação.", "The option has overlapping patches.": "A opção contém alterações sobrepostas.", "Invalid review action.": "Ação de revisão inválida.", - "Send noteAlternativeRange as few, balanced or many.": "Defina noteAlternativeRange como few, balanced ou many." + "Send noteAlternativeRange as few, balanced or many.": "Defina noteAlternativeRange como few, balanced ou many.", + "Codex was not found on this computer. Install Codex, then select Reconnect.": "O Codex não foi encontrado neste computador. Instale o Codex e selecione Reconectar.", + "Codex is installed but could not be run. Reinstall Codex, then select Reconnect.": "O Codex está instalado, mas não pôde ser executado. Reinstale o Codex e selecione Reconectar." } diff --git a/src/web/locales/errors/sv.json b/src/web/locales/errors/sv.json index 1abc2919..7234d5b7 100644 --- a/src/web/locales/errors/sv.json +++ b/src/web/locales/errors/sv.json @@ -264,5 +264,7 @@ "The option changes source outside this note.": "Alternativet ändrar källtext utanför den här anteckningen.", "The option has overlapping patches.": "Alternativet innehåller överlappande ändringar.", "Invalid review action.": "Ogiltig granskningsåtgärd.", - "Send noteAlternativeRange as few, balanced or many.": "Ange noteAlternativeRange som few, balanced eller many." + "Send noteAlternativeRange as few, balanced or many.": "Ange noteAlternativeRange som few, balanced eller many.", + "Codex was not found on this computer. Install Codex, then select Reconnect.": "Codex hittades inte på den här datorn. Installera Codex och välj sedan Återanslut.", + "Codex is installed but could not be run. Reinstall Codex, then select Reconnect.": "Codex är installerat men kunde inte köras. Installera om Codex och välj sedan Återanslut." } diff --git a/src/web/locales/errors/zh-CN.json b/src/web/locales/errors/zh-CN.json index 48cdcb28..944d36b1 100644 --- a/src/web/locales/errors/zh-CN.json +++ b/src/web/locales/errors/zh-CN.json @@ -264,5 +264,7 @@ "The option changes source outside this note.": "该方案修改了本批注范围之外的源码。", "The option has overlapping patches.": "该方案包含重叠的修改。", "Invalid review action.": "无效的审阅操作。", - "Send noteAlternativeRange as few, balanced or many.": "请将 noteAlternativeRange 设为 few、balanced 或 many。" + "Send noteAlternativeRange as few, balanced or many.": "请将 noteAlternativeRange 设为 few、balanced 或 many。", + "Codex was not found on this computer. Install Codex, then select Reconnect.": "本机未找到 Codex。安装 Codex 后点“重新连接”。", + "Codex is installed but could not be run. Reinstall Codex, then select Reconnect.": "Codex 已安装,但无法运行。重新安装 Codex 后点“重新连接”。" } diff --git a/src/web/locales/errors/zh-Hant.json b/src/web/locales/errors/zh-Hant.json index cacd9070..42f8f0e5 100644 --- a/src/web/locales/errors/zh-Hant.json +++ b/src/web/locales/errors/zh-Hant.json @@ -264,5 +264,7 @@ "The option changes source outside this note.": "此方案修改了本批註範圍之外的原始碼。", "The option has overlapping patches.": "此方案包含重疊的修改。", "Invalid review action.": "無效的審閱操作。", - "Send noteAlternativeRange as few, balanced or many.": "請將 noteAlternativeRange 設為 few、balanced 或 many。" + "Send noteAlternativeRange as few, balanced or many.": "請將 noteAlternativeRange 設為 few、balanced 或 many。", + "Codex was not found on this computer. Install Codex, then select Reconnect.": "在此電腦上找不到 Codex。請先安裝 Codex,然後選取「重新連線」。", + "Codex is installed but could not be run. Reinstall Codex, then select Reconnect.": "Codex 已安裝,但無法執行。請重新安裝 Codex,然後選取「重新連線」。" } diff --git a/src/web/locales/es.json b/src/web/locales/es.json index 1f84e01d..9befb335 100644 --- a/src/web/locales/es.json +++ b/src/web/locales/es.json @@ -777,7 +777,6 @@ "Revise for reviewer comments and draft a point-by-point reply": "Revisar según los comentarios de los revisores y redactar una respuesta punto por punto", "Ruler": "Regla", "Run command": "Ejecutar comando", - "Run papilot codex login on the host. It installs a supported Codex CLI when needed.": "Ejecuta papilot codex login en el host. Instalará una CLI de Codex compatible cuando sea necesario.", "Run tool": "Ejecutar herramienta", "Running": "En ejecución", "Runt": "Línea corta", @@ -1019,7 +1018,7 @@ "Uses Kimi Code CLI and its saved login through ACP.": "Usa Kimi Code CLI y su sesión guardada mediante ACP.", "Uses OpenCode with the providers and models you have connected.": "Usa OpenCode con los proveedores y modelos que hayas conectado.", "Uses Qwen Code and its saved login through ACP.": "Usa Qwen Code y su sesión guardada mediante ACP.", - "Uses a compatible local Codex CLI. Papilot installs its own copy when needed.": "Usa una interfaz de línea de comandos local compatible de Codex CLI. Papilot instala su propia copia cuando es necesario.", + "Uses the local Codex CLI and its saved login.": "Usa la CLI de Codex local y su inicio de sesión guardado.", "Uses the Cursor SDK with CURSOR_API_KEY. Papilot installs the SDK on first use.": "Usa Cursor SDK con CURSOR_API_KEY. Papilot instala el SDK en el primer uso.", "Uses the local Antigravity CLI and its saved Google login.": "Usa Antigravity CLI local y su sesión guardada de Google.", "Verified": "Verificada", diff --git a/src/web/locales/fr.json b/src/web/locales/fr.json index 6fb3824d..75548a11 100644 --- a/src/web/locales/fr.json +++ b/src/web/locales/fr.json @@ -777,7 +777,6 @@ "Revise for reviewer comments and draft a point-by-point reply": "Réviser selon les commentaires des évaluateurs et rédiger une réponse point par point", "Ruler": "Règle", "Run command": "Exécuter la commande", - "Run papilot codex login on the host. It installs a supported Codex CLI when needed.": "Exécutez papilot codex login sur l’hôte. Cela installe une interface CLI Codex prise en charge si nécessaire.", "Run tool": "Exécuter l’outil", "Running": "En cours d’exécution", "Runt": "Ligne creuse", @@ -1019,7 +1018,7 @@ "Uses Kimi Code CLI and its saved login through ACP.": "Utilise Kimi Code CLI et sa connexion enregistrée via ACP.", "Uses OpenCode with the providers and models you have connected.": "Utilise OpenCode avec les fournisseurs et modèles que vous avez connectés.", "Uses Qwen Code and its saved login through ACP.": "Utilise Qwen Code et sa connexion enregistrée via ACP.", - "Uses a compatible local Codex CLI. Papilot installs its own copy when needed.": "Utilise un Codex CLI local compatible. Papilot installe sa propre copie si nécessaire.", + "Uses the local Codex CLI and its saved login.": "Utilise le Codex CLI local et sa connexion enregistrée.", "Uses the Cursor SDK with CURSOR_API_KEY. Papilot installs the SDK on first use.": "Utilise le SDK Cursor avec CURSOR_API_KEY. Papilot installe le SDK lors de la première utilisation.", "Uses the local Antigravity CLI and its saved Google login.": "Utilise l’outil local Antigravity CLI et sa connexion Google enregistrée.", "Verified": "Vérifié", diff --git a/src/web/locales/it.json b/src/web/locales/it.json index f833077a..7a0e7a13 100644 --- a/src/web/locales/it.json +++ b/src/web/locales/it.json @@ -777,7 +777,6 @@ "Revise for reviewer comments and draft a point-by-point reply": "Rivedi il testo in base ai commenti dei revisori e prepara una risposta punto per punto", "Ruler": "Righello", "Run command": "Esegui comando", - "Run papilot codex login on the host. It installs a supported Codex CLI when needed.": "Esegui papilot codex login sull’host. Se necessario, installa una Codex CLI supportata.", "Run tool": "Esegui strumento", "Running": "In esecuzione", "Runt": "Riga corta", @@ -1019,7 +1018,7 @@ "Uses Kimi Code CLI and its saved login through ACP.": "Usa Kimi Code CLI e le relative credenziali di accesso salvate tramite ACP.", "Uses OpenCode with the providers and models you have connected.": "Usa OpenCode con i provider e i modelli che hai collegato.", "Uses Qwen Code and its saved login through ACP.": "Usa Qwen Code e le relative credenziali di accesso salvate tramite ACP.", - "Uses a compatible local Codex CLI. Papilot installs its own copy when needed.": "Usa una Codex CLI locale compatibile. Papilot installa una propria copia quando necessario.", + "Uses the local Codex CLI and its saved login.": "Usa la Codex CLI locale e il relativo accesso salvato.", "Uses the Cursor SDK with CURSOR_API_KEY. Papilot installs the SDK on first use.": "Usa Cursor SDK con CURSOR_API_KEY. Papilot installa l'SDK al primo utilizzo.", "Uses the local Antigravity CLI and its saved Google login.": "Usa Antigravity CLI locale e l'accesso Google salvato.", "Verified": "Verificato", diff --git a/src/web/locales/ja.json b/src/web/locales/ja.json index d5e4949f..d1f22ec4 100644 --- a/src/web/locales/ja.json +++ b/src/web/locales/ja.json @@ -777,7 +777,6 @@ "Revise for reviewer comments and draft a point-by-point reply": "査読者コメントに合わせて原稿を改訂し、項目別の回答を作成します", "Ruler": "定規", "Run command": "コマンドを実行", - "Run papilot codex login on the host. It installs a supported Codex CLI when needed.": "ホスト上で papilot codex login を実行してください。必要に応じてサポートされているCodex CLIがインストールされます。", "Run tool": "ツールを実行", "Running": "実行中", "Runt": "端数行", @@ -1019,7 +1018,7 @@ "Uses Kimi Code CLI and its saved login through ACP.": "ACP 経由で Kimi Code CLI と保存されたログイン情報を使用します。", "Uses OpenCode with the providers and models you have connected.": "接続済みのプロバイダとモデルで OpenCode を使用します。", "Uses Qwen Code and its saved login through ACP.": "ACP 経由で Qwen Code と保存されたログイン情報を使用します。", - "Uses a compatible local Codex CLI. Papilot installs its own copy when needed.": "互換性のあるローカルの Codex CLI を使用します。必要に応じて Papilot が独自のコピーをインストールします。", + "Uses the local Codex CLI and its saved login.": "ローカルの Codex CLI と保存されたログイン情報を使用します。", "Uses the Cursor SDK with CURSOR_API_KEY. Papilot installs the SDK on first use.": "CURSOR_API_KEY を指定して Cursor SDK を使用します。初回使用時に Papilot が SDK をインストールします。", "Uses the local Antigravity CLI and its saved Google login.": "ローカルの Antigravity CLI と保存された Google ログイン情報を使用します。", "Verified": "確認済み", diff --git a/src/web/locales/ko.json b/src/web/locales/ko.json index b977a4aa..ecbbd933 100644 --- a/src/web/locales/ko.json +++ b/src/web/locales/ko.json @@ -777,7 +777,6 @@ "Revise for reviewer comments and draft a point-by-point reply": "심사위원 코멘트에 맞춰 수정하고 항목별 답변서 초안 작성", "Ruler": "자", "Run command": "명령어 실행", - "Run papilot codex login on the host. It installs a supported Codex CLI when needed.": "호스트에서 papilot codex login을 실행하세요. 필요한 경우 지원되는 Codex CLI를 설치합니다.", "Run tool": "도구 실행", "Running": "실행 중", "Runt": "짧은 끝줄", @@ -1019,7 +1018,7 @@ "Uses Kimi Code CLI and its saved login through ACP.": "ACP를 통해 Kimi Code CLI 및 저장된 로그인 정보를 사용합니다.", "Uses OpenCode with the providers and models you have connected.": "연결한 제공자 및 모델과 함께 OpenCode를 사용합니다.", "Uses Qwen Code and its saved login through ACP.": "ACP를 통해 Qwen Code 및 저장된 로그인 정보를 사용합니다.", - "Uses a compatible local Codex CLI. Papilot installs its own copy when needed.": "호환되는 로컬 Codex CLI를 사용합니다. 필요한 경우 Papilot이 자체 사본을 설치합니다.", + "Uses the local Codex CLI and its saved login.": "로컬 Codex CLI와 저장된 로그인 정보를 사용합니다.", "Uses the Cursor SDK with CURSOR_API_KEY. Papilot installs the SDK on first use.": "CURSOR_API_KEY와 함께 Cursor SDK를 사용합니다. 처음 사용할 때 Papilot이 SDK를 설치합니다.", "Uses the local Antigravity CLI and its saved Google login.": "로컬 Antigravity CLI 및 저장된 Google 로그인 정보를 사용합니다.", "Verified": "확인됨", diff --git a/src/web/locales/pt.json b/src/web/locales/pt.json index 7d7fc24a..382bdc39 100644 --- a/src/web/locales/pt.json +++ b/src/web/locales/pt.json @@ -777,7 +777,6 @@ "Revise for reviewer comments and draft a point-by-point reply": "Revisar com base nos comentários dos revisores e redigir uma resposta ponto a ponto", "Ruler": "Régua", "Run command": "Executar comando", - "Run papilot codex login on the host. It installs a supported Codex CLI when needed.": "Execute papilot codex login no host. Ele instala uma CLI do Codex compatível quando necessário.", "Run tool": "Executar ferramenta", "Running": "Em execução", "Runt": "Linha curta", @@ -1019,7 +1018,7 @@ "Uses Kimi Code CLI and its saved login through ACP.": "Usa o Kimi Code CLI e o login salvo via ACP.", "Uses OpenCode with the providers and models you have connected.": "Usa o OpenCode com os provedores e modelos que você conectou.", "Uses Qwen Code and its saved login through ACP.": "Usa o Qwen Code e o login salvo via ACP.", - "Uses a compatible local Codex CLI. Papilot installs its own copy when needed.": "Usa um Codex CLI local compatível. O Papilot instala sua própria cópia quando necessário.", + "Uses the local Codex CLI and its saved login.": "Usa a CLI do Codex local e o login salvo.", "Uses the Cursor SDK with CURSOR_API_KEY. Papilot installs the SDK on first use.": "Usa o Cursor SDK com CURSOR_API_KEY. O Papilot instala o SDK no primeiro uso.", "Uses the local Antigravity CLI and its saved Google login.": "Usa o Antigravity CLI local e o login do Google salvo.", "Verified": "Verificado", diff --git a/src/web/locales/sv.json b/src/web/locales/sv.json index e5c14a49..ff503423 100644 --- a/src/web/locales/sv.json +++ b/src/web/locales/sv.json @@ -777,7 +777,6 @@ "Revise for reviewer comments and draft a point-by-point reply": "Revidera utifrån granskarkommentarer och utforma ett svar punkt för punkt", "Ruler": "Linjal", "Run command": "Kör kommando", - "Run papilot codex login on the host. It installs a supported Codex CLI when needed.": "Kör papilot codex login på värddatorn. Det installerar ett Codex CLI som stöds vid behov.", "Run tool": "Kör verktyg", "Running": "Körs", "Runt": "Kort slutrad", @@ -1019,7 +1018,7 @@ "Uses Kimi Code CLI and its saved login through ACP.": "Använder Kimi Code CLI och dess sparade inloggning via ACP.", "Uses OpenCode with the providers and models you have connected.": "Använder OpenCode med de leverantörer och modeller du har anslutit.", "Uses Qwen Code and its saved login through ACP.": "Använder Qwen Code och dess sparade inloggning via ACP.", - "Uses a compatible local Codex CLI. Papilot installs its own copy when needed.": "Använder ett kompatibelt lokalt Codex CLI. Papilot installerar en egen kopia vid behov.", + "Uses the local Codex CLI and its saved login.": "Använder det lokala Codex CLI och dess sparade inloggning.", "Uses the Cursor SDK with CURSOR_API_KEY. Papilot installs the SDK on first use.": "Använder Cursor SDK med CURSOR_API_KEY. Papilot installerar SDK:t vid första användningen.", "Uses the local Antigravity CLI and its saved Google login.": "Använder det lokala Antigravity CLI och dess sparade Google-inloggning.", "Verified": "Verifierad", diff --git a/src/web/locales/zh-Hant.json b/src/web/locales/zh-Hant.json index b3b0c421..45381713 100644 --- a/src/web/locales/zh-Hant.json +++ b/src/web/locales/zh-Hant.json @@ -777,7 +777,6 @@ "Revise for reviewer comments and draft a point-by-point reply": "根據審稿人意見進行修訂並起草逐點回覆", "Ruler": "尺規", "Run command": "執行指令", - "Run papilot codex login on the host. It installs a supported Codex CLI when needed.": "在主機上執行 papilot codex login。它會在需要時安裝受支援的 Codex CLI。", "Run tool": "執行工具", "Running": "執行中", "Runt": "孤字尾行", @@ -1019,7 +1018,7 @@ "Uses Kimi Code CLI and its saved login through ACP.": "透過 ACP 使用 Kimi Code CLI 及其儲存的登入資訊。", "Uses OpenCode with the providers and models you have connected.": "使用 OpenCode 以及您已連結的供應商與模型。", "Uses Qwen Code and its saved login through ACP.": "透過 ACP 使用 Qwen Code 及其儲存的登入資訊。", - "Uses a compatible local Codex CLI. Papilot installs its own copy when needed.": "使用相容的本機 Codex CLI。Papilot 會在需要時自行安裝副本。", + "Uses the local Codex CLI and its saved login.": "使用本機的 Codex CLI 及其已儲存的登入資訊。", "Uses the Cursor SDK with CURSOR_API_KEY. Papilot installs the SDK on first use.": "搭配 CURSOR_API_KEY 使用 Cursor SDK。Papilot 會在首次使用時安裝 SDK。", "Uses the local Antigravity CLI and its saved Google login.": "使用本機 Antigravity CLI 及其儲存的 Google 登入資訊。", "Verified": "已驗證", diff --git a/src/web/runtime-agents.tsx b/src/web/runtime-agents.tsx index 1447c06b..225a9ce1 100644 --- a/src/web/runtime-agents.tsx +++ b/src/web/runtime-agents.tsx @@ -12,7 +12,7 @@ import { RuntimeCardGrid } from './runtime-card-grid.tsx'; function connectionInfo(id: AgentId) { const descriptions = { - codex: t('Uses a compatible local Codex CLI. Papilot installs its own copy when needed.'), + codex: t('Uses the local Codex CLI and its saved login.'), claude: t('Uses Claude Code when installed, or the Claude Agent SDK with API credentials.'), cursor: t('Uses the Cursor SDK with CURSOR_API_KEY. Papilot installs the SDK on first use.'), grok: t('Uses Grok Build CLI through ACP with its saved login or configured API credentials.'), @@ -24,7 +24,7 @@ function connectionInfo(id: AgentId) { qwen: t('Uses Qwen Code and its saved login through ACP.'), opencode: t('Uses OpenCode with the providers and models you have connected.'), }; - const commands = { codex: 'papilot codex login', claude: 'claude auth login', cursor: 'CURSOR_API_KEY', copilot: 'copilot login', grok: 'grok login', antigravity: 'agy', kimi: 'kimi login', glm: 'ZHIPU_API_KEY / ZAI_API_KEY', deepseek: 'dsh web', qwen: 'qwen', opencode: 'opencode auth login' }; + const commands = { codex: 'codex login', claude: 'claude auth login', cursor: 'CURSOR_API_KEY', copilot: 'copilot login', grok: 'grok login', antigravity: 'agy', kimi: 'kimi login', glm: 'ZHIPU_API_KEY / ZAI_API_KEY', deepseek: 'dsh web', qwen: 'qwen', opencode: 'opencode auth login' }; return { description: descriptions[id], command: commands[id], key: id === 'cursor' || id === 'glm' }; } diff --git a/src/web/zh-CN.ts b/src/web/zh-CN.ts index 1854b4e7..ecb91eca 100644 --- a/src/web/zh-CN.ts +++ b/src/web/zh-CN.ts @@ -431,7 +431,6 @@ export const zhCN: Record = { 'Set ZHIPU_API_KEY for open.bigmodel.cn, or ZAI_API_KEY for api.z.ai, in the environment that starts Papilot. Then restart Papilot.': '在启动 Papilot 的环境中设置 ZHIPU_API_KEY(open.bigmodel.cn)或 ZAI_API_KEY(api.z.ai),然后重启 Papilot。', 'The last connection attempt found neither Claude Code nor supported API credentials in the Papilot server environment.': '上次连接时,Papilot 服务环境中既未找到 Claude Code,也未找到支持的 API 凭据。', 'The last connection attempt could not find a runnable {command} CLI in the Papilot server environment. It may still be installed in another location or for another user.': '上次连接时,Papilot 服务环境中未找到可运行的 {command} CLI。它可能已安装在其他位置或另一个用户下。', - 'Run papilot codex login on the host. It installs a supported Codex CLI when needed.': '在主机上运行 papilot codex login。需要时,它会安装支持的 Codex CLI。', 'Install {command} on the host running Papilot, then complete its login or provider setup below.': '在运行 Papilot 的主机上安装 {command},然后按下方说明完成登录或模型服务商配置。', 'If it is already installed, set {variable} to its executable path in the environment that starts Papilot, then restart Papilot.': '如果已经安装,请在启动 Papilot 的环境中将 {variable} 设置为其可执行文件路径,然后重启 Papilot。', 'For the SDK instead, start Papilot with ANTHROPIC_API_KEY or a supported cloud provider configured. Restart Papilot after changing its environment.': '若使用 SDK,请配置 ANTHROPIC_API_KEY 或支持的云服务商后启动 Papilot。更改环境后需要重启 Papilot。', @@ -450,7 +449,7 @@ export const zhCN: Record = { 'Installation & setup': '安装与配置', 'Usage & limits': '用量与额度', 'Installation check': '安装检测', 'Environment variable': '环境变量', 'Set up on the host': '在主机上配置', 'Setup guide': '配置指南', 'No installation details were returned. Run the checks again to refresh.': '尚无安装检测结果,可重新检查以刷新。', - 'Uses a compatible local Codex CLI. Papilot installs its own copy when needed.': '使用本机兼容的 Codex CLI;需要时,Papilot 会自动安装。', + 'Uses the local Codex CLI and its saved login.': '使用本机的 Codex CLI 及其已保存的登录。', 'Uses Claude Code when installed, or the Claude Agent SDK with API credentials.': '优先使用已安装的 Claude Code,也可通过 API 凭据使用 Claude Agent SDK。', 'Uses the Cursor SDK with CURSOR_API_KEY. Papilot installs the SDK on first use.': '通过 CURSOR_API_KEY 使用 Cursor SDK,首次使用时由 Papilot 安装 SDK。', 'Uses the local Antigravity CLI and its saved Google login.': '使用本机 Antigravity CLI 及其保存的 Google 登录。', diff --git a/tests/codex-binary.test.ts b/tests/codex-binary.test.ts new file mode 100644 index 00000000..8ba1287c --- /dev/null +++ b/tests/codex-binary.test.ts @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { codexBinary, codexCandidates } from '../src/server/codex-binary.ts'; +import { Codex } from '../src/server/codex.ts'; + +// A stand-in Codex that only reports its version. +function fakeCodex(dir: string, version: string) { + fs.mkdirSync(dir, { recursive: true }); + if (process.platform === 'win32') { fs.writeFileSync(path.join(dir, 'codex.cmd'), `@echo codex-cli ${version}\r\n`); return path.join(dir, 'codex.cmd'); } + fs.writeFileSync(path.join(dir, 'codex'), `#!/bin/sh\necho "codex-cli ${version}"\n`, { mode: 0o755 }); + return path.join(dir, 'codex'); +} + +test('Papilot uses the first Codex on this computer that runs', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'papilot-codex-')), data = path.join(root, 'data'); + // Only the given folder: the host's own Codex must not answer for the test. + const env = (dir: string): NodeJS.ProcessEnv => ({ PATH: dir, Path: dir, HOME: root, USERPROFILE: root, SystemRoot: process.env.SystemRoot, ComSpec: process.env.ComSpec, PATHEXT: process.env.PATHEXT }); + try { + const none = path.join(root, 'empty'); fs.mkdirSync(none); + // Standard install folders are searched too; a host with Codex there skips this part. + if (!codexCandidates(env(none), data).length) { + assert.equal(new Codex(undefined, undefined, env(none)).detect(), false, 'a missing Codex is not offered'); + await assert.rejects(codexBinary(env(none), data), /not found/); + } + assert.equal(await codexBinary({ ...env(none), PAPILOT_CODEX_BIN: '/configured/codex' }, data), '/configured/codex'); + + const recent = fakeCodex(path.join(root, 'recent'), '0.170.0'); + assert.equal(await codexBinary(env(path.dirname(recent)), data), recent); + + const old = fakeCodex(path.join(root, 'old'), '0.1.0'); + assert.equal(await codexBinary(env(path.dirname(old)), data), old, 'any version that runs is used'); + const broken = path.join(root, 'broken'); fs.mkdirSync(broken); + if (process.platform === 'win32') fs.writeFileSync(path.join(broken, 'codex.cmd'), '@exit /b 1\r\n'); + else fs.writeFileSync(path.join(broken, 'codex'), '#!/bin/sh\nexit 1\n', { mode: 0o755 }); + assert.equal(new Codex(undefined, undefined, env(broken)).detect(), true, 'a broken Codex is offered, to say why it cannot be used'); + await assert.rejects(codexBinary(env(broken), path.join(root, 'none')), /could not be run\. Reinstall Codex/); + const both = [broken, path.dirname(recent)].join(path.delimiter); + assert.equal(await codexBinary(env(both), data), recent, 'a working Codex later on PATH is used'); + const own = fakeCodex(path.join(data, 'agents/node_modules/.bin'), '0.164.0'); + assert.equal(await codexBinary(env(broken), data), own, 'the copy papilot codex login installed still counts'); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); diff --git a/tests/desktop-layer.test.ts b/tests/desktop-layer.test.ts index f3afb5f2..cf0f063f 100644 --- a/tests/desktop-layer.test.ts +++ b/tests/desktop-layer.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { desktopVersion, INSTALLER_SUFFIXES, reusableInstallers, shellFingerprint } from '../scripts/desktop-layer.mjs'; +import { dedupeEngine, desktopVersion, INSTALLER_SUFFIXES, reusableInstallers, shellFingerprint } from '../scripts/desktop-layer.mjs'; const write = (file: string, value: unknown) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, typeof value === 'string' ? value : JSON.stringify(value)); }; function checkout() { @@ -28,6 +28,7 @@ test('the shell fingerprint changes exactly when an update would need a new inst same(() => write(path.join(root, 'desktop/README.md'), 'changed docs'), 'Documentation is not part of the shell'); const lock = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf8')); same(() => { lock.packages['node_modules/typescript'].version = '7.1.0'; write(path.join(root, 'package-lock.json'), lock); }, 'Development dependencies are not installed'); + same(() => { const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); pkg.devDependencies['@openai/codex'] = '0.165.0'; write(path.join(root, 'package.json'), pkg); }, 'Codex is a host tool, not part of the shell'); for (const [message, change] of [ ['a new Node major', () => {}], ['a production dependency', () => { lock.packages['node_modules/fastify'].version = '5.1.0'; write(path.join(root, 'package-lock.json'), lock); }], @@ -35,7 +36,6 @@ test('the shell fingerprint changes exactly when an update would need a new inst ['the Electron app', () => write(path.join(root, 'desktop/main.ts'), 'changed main')], ['a helper copied into Electron', () => write(path.join(root, 'engine/src/core/platform.mjs'), 'updated process handling')], ['how the installer is staged', () => write(path.join(root, 'scripts/desktop.mjs'), 'changed stage')], - ['Codex', () => { const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); pkg.devDependencies['@openai/codex'] = '0.165.0'; write(path.join(root, 'package.json'), pkg); }], ] as const) { const before = shellFingerprint(root, '24.11.0'); change(); @@ -61,3 +61,24 @@ test('a dev build reuses the published installers only for the same shell', () = assert.equal(reusableInstallers(previous, assets.slice(0, -1), fingerprint), null, 'An installer is missing from the release'); assert.equal(reusableInstallers({ ...previous, installers: installers.slice(1) }, assets, fingerprint), null, 'Every platform needs an installer'); }); + +test('the engine keeps only dependencies the server does not already provide', () => { + const server = fs.mkdtempSync(path.join(os.tmpdir(), 'papilot-dedupe-')); + const pkg = (folder: string, name: string, version: string) => write(path.join(server, folder, 'node_modules', name, 'package.json'), { name, version }); + try { + pkg('.', 'mupdf', '1.28.1'); pkg('.', '@fastify/static', '10.1.9'); pkg('.', 'react', '19.3.1'); + pkg('engine', 'mupdf', '1.28.1'); pkg('engine', '@fastify/static', '10.1.9'); pkg('engine', 'react', '19.2.0'); pkg('engine', 'only-engine', '1.0.0'); + write(path.join(server, 'engine/node_modules/mupdf/dist/mupdf.wasm'), 'x'.repeat(1000)); + const bin = path.join(server, 'engine/node_modules/.bin'); + fs.mkdirSync(bin, { recursive: true }); + fs.symlinkSync('../mupdf/cli.js', path.join(bin, 'mupdf')); + fs.symlinkSync('../only-engine/cli.js', path.join(bin, 'only-engine')); + write(path.join(bin, 'static.cmd'), '@"%dp0%\\..\\@fastify\\static\\cli.js" %*'); + write(path.join(server, 'engine/node_modules/only-engine/cli.js'), ''); + assert.ok(dedupeEngine(server) >= 1000, 'reports what it removed'); + const left = (name: string) => fs.existsSync(path.join(server, 'engine/node_modules', name)); + assert.ok(!left('mupdf') && !left('@fastify'), 'identical versions resolve from the server'); + assert.ok(left('react') && left('only-engine'), 'other versions and engine-only packages stay'); + assert.deepEqual(fs.readdirSync(bin), ['only-engine'], 'executables of removed packages are gone'); + } finally { fs.rmSync(server, { recursive: true, force: true }); } +}); diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index 1ca7c02f..e1f6bee3 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -157,6 +157,12 @@ test('doctor uses the supported managed Codex and never leaks login output or kn assert.equal(item(unavailable.report, 'agent.codex').ok, false); assert.match(item(unavailable.report, 'agent.codex').fix!, /papilot codex login/); assert.doesNotMatch(unavailable.stdout + unavailable.stderr, /top-secret-login/); + // The desktop app uses any installed Codex that runs, whatever its version. + fs.rmSync(managed, { recursive: true, force: true }); + const desktop = item((await f.run(['--json'], { PAPILOT_DESKTOP: '1' })).report, 'agent.codex'); + assert.match(desktop.detail, /^Codex 0\.1\.0 /, 'an older version is not refused'); + assert.match(desktop.fix!, /^Run codex login/); + assert.doesNotMatch(desktop.fix!, /papilot/); } finally { f.cleanup(); } }); From 4e15e5ab64d716091d0911deb26b827a07c9491e Mon Sep 17 00:00:00 2001 From: Cui Chen Date: Mon, 9 Nov 2026 18:00:49 +0800 Subject: [PATCH 3/9] Offer only found agents and tighten the Codex lookup - Without Codex on a desktop host, the agent menu offered it anyway and new tasks went to it; only found agents are offered now, falling back to the first one. - Closing Codex while it looks for an installation no longer starts an app-server afterwards. - The --version probe uses the PATH Codex starts with, so a Node-wrapped codex found in a standard folder is not called broken. - A source checkout's development Codex is found again. - Agent errors in the connection notice and Agents card are translated. - The setup screen and desktop README no longer say Codex is included; Codex's sign-in hint is codex login in the desktop app. - doctor waits as long as the server for codex --version. - Windows npm shims of scoped packages that stay are kept when packaging. - Codex lookup tests no longer depend on the Codex installed on the host. --- bin/doctor.mjs | 3 +- desktop/README.md | 4 +-- desktop/environment.html | 2 +- scripts/desktop-layer.mjs | 2 +- src/server/codex-binary.ts | 27 ++++++++------ src/server/codex.ts | 7 +++- src/web/agent-choice.ts | 12 +++++++ src/web/agent-picker.tsx | 4 +-- src/web/main.tsx | 6 ++-- src/web/runtime-agents.tsx | 6 ++-- tests/agent-choice.test.ts | 17 +++++++++ tests/codex-binary.test.ts | 72 ++++++++++++++++++++++--------------- tests/desktop-layer.test.ts | 6 ++-- 13 files changed, 113 insertions(+), 55 deletions(-) create mode 100644 src/web/agent-choice.ts create mode 100644 tests/agent-choice.test.ts diff --git a/bin/doctor.mjs b/bin/doctor.mjs index 5efa69c7..fe9d9981 100644 --- a/bin/doctor.mjs +++ b/bin/doctor.mjs @@ -141,7 +141,8 @@ export async function doctor(args) { for (const candidate of candidates) { const file = agentExecutable(candidate); if (!file) continue; - const result = await command(file, ['--version']); + // As long as the server waits, so a slow start on a busy machine counts as running. + const result = await command(file, ['--version'], { timeout: 15000 }); const version = result.stdout.match(/\d+\.\d+\.\d+/)?.[0]; found.push(file); if (!result.ok || (minimum && (!version || !atLeast(version, minimum)))) { diff --git a/desktop/README.md b/desktop/README.md index 64a6f76c..59c7d39d 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -12,7 +12,7 @@ npm run desktop # build the UI and open a development window npm run desktop:build # build installers for this OS and CPU ``` -Artifacts are in `release/desktop/`. macOS produces DMG and ZIP files: Apple Silicon uses `mac-arm64/Papilot.app`; Intel uses `mac/Papilot.app`. Linux produces AppImage and deb files; the unpacked binary is `linux-unpacked/papilot` on x64 or `linux-arm64-unpacked/papilot` on ARM64. Windows produces an NSIS installer and a portable executable; the unpacked binary is `win-unpacked/Papilot.exe`. Build on each native architecture: the bundled Node, Codex and dependency binaries must match it. `npm run desktop:build -- --dir` builds only the app directory. +Artifacts are in `release/desktop/`. macOS produces DMG and ZIP files: Apple Silicon uses `mac-arm64/Papilot.app`; Intel uses `mac/Papilot.app`. Linux produces AppImage and deb files; the unpacked binary is `linux-unpacked/papilot` on x64 or `linux-arm64-unpacked/papilot` on ARM64. Windows produces an NSIS installer and a portable executable; the unpacked binary is `win-unpacked/Papilot.exe`. Build on each native architecture: the bundled Node and dependency binaries must match it. `npm run desktop:build -- --dir` builds only the app directory. Linux release CI builds on Ubuntu 22.04 x64. Other Linux versions and desktop environments need their own compatibility checks. The AppImage is a portable executable; the deb installs Papilot in the application menu and declares Git and desktop library dependencies. Install Git separately when using AppImage, and TeX Live to compile manuscript edits. The included demo PDF can be read without TeX. Windows builds produce a per-user NSIS installer (`Papilot-VERSION-win-x64.exe`), a portable executable (`Papilot-VERSION-win-x64-portable.exe`) and `win-unpacked/Papilot.exe`. Windows x64 is included in the native desktop CI matrix. See [Windows setup](../docs/windows.md) for prerequisites, PowerShell commands and network setup. Windows support must pass the native CI checks before publishing its installer. @@ -39,7 +39,7 @@ The smoke test opens the real app with temporary data, exercises the local API a - One app instance owns the server. Closing the window on macOS hides it and keeps tasks and notifications running; Quit stops the backend. Closing the Linux or Windows window also quits the app and stops its backend. A crashed Electron parent disconnects its backend. - The backend listens only on `127.0.0.1`, remembers its assigned port to preserve local drafts/preferences, and picks another port if the remembered one is occupied. That exceptional port change starts a new browser origin; database state remains intact. - The app finds common agent and MacTeX/Homebrew locations even when opened from Finder. It uses existing agent sign-ins. An explicit proxy environment takes precedence; otherwise it passes the system HTTP proxy to backend tools. -- Updates download only Papilot's own code when they can. An installer is a shell (Electron, the bundled Node, Codex and the production dependencies) around Papilot's code, and the shell's fingerprint (`scripts/desktop-layer.mjs`) covers everything except that code: the Node major version, Electron, electron-builder and Codex versions, the production entries of both lockfiles, and the files in `desktop/` and `scripts/desktop.mjs`. Every full desktop build also packs Papilot's code as an update layer, `papilot-app-VERSION.tgz` (about 5 MB, versus about 330 MB for an installer), with a `desktop.json` manifest naming its version, fingerprint, size and SHA-256. +- Updates download only Papilot's own code when they can. An installer is a shell (Electron, the bundled Node and the production dependencies) around Papilot's code, and the shell's fingerprint (`scripts/desktop-layer.mjs`) covers everything except that code: the Node major version, Electron and electron-builder versions, the production entries of both lockfiles, and the files in `desktop/` and `scripts/desktop.mjs`. Every full desktop build also packs Papilot's code as an update layer, `papilot-app-VERSION.tgz` (about 5 MB, versus about 190 MB for an installer), with a `desktop.json` manifest naming its version, fingerprint, size and SHA-256. - Settings → Updates has the same **Stable** and **Dev (GitHub main)** channels as the web server. Stable reads `desktop.json` from the latest release (`releases/latest/download/desktop.json`), Dev from the rolling `desktop-dev` prerelease, which a nightly build of main updates. When the newest version's layer has this installer's fingerprint, **Update now** downloads it into `updates/desktop/layers`, verifies its size and hash, checks that it starts, and switches once tasks and PDF builds are idle; the page reconnects by itself. The layer runs on the installer's Node and dependencies through `node_modules` links, renewed at each launch because an AppImage mounts at a new path. Otherwise the update links to that release's installers. Returning to the installer's own version (for example from Dev back to Stable) needs no download. - Electron owns the switch (`desktop/runtime.ts`): it backs up the database, starts the new code on the same port, and restores the database and the previous version if it does not start. A layer that later fails at launch falls back to the installer's own server with a notice. Installing another app version retires downloaded layers. The development app (`npm run desktop`) always runs its checkout and has no channel. - Desktop update state lives in `updates/desktop/` (status, channel, active layer, lock and log). Legacy `updates/active.json` records and npm packages in `updates/releases` are ignored and left intact; web/CLI updates keep their own status, lock and stable/dev preference. diff --git a/desktop/environment.html b/desktop/environment.html index f7d3a7d3..4d528a5b 100644 --- a/desktop/environment.html +++ b/desktop/environment.html @@ -13,7 +13,7 @@
- +

diff --git a/scripts/desktop-layer.mjs b/scripts/desktop-layer.mjs index cd82d98e..b45aaf4f 100644 --- a/scripts/desktop-layer.mjs +++ b/scripts/desktop-layer.mjs @@ -65,7 +65,7 @@ export function dedupeEngine(server) { if (fs.existsSync(bin)) for (const name of fs.readdirSync(bin)) { const file = path.join(bin, name); const targets = fs.lstatSync(file).isSymbolicLink() ? [path.resolve(bin, fs.readlinkSync(file))] - : [...fs.readFileSync(file, 'utf8').matchAll(/\.\.[\\/]((?:@[^\\/"']+[\\/])?[^\\/"']+)[\\/]/g)].map(match => path.join(inner, match[1])); + : [...fs.readFileSync(file, 'utf8').matchAll(/\.\.[\\/]((?:@[^\\/"']+[\\/])?[^\\/"']+)[\\/]/g)].map(match => path.join(inner, ...match[1].split(/[\\/]/))); if (targets.some(target => !fs.existsSync(target))) fs.rmSync(file, { force: true }); } return bytes; diff --git a/src/server/codex-binary.ts b/src/server/codex-binary.ts index 2049a266..68cdb5cb 100644 --- a/src/server/codex-binary.ts +++ b/src/server/codex-binary.ts @@ -1,26 +1,33 @@ import path from 'node:path'; -import { execCommand, findExecutables } from '../../bin/agent-paths.mjs'; -import { DATA } from './paths.ts'; +import { agentPath, execCommand, findExecutables } from '../../bin/agent-paths.mjs'; +import { DATA, ROOT } from './paths.ts'; // Papilot uses the Codex installed on this computer, like every other agent. // The desktop installer does not carry Codex: it was the largest part of one, // and every Codex update meant downloading the whole app again. // Every codex on PATH, in order (an early one may be a broken installation -// while a later one works), then the copy the npm command installs into the -// data folder (`papilot codex login`). -export function codexCandidates(env: NodeJS.ProcessEnv = process.env, data = DATA) { - return env.PAPILOT_CODEX_BIN ? [env.PAPILOT_CODEX_BIN] : findExecutables('codex', env, [path.join(data, 'agents', 'node_modules', '.bin')]); +// while a later one works), then a source checkout's own development copy and +// the copy the npm command installs into the data folder (`papilot codex login`). +export function codexCandidates(env: NodeJS.ProcessEnv = process.env, data = DATA, root = ROOT) { + return env.PAPILOT_CODEX_BIN ? [env.PAPILOT_CODEX_BIN] : findExecutables('codex', env, [path.join(root, 'node_modules', '.bin'), path.join(data, 'agents', 'node_modules', '.bin')]); +} + +/** The first candidate that runs, tried with the PATH Codex is started with. */ +export async function firstRunnable(candidates: string[], env: NodeJS.ProcessEnv = process.env) { + const probe = { ...env, PATH: agentPath(env) }; + for (const file of candidates) { + try { await execCommand(file, ['--version'], { env: probe, timeout: 15000 }); return file; } catch { /* a broken installation; try the next */ } + } + return null; } /** The first Codex that runs. */ export async function codexBinary(env: NodeJS.ProcessEnv = process.env, data = DATA): Promise { // The npm command has already chosen its Codex. if (env.PAPILOT_CODEX_BIN) return env.PAPILOT_CODEX_BIN; - const candidates = codexCandidates(env, data); - for (const file of candidates) { - try { await execCommand(file, ['--version'], { env, timeout: 15000 }); return file; } catch { /* a broken installation; try the next */ } - } + const candidates = codexCandidates(env, data), file = await firstRunnable(candidates, env); + if (file) return file; if (!candidates.length) throw new Error('Codex was not found on this computer. Install Codex, then select Reconnect.'); console.error(`No installed Codex could be run: ${candidates.join(', ')}.`); throw new Error('Codex is installed but could not be run. Reinstall Codex, then select Reconnect.'); diff --git a/src/server/codex.ts b/src/server/codex.ts index c6d9732b..37d840b9 100644 --- a/src/server/codex.ts +++ b/src/server/codex.ts @@ -51,9 +51,14 @@ export class Codex extends EventEmitter { this.starting = this.launch().finally(() => { this.starting = null; }); return this.starting; } + // close() during the lookup must not be followed by a new app-server. + private generation = 0; private async launch() { this.stderr = ''; + const generation = this.generation; const binary = this.binary || await codexBinary(this.env); + // Stopped meanwhile: start nothing; requests then report Codex as not connected. + if (generation !== this.generation) return; const child = spawnCommand(binary, this.args, { cwd: ROOT, env: { ...this.env, PATH: agentPath(this.env) }, stdio: ['pipe', 'pipe', 'pipe'] }); this.child = child; const lines = createInterface({ input: child.stdout! }); @@ -93,5 +98,5 @@ export class Codex extends EventEmitter { notify(method: string, params: unknown) { this.child?.stdin?.write(JSON.stringify({ method, params }) + '\n'); } respond(id: string | number, result: unknown) { if (!this.child) throw new Error('Codex disconnected. Resume the task before retrying.'); this.child.stdin!.write(JSON.stringify({ id, result }) + '\n'); } reject(id: string | number, message: string) { this.child?.stdin?.write(JSON.stringify({ id, error: { code: -32601, message } }) + '\n'); } - close() { this.child?.stdin?.end(); this.child?.kill('SIGTERM'); } + close() { this.generation++; this.child?.stdin?.end(); this.child?.kill('SIGTERM'); } } diff --git a/src/web/agent-choice.ts b/src/web/agent-choice.ts new file mode 100644 index 00000000..1f69ca61 --- /dev/null +++ b/src/web/agent-choice.ts @@ -0,0 +1,12 @@ +import { AGENT_IDS, type AgentId, type RuntimeStatus } from '../shared.ts'; + +// Only agents found on the host are offered (the desktop app does not include +// Codex). Codex is offered until the host says it is missing. +export function offeredAgents(runtimes: Partial>) { + return AGENT_IDS.filter(id => id === 'codex' ? runtimes.codex?.installed !== false : Boolean(runtimes[id] && runtimes[id]!.installed !== false)); +} +// A remembered agent that is not found falls back to the first one offered, +// Codex when it is there, without forgetting the choice. +export function chosenAgent(saved: AgentId, offered: AgentId[]): AgentId { + return offered.includes(saved) ? saved : offered[0] || 'codex'; +} diff --git a/src/web/agent-picker.tsx b/src/web/agent-picker.tsx index d13192cd..10a05135 100644 --- a/src/web/agent-picker.tsx +++ b/src/web/agent-picker.tsx @@ -2,7 +2,7 @@ import { type ReactNode, useEffect, useId, useLayoutEffect, useRef, useState } f import type { AgentId, RuntimeStatus, TaskPermissions } from '../shared.ts'; import { Icon } from './icons.tsx'; import { BrandIcon } from './brand-icon.tsx'; -import { t } from './i18n.ts'; +import { errorText, t } from './i18n.ts'; import { browserStorage } from './storage.ts'; import { EffortSlider, effortName } from './effort-slider.tsx'; import { permissionText } from './task-permissions.tsx'; @@ -14,7 +14,7 @@ export function ModelLabel({ model, effort, fast }: { model: string; effort?: st export function AgentConnectionNotice({ name, runtime, reconnecting, onReconnect }: { name: string; runtime?: RuntimeStatus; reconnecting: boolean; onReconnect: () => void }) { if (!runtime || (runtime.connected && runtime.authenticated)) return null; - const error = runtime.error || '', first = error.split(/:\s|\.\s/)[0]; + const error = runtime.error ? errorText(runtime.error) : '', first = error.split(/:\s|\.\s/)[0]; const connecting = runtime.installed !== false && !error && !runtime.connected; // An agent that was not found is not connecting: say so until it is installed and Reconnect finds it. const message = runtime.installed === false ? t('{agent} was not found on this computer. Install or set it up, then select Reconnect.', { agent: name }) diff --git a/src/web/main.tsx b/src/web/main.tsx index dfc196cc..d5454857 100644 --- a/src/web/main.tsx +++ b/src/web/main.tsx @@ -1,4 +1,5 @@ import { requestApi, requestState } from './api.ts'; +import { chosenAgent, offeredAgents } from './agent-choice.ts'; import { isPlayground, pdfAsset, wordAsset, requestPlaygroundDownload } from './playground-mode.ts'; import { canonicalGroup, groupsWithoutInstructions, noteGroups, type NoteGroup } from '../note-groups.ts'; import { GroupCard, GroupInstruction } from './note-groups.tsx'; @@ -1355,10 +1356,7 @@ function App() { // A remembered choice the host no longer offers falls back to its default; // the preference is kept in case another model offers it again. const runtimes: Partial> = state?.runtimes || (state ? { codex: state.runtime } : {}); - // Only agents found on the host are offered. A remembered agent that is not - // found falls back to Codex without forgetting the choice. - const offered = AGENT_IDS.filter(id => id === 'codex' || Boolean(runtimes[id] && runtimes[id]!.installed !== false)); - const agentChoice = offered.includes(savedAgent) ? savedAgent : 'codex'; + const offered = offeredAgents(runtimes), agentChoice = chosenAgent(savedAgent, offered); const effectivePermissions = runtimes[agentChoice]?.permissionOptions?.some(option => option.value === newPermissions) ? newPermissions : 'workspace'; const models = runtimes[agentChoice]?.models || [], pref = agentPrefs[agentChoice]; const chosenModel = models.length && !models.some(m => m.id === pref.model) ? '' : pref.model; diff --git a/src/web/runtime-agents.tsx b/src/web/runtime-agents.tsx index 225a9ce1..df72f4af 100644 --- a/src/web/runtime-agents.tsx +++ b/src/web/runtime-agents.tsx @@ -3,7 +3,7 @@ import { AGENT_IDS, AGENT_NAMES, type AgentId, type DiagnosticReport, type Runti import info from 'virtual:papilot-info'; import { BrandIcon } from './brand-icon.tsx'; import { Icon } from './icons.tsx'; -import { t } from './i18n.ts'; +import { errorText, t } from './i18n.ts'; import { UsageSummary } from './usage.tsx'; import { agentSetup } from './agent-setup.ts'; import { RuntimeModels } from './runtime-models.tsx'; @@ -24,7 +24,7 @@ function connectionInfo(id: AgentId) { qwen: t('Uses Qwen Code and its saved login through ACP.'), opencode: t('Uses OpenCode with the providers and models you have connected.'), }; - const commands = { codex: 'codex login', claude: 'claude auth login', cursor: 'CURSOR_API_KEY', copilot: 'copilot login', grok: 'grok login', antigravity: 'agy', kimi: 'kimi login', glm: 'ZHIPU_API_KEY / ZAI_API_KEY', deepseek: 'dsh web', qwen: 'qwen', opencode: 'opencode auth login' }; + const commands = { codex: window.papilotDesktop ? 'codex login' : 'papilot codex login', claude: 'claude auth login', cursor: 'CURSOR_API_KEY', copilot: 'copilot login', grok: 'grok login', antigravity: 'agy', kimi: 'kimi login', glm: 'ZHIPU_API_KEY / ZAI_API_KEY', deepseek: 'dsh web', qwen: 'qwen', opencode: 'opencode auth login' }; return { description: descriptions[id], command: commands[id], key: id === 'cursor' || id === 'glm' }; } @@ -106,7 +106,7 @@ export function RuntimeAgents({ online, runtimes, report, busy, checkFailed, che

{setup.description}

{blocked && guidance ? <>

{state === 'missing' ? guidance.reason : state === 'signin' ? t('Papilot reached this agent, but could not confirm a signed-in account on this host.') : t('Papilot could not establish a working connection to this agent. The latest connection error is shown below.')}

- {runtime?.error &&

{runtime.error}

} + {runtime?.error &&

{errorText(runtime.error)}

} {state === 'missing' && checked?.ok ?

{t('If the live status still disagrees with the check, restart Papilot under the same user and environment where you configured the agent.')}

: state === 'signin' ?

{t('Sign in as the user running Papilot, then restart Papilot.')}

: state === 'unavailable' ?

{t('Check the connection error above and the agent’s login, provider, and network settings on this host. Restart Papilot after changing credentials or executable paths.')}

:
    {guidance.steps.map(step =>
  1. {step}
  2. )}
}

{t('Already set up?')} {guidance.existing}

: <>{setup.key ? t('Environment variable') : t('Set up on the host')}{setup.command}} diff --git a/tests/agent-choice.test.ts b/tests/agent-choice.test.ts new file mode 100644 index 00000000..83f5ce47 --- /dev/null +++ b/tests/agent-choice.test.ts @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { chosenAgent, offeredAgents } from '../src/web/agent-choice.ts'; +import type { RuntimeStatus } from '../src/shared.ts'; + +const runtime = (installed?: boolean): RuntimeStatus => ({ connected: false, authenticated: false, account: null, error: null, models: [], ...(installed === undefined ? {} : { installed }) }); + +test('a desktop host without Codex offers and picks the agents it has', () => { + assert.deepEqual(offeredAgents({}), ['codex'], 'Codex is offered until the host reports on it'); + assert.deepEqual(offeredAgents({ codex: runtime(), claude: runtime(false) }), ['codex']); + const noCodex = offeredAgents({ codex: runtime(false), claude: runtime(true), kimi: runtime(false) }); + assert.deepEqual(noCodex, ['claude']); + assert.equal(chosenAgent('codex', noCodex), 'claude', 'a first task does not go to a missing Codex'); + assert.equal(chosenAgent('kimi', offeredAgents({ codex: runtime(true), kimi: runtime(false) })), 'codex'); + assert.equal(chosenAgent('claude', ['codex', 'claude']), 'claude'); + assert.equal(chosenAgent('claude', []), 'codex', 'with no agent at all, Codex explains what is missing'); +}); diff --git a/tests/codex-binary.test.ts b/tests/codex-binary.test.ts index 8ba1287c..300dd25a 100644 --- a/tests/codex-binary.test.ts +++ b/tests/codex-binary.test.ts @@ -3,43 +3,59 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { codexBinary, codexCandidates } from '../src/server/codex-binary.ts'; +import { codexBinary, codexCandidates, firstRunnable } from '../src/server/codex-binary.ts'; import { Codex } from '../src/server/codex.ts'; -// A stand-in Codex that only reports its version. -function fakeCodex(dir: string, version: string) { +const POSIX = process.platform !== 'win32'; +// A stand-in Codex that reports a version, or fails like a broken installation. +function fakeCodex(dir: string, version: string | null, delay = 0) { fs.mkdirSync(dir, { recursive: true }); - if (process.platform === 'win32') { fs.writeFileSync(path.join(dir, 'codex.cmd'), `@echo codex-cli ${version}\r\n`); return path.join(dir, 'codex.cmd'); } - fs.writeFileSync(path.join(dir, 'codex'), `#!/bin/sh\necho "codex-cli ${version}"\n`, { mode: 0o755 }); + if (!POSIX) { fs.writeFileSync(path.join(dir, 'codex.cmd'), version ? `@echo codex-cli ${version}\r\n` : '@exit /b 1\r\n'); return path.join(dir, 'codex.cmd'); } + fs.writeFileSync(path.join(dir, 'codex'), `#!/bin/sh\n${delay ? `sleep ${delay}\n` : ''}${version ? `echo "codex-cli ${version}"` : 'exit 1'}\n`, { mode: 0o755 }); return path.join(dir, 'codex'); } +// Only the given folders: the host's own Codex must not answer for the test. +const env = (root: string, ...dirs: string[]): NodeJS.ProcessEnv => ({ PATH: dirs.join(path.delimiter), Path: dirs.join(path.delimiter), HOME: root, USERPROFILE: root, + SystemRoot: process.env.SystemRoot, ComSpec: process.env.ComSpec, PATHEXT: process.env.PATHEXT }); -test('Papilot uses the first Codex on this computer that runs', async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'papilot-codex-')), data = path.join(root, 'data'); - // Only the given folder: the host's own Codex must not answer for the test. - const env = (dir: string): NodeJS.ProcessEnv => ({ PATH: dir, Path: dir, HOME: root, USERPROFILE: root, SystemRoot: process.env.SystemRoot, ComSpec: process.env.ComSpec, PATHEXT: process.env.PATHEXT }); +test('the first Codex that runs is used, whatever its version', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'papilot-codex-')); try { + const broken = fakeCodex(path.join(root, 'broken'), null), old = fakeCodex(path.join(root, 'old'), '0.1.0'), recent = fakeCodex(path.join(root, 'recent'), '0.170.0'); + const plain = env(root); + assert.equal(await firstRunnable([broken, old, recent], plain), old, 'a broken installation is skipped; no version is required'); + assert.equal(await firstRunnable([broken], plain), null); + assert.equal(await codexBinary({ ...plain, PAPILOT_CODEX_BIN: '/configured/codex' }), '/configured/codex', 'the npm command chose already'); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test('Codex is looked for on PATH first, then in the checkout and the data folder', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'papilot-codex-')), data = path.join(root, 'data'), checkout = path.join(root, 'checkout'); + try { + const first = fakeCodex(path.join(root, 'a'), null), second = fakeCodex(path.join(root, 'b'), '0.170.0'); + const dev = fakeCodex(path.join(checkout, 'node_modules/.bin'), '0.164.0'), own = fakeCodex(path.join(data, 'agents/node_modules/.bin'), '0.164.0'); + const found = codexCandidates(env(root, path.dirname(first), path.dirname(second)), data, checkout); + // Standard install folders (Homebrew, /usr/local/bin) sit between PATH and the copies. + assert.deepEqual(found.slice(0, 2), [first, second]); + assert.ok(found.indexOf(dev) > 1 && found.indexOf(own) > found.indexOf(dev)); const none = path.join(root, 'empty'); fs.mkdirSync(none); - // Standard install folders are searched too; a host with Codex there skips this part. - if (!codexCandidates(env(none), data).length) { - assert.equal(new Codex(undefined, undefined, env(none)).detect(), false, 'a missing Codex is not offered'); - await assert.rejects(codexBinary(env(none), data), /not found/); + if (!codexCandidates(env(root, none), path.join(root, 'nothing'), path.join(root, 'nothing')).length) { + assert.equal(new Codex(undefined, undefined, env(root, none)).detect(), false, 'a missing Codex is not offered'); + await assert.rejects(codexBinary(env(root, none), path.join(root, 'nothing')), /not found on this computer/); } - assert.equal(await codexBinary({ ...env(none), PAPILOT_CODEX_BIN: '/configured/codex' }, data), '/configured/codex'); - - const recent = fakeCodex(path.join(root, 'recent'), '0.170.0'); - assert.equal(await codexBinary(env(path.dirname(recent)), data), recent); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); - const old = fakeCodex(path.join(root, 'old'), '0.1.0'); - assert.equal(await codexBinary(env(path.dirname(old)), data), old, 'any version that runs is used'); - const broken = path.join(root, 'broken'); fs.mkdirSync(broken); - if (process.platform === 'win32') fs.writeFileSync(path.join(broken, 'codex.cmd'), '@exit /b 1\r\n'); - else fs.writeFileSync(path.join(broken, 'codex'), '#!/bin/sh\nexit 1\n', { mode: 0o755 }); - assert.equal(new Codex(undefined, undefined, env(broken)).detect(), true, 'a broken Codex is offered, to say why it cannot be used'); - await assert.rejects(codexBinary(env(broken), path.join(root, 'none')), /could not be run\. Reinstall Codex/); - const both = [broken, path.dirname(recent)].join(path.delimiter); - assert.equal(await codexBinary(env(both), data), recent, 'a working Codex later on PATH is used'); - const own = fakeCodex(path.join(data, 'agents/node_modules/.bin'), '0.164.0'); - assert.equal(await codexBinary(env(broken), data), own, 'the copy papilot codex login installed still counts'); +test('stopping Codex while it looks for an installation starts no app-server', { skip: !POSIX && 'uses a POSIX sleep' }, async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'papilot-codex-')); + try { + const slow = fakeCodex(path.join(root, 'slow'), '0.170.0', 1); + const codex = new Codex(undefined, undefined, env(root, path.dirname(slow))); + const starting = codex.start(); + codex.close(); + await starting; + assert.equal(codex.child, null); + assert.equal(codex.ready, false); + await assert.rejects(codex.request('account/read', {}), /not connected/); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); diff --git a/tests/desktop-layer.test.ts b/tests/desktop-layer.test.ts index cf0f063f..d6689169 100644 --- a/tests/desktop-layer.test.ts +++ b/tests/desktop-layer.test.ts @@ -74,11 +74,13 @@ test('the engine keeps only dependencies the server does not already provide', ( fs.symlinkSync('../mupdf/cli.js', path.join(bin, 'mupdf')); fs.symlinkSync('../only-engine/cli.js', path.join(bin, 'only-engine')); write(path.join(bin, 'static.cmd'), '@"%dp0%\\..\\@fastify\\static\\cli.js" %*'); + write(path.join(bin, 'keep.cmd'), '@"%dp0%\\..\\@scope\\kept\\cli.js" %*'); + pkg('engine', '@scope/kept', '2.0.0'); pkg('.', '@scope/kept', '1.0.0'); write(path.join(server, 'engine/node_modules/only-engine/cli.js'), ''); assert.ok(dedupeEngine(server) >= 1000, 'reports what it removed'); const left = (name: string) => fs.existsSync(path.join(server, 'engine/node_modules', name)); assert.ok(!left('mupdf') && !left('@fastify'), 'identical versions resolve from the server'); - assert.ok(left('react') && left('only-engine'), 'other versions and engine-only packages stay'); - assert.deepEqual(fs.readdirSync(bin), ['only-engine'], 'executables of removed packages are gone'); + assert.ok(left('react') && left('only-engine') && left('@scope/kept'), 'other versions and engine-only packages stay'); + assert.deepEqual(fs.readdirSync(bin).sort(), ['keep.cmd', 'only-engine'], 'executables of removed packages are gone, Windows shims too'); } finally { fs.rmSync(server, { recursive: true, force: true }); } }); From e6288288c2e86178e329e8b53a4271de1909adcf Mon Sep 17 00:00:00 2001 From: Chen Cui Date: Mon, 9 Nov 2026 22:47:47 +0800 Subject: [PATCH 4/9] Bound the Codex probe, keep state uncacheable and tighten packaging - The codex --version probe owns its deadline and kills the whole process tree, so a wrapper whose child holds the output open cannot hang connecting or Reconnect. - /api/state keeps the access guard's Cache-Control: no-store (it carries the CSRF token); the ETag alone gives the empty 304. - A Codex launch stopped during the lookup rejects, and starting again launches afresh instead of reusing the stale lookup. - doctor searches and probes Codex like the server in the desktop app. - Engine dedupe moves to scripts/desktop-dedupe.mjs, which the shell fingerprint now covers, and removes a package only when both lockfiles agree on its version and integrity. - The desktop app also looks in pnpm, mise, fnm, Yarn and custom npm prefix folders, since Codex is no longer bundled. - Codex lookup tests no longer depend on the host's installations. --- bin/doctor.mjs | 6 +-- desktop/runtime.ts | 13 +++++- scripts/desktop-dedupe.d.mts | 1 + scripts/desktop-dedupe.mjs | 39 ++++++++++++++++ scripts/desktop-layer.d.mts | 1 - scripts/desktop-layer.mjs | 35 ++------------ scripts/desktop.mjs | 3 +- src/server/app.ts | 2 +- src/server/codex-binary.ts | 34 ++++++++++---- src/server/codex.ts | 10 ++-- tests/app.test.ts | 3 ++ tests/codex-binary.test.ts | 86 +++++++++++++++++++++++++++++++---- tests/desktop-layer.test.ts | 40 ++++++++++++++-- tests/desktop-runtime.test.ts | 24 ++++++++++ tests/doctor.test.ts | 42 +++++++++++++++++ 15 files changed, 275 insertions(+), 64 deletions(-) create mode 100644 scripts/desktop-dedupe.d.mts create mode 100644 scripts/desktop-dedupe.mjs diff --git a/bin/doctor.mjs b/bin/doctor.mjs index fe9d9981..4c1f1cd5 100644 --- a/bin/doctor.mjs +++ b/bin/doctor.mjs @@ -1,4 +1,4 @@ -import { findExecutable, findExecutables, killTree, spawnCommand } from './agent-paths.mjs'; +import { agentPath, findExecutable, findExecutables, killTree, spawnCommand } from './agent-paths.mjs'; // Deliberately uses only Node built-ins: diagnostics must work before npm setup. import fs from 'node:fs'; import os from 'node:os'; @@ -136,13 +136,13 @@ export async function doctor(args) { const configured = process.env.PAPILOT_CODEX_BIN; // Like the server: the desktop app tries every codex on PATH, the npm command the first. const managed = path.join(data, 'agents/node_modules/.bin'); - const candidates = configured ? [configured] : desktop ? findExecutables('codex', process.env, [managed]) : ['codex', path.join(managed, 'codex')]; + const candidates = configured ? [configured] : desktop ? findExecutables('codex', process.env, [path.join(root, 'node_modules/.bin'), managed]) : ['codex', path.join(managed, 'codex')]; const found = []; for (const candidate of candidates) { const file = agentExecutable(candidate); if (!file) continue; // As long as the server waits, so a slow start on a busy machine counts as running. - const result = await command(file, ['--version'], { timeout: 15000 }); + const result = await command(file, ['--version'], { timeout: 15000, env: { ...process.env, PATH: agentPath(process.env) } }); const version = result.stdout.match(/\d+\.\d+\.\d+/)?.[0]; found.push(file); if (!result.ok || (minimum && (!version || !atLeast(version, minimum)))) { diff --git a/desktop/runtime.ts b/desktop/runtime.ts index 4fe74556..23012f5f 100644 --- a/desktop/runtime.ts +++ b/desktop/runtime.ts @@ -26,15 +26,24 @@ export function backendEnvironment(node: string, root: string, data: string, inh const inheritedPath = process.platform === 'win32' ? Object.keys(inherited).filter(key => key.toLowerCase() === 'path').map(key => inherited[key] || '').join(path.delimiter) : envPath(inherited); - const home = os.homedir(); + const home = inherited.HOME || os.homedir(); const nvm = path.join(inherited.NVM_DIR || path.join(home, '.nvm'), 'versions/node'); let nodeBins: string[] = []; try { nodeBins = fs.readdirSync(nvm).filter(version => /^v\d+\.\d+\.\d+$/.test(version)).sort((a, b) => b.localeCompare(a, undefined, { numeric: true })).map(version => path.join(nvm, version, 'bin')); } catch { /* nvm is optional */ } + let npmBin: string[] = []; + try { + const prefix = fs.readFileSync(path.join(home, '.npmrc'), 'utf8').match(/^\s*prefix\s*=\s*(.+?)\s*$/m)?.[1].replace(/^(['"])(.*)\1$/, '$2'); + if (prefix) npmBin = [path.join(prefix.startsWith('~/') ? path.join(home, prefix.slice(2)) : prefix, 'bin')]; + } catch { /* a custom npm prefix is optional */ } + const fnm = [inherited.FNM_DIR || path.join(home, '.local/share/fnm'), path.join(home, 'Library/Application Support/fnm')]; // An installed release has its dependencies beside it in node_modules. const hoisted = path.basename(path.dirname(root)) === 'node_modules' ? [path.join(path.dirname(root), '.bin')] : []; const directories = [path.dirname(node), path.join(root, 'node_modules/.bin'), ...hoisted, ...inheritedPath.split(path.delimiter), ...agentDirs(inherited), - ...['.local/bin', '.claude/local', '.kimi-code/bin', '.grok/bin', '.opencode/bin', '.bun/bin', '.npm-global/bin', '.volta/bin', '.asdf/shims'].map(dir => path.join(home, dir)), ...nodeBins, + ...['.local/bin', '.claude/local', '.kimi-code/bin', '.grok/bin', '.opencode/bin', '.bun/bin', '.npm-global/bin', '.volta/bin', '.asdf/shims'].map(dir => path.join(home, dir)), + // Finder does not load the shell setup that adds these Node/npm installations. + inherited.PNPM_HOME || '', ...['Library/pnpm', '.local/share/pnpm', '.local/share/mise/shims', '.yarn/bin'].map(dir => path.join(home, dir)), + ...fnm.map(dir => path.join(dir, 'aliases/default/bin')), ...npmBin, ...nodeBins, '/Library/TeX/texbin', '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin']; const env = { ...inherited, PATH: [...new Set(directories.filter(dir => path.isAbsolute(dir)))].join(path.delimiter), NODE_ENV: 'production', PAPILOT_DESKTOP: '1', PAPILOT_DATA: data, PAPILOT_PORT: '0', diff --git a/scripts/desktop-dedupe.d.mts b/scripts/desktop-dedupe.d.mts new file mode 100644 index 00000000..60593c1e --- /dev/null +++ b/scripts/desktop-dedupe.d.mts @@ -0,0 +1 @@ +export function dedupeEngine(server: string): number; diff --git a/scripts/desktop-dedupe.mjs b/scripts/desktop-dedupe.mjs new file mode 100644 index 00000000..fc2e27a2 --- /dev/null +++ b/scripts/desktop-dedupe.mjs @@ -0,0 +1,39 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +// The review engine sits inside the server folder, so Node resolves a package +// it lacks from the server's node_modules (also through a layer's links, see +// linkBundle). Its own copies of the same packages (mupdf, esbuild, fastify, +// React…) only made the installer larger. +export function dedupeEngine(server) { + const outer = path.join(server, 'node_modules'), inner = path.join(server, 'engine/node_modules'); + const packages = folder => { try { return JSON.parse(fs.readFileSync(path.join(folder, 'package-lock.json'), 'utf8')).packages || {}; } catch { return {}; } }; + const serverLock = packages(server), engineLock = packages(path.join(server, 'engine')); + const version = folder => { try { return JSON.parse(fs.readFileSync(path.join(folder, 'package.json'), 'utf8')).version || null; } catch { return null; } }; + const names = fs.readdirSync(inner).filter(name => !name.startsWith('.')) + .flatMap(name => name.startsWith('@') ? fs.readdirSync(path.join(inner, name)).map(child => `${name}/${child}`) : [name]); + let bytes = 0; + const size = file => { const stat = fs.lstatSync(file); return stat.isDirectory() ? fs.readdirSync(file).reduce((sum, name) => sum + size(path.join(file, name)), 0) : stat.size; }; + for (const name of names) { + const shared = serverLock[`node_modules/${name}`], local = engineLock[`node_modules/${name}`]; + // A version alone cannot distinguish packages from different registries or builds. + if (!shared || !local || !local.version || local.version !== shared.version) continue; + const identity = local.integrity || local.resolved; + if (!identity || identity !== (shared.integrity || shared.resolved)) continue; + const own = version(path.join(inner, name)); + if (!own || own !== version(path.join(outer, name))) continue; + bytes += size(path.join(inner, name)); + fs.rmSync(path.join(inner, name), { recursive: true, force: true }); + } + for (const name of fs.readdirSync(inner)) if (name.startsWith('@') && !fs.readdirSync(path.join(inner, name)).length) fs.rmdirSync(path.join(inner, name)); + // Executables of removed packages: links on macOS and Linux, shims on Windows. + const bin = path.join(inner, '.bin'); + if (fs.existsSync(bin)) for (const name of fs.readdirSync(bin)) { + const file = path.join(bin, name), stat = fs.lstatSync(file); + if (!stat.isFile() && !stat.isSymbolicLink()) continue; + const targets = stat.isSymbolicLink() ? [path.resolve(bin, fs.readlinkSync(file))] + : [...fs.readFileSync(file, 'utf8').matchAll(/\.\.[\\/]((?:@[^\\/"']+[\\/])?[^\\/"']+)[\\/]/g)].map(match => path.join(inner, ...match[1].split(/[\\/]/))); + if (targets.some(target => !fs.existsSync(target))) fs.rmSync(file, { force: true }); + } + return bytes; +} diff --git a/scripts/desktop-layer.d.mts b/scripts/desktop-layer.d.mts index f7f1bcc6..b129de14 100644 --- a/scripts/desktop-layer.d.mts +++ b/scripts/desktop-layer.d.mts @@ -6,4 +6,3 @@ export function layerName(version: string): string; export function buildLayer(root?: string, output?: string, revision?: string): { format: number; version: string; fingerprint: string; layer: { name: string; size: number; sha256: string } }; export const INSTALLER_SUFFIXES: string[]; export function reusableInstallers(previous: any, assets: string[], fingerprint: string): string[] | null; -export function dedupeEngine(server: string): number; diff --git a/scripts/desktop-layer.mjs b/scripts/desktop-layer.mjs index b45aaf4f..ee86314a 100644 --- a/scripts/desktop-layer.mjs +++ b/scripts/desktop-layer.mjs @@ -28,9 +28,9 @@ const production = lock => Object.entries(read(lock).packages || {}).filter(([na export function shellFingerprint(root = process.cwd(), node = process.versions.node) { const pkg = read(path.join(root, 'package.json')); const hash = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex'); - // These sources are copied into Electron at compile time, so a layer - // cannot replace them. Older checkouts may not contain every helper. - const copied = ['scripts/desktop.mjs', 'scripts/desktop-agent-paths.mjs', 'bin/agent-paths.mjs', 'engine/src/core/platform.mjs'].map(file => path.join(root, file)).filter(file => fs.existsSync(file)); + // These sources build the installer or are copied into Electron, so a layer + // cannot replace their effects. Older checkouts may not contain every helper. + const copied = ['scripts/desktop.mjs', 'scripts/desktop-dedupe.mjs', 'scripts/desktop-agent-paths.mjs', 'bin/agent-paths.mjs', 'engine/src/core/platform.mjs'].map(file => path.join(root, file)).filter(file => fs.existsSync(file)); const shell = [...files(path.join(root, 'desktop')), ...copied] .map(file => [path.relative(root, file).split(path.sep).join('/'), hash(file)]).sort(([a], [b]) => a.localeCompare(b)); const inputs = { @@ -42,35 +42,6 @@ export function shellFingerprint(root = process.cwd(), node = process.versions.n return createHash('sha256').update(JSON.stringify(inputs)).digest('hex'); } -// The review engine sits inside the server folder, so Node resolves a package -// it lacks from the server's node_modules (also through a layer's links, see -// linkBundle). Its own copies of the same versions (mupdf, esbuild, fastify, -// React…) only made the installer larger. -export function dedupeEngine(server) { - const outer = path.join(server, 'node_modules'), inner = path.join(server, 'engine/node_modules'); - const version = folder => { try { return JSON.parse(fs.readFileSync(path.join(folder, 'package.json'), 'utf8')).version || null; } catch { return null; } }; - const names = fs.readdirSync(inner).filter(name => !name.startsWith('.')) - .flatMap(name => name.startsWith('@') ? fs.readdirSync(path.join(inner, name)).map(child => `${name}/${child}`) : [name]); - let bytes = 0; - const size = file => { const stat = fs.lstatSync(file); return stat.isDirectory() ? fs.readdirSync(file).reduce((sum, name) => sum + size(path.join(file, name)), 0) : stat.size; }; - for (const name of names) { - const own = version(path.join(inner, name)); - if (!own || own !== version(path.join(outer, name))) continue; - bytes += size(path.join(inner, name)); - fs.rmSync(path.join(inner, name), { recursive: true, force: true }); - } - for (const name of fs.readdirSync(inner)) if (name.startsWith('@') && !fs.readdirSync(path.join(inner, name)).length) fs.rmdirSync(path.join(inner, name)); - // Executables of removed packages: links on macOS and Linux, shims on Windows. - const bin = path.join(inner, '.bin'); - if (fs.existsSync(bin)) for (const name of fs.readdirSync(bin)) { - const file = path.join(bin, name); - const targets = fs.lstatSync(file).isSymbolicLink() ? [path.resolve(bin, fs.readlinkSync(file))] - : [...fs.readFileSync(file, 'utf8').matchAll(/\.\.[\\/]((?:@[^\\/"']+[\\/])?[^\\/"']+)[\\/]/g)].map(match => path.join(inner, ...match[1].split(/[\\/]/))); - if (targets.some(target => !fs.existsSync(target))) fs.rmSync(file, { force: true }); - } - return bytes; -} - // Nightly dev builds carry the commit, like the web server's GitHub dev channel. export function desktopVersion(version, revision = process.env.PAPILOT_DESKTOP_REVISION) { if (!revision) return version; diff --git a/scripts/desktop.mjs b/scripts/desktop.mjs index 342e5813..f339d20c 100644 --- a/scripts/desktop.mjs +++ b/scripts/desktop.mjs @@ -4,7 +4,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module'; import { spawnSync, spawn } from 'node:child_process'; -import { buildLayer, dedupeEngine, desktopVersion, shellFingerprint } from './desktop-layer.mjs'; +import { buildLayer, desktopVersion, shellFingerprint } from './desktop-layer.mjs'; +import { dedupeEngine } from './desktop-dedupe.mjs'; import { tarCommand } from '../bin/agent-paths.mjs'; import { buildWindowsIcon } from './build-windows-icon.mjs'; diff --git a/src/server/app.ts b/src/server/app.ts index a27b11ac..b7e881f7 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -220,7 +220,7 @@ export async function buildApp(options: AppOptions) { // The app polls every second or two and most polls find nothing new: those // get an empty 304 instead of every task again to download and parse. const tag = `"${createHash('sha1').update(body).digest('base64url')}"`; - reply.header('ETag', tag).header('Cache-Control', 'no-cache'); + reply.header('ETag', tag); if (request.headers['if-none-match']?.replace(/^W\//, '') === tag) return reply.code(304).send(); return reply.type('application/json; charset=utf-8').send(body); }); diff --git a/src/server/codex-binary.ts b/src/server/codex-binary.ts index 68cdb5cb..41b8231e 100644 --- a/src/server/codex-binary.ts +++ b/src/server/codex-binary.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { agentPath, execCommand, findExecutables } from '../../bin/agent-paths.mjs'; +import { agentPath, findExecutables, killTree, spawnCommand, WINDOWS } from '../../bin/agent-paths.mjs'; import { DATA, ROOT } from './paths.ts'; // Papilot uses the Codex installed on this computer, like every other agent. @@ -14,21 +14,39 @@ export function codexCandidates(env: NodeJS.ProcessEnv = process.env, data = DAT } /** The first candidate that runs, tried with the PATH Codex is started with. */ -export async function firstRunnable(candidates: string[], env: NodeJS.ProcessEnv = process.env) { +export async function firstRunnable(candidates: string[], env: NodeJS.ProcessEnv = process.env, timeout = 15000) { const probe = { ...env, PATH: agentPath(env) }; for (const file of candidates) { - try { await execCommand(file, ['--version'], { env: probe, timeout: 15000 }); return file; } catch { /* a broken installation; try the next */ } + try { + const runs = await new Promise(resolve => { + const child = spawnCommand(file, ['--version'], { env: probe, stdio: 'ignore', detached: !WINDOWS }); + const finish = (runs: boolean) => { clearTimeout(timer); resolve(runs); }; + // A wrapper's children must neither hold pipes open nor outlive the probe. + const timer = setTimeout(() => { + try { if (child.pid) killTree(child.pid, 'SIGKILL'); } + catch { try { child.kill('SIGKILL'); } catch { /* already stopped */ } } + finally { finish(false); } + }, timeout); + child.once('exit', code => finish(code === 0)); + child.once('error', () => finish(false)); + }); + if (runs) return file; + } catch { /* a broken installation; try the next */ } } return null; } -/** The first Codex that runs. */ -export async function codexBinary(env: NodeJS.ProcessEnv = process.env, data = DATA): Promise { - // The npm command has already chosen its Codex. - if (env.PAPILOT_CODEX_BIN) return env.PAPILOT_CODEX_BIN; - const candidates = codexCandidates(env, data), file = await firstRunnable(candidates, env); +export async function resolveCodex(candidates: string[], env: NodeJS.ProcessEnv = process.env): Promise { + const file = await firstRunnable(candidates, env); if (file) return file; if (!candidates.length) throw new Error('Codex was not found on this computer. Install Codex, then select Reconnect.'); console.error(`No installed Codex could be run: ${candidates.join(', ')}.`); throw new Error('Codex is installed but could not be run. Reinstall Codex, then select Reconnect.'); } + +/** The first Codex that runs. */ +export async function codexBinary(env: NodeJS.ProcessEnv = process.env, data = DATA): Promise { + // The npm command has already chosen its Codex. + if (env.PAPILOT_CODEX_BIN) return env.PAPILOT_CODEX_BIN; + return resolveCodex(codexCandidates(env, data), env); +} diff --git a/src/server/codex.ts b/src/server/codex.ts index 37d840b9..c08cd4ad 100644 --- a/src/server/codex.ts +++ b/src/server/codex.ts @@ -48,8 +48,9 @@ export class Codex extends EventEmitter { start(): Promise { if (this.ready) return Promise.resolve(); if (this.starting) return this.starting; - this.starting = this.launch().finally(() => { this.starting = null; }); - return this.starting; + const starting = this.launch().finally(() => { if (this.starting === starting) this.starting = null; }); + this.starting = starting; + return starting; } // close() during the lookup must not be followed by a new app-server. private generation = 0; @@ -57,8 +58,7 @@ export class Codex extends EventEmitter { this.stderr = ''; const generation = this.generation; const binary = this.binary || await codexBinary(this.env); - // Stopped meanwhile: start nothing; requests then report Codex as not connected. - if (generation !== this.generation) return; + if (generation !== this.generation) throw new Error('Codex is not connected.'); const child = spawnCommand(binary, this.args, { cwd: ROOT, env: { ...this.env, PATH: agentPath(this.env) }, stdio: ['pipe', 'pipe', 'pipe'] }); this.child = child; const lines = createInterface({ input: child.stdout! }); @@ -98,5 +98,5 @@ export class Codex extends EventEmitter { notify(method: string, params: unknown) { this.child?.stdin?.write(JSON.stringify({ method, params }) + '\n'); } respond(id: string | number, result: unknown) { if (!this.child) throw new Error('Codex disconnected. Resume the task before retrying.'); this.child.stdin!.write(JSON.stringify({ id, result }) + '\n'); } reject(id: string | number, message: string) { this.child?.stdin?.write(JSON.stringify({ id, error: { code: -32601, message } }) + '\n'); } - close() { this.generation++; this.child?.stdin?.end(); this.child?.kill('SIGTERM'); } + close() { this.generation++; this.starting = null; this.child?.stdin?.end(); this.child?.kill('SIGTERM'); } } diff --git a/tests/app.test.ts b/tests/app.test.ts index 9bb58db5..1178a707 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -207,11 +207,14 @@ test('an unchanged workspace state is answered with an empty 304', async () => { const f = await fixture(); try { const first = await f.local.inject({ url: '/api/state', headers: LOCAL }); + assert.equal(first.statusCode, 200); + assert.equal(first.headers['cache-control'], 'no-store'); const tag = String(first.headers.etag); assert.match(tag, /^"[\w-]+"$/); assert.equal(first.json().csrf, 'local-csrf'); const same = await f.local.inject({ url: '/api/state', headers: { ...LOCAL, 'if-none-match': tag } }); assert.equal(same.statusCode, 304); + assert.equal(same.headers['cache-control'], 'no-store'); assert.equal(same.body, ''); assert.equal((await f.local.inject({ url: '/api/state', headers: { ...LOCAL, 'if-none-match': 'W/' + tag } })).statusCode, 304, 'a proxy may weaken the tag'); f.store.updateTask('grant-task', { title: 'Renamed' }); diff --git a/tests/codex-binary.test.ts b/tests/codex-binary.test.ts index 300dd25a..c2dfba8c 100644 --- a/tests/codex-binary.test.ts +++ b/tests/codex-binary.test.ts @@ -3,7 +3,9 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { codexBinary, codexCandidates, firstRunnable } from '../src/server/codex-binary.ts'; +import { setTimeout as delay } from 'node:timers/promises'; +import { killTree } from '../bin/agent-paths.mjs'; +import { codexBinary, codexCandidates, firstRunnable, resolveCodex } from '../src/server/codex-binary.ts'; import { Codex } from '../src/server/codex.ts'; const POSIX = process.platform !== 'win32'; @@ -11,7 +13,7 @@ const POSIX = process.platform !== 'win32'; function fakeCodex(dir: string, version: string | null, delay = 0) { fs.mkdirSync(dir, { recursive: true }); if (!POSIX) { fs.writeFileSync(path.join(dir, 'codex.cmd'), version ? `@echo codex-cli ${version}\r\n` : '@exit /b 1\r\n'); return path.join(dir, 'codex.cmd'); } - fs.writeFileSync(path.join(dir, 'codex'), `#!/bin/sh\n${delay ? `sleep ${delay}\n` : ''}${version ? `echo "codex-cli ${version}"` : 'exit 1'}\n`, { mode: 0o755 }); + fs.writeFileSync(path.join(dir, 'codex'), `#!/bin/sh\n${delay ? `/bin/sleep ${delay}\n` : ''}${version ? `echo "codex-cli ${version}"` : 'exit 1'}\n`, { mode: 0o755 }); return path.join(dir, 'codex'); } // Only the given folders: the host's own Codex must not answer for the test. @@ -25,6 +27,10 @@ test('the first Codex that runs is used, whatever its version', async () => { const plain = env(root); assert.equal(await firstRunnable([broken, old, recent], plain), old, 'a broken installation is skipped; no version is required'); assert.equal(await firstRunnable([broken], plain), null); + assert.equal(await firstRunnable([path.join(root, 'absent'), recent], plain), recent, 'a spawn error is skipped'); + assert.equal(await resolveCodex([broken, old, recent], plain), old); + await assert.rejects(resolveCodex([], plain), /not found on this computer/); + await assert.rejects(resolveCodex([broken], plain), /could not be run/); assert.equal(await codexBinary({ ...plain, PAPILOT_CODEX_BIN: '/configured/codex' }), '/configured/codex', 'the npm command chose already'); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); @@ -38,11 +44,6 @@ test('Codex is looked for on PATH first, then in the checkout and the data folde // Standard install folders (Homebrew, /usr/local/bin) sit between PATH and the copies. assert.deepEqual(found.slice(0, 2), [first, second]); assert.ok(found.indexOf(dev) > 1 && found.indexOf(own) > found.indexOf(dev)); - const none = path.join(root, 'empty'); fs.mkdirSync(none); - if (!codexCandidates(env(root, none), path.join(root, 'nothing'), path.join(root, 'nothing')).length) { - assert.equal(new Codex(undefined, undefined, env(root, none)).detect(), false, 'a missing Codex is not offered'); - await assert.rejects(codexBinary(env(root, none), path.join(root, 'nothing')), /not found on this computer/); - } } finally { fs.rmSync(root, { recursive: true, force: true }); } }); @@ -53,9 +54,78 @@ test('stopping Codex while it looks for an installation starts no app-server', { const codex = new Codex(undefined, undefined, env(root, path.dirname(slow))); const starting = codex.start(); codex.close(); - await starting; + await assert.rejects(starting, { message: 'Codex is not connected.' }); assert.equal(codex.child, null); assert.equal(codex.ready, false); await assert.rejects(codex.request('account/read', {}), /not connected/); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); + +async function waitFor(check: () => boolean) { + const deadline = Date.now() + 3000; + while (!check() && Date.now() < deadline) await delay(10); + assert.ok(check(), 'the child process reached the expected state'); +} + +test('a Codex wrapper with a waiting child times out and the next installation is tried', { skip: !POSIX && 'uses a POSIX process group', timeout: 12000 }, async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'papilot-codex-')); + const pids = path.join(root, 'pids'), wrapper = path.join(root, 'codex'); + try { + fs.writeFileSync(wrapper, `#!/bin/sh\n/bin/sleep 30 &\necho "$$ $!" > "$PROBE_PIDS"\nwait\n`, { mode: 0o755 }); + const working = fakeCodex(path.join(root, 'working'), '0.1.0'); + const started = Date.now(); + assert.equal(await firstRunnable([wrapper, working], { ...env(root), PROBE_PIDS: pids }, 2500), working); + assert.ok(Date.now() - started < 8000, 'the wrapper cannot hold the lookup open'); + const children = fs.readFileSync(pids, 'utf8').trim().split(' ').map(Number); + assert.equal(children.length, 2); + await waitFor(() => children.every(pid => { try { process.kill(pid, 0); return false; } catch { return true; } })); + } finally { + if (fs.existsSync(pids)) killTree(Number(fs.readFileSync(pids, 'utf8').split(' ')[0]), 'SIGKILL'); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('starting Codex again during a stopped lookup launches afresh and retains the new promise', { skip: !POSIX && 'uses a POSIX executable', timeout: 10000 }, async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'papilot-codex-')); + const probes = path.join(root, 'probes'), launches = path.join(root, 'launches'); + const codex = new Codex(undefined, undefined, env(root, root)); + let first: Promise | undefined, second: Promise | undefined; + const pids = () => fs.existsSync(probes) ? fs.readFileSync(probes, 'utf8').trim().split('\n') : []; + try { + fs.writeFileSync(path.join(root, 'codex'), `#!${process.execPath} + const fs = require('node:fs'), readline = require('node:readline'); + if (process.argv.includes('--version')) { + fs.appendFileSync(${JSON.stringify(probes)}, process.pid + '\\n'); + const timer = setInterval(() => { + if (fs.existsSync(${JSON.stringify(root)} + '/' + process.pid)) { clearInterval(timer); process.exit(0); } + }, 10); + } else { + fs.appendFileSync(${JSON.stringify(launches)}, 'launch\\n'); + readline.createInterface({ input: process.stdin }).on('line', line => { + const message = JSON.parse(line); + if (message.id !== undefined) console.log(JSON.stringify({ id: message.id, result: {} })); + }); + } + `, { mode: 0o755 }); + first = codex.start(); + const stopped = assert.rejects(first, { message: 'Codex is not connected.' }); + await waitFor(() => pids().length === 1); + codex.close(); + second = codex.start(); + assert.notEqual(first, second); + await waitFor(() => pids().length === 2); + fs.writeFileSync(path.join(root, pids()[0]), ''); + await stopped; + assert.equal(codex.child, null, 'the stale lookup starts no app-server'); + assert.equal(codex.start(), second, 'a stale finally must not clear the new launch'); + fs.writeFileSync(path.join(root, pids()[1]), ''); + await second; + assert.equal(codex.ready, true); + assert.equal(fs.readFileSync(launches, 'utf8'), 'launch\n'); + } finally { + codex.close(); + for (const pid of pids()) fs.writeFileSync(path.join(root, pid), ''); + await Promise.allSettled([first, second]); + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/desktop-layer.test.ts b/tests/desktop-layer.test.ts index d6689169..1501a41f 100644 --- a/tests/desktop-layer.test.ts +++ b/tests/desktop-layer.test.ts @@ -3,7 +3,8 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { dedupeEngine, desktopVersion, INSTALLER_SUFFIXES, reusableInstallers, shellFingerprint } from '../scripts/desktop-layer.mjs'; +import { desktopVersion, INSTALLER_SUFFIXES, reusableInstallers, shellFingerprint } from '../scripts/desktop-layer.mjs'; +import { dedupeEngine } from '../scripts/desktop-dedupe.mjs'; const write = (file: string, value: unknown) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, typeof value === 'string' ? value : JSON.stringify(value)); }; function checkout() { @@ -13,6 +14,7 @@ function checkout() { write(path.join(root, 'engine/package-lock.json'), { packages: { '': {}, 'node_modules/diff': { version: '9.0.0', integrity: 'sha512-c' } } }); write(path.join(root, 'desktop/main.ts'), 'main'); write(path.join(root, 'desktop/README.md'), 'docs'); write(path.join(root, 'scripts/desktop.mjs'), 'stage'); + write(path.join(root, 'scripts/desktop-dedupe.mjs'), 'dedupe'); write(path.join(root, 'src/server/start.ts'), 'server'); return root; } @@ -36,6 +38,7 @@ test('the shell fingerprint changes exactly when an update would need a new inst ['the Electron app', () => write(path.join(root, 'desktop/main.ts'), 'changed main')], ['a helper copied into Electron', () => write(path.join(root, 'engine/src/core/platform.mjs'), 'updated process handling')], ['how the installer is staged', () => write(path.join(root, 'scripts/desktop.mjs'), 'changed stage')], + ['how engine dependencies are deduplicated', () => write(path.join(root, 'scripts/desktop-dedupe.mjs'), 'changed dedupe')], ] as const) { const before = shellFingerprint(root, '24.11.0'); change(); @@ -64,7 +67,11 @@ test('a dev build reuses the published installers only for the same shell', () = test('the engine keeps only dependencies the server does not already provide', () => { const server = fs.mkdtempSync(path.join(os.tmpdir(), 'papilot-dedupe-')); - const pkg = (folder: string, name: string, version: string) => write(path.join(server, folder, 'node_modules', name, 'package.json'), { name, version }); + const locks: Record }> = { '.': { packages: {} }, engine: { packages: {} } }; + const pkg = (folder: string, name: string, version: string) => { + write(path.join(server, folder, 'node_modules', name, 'package.json'), { name, version }); + locks[folder].packages[`node_modules/${name}`] = { version, integrity: `sha512-${name}-${version}` }; + }; try { pkg('.', 'mupdf', '1.28.1'); pkg('.', '@fastify/static', '10.1.9'); pkg('.', 'react', '19.3.1'); pkg('engine', 'mupdf', '1.28.1'); pkg('engine', '@fastify/static', '10.1.9'); pkg('engine', 'react', '19.2.0'); pkg('engine', 'only-engine', '1.0.0'); @@ -77,10 +84,37 @@ test('the engine keeps only dependencies the server does not already provide', ( write(path.join(bin, 'keep.cmd'), '@"%dp0%\\..\\@scope\\kept\\cli.js" %*'); pkg('engine', '@scope/kept', '2.0.0'); pkg('.', '@scope/kept', '1.0.0'); write(path.join(server, 'engine/node_modules/only-engine/cli.js'), ''); + fs.mkdirSync(path.join(bin, 'directory')); + for (const folder of ['.', 'engine']) write(path.join(server, folder, 'package-lock.json'), locks[folder]); assert.ok(dedupeEngine(server) >= 1000, 'reports what it removed'); const left = (name: string) => fs.existsSync(path.join(server, 'engine/node_modules', name)); assert.ok(!left('mupdf') && !left('@fastify'), 'identical versions resolve from the server'); assert.ok(left('react') && left('only-engine') && left('@scope/kept'), 'other versions and engine-only packages stay'); - assert.deepEqual(fs.readdirSync(bin).sort(), ['keep.cmd', 'only-engine'], 'executables of removed packages are gone, Windows shims too'); + assert.deepEqual(fs.readdirSync(bin).sort(), ['directory', 'keep.cmd', 'only-engine'], 'directories stay; executables of removed packages are gone, Windows shims too'); + } finally { fs.rmSync(server, { recursive: true, force: true }); } +}); + +test('engine deduplication requires matching lock identities and installed versions', () => { + const server = fs.mkdtempSync(path.join(os.tmpdir(), 'papilot-dedupe-')); + const entry = { version: '1.0.0', integrity: 'sha512-same' }, resolved = { version: '1.0.0', resolved: 'https://example.com/pkg.tgz' }; + const cases = [ + ['different-integrity', entry, { ...entry, integrity: 'sha512-other' }, '1.0.0', true], + ['missing-server-entry', undefined, entry, '1.0.0', true], + ['missing-engine-entry', entry, undefined, '1.0.0', true], + ['different-lock-version', entry, { ...entry, version: '2.0.0' }, '1.0.0', true], + ['different-installed-version', entry, entry, '2.0.0', true], + ['same-resolved', resolved, resolved, '1.0.0', false], + ['different-resolved', resolved, { ...resolved, resolved: 'https://example.com/other.tgz' }, '1.0.0', true], + ['missing-identity', { version: '1.0.0' }, { version: '1.0.0' }, '1.0.0', true], + ] as const; + try { + for (const [name, , , installed] of cases) { + write(path.join(server, 'node_modules', name, 'package.json'), { name, version: '1.0.0' }); + write(path.join(server, 'engine/node_modules', name, 'package.json'), { name, version: installed }); + } + write(path.join(server, 'package-lock.json'), { packages: Object.fromEntries(cases.map(([name, outer]) => [`node_modules/${name}`, outer])) }); + write(path.join(server, 'engine/package-lock.json'), { packages: Object.fromEntries(cases.map(([name, , inner]) => [`node_modules/${name}`, inner])) }); + dedupeEngine(server); + for (const [name, , , , kept] of cases) assert.equal(fs.existsSync(path.join(server, 'engine/node_modules', name)), kept, name); } finally { fs.rmSync(server, { recursive: true, force: true }); } }); diff --git a/tests/desktop-runtime.test.ts b/tests/desktop-runtime.test.ts index 4cde32f6..72000646 100644 --- a/tests/desktop-runtime.test.ts +++ b/tests/desktop-runtime.test.ts @@ -16,6 +16,30 @@ test('desktop backend environment clears legacy npm activation settings', () => for (const key of ['PAPILOT_DESKTOP_SHELL', 'PAPILOT_DESKTOP_INSTALLER', 'PAPILOT_DESKTOP_CODEX', 'NODE_OPTIONS', 'ELECTRON_RUN_AS_NODE']) assert.equal((env as NodeJS.ProcessEnv)[key], undefined); }); +test('desktop PATH includes Node/npm install folders without a shell environment', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'papilot-desktop-home-')); + const node = path.join(home, 'runtime/bin/node'), root = path.join(home, 'app'), data = path.join(home, 'data'); + const paths = (extra: NodeJS.ProcessEnv = {}) => backendEnvironment(node, root, data, { HOME: home, PATH: path.join(home, 'inherited'), ...extra }).PATH.split(path.delimiter); + try { + const defaults = paths(); + for (const dir of ['Library/pnpm', '.local/share/pnpm', '.local/share/mise/shims', '.yarn/bin', + '.local/share/fnm/aliases/default/bin', 'Library/Application Support/fnm/aliases/default/bin']) { + assert.ok(defaults.includes(path.join(home, dir)), dir); + assert.ok(defaults.indexOf(path.join(home, dir)) > defaults.indexOf(path.join(home, '.asdf/shims')), 'existing home folders keep their precedence'); + } + fs.writeFileSync(path.join(home, '.npmrc'), '# user configuration\nprefix=~/custom npm\n'); + const custom = paths({ PNPM_HOME: path.join(home, 'custom-pnpm'), FNM_DIR: path.join(home, 'custom-fnm') }); + for (const dir of ['custom-pnpm', 'custom-fnm/aliases/default/bin', 'custom npm/bin']) assert.ok(custom.includes(path.join(home, dir)), dir); + assert.equal(custom[0], path.dirname(node), 'the bundled Node stays first'); + assert.ok(custom.indexOf(path.join(home, 'inherited')) < custom.indexOf(path.join(home, 'custom-pnpm'))); + fs.writeFileSync(path.join(home, '.npmrc'), `prefix="${path.join(home, 'absolute npm')}"\n`); + assert.ok(paths().includes(path.join(home, 'absolute npm/bin')), 'absolute and quoted prefixes work'); + fs.rmSync(path.join(home, '.npmrc')); + fs.mkdirSync(path.join(home, '.npmrc')); + assert.deepEqual(paths(), defaults, 'an unreadable npmrc does not prevent startup'); + } finally { fs.rmSync(home, { recursive: true, force: true }); } +}); + test('the development desktop app ignores legacy web update state and rejects install and channel requests', async () => { const data = fs.mkdtempSync(path.join(os.tmpdir(), 'papilot-desktop-runtime-')), root = process.cwd(); const version = readJson(path.join(root, 'package.json')).version; diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index e1f6bee3..e0b7ec3f 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -166,6 +166,48 @@ test('doctor uses the supported managed Codex and never leaks login output or kn } finally { f.cleanup(); } }); +test('desktop doctor searches PATH, the package and data in order with the server probe PATH', async () => { + const f = fixture(); + try { + f.baseTools(); + // Keep real discovery, but exclude installations outside this fixture. + const paths = path.join(f.app, 'bin/agent-paths.mjs'); + fs.renameSync(paths, path.join(f.app, 'bin/agent-paths-real.mjs')); + fs.writeFileSync(paths, ` + import fs from 'node:fs'; + import * as real from './agent-paths-real.mjs'; + export * from './agent-paths-real.mjs'; + export function findExecutables(...args) { return real.findExecutables(...args).filter(file => fs.realpathSync(file).startsWith(${JSON.stringify(fs.realpathSync(f.dir) + path.sep)})); } + `); + fs.unlinkSync(path.join(f.app, 'node_modules')); + const local = path.join(f.app, 'node_modules/.bin'), managed = path.join(f.data, 'agents/node_modules/.bin'); + const script = (version: string) => ` + if (process.argv.includes('--version')) { + if (!process.env.PATH.split(${JSON.stringify(path.delimiter)}).includes(${JSON.stringify(path.join(f.env.HOME!, '.local/bin'))})) process.exit(2); + console.log('codex-cli ${version}'); + } else console.log('Logged in'); + `; + const install = (dir: string, name: string, version: string) => { + fs.mkdirSync(dir, { recursive: true }); + const file = f.tool(name, script(version)); + if (process.platform === 'win32') fs.copyFileSync(file, path.join(dir, 'codex.cmd')); + else fs.symlinkSync(file, path.join(dir, 'codex')); + }; + f.tool('codex', script('0.1.0')); + install(local, 'local-codex', '0.2.0'); install(managed, 'data-codex', '0.3.0'); + const check = async (version: string) => { + const result = item((await f.run(['--json'], { PAPILOT_DESKTOP: '1' })).report, 'agent.codex'); + assert.equal(result.ok, true, result.detail); + assert.ok(result.detail.startsWith(`Codex ${version} (`), result.detail); + }; + await check('0.1.0'); + f.tool('codex', 'process.exit(1);'); + await check('0.2.0'); + f.tool('local-codex', 'process.exit(1);'); + await check('0.3.0'); + } finally { f.cleanup(); } +}); + test('doctor checks real repository configuration without changing files or making worktrees', { skip: process.platform === 'win32' && 'uses the POSIX which command and file links' }, async () => { const f = fixture(); try { From e140e3ae9fd41dbb2728d3fb76ce3f57fb7b8c78 Mon Sep 17 00:00:00 2001 From: Chen Cui Date: Mon, 9 Nov 2026 23:26:25 +0800 Subject: [PATCH 5/9] Say when no agent is set up, share the Codex lookup and cancel its probe - With no agent found, the new-task page says so and offers Reconnect and Add an agent (Settings > Agents) instead of a bare "codex" picker with an empty list; starting a task waits until an agent is set up, keeping the draft. - The server and doctor share one Codex lookup (bin/codex-lookup.mjs); doctor checks the login with the same PATH and deadline as the probe. - Closing Codex during the lookup kills the running probe and stops trying further installations. - A custom npm prefix expands ${VAR} and, on Windows, adds the prefix folder itself, where npm puts global commands. - On Windows the home folder is USERPROFILE again, as in agentDirs. --- bin/codex-lookup.d.mts | 2 ++ bin/codex-lookup.mjs | 40 +++++++++++++++++++++++++ bin/doctor.mjs | 19 +++++++----- desktop/runtime.ts | 11 +++++-- src/server/codex-binary.ts | 42 ++++---------------------- src/server/codex.ts | 14 +++++---- src/web/agent-choice.ts | 4 +++ src/web/agent-picker.tsx | 22 +++++++++----- src/web/locales/de.json | 3 ++ src/web/locales/es.json | 3 ++ src/web/locales/fr.json | 3 ++ src/web/locales/it.json | 3 ++ src/web/locales/ja.json | 3 ++ src/web/locales/ko.json | 3 ++ src/web/locales/pt.json | 3 ++ src/web/locales/sv.json | 3 ++ src/web/locales/zh-Hant.json | 3 ++ src/web/main.tsx | 12 ++++---- src/web/settings.tsx | 2 +- src/web/zh-CN.ts | 2 ++ tests/agent-choice.test.ts | 17 +++++++++-- tests/codex-binary.test.ts | 55 +++++++++++++++++++++++++++++++++-- tests/desktop-runtime.test.ts | 21 ++++++++++--- tests/doctor.test.ts | 2 +- 24 files changed, 217 insertions(+), 75 deletions(-) create mode 100644 bin/codex-lookup.d.mts create mode 100644 bin/codex-lookup.mjs diff --git a/bin/codex-lookup.d.mts b/bin/codex-lookup.d.mts new file mode 100644 index 00000000..a115a76c --- /dev/null +++ b/bin/codex-lookup.d.mts @@ -0,0 +1,2 @@ +export function codexCandidates(env: NodeJS.ProcessEnv, data: string, root: string): string[]; +export function firstRunnable(candidates: string[], env?: NodeJS.ProcessEnv, options?: { timeout?: number; signal?: AbortSignal }): Promise; diff --git a/bin/codex-lookup.mjs b/bin/codex-lookup.mjs new file mode 100644 index 00000000..8d25cfe2 --- /dev/null +++ b/bin/codex-lookup.mjs @@ -0,0 +1,40 @@ +import path from 'node:path'; +import { agentPath, findExecutables, killTree, spawnCommand, WINDOWS } from './agent-paths.mjs'; + +// Try PATH in order, then the checkout and the copy installed by the npm command. +export function codexCandidates(env, data, root) { + return env.PAPILOT_CODEX_BIN ? [env.PAPILOT_CODEX_BIN] : findExecutables('codex', env, [path.join(root, 'node_modules', '.bin'), path.join(data, 'agents', 'node_modules', '.bin')]); +} + +/** The first candidate that runs, tried with the PATH Codex is started with. */ +export async function firstRunnable(candidates, env = process.env, { timeout = 15000, signal } = {}) { + if (signal?.aborted) return null; + const probe = { ...env, PATH: agentPath(env) }; + for (const file of candidates) { + if (signal?.aborted) return null; + try { + const runs = await new Promise(resolve => { + const child = spawnCommand(file, ['--version'], { env: probe, stdio: 'ignore', detached: !WINDOWS }); + let settled = false; + const finish = runs => { + if (settled) return; + settled = true; clearTimeout(timer); signal?.removeEventListener('abort', stop); resolve(runs); + }; + // A wrapper's children must neither hold pipes open nor outlive the probe. + const stop = () => { + try { if (child.pid) killTree(child.pid, 'SIGKILL'); } + catch { try { child.kill('SIGKILL'); } catch { /* already stopped */ } } + finally { finish(false); } + }; + const timer = setTimeout(stop, timeout); + child.once('exit', code => finish(code === 0)); + child.once('error', () => finish(false)); + signal?.addEventListener('abort', stop, { once: true }); + if (signal?.aborted) stop(); + }); + if (signal?.aborted) return null; + if (runs) return file; + } catch { /* a broken installation; try the next */ } + } + return null; +} diff --git a/bin/doctor.mjs b/bin/doctor.mjs index 4c1f1cd5..b3bc2db0 100644 --- a/bin/doctor.mjs +++ b/bin/doctor.mjs @@ -1,4 +1,5 @@ -import { agentPath, findExecutable, findExecutables, killTree, spawnCommand } from './agent-paths.mjs'; +import { agentPath, findExecutable, killTree, spawnCommand } from './agent-paths.mjs'; +import { codexCandidates, firstRunnable } from './codex-lookup.mjs'; // Deliberately uses only Node built-ins: diagnostics must work before npm setup. import fs from 'node:fs'; import os from 'node:os'; @@ -134,23 +135,25 @@ export async function doctor(args) { const desktop = process.env.PAPILOT_DESKTOP === '1'; const minimum = desktop ? null : pkg.devDependencies['@openai/codex']; const configured = process.env.PAPILOT_CODEX_BIN; - // Like the server: the desktop app tries every codex on PATH, the npm command the first. const managed = path.join(data, 'agents/node_modules/.bin'); - const candidates = configured ? [configured] : desktop ? findExecutables('codex', process.env, [path.join(root, 'node_modules/.bin'), managed]) : ['codex', path.join(managed, 'codex')]; - const found = []; + const env = { ...process.env, PATH: agentPath(process.env) }; + const discovered = desktop ? codexCandidates(process.env, data, root) : []; + const selected = desktop ? await firstRunnable(discovered, process.env) : null; + const candidates = desktop ? (selected ? [selected] : []) : configured ? [configured] : ['codex', path.join(managed, 'codex')]; + const found = desktop && !selected ? discovered : []; for (const candidate of candidates) { - const file = agentExecutable(candidate); + const file = desktop ? candidate : agentExecutable(candidate); if (!file) continue; // As long as the server waits, so a slow start on a busy machine counts as running. - const result = await command(file, ['--version'], { timeout: 15000, env: { ...process.env, PATH: agentPath(process.env) } }); + const result = await command(file, ['--version'], { timeout: 15000, env }); const version = result.stdout.match(/\d+\.\d+\.\d+/)?.[0]; found.push(file); - if (!result.ok || (minimum && (!version || !atLeast(version, minimum)))) { + if (!desktop && (!result.ok || (minimum && (!version || !atLeast(version, minimum))))) { if (!configured) continue; check('agent.codex', false, 'warn', `Configured Codex ${result.ok ? 'is older than the supported version or its version is unknown' : result.reason}.`, minimum ? `Set PAPILOT_CODEX_BIN to Codex ${minimum} or newer.` : 'Correct PAPILOT_CODEX_BIN.'); return false; } - const login = await command(file, ['login', 'status']); + const login = await command(file, ['login', 'status'], desktop ? { timeout: 15000, env } : {}); const ready = login.ok && /^\s*logged in\b/im.test(login.stdout + '\n' + login.stderr); check('agent.codex', ready, 'warn', `${version ? `Codex ${version}` : 'Codex'} (${file}): ${ready ? 'signed in' : login.reason || 'login could not be confirmed'}.`, desktop ? 'Run codex login, then select Reconnect.' : 'Run papilot codex login, then rerun doctor.'); return ready; diff --git a/desktop/runtime.ts b/desktop/runtime.ts index 23012f5f..0f19fa1c 100644 --- a/desktop/runtime.ts +++ b/desktop/runtime.ts @@ -20,20 +20,25 @@ export async function refreshWindowsPath(inherited = process.env): Promise key.toLowerCase() === 'path').map(key => inherited[key] || '').join(path.delimiter) : envPath(inherited); - const home = inherited.HOME || os.homedir(); + const home = (process.platform === 'win32' ? inherited.USERPROFILE || inherited.HOME : inherited.HOME) || os.homedir(); const nvm = path.join(inherited.NVM_DIR || path.join(home, '.nvm'), 'versions/node'); let nodeBins: string[] = []; try { nodeBins = fs.readdirSync(nvm).filter(version => /^v\d+\.\d+\.\d+$/.test(version)).sort((a, b) => b.localeCompare(a, undefined, { numeric: true })).map(version => path.join(nvm, version, 'bin')); } catch { /* nvm is optional */ } let npmBin: string[] = []; try { - const prefix = fs.readFileSync(path.join(home, '.npmrc'), 'utf8').match(/^\s*prefix\s*=\s*(.+?)\s*$/m)?.[1].replace(/^(['"])(.*)\1$/, '$2'); - if (prefix) npmBin = [path.join(prefix.startsWith('~/') ? path.join(home, prefix.slice(2)) : prefix, 'bin')]; + const prefix = fs.readFileSync(path.join(home, '.npmrc'), 'utf8').match(/^\s*prefix\s*=\s*(.+?)\s*$/m)?.[1].replace(/^(['"])(.*)\1$/, '$2') + .replace(/\$\{([^}]+)\}/g, (match, name) => inherited[name] ?? match); + if (prefix) npmBin = [npmBinFolder(prefix.startsWith('~/') ? path.join(home, prefix.slice(2)) : prefix)]; } catch { /* a custom npm prefix is optional */ } const fnm = [inherited.FNM_DIR || path.join(home, '.local/share/fnm'), path.join(home, 'Library/Application Support/fnm')]; // An installed release has its dependencies beside it in node_modules. diff --git a/src/server/codex-binary.ts b/src/server/codex-binary.ts index 41b8231e..d3fb0377 100644 --- a/src/server/codex-binary.ts +++ b/src/server/codex-binary.ts @@ -1,43 +1,13 @@ -import path from 'node:path'; -import { agentPath, findExecutables, killTree, spawnCommand, WINDOWS } from '../../bin/agent-paths.mjs'; +import { codexCandidates, firstRunnable } from '../../bin/codex-lookup.mjs'; import { DATA, ROOT } from './paths.ts'; // Papilot uses the Codex installed on this computer, like every other agent. // The desktop installer does not carry Codex: it was the largest part of one, // and every Codex update meant downloading the whole app again. -// Every codex on PATH, in order (an early one may be a broken installation -// while a later one works), then a source checkout's own development copy and -// the copy the npm command installs into the data folder (`papilot codex login`). -export function codexCandidates(env: NodeJS.ProcessEnv = process.env, data = DATA, root = ROOT) { - return env.PAPILOT_CODEX_BIN ? [env.PAPILOT_CODEX_BIN] : findExecutables('codex', env, [path.join(root, 'node_modules', '.bin'), path.join(data, 'agents', 'node_modules', '.bin')]); -} - -/** The first candidate that runs, tried with the PATH Codex is started with. */ -export async function firstRunnable(candidates: string[], env: NodeJS.ProcessEnv = process.env, timeout = 15000) { - const probe = { ...env, PATH: agentPath(env) }; - for (const file of candidates) { - try { - const runs = await new Promise(resolve => { - const child = spawnCommand(file, ['--version'], { env: probe, stdio: 'ignore', detached: !WINDOWS }); - const finish = (runs: boolean) => { clearTimeout(timer); resolve(runs); }; - // A wrapper's children must neither hold pipes open nor outlive the probe. - const timer = setTimeout(() => { - try { if (child.pid) killTree(child.pid, 'SIGKILL'); } - catch { try { child.kill('SIGKILL'); } catch { /* already stopped */ } } - finally { finish(false); } - }, timeout); - child.once('exit', code => finish(code === 0)); - child.once('error', () => finish(false)); - }); - if (runs) return file; - } catch { /* a broken installation; try the next */ } - } - return null; -} - -export async function resolveCodex(candidates: string[], env: NodeJS.ProcessEnv = process.env): Promise { - const file = await firstRunnable(candidates, env); +export async function resolveCodex(candidates: string[], env: NodeJS.ProcessEnv = process.env, signal?: AbortSignal): Promise { + const file = await firstRunnable(candidates, env, { signal }); + if (signal?.aborted) throw new Error('Codex is not connected.'); if (file) return file; if (!candidates.length) throw new Error('Codex was not found on this computer. Install Codex, then select Reconnect.'); console.error(`No installed Codex could be run: ${candidates.join(', ')}.`); @@ -45,8 +15,8 @@ export async function resolveCodex(candidates: string[], env: NodeJS.ProcessEnv } /** The first Codex that runs. */ -export async function codexBinary(env: NodeJS.ProcessEnv = process.env, data = DATA): Promise { +export async function codexBinary(env: NodeJS.ProcessEnv = process.env, data = DATA, signal?: AbortSignal): Promise { // The npm command has already chosen its Codex. if (env.PAPILOT_CODEX_BIN) return env.PAPILOT_CODEX_BIN; - return resolveCodex(codexCandidates(env, data), env); + return resolveCodex(codexCandidates(env, data, ROOT), env, signal); } diff --git a/src/server/codex.ts b/src/server/codex.ts index c08cd4ad..f139b71e 100644 --- a/src/server/codex.ts +++ b/src/server/codex.ts @@ -3,8 +3,9 @@ import { type ChildProcess } from 'node:child_process'; import { createInterface } from 'node:readline'; import { EventEmitter } from 'node:events'; import type { TaskPermissions, TaskPermissionOption } from '../shared.ts'; -import { ROOT, VERSION } from './paths.ts'; -import { codexBinary, codexCandidates } from './codex-binary.ts'; +import { DATA, ROOT, VERSION } from './paths.ts'; +import { codexBinary } from './codex-binary.ts'; +import { codexCandidates } from '../../bin/codex-lookup.mjs'; import { taskAccessInstructions } from './task-permissions.ts'; export class RpcResponseError extends Error {} @@ -40,11 +41,12 @@ export class Codex extends EventEmitter { private sequence = 0; private pending = new Map void; reject: (error: Error) => void; timer: NodeJS.Timeout }>(); private starting: Promise | null = null; + private launchAbort: AbortController | null = null; ready = false; stderr = ''; // Without a binary of its own, this host's Codex is used (see codex-binary.ts). constructor(private binary?: string, private args = ['app-server', '--listen', 'stdio://'], private env: NodeJS.ProcessEnv = process.env) { super(); } - detect() { return Boolean(this.binary || codexCandidates(this.env).length); } + detect() { return Boolean(this.binary || codexCandidates(this.env, DATA, ROOT).length); } start(): Promise { if (this.ready) return Promise.resolve(); if (this.starting) return this.starting; @@ -56,8 +58,10 @@ export class Codex extends EventEmitter { private generation = 0; private async launch() { this.stderr = ''; + const controller = new AbortController(); + this.launchAbort = controller; const generation = this.generation; - const binary = this.binary || await codexBinary(this.env); + const binary = this.binary || await codexBinary(this.env, DATA, controller.signal); if (generation !== this.generation) throw new Error('Codex is not connected.'); const child = spawnCommand(binary, this.args, { cwd: ROOT, env: { ...this.env, PATH: agentPath(this.env) }, stdio: ['pipe', 'pipe', 'pipe'] }); this.child = child; @@ -98,5 +102,5 @@ export class Codex extends EventEmitter { notify(method: string, params: unknown) { this.child?.stdin?.write(JSON.stringify({ method, params }) + '\n'); } respond(id: string | number, result: unknown) { if (!this.child) throw new Error('Codex disconnected. Resume the task before retrying.'); this.child.stdin!.write(JSON.stringify({ id, result }) + '\n'); } reject(id: string | number, message: string) { this.child?.stdin?.write(JSON.stringify({ id, error: { code: -32601, message } }) + '\n'); } - close() { this.generation++; this.starting = null; this.child?.stdin?.end(); this.child?.kill('SIGTERM'); } + close() { this.generation++; this.launchAbort?.abort(); this.launchAbort = null; this.starting = null; this.child?.stdin?.end(); this.child?.kill('SIGTERM'); } } diff --git a/src/web/agent-choice.ts b/src/web/agent-choice.ts index 1f69ca61..bbff98b4 100644 --- a/src/web/agent-choice.ts +++ b/src/web/agent-choice.ts @@ -5,6 +5,10 @@ import { AGENT_IDS, type AgentId, type RuntimeStatus } from '../shared.ts'; export function offeredAgents(runtimes: Partial>) { return AGENT_IDS.filter(id => id === 'codex' ? runtimes.codex?.installed !== false : Boolean(runtimes[id] && runtimes[id]!.installed !== false)); } +// Wait for the host's report before treating an unknown installation as absent. +export function noAgent(runtimes: Partial>) { + return AGENT_IDS.every(id => runtimes[id]?.installed === false); +} // A remembered agent that is not found falls back to the first one offered, // Codex when it is there, without forgetting the choice. export function chosenAgent(saved: AgentId, offered: AgentId[]): AgentId { diff --git a/src/web/agent-picker.tsx b/src/web/agent-picker.tsx index 10a05135..be13055e 100644 --- a/src/web/agent-picker.tsx +++ b/src/web/agent-picker.tsx @@ -26,6 +26,14 @@ export function AgentConnectionNotice({ name, runtime, reconnecting, onReconnect ; } +export function NoAgentNotice({ reconnecting, onReconnect, onAdd }: { reconnecting: boolean; onReconnect: () => void; onAdd: () => void }) { + return
+
{t('No agent is set up on this computer yet. Add one, such as Codex or Claude Code, then select Reconnect.')}
+ + +
; +} + // Recent agent and model choices, newest first, shown above the full list. const RECENT = 'papilot:recent-models'; function readRecent(): { agent: AgentId; model: string }[] { @@ -52,11 +60,11 @@ function agentState(runtime?: RuntimeStatus) { // selected model's reasoning effort, fast mode and permissions sit below the list. Native // popovers keep the menu above the PDF and scrolling panels, with // outside-click and Escape dismissal. -export function AgentPicker({ agents, agent, model, effort, fast, reconnecting, onReconnect, onSelect, onChange, disabled, description, permissions, permissionMode, allowUnknownDefault = true }: { +export function AgentPicker({ agents, agent, model, effort, fast, reconnecting, onReconnect, onSelect, onChange, disabled, description, permissions, permissionMode, allowUnknownDefault = true, noAgent = false }: { agents: AgentOption[]; agent: AgentId; model: string; effort: string; fast: boolean; reconnecting: boolean; onReconnect: () => void; onSelect: (agent: AgentId, model: string) => void; onChange: (change: Partial<{ effort: string; fast: boolean }>) => void; disabled?: boolean; description?: string; allowUnknownDefault?: boolean; - permissions?: ReactNode; permissionMode?: TaskPermissions; + permissions?: ReactNode; permissionMode?: TaskPermissions; noAgent?: boolean; }) { const id = useId(), trigger = useRef(null), panel = useRef(null), search = useRef(null), list = useRef(null); const [open, setOpen] = useState(false), [query, setQuery] = useState(''), [active, setActive] = useState(''), [recent, setRecent] = useState(readRecent); @@ -69,7 +77,7 @@ export function AgentPicker({ agents, agent, model, effort, fast, reconnecting, const fastMode = agent === 'codex' ? selected?.fastMode : undefined; const permission = runtime?.permissionOptions?.find(option => option.value === permissionMode); const disconnected = runtime && (!runtime.connected || !runtime.authenticated); - const summary = [agentName, modelName, currentEffort ? effortName(currentEffort) : '', fastMode && fast ? t('Fast') : '', permission ? permissionText(permission.label) : ''].filter((part, index, all) => part && all.indexOf(part) === index).join(' · '); + const summary = noAgent ? t('No agent') : [agentName, modelName, currentEffort ? effortName(currentEffort) : '', fastMode && fast ? t('Fast') : '', permission ? permissionText(permission.label) : ''].filter((part, index, all) => part && all.indexOf(part) === index).join(' · '); // Every model of every agent; an agent without a model list offers its default. const choices = (option: AgentOption): Choice[] => { const list = option.runtime?.models || []; @@ -104,7 +112,7 @@ export function AgentPicker({ agents, agent, model, effort, fast, reconnecting, window.addEventListener('resize', dismiss); document.addEventListener('scroll', scroll, true); return () => { observer.disconnect(); window.removeEventListener('resize', dismiss); document.removeEventListener('scroll', scroll, true); }; }, [open]); - useEffect(() => { if (disabled) panel.current?.hidePopover(); }, [disabled]); + useEffect(() => { if (disabled || noAgent) panel.current?.hidePopover(); }, [disabled, noAgent]); // Keep the active option in view while moving through the list with the keyboard. useEffect(() => { if (open && active) list.current?.querySelector(`[data-key="${CSS.escape(active)}"]`)?.scrollIntoView({ block: 'nearest' }); }, [active, open]); function show() { @@ -138,15 +146,15 @@ export function AgentPicker({ agents, agent, model, effort, fast, reconnecting, }; const activeChoice = visible.find(choice => choice.key === active); return -