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..e9fc350f 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[], 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; diff --git a/bin/agent-paths.mjs b/bin/agent-paths.mjs index 780c0b82..caceca13 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 = [], 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) { diff --git a/bin/codex-lookup.d.mts b/bin/codex-lookup.d.mts new file mode 100644 index 00000000..c4f1f806 --- /dev/null +++ b/bin/codex-lookup.d.mts @@ -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; diff --git a/bin/codex-lookup.mjs b/bin/codex-lookup.mjs new file mode 100644 index 00000000..1128e3f4 --- /dev/null +++ b/bin/codex-lookup.mjs @@ -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; +} diff --git a/bin/doctor.mjs b/bin/doctor.mjs index 31e8d88b..5b223f93 100644 --- a/bin/doctor.mjs +++ b/bin/doctor.mjs @@ -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'; @@ -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() { diff --git a/desktop/README.md b/desktop/README.md index 9bb941e9..59c7d39d 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 @@ -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/desktop/runtime.ts b/desktop/runtime.ts index 4fe74556..0f19fa1c 100644 --- a/desktop/runtime.ts +++ b/desktop/runtime.ts @@ -20,21 +20,35 @@ export async function refreshWindowsPath(inherited = process.env): Promise key.toLowerCase() === 'path').map(key => inherited[key] || '').join(path.delimiter) : envPath(inherited); - const 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') + .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. 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/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-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..0bec0a91 --- /dev/null +++ b/scripts/desktop-dedupe.mjs @@ -0,0 +1,45 @@ +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; + // Removing a package also moves where Node resolves its dependencies. + const dependencies = Object.keys({ ...local.dependencies, ...local.optionalDependencies, ...local.peerDependencies }); + if (dependencies.some(dependency => { + const top = `node_modules/${dependency}`, nested = `node_modules/${name}/node_modules/${dependency}`; + return !engineLock[top]?.version || engineLock[top].version !== serverLock[top]?.version || engineLock[nested] || serverLock[nested]; + })) 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.mjs b/scripts/desktop-layer.mjs index 9f998e89..ee86314a 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. @@ -28,14 +28,15 @@ 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 = { 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'); diff --git a/scripts/desktop.mjs b/scripts/desktop.mjs index ac43cc03..f339d20c 100644 --- a/scripts/desktop.mjs +++ b/scripts/desktop.mjs @@ -5,6 +5,7 @@ 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 { dedupeEngine } from './desktop-dedupe.mjs'; import { tarCommand } from '../bin/agent-paths.mjs'; import { buildWindowsIcon } from './build-windows-icon.mjs'; @@ -78,13 +79,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/app.ts b/src/server/app.ts index 8ea5d738..b7e881f7 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); + 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/codex-binary.ts b/src/server/codex-binary.ts new file mode 100644 index 00000000..d3fb0377 --- /dev/null +++ b/src/server/codex-binary.ts @@ -0,0 +1,22 @@ +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. + +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(', ')}.`); + 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, 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, ROOT), env, signal); +} diff --git a/src/server/codex.ts b/src/server/codex.ts index c383b05c..cc405caf 100644 --- a/src/server/codex.ts +++ b/src/server/codex.ts @@ -3,7 +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 { CODEX, ROOT, VERSION } from './paths.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 {} @@ -39,21 +41,33 @@ 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 = ''; - 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, DATA, ROOT).length); } 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() invalidates both the lookup and initialization of that launch. + private generation = 0; 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 controller = new AbortController(); + this.launchAbort = controller; + const generation = this.generation; + 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; const lines = createInterface({ input: child.stdout! }); - lines.on('line', line => { try { this.receive(JSON.parse(line)); } catch { /* non-protocol stdout is ignored */ } }); + // A closed launch's child may still print before it exits; only the current one speaks for Codex. + lines.on('line', line => { if (this.child !== child) return; try { this.receive(JSON.parse(line)); } catch { /* non-protocol stdout is ignored */ } }); child.stderr!.on('data', chunk => { this.stderr = (this.stderr + chunk.toString()).slice(-12000); }); const failed = (error: Error) => { if (this.child !== child) return; @@ -65,6 +79,7 @@ export class Codex extends EventEmitter { child.once('exit', (code, signal) => failed(new Error(`Codex exited (${signal || code})`))); // The experimental API carries the tools Papilot gives its threads (see VIEW_PDF_TOOL). await this.request('initialize', { clientInfo: { name: 'papilot', title: 'Papilot', version: VERSION }, capabilities: { experimentalApi: true } }); + if (generation !== this.generation) throw new Error('Codex is not connected.'); this.notify('initialized', {}); this.ready = true; } private receive(message: any) { @@ -89,5 +104,10 @@ 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.launchAbort?.abort(); this.launchAbort = null; this.starting = null; this.ready = false; + for (const wait of this.pending.values()) { clearTimeout(wait.timer); wait.reject(new Error('Codex is not connected.')); } + this.pending.clear(); + this.child?.stdin?.end(); this.child?.kill('SIGTERM'); this.child = null; + } } 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/server/store.ts b/src/server/store.ts index ae5e8ca9..5597e143 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) { @@ -62,11 +72,13 @@ export class Store extends EventEmitter { if (value === undefined) this.db.prepare('DELETE FROM settings WHERE key=?').run(key); else this.db.prepare('INSERT INTO settings VALUES(?,?) ON CONFLICT(key) DO UPDATE SET data=excluded.data').run(key, JSON.stringify(value)); } - saveTask(t: Task) { t.updatedAt = Date.now(); this.db.prepare('INSERT INTO tasks VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data').run(t.id, t.projectId, JSON.stringify(t)); this.emit('change'); return t; } + saveTask(t: Task) { t.updatedAt = Date.now(); this.db.prepare('INSERT INTO tasks VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET project_id=excluded.project_id, data=excluded.data').run(t.id, t.projectId, JSON.stringify(t)); this.emit('change'); return t; } updateTask(id: string, update: Partial) { return this.saveTask({ ...this.task(id), ...update }); } 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/agent-choice.ts b/src/web/agent-choice.ts new file mode 100644 index 00000000..637290d7 --- /dev/null +++ b/src/web/agent-choice.ts @@ -0,0 +1,19 @@ +import { AGENT_IDS, type AgentId, type RuntimeStatus } from '../shared.ts'; + +// Offer only reported runtimes whose installation is not known to be missing. +export function offeredAgents(runtimes: Partial>) { + return AGENT_IDS.filter(id => 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); +} +export function agentsSettled(runtimes: Partial>) { + const reported = Object.values(runtimes).filter(Boolean); + return reported.length > 0 && reported.every(runtime => runtime.connected || Boolean(runtime.error) || runtime.installed === false); +} +// Wait for startup to settle before preferring an agent ready for the first task. +export function chosenAgent(saved: AgentId | null, offered: AgentId[], runtimes: Partial>): AgentId { + return saved && offered.includes(saved) ? saved + : (agentsSettled(runtimes) && offered.find(id => runtimes[id]?.connected && runtimes[id]?.authenticated)) || offered[0] || 'codex'; +} diff --git a/src/web/agent-picker.tsx b/src/web/agent-picker.tsx index d13192cd..1fd2f45a 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 }) @@ -26,6 +26,18 @@ export function AgentConnectionNotice({ name, runtime, reconnecting, onReconnect ; } +// Adding an agent is the way forward, so it is the main action; Reconnect +// checks again once one is installed. +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 +64,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 +81,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 +116,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 +150,15 @@ export function AgentPicker({ agents, agent, model, effort, fast, reconnecting, }; const activeChoice = visible.find(choice => choice.key === active); return -