Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions bin/agent-paths.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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[], include?: (dir: string) => boolean): 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<Buffer>;
Expand Down
10 changes: 10 additions & 0 deletions bin/agent-paths.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [], include = () => true) {
const seen = new Set();
return [...agentPath(env).split(path.delimiter), ...extra].filter(Boolean).filter(include).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) {
Expand Down
4 changes: 4 additions & 0 deletions bin/codex-lookup.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export function isWsl(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, release?: string): boolean;
export function windowsDrive(dir: string): boolean;
export function codexCandidates(env: NodeJS.ProcessEnv, data: string, root: string, wsl?: boolean): string[];
export function firstRunnable(candidates: string[], env?: NodeJS.ProcessEnv, options?: { timeout?: number; budget?: number; signal?: AbortSignal }): Promise<string | null>;
54 changes: 54 additions & 0 deletions bin/codex-lookup.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import fs from 'node:fs';
import path from 'node:path';
import { agentPath, findExecutables, killTree, spawnCommand, WINDOWS } from './agent-paths.mjs';

const kernelRelease = (() => {
try { return process.platform === 'linux' ? fs.readFileSync('/proc/sys/kernel/osrelease', 'utf8') : ''; }
catch { return ''; }
})();
export function isWsl(env = process.env, platform = process.platform, release = kernelRelease) {
return platform === 'linux' && Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP || /microsoft/i.test(release));
}
export function windowsDrive(dir) {
return /^\/mnt\/[a-z](?:\/|$)/i.test(dir.replace(/^"|"$/g, ''));
}
// Try PATH in order, then the checkout and the copy installed by the npm command.
export function codexCandidates(env, data, root, wsl = isWsl(env)) {
// Windows shims do not supply WSL's Codex; avoid their slow 9P folders too.
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')], dir => !wsl || !windowsDrive(dir));
}

/** The first candidate that runs, tried with the PATH Codex is started with. */
export async function firstRunnable(candidates, env = process.env, { timeout = 15000, budget = 30000, signal } = {}) {
if (signal?.aborted) return null;
const deadline = Date.now() + budget;
const probe = { ...env, PATH: agentPath(env) };
for (const file of candidates) {
const remaining = deadline - Date.now();
if (signal?.aborted || remaining <= 0) 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, Math.min(timeout, remaining));
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;
}
50 changes: 42 additions & 8 deletions bin/doctor.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { findExecutable, killTree, spawnCommand } from './agent-paths.mjs';
import { agentPath, findExecutable, killTree, spawnCommand } from './agent-paths.mjs';
import { codexCandidates } 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';
Expand Down Expand Up @@ -128,28 +129,61 @@ export async function doctor(args) {
const availableEngines = texEngines.filter(Boolean);
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 desktopCodexCheck() {
const configured = process.env.PAPILOT_CODEX_BIN;
const candidates = codexCandidates(process.env, data, root);
const env = { ...process.env, PATH: agentPath(process.env) };
// Probes and login share a budget within the diagnostics deadline.
const deadline = Date.now() + 20000;
const tried = [];
for (const file of candidates) {
const remaining = deadline - Date.now();
if (remaining <= 0) break;
tried.push(file);
const result = await command(file, ['--version'], { timeout: Math.min(10000, remaining), env });
if (!result.ok) continue;
const version = result.stdout.match(/\d+\.\d+\.\d+/)?.[0];
const login = await command(file, ['login', 'status'], { timeout: Math.max(3000, Math.min(10000, deadline - Date.now())), 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'}.`, 'Run codex login, then select Reconnect.');
return ready;
}
const untried = candidates.slice(tried.length);
const failure = [tried.length ? `Codex could not be run (${tried.join(', ')}).` : '', untried.length ? `Codex candidates not checked in time (${untried.join(', ')}).` : ''].filter(Boolean).join(' ');
check('agent.codex', false, 'warn', configured ? 'PAPILOT_CODEX_BIN does not point to an executable.' : !candidates.length ? 'Codex is not installed.' : failure,
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.'
: `${candidates.length ? 'Reinstall' : 'Install'} Codex (npm install -g @openai/codex), then select Reconnect.`);
return false;
}
async function codexCheck() {
if (process.env.PAPILOT_DESKTOP === '1') return desktopCodexCheck();
// The npm command installs the tested version when the installed one is older.
const minimum = 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;
const managed = path.join(data, 'agents/node_modules/.bin');
const candidates = configured ? [configured] : ['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'}.`, '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.' : `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 or unset it to use automatic installation.'
: 'Run papilot codex login; it installs the supported Codex when needed.');
return false;
}
async function claudeCheck() {
Expand Down
6 changes: 3 additions & 3 deletions desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ 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
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.

Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion desktop/environment.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<div id="wsl"><div class="distribution" id="distribution-row"><label for="distribution" data-en="Linux distribution" data-zh="Linux 发行版">Linux distribution</label><select id="distribution"></select></div>
<div id="install-wsl" hidden><div class="install-heading"><span class="setup-symbol">↓</span><div><h3 data-en="Start with WSL + Ubuntu" data-zh="先安装 WSL + Ubuntu">Start with WSL + Ubuntu</h3><p class="muted" data-en="Open PowerShell as administrator and run this command. Restart if prompted, then open Ubuntu to finish setup." data-zh="以管理员身份打开 PowerShell,运行下方命令。按提示重启,再打开 Ubuntu 完成首次设置。">Open PowerShell as administrator and run this command. Restart if prompted, then open Ubuntu to finish setup.</p></div></div><div class="command"><code>wsl --install -d Ubuntu</code><button id="copy-install" type="button" data-en="Copy" data-zh="复制">Copy</button></div><button id="help" class="text-button" type="button" data-en="Microsoft installation guide ↗" data-zh="微软安装指南 ↗">Microsoft installation guide ↗</button></div></div>
<div id="checks" aria-busy="true"></div>
<p id="agent-note" class="panel-note" hidden data-en="Codex is included with Papilot. Sign-in is checked when you open your workspace." data-zh="Papilot 随附 Codex,进入工作区后会检查登录状态。">Codex is included with Papilot. Sign-in is checked when you open your workspace.</p>
<p id="agent-note" class="panel-note" hidden data-en="Papilot uses the Codex installed in this environment. Sign-in is checked when you open your workspace." data-zh="Papilot 使用此环境中安装的 Codex,进入工作区后会检查登录状态。">Papilot uses the Codex installed in this environment. Sign-in is checked when you open your workspace.</p>
</section>
<details id="output" hidden><summary data-en="Setup details" data-zh="准备详情">Setup details</summary><pre id="log"></pre></details>
<p id="status" role="status" aria-live="polite"></p>
Expand Down
Loading
Loading