papilot 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.de.md +110 -0
  2. package/README.es.md +110 -0
  3. package/README.fr.md +110 -0
  4. package/README.it.md +110 -0
  5. package/README.ja.md +110 -0
  6. package/README.ko.md +110 -0
  7. package/README.md +3 -1
  8. package/README.pt.md +110 -0
  9. package/README.sv.md +110 -0
  10. package/README.zh-CN.md +39 -29
  11. package/README.zh-Hant.md +110 -0
  12. package/dist/assets/index-oAIkxhfp.js +265 -0
  13. package/dist/assets/index-t_qCOF_B.css +1 -0
  14. package/dist/assets/{math-Bu3xEyrC.js → math-CEU4m4-H.js} +1 -1
  15. package/dist/assets/{pdf-view-PbkCuTZ-.js → pdf-view-HWRGBLsZ.js} +1 -1
  16. package/dist/index.html +2 -2
  17. package/package.json +11 -4
  18. package/src/server/acp.ts +9 -5
  19. package/src/server/agents.ts +4 -2
  20. package/src/server/app.ts +7 -0
  21. package/src/server/imports.ts +60 -21
  22. package/src/server/manuscript.ts +26 -8
  23. package/src/server/start.ts +8 -1
  24. package/src/server/store.ts +0 -8
  25. package/src/server/update-install.ts +22 -2
  26. package/src/server/updates.ts +27 -13
  27. package/src/server/workspace.ts +24 -2
  28. package/src/shared.ts +3 -0
  29. package/src/skill-metadata.ts +1 -1
  30. package/vendor/latex-review/dist/web/assets/index-DVs7cTBl.js +19 -0
  31. package/vendor/latex-review/dist/web/assets/index-ysOpu05D.css +1 -0
  32. package/vendor/latex-review/dist/web/index.html +2 -2
  33. package/vendor/latex-review/src/core/build.ts +180 -97
  34. package/vendor/latex-review/src/core/git.ts +133 -28
  35. package/vendor/latex-review/src/core/outline.ts +0 -9
  36. package/vendor/latex-review/src/locales.ts +47 -0
  37. package/vendor/latex-review/src/web/i18n.ts +9 -21
  38. package/vendor/latex-review/src/web/locales/de.json +250 -0
  39. package/vendor/latex-review/src/web/locales/es.json +250 -0
  40. package/vendor/latex-review/src/web/locales/fr.json +250 -0
  41. package/vendor/latex-review/src/web/locales/index.ts +16 -0
  42. package/vendor/latex-review/src/web/locales/it.json +250 -0
  43. package/vendor/latex-review/src/web/locales/ja.json +250 -0
  44. package/vendor/latex-review/src/web/locales/ko.json +250 -0
  45. package/vendor/latex-review/src/web/locales/pt.json +250 -0
  46. package/vendor/latex-review/src/web/locales/sv.json +250 -0
  47. package/vendor/latex-review/src/web/locales/zh-Hant.json +250 -0
  48. package/vendor/latex-review/src/web/references.tsx +1 -1
  49. package/vendor/latex-review/src/web/review.tsx +1 -1
  50. package/vendor/latex-review/src/web/style.css +1 -11
  51. package/dist/assets/index-BlLyIqT9.js +0 -265
  52. package/dist/assets/index-DVZtyZvu.css +0 -1
  53. package/vendor/latex-review/dist/web/assets/index-CXdnUjct.js +0 -19
  54. package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +0 -1
package/dist/index.html CHANGED
@@ -8,6 +8,6 @@
8
8
  document.documentElement.dataset.theme = dark ? 'dark' : 'light';
9
9
  document.querySelector('meta[name="theme-color"]').content = dark ? '#26272b' : '#ffffff';
10
10
  })();
11
- </script><style>html{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;background:#fff}html[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#26272b}</style> <script type="module" crossorigin src="/assets/index-BlLyIqT9.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-DVZtyZvu.css">
11
+ </script><style>html{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;background:#fff}html[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#26272b}</style> <script type="module" crossorigin src="/assets/index-oAIkxhfp.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-t_qCOF_B.css">
13
13
  </head><body><div id="root"></div></body></html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "papilot",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "A Cursor-like workspace for your papers: annotate and review PDFs directly without ever touching LaTeX.",
5
5
  "keywords": [
6
6
  "latex",
@@ -57,6 +57,7 @@
57
57
  "engines": {
58
58
  "node": ">=22.22.0"
59
59
  },
60
+ "desktopShell": 1,
60
61
  "scripts": {
61
62
  "dev": "tsx watch src/server/main.ts",
62
63
  "start": "tsx src/server/main.ts",
@@ -68,14 +69,17 @@
68
69
  "doctor": "node bin/papilot.mjs doctor",
69
70
  "service:install": "tsx scripts/service.ts install",
70
71
  "service:stop": "tsx scripts/service.ts stop",
71
- "test:ui": "npm run build && node scripts/check-ui.mjs && node scripts/check-appearance.mjs && node scripts/check-updates.mjs && node scripts/check-papers.mjs && node scripts/check-imports.mjs && node scripts/check-compilation.mjs && node scripts/check-build-problems.mjs && node scripts/check-review-notes.mjs && node --import tsx scripts/check-note-groups.mjs && node scripts/check-note-selection.mjs && node scripts/check-ai-review.mjs && node --import tsx scripts/check-note-results.mjs && node --import tsx scripts/check-review-flow.mjs && node scripts/check-skills.mjs && node --import tsx scripts/check-skill-library.mjs && node scripts/check-attachments.mjs && node --import tsx scripts/check-accounts.mjs",
72
+ "test:ui": "npm run build && node scripts/check-ui.mjs && node scripts/check-task-model.mjs && node scripts/check-appearance.mjs && node scripts/check-updates.mjs && node scripts/check-papers.mjs && node scripts/check-imports.mjs && node scripts/check-compilation.mjs && node scripts/check-build-problems.mjs && node scripts/check-review-notes.mjs && node --import tsx scripts/check-note-groups.mjs && node scripts/check-note-selection.mjs && node scripts/check-ai-review.mjs && node --import tsx scripts/check-note-results.mjs && node --import tsx scripts/check-review-flow.mjs && node scripts/check-skills.mjs && node --import tsx scripts/check-skill-library.mjs && node scripts/check-attachments.mjs && node --import tsx scripts/check-accounts.mjs",
72
73
  "site": "node scripts/build-site.mjs",
73
74
  "test:site": "npm run site && node scripts/check-site.mjs",
74
75
  "prepack": "npm run build && npm run setup:review",
75
76
  "desktop:compile": "tsc -p desktop/tsconfig.json",
76
77
  "desktop": "npm run setup:review && npm run build && npm run desktop:compile && node scripts/desktop.mjs dev",
77
78
  "desktop:build": "npm run setup:review && npm run build && npm run desktop:compile && node scripts/desktop.mjs package",
78
- "test:desktop": "node scripts/check-desktop.mjs"
79
+ "test:desktop": "node scripts/check-desktop.mjs",
80
+ "i18n": "tsx scripts/i18n.ts",
81
+ "i18n:check-app": "tsx scripts/i18n/app-locales.ts",
82
+ "test:locales": "npm run build && npm --prefix vendor/latex-review run build && node --import tsx scripts/check-locales.mjs"
79
83
  },
80
84
  "dependencies": {
81
85
  "@fastify/compress": "^9.2.2",
@@ -109,6 +113,9 @@
109
113
  "remark-gfm": "^4.0.1",
110
114
  "remark-math": "^6.0.0",
111
115
  "typescript": "^7.0.2",
112
- "vite": "^8.3.5"
116
+ "vite": "^8.3.5",
117
+ "parse5": "^8.0.1",
118
+ "@babel/parser": "^8.0.10",
119
+ "@babel/types": "^8.0.10"
113
120
  }
114
121
  }
package/src/server/acp.ts CHANGED
@@ -218,7 +218,8 @@ export class AcpAgent extends Adapter {
218
218
  const previous = this.catalog.find(entry => entry.model === option.value);
219
219
  const entry = { model: option.value, displayName: option.name, isDefault: option.value === this.defaultModel,
220
220
  supportedReasoningEfforts: option.value === current ? levels.map(choice => ({ reasoningEffort: choice.value })) : previous?.supportedReasoningEfforts || [],
221
- defaultReasoningEffort: option.value === current ? level : previous?.defaultReasoningEffort || null };
221
+ // A turn's explicit effort must not become the catalog's default.
222
+ defaultReasoningEffort: previous?.supportedReasoningEfforts.length ? previous.defaultReasoningEffort : option.value === current ? level : null };
222
223
  if (previous) Object.assign(previous, entry); else this.catalog.push(entry);
223
224
  }
224
225
  }
@@ -239,10 +240,13 @@ export class AcpAgent extends Adapter {
239
240
  };
240
241
  try {
241
242
  const session = await this.session(turn.threadId, thread.cwd, thread.model);
242
- if (effort) {
243
- const option = session.configOptions?.find(option => option.category === 'thought_level' || option.id === 'thinking');
244
- if (!option || !choices(option).some(choice => choice.value === effort)) throw new Error(`${this.name} does not support reasoning effort ${effort} for this model.`);
245
- await this.rpc('session/set_config_option', { sessionId: turn.threadId, configId: option.id, value: effort });
243
+ const option = session.configOptions?.find(option => option.category === 'thought_level' || option.id === 'thinking');
244
+ const defaultEffort = choices(option).some(choice => choice.value === 'default') ? 'default' : this.catalog.find(model => model.model === thread.model)?.defaultReasoningEffort;
245
+ const chosenEffort = effort || defaultEffort;
246
+ if (chosenEffort) {
247
+ if (!option || !choices(option).some(choice => choice.value === chosenEffort)) throw new Error(`${this.name} does not support reasoning effort ${chosenEffort} for this model.`);
248
+ const result = await this.rpc('session/set_config_option', { sessionId: turn.threadId, configId: option.id, value: chosenEffort });
249
+ if (result?.configOptions) session.configOptions = result.configOptions;
246
250
  }
247
251
  const prompt: any[] = [{ type: 'text', text: thread.started ? text : `${thread.instructions}\n\n---\n\n${text}` }];
248
252
  if (images.length && !this.capabilities.promptCapabilities?.image) throw new Error(`This ${this.name} version does not support image input. Update it, or send the images to another agent.`);
@@ -8,7 +8,7 @@ import { createRequire } from 'node:module';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
10
  import { pathToFileURL } from 'node:url';
11
- import type { AccountInfo, ModelInfo, Options, PermissionResult, Query, SDKMessage, SDKRateLimitInfo, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
11
+ import type { AccountInfo, ModelInfo, Options, PermissionResult, SDKMessage, SDKRateLimitInfo, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
12
12
  import { AGENT_NAMES } from '../shared.ts';
13
13
  import { DATA, ROOT } from './paths.ts';
14
14
  import { imageType } from './attachments.ts';
@@ -79,13 +79,15 @@ export abstract class Adapter extends EventEmitter implements AgentRuntime {
79
79
  if (method === 'turn/start') {
80
80
  const thread = this.threads.get(String(params.threadId));
81
81
  if (!thread) throw new Error('Start or resume the thread before starting a turn.');
82
+ if (params.model !== undefined) thread.model = params.model || null;
82
83
  const text = (params.input || []).filter((part: { text?: unknown }) => typeof part.text === 'string').map((part: { text: string }) => part.text).join('\n');
83
84
  const images = (params.input || []).filter((part: { type?: string; path?: string }) => part.type === 'localImage' && typeof part.path === 'string').map((part: { path: string }) => part.path);
84
85
  const turn: Turn = { id: randomUUID(), threadId: String(params.threadId), clientId: params.clientUserMessageId, status: 'inProgress', stop: () => {} };
85
86
  turn.stop = () => this.finish(turn, 'interrupted');
86
87
  this.turns.set(turn.id, turn);
87
88
  // Papilot records the turn ID before the first event arrives.
88
- setImmediate(() => { if (turn.status === 'inProgress') this.run(turn, { ...thread, cwd: params.cwd || thread.cwd }, text, params.effort || null, images); });
89
+ const settings = { ...thread, cwd: params.cwd || thread.cwd };
90
+ setImmediate(() => { if (turn.status === 'inProgress') this.run(turn, settings, text, params.effort || null, images); });
89
91
  return { turn: { id: turn.id } };
90
92
  }
91
93
  if (method === 'turn/interrupt') { this.turns.get(String(params.turnId))?.stop(); return {}; }
package/src/server/app.ts CHANGED
@@ -263,6 +263,13 @@ export async function buildApp(options: AppOptions) {
263
263
  const task = store.task(request.params.id);
264
264
  return workspace.commitMessage(task.projectId, task.review?.files || [], { taskId: task.id, agent: task.agent, fresh: (request.body as any)?.fresh === true });
265
265
  });
266
+ app.post<{ Params: { id: string } }>('/api/tasks/:id/model', task, request => {
267
+ const b = (request.body || {}) as Record<string, unknown>;
268
+ if (Object.keys(b).some(key => !['model', 'effort', 'serviceTier'].includes(key))) throw new Error('Only the model and its settings can be changed.');
269
+ if (b.model !== undefined && (typeof b.model !== 'string' || b.model.length > 200)) throw new Error('Invalid model.');
270
+ if (b.effort !== undefined && b.effort !== '' && !effort(b.effort)) throw new Error('Invalid reasoning effort.');
271
+ return workspace.updateTaskModel(request.params.id, { ...(b.model !== undefined ? { model: b.model as string } : {}), ...(b.effort !== undefined ? { effort: b.effort as string } : {}), ...(b.serviceTier !== undefined ? { serviceTier: string(b.serviceTier, 'Service tier', 64) } : {}) });
272
+ });
266
273
  app.post<{ Params: { id: string } }>('/api/tasks/:id/rename', task, request => workspace.renameTask(request.params.id, string((request.body as any).title, 'Task title', 120)));
267
274
  app.post<{ Params: { id: string } }>('/api/tasks/:id/title', task, request => workspace.requestTaskTitle(request.params.id));
268
275
  app.post<{ Params: { id: string } }>('/api/tasks/:id/archive', task, request => workspace.archiveTask(request.params.id, (request.body as any)?.archived, (request.body as any)?.finished === true));
@@ -12,6 +12,7 @@ import type { PaperImport, Project } from '../shared.ts';
12
12
  import type { Manuscripts } from './manuscript.ts';
13
13
 
14
14
  const execute = promisify(execFile);
15
+ // These limits apply to uploads, not folders already on this computer.
15
16
  const MAX_BYTES = 64 * 1024 * 1024, MAX_FILES = 5000;
16
17
  const CONFIG = '.latex-review.json';
17
18
  const skip = (name: string) => ['.git', '__macosx', '.ds_store', 'node_modules', '.papilot'].includes(name.toLowerCase()) || name.startsWith('._');
@@ -33,40 +34,71 @@ function config(root: string): Record<string, unknown> {
33
34
  return data;
34
35
  }
35
36
  export function texFiles(root: string) {
36
- const files: string[] = []; let count = 0;
37
- function walk(folder: string, depth: number) {
38
- if (depth > 20) return;
37
+ const files: string[] = [], folders = [''];
38
+ while (folders.length) {
39
+ const folder = folders.pop()!;
39
40
  for (const item of fs.readdirSync(path.join(root, folder), { withFileTypes: true })) {
40
- if (++count > MAX_FILES) throw new Error('This folder has too many files. Select the folder containing just the paper.');
41
41
  if (skip(item.name) || ['build', 'out', 'output'].includes(item.name)) continue;
42
42
  const file = path.posix.join(folder, item.name);
43
- if (item.isDirectory()) walk(file, depth + 1);
43
+ if (item.isDirectory()) folders.push(file);
44
44
  else if (item.isFile() && /\.tex$/i.test(file)) files.push(file);
45
45
  }
46
46
  }
47
- walk('', 0); return files.sort();
47
+ return files.sort();
48
+ }
49
+ function existingTex(root: string, file: unknown): file is string {
50
+ if (typeof file !== 'string' || !/\.tex$/i.test(file)) return false;
51
+ try {
52
+ safeName(file);
53
+ let current = root;
54
+ for (const part of file.split('/')) {
55
+ current = path.join(current, part);
56
+ if (fs.lstatSync(current).isSymbolicLink()) return false;
57
+ }
58
+ return fs.statSync(current).isFile();
59
+ } catch { return false; }
60
+ }
61
+ function sourceHead(file: string) {
62
+ const fd = fs.openSync(file, 'r');
63
+ try {
64
+ const buffer = Buffer.alloc(300000);
65
+ return buffer.subarray(0, fs.readSync(fd, buffer, 0, buffer.length, 0)).toString('utf8');
66
+ } finally { fs.closeSync(fd); }
48
67
  }
49
68
  export async function inspectPaper(root: string) {
50
- const files = texFiles(root), roots = files.filter(file => {
51
- const text = fs.readFileSync(path.join(root, file), 'utf8').slice(0, 300000).replace(/(?<!\\)%[^\n]*/g, '');
52
- return /\\documentclass\b/.test(text) && /\\begin\s*\{document\}/.test(text);
53
- });
54
- let main = roots.length === 1 ? roots[0] : '', engine = '', error: string | undefined;
69
+ let settings: Record<string, unknown> = {}, error: string | undefined;
70
+ try { settings = config(root); } catch (failure) { error = (failure as Error).message; }
71
+ let main = existingTex(root, settings.main) ? settings.main : '', engine = '';
72
+ // Most papers already declare their main file or keep it near the root.
73
+ // Do not enumerate caches, assets and supporting papers just to open them.
74
+ if (!main) {
75
+ try {
76
+ const module = await import(pathToFileURL(path.join(ENGINE, 'src/core/config.ts')).href);
77
+ const detected = module.detectMain(root);
78
+ if (existingTex(root, detected)) main = detected;
79
+ } catch { /* Unusual layouts fall back to a deeper search. */ }
80
+ }
81
+ let candidates = main ? [main] : [];
82
+ if (!main) {
83
+ const files = texFiles(root), roots = files.filter(file => {
84
+ const text = sourceHead(path.join(root, file)).replace(/(?<!\\)%[^\n]*/g, '');
85
+ return /\\documentclass\b/.test(text) && /\\begin\s*\{document\}/.test(text);
86
+ });
87
+ main = roots.length === 1 ? roots[0] : '';
88
+ const found = new Set(roots);
89
+ candidates = [...roots, ...files.filter(file => !found.has(file))];
90
+ }
55
91
  try {
56
- const settings = config(root);
57
- if (typeof settings.main === 'string' && files.includes(settings.main)) main = settings.main;
58
- const module = await import(pathToFileURL(path.join(ENGINE, 'src/core/config.ts')).href);
59
- if (!main) { try { main = module.detectMain(root); } catch { /* The author chooses among all TeX files. */ } }
60
92
  // Engine detection also works before Git initialization by reading the source hints.
61
93
  if (main) {
62
- const text = fs.readFileSync(path.join(root, main), 'utf8');
94
+ const text = sourceHead(path.join(root, main));
63
95
  engine = typeof settings.engine === 'string' ? settings.engine : '';
64
96
  const rc = [path.join(path.dirname(main), '.latexmkrc'), path.join(path.dirname(main), 'latexmkrc'), '.latexmkrc', 'latexmkrc'].find(file => fs.existsSync(path.join(root, file)));
65
97
  const mode = rc ? fs.readFileSync(path.join(root, rc), 'utf8').match(/^\s*\$pdf_mode\s*=\s*(\d)/m)?.[1] : undefined;
66
98
  if (!engine) engine = text.match(/^%\s*!\s*TEX\s+(?:TS-)?program\s*=\s*(pdflatex|xelatex|lualatex)/im)?.[1].toLowerCase() || ({ '1': 'pdflatex', '4': 'lualatex', '5': 'xelatex' } as Record<string, string>)[mode || ''] || (/\\(?:usepackage(?:\[[^\]]*\])?\{[^}]*\b(?:fontspec|xeCJK|unicode-math|polyglossia)\b|documentclass(?:\[[^\]]*\])?\{ctex)/.test(text) ? 'xelatex' : 'pdflatex');
67
99
  }
68
100
  } catch (failure) { error = (failure as Error).message; }
69
- return { candidates: [...roots, ...files.filter(file => !roots.includes(file))], main, engine: engine || 'pdflatex', error };
101
+ return { candidates, main, engine: engine || 'pdflatex', error };
70
102
  }
71
103
  async function repositoryRoot(root: string) {
72
104
  let top: string | undefined;
@@ -170,7 +202,7 @@ export class PaperImports {
170
202
  if (this.active.has(id)) return value;
171
203
  if (this.registered(value.path)) throw new Error('This paper is already added. Open it from the paper list.');
172
204
  const main = input.main ?? value.main, engine = input.engine ?? value.engine;
173
- if (typeof main !== 'string' || !texFiles(value.path).includes(main)) throw new Error('Choose an existing TeX main file.');
205
+ if (!existingTex(value.path, main)) throw new Error('Choose an existing TeX main file.');
174
206
  if (!['pdflatex', 'xelatex', 'lualatex'].includes(engine as string)) throw new Error('Choose a supported LaTeX engine.');
175
207
  if (input.name !== undefined && (typeof input.name !== 'string' || !input.name.trim() || input.name.length > 120)) throw new Error('Enter a paper name under 120 characters.');
176
208
  this.active.add(id);
@@ -181,13 +213,20 @@ export class PaperImports {
181
213
  try {
182
214
  await repositoryRoot(value.path);
183
215
  if (!repair) {
184
- const settings = config(value.path);
185
- fs.writeFileSync(path.join(value.path, CONFIG), JSON.stringify({ ...settings, main, engine, source_dir: '' }, null, 2) + '\n');
216
+ // Keep the author's settings, including a source folder that still
217
+ // contains the main file; without one, the whole folder compiles.
218
+ const settings = config(value.path), folder = typeof settings.source_dir === 'string' ? settings.source_dir.replace(/\/+$/, '') : null;
219
+ const contains = folder === '' || (folder !== null && (main as string).startsWith(folder + '/'));
220
+ const next = { ...settings, main, engine, ...(contains ? {} : { source_dir: '' }) };
221
+ if (JSON.stringify(next) !== JSON.stringify(settings)) fs.writeFileSync(path.join(value.path, CONFIG), JSON.stringify(next, null, 2) + '\n');
186
222
  }
187
223
  await prepareRepository(value.path); value.versioned = true;
188
224
  if (repair) { value.summary = await repair(value.path, previousError); Object.assign(value, await inspectPaper(value.path)); }
189
225
  value.phase = 'building'; this.save(value);
190
- let result = await this.manuscripts.state('import-' + id, value.path, true);
226
+ // A retry starts a new build without forcing a clean one, so the
227
+ // opened paper finds this PDF in the cache instead of compiling again.
228
+ this.manuscripts.forgetProject('import-' + id);
229
+ let result = await this.manuscripts.state('import-' + id, value.path);
191
230
  while (result.build.status === 'building') { await new Promise(resolve => setTimeout(resolve, 400)); result = await this.manuscripts.state('import-' + id, value.path); }
192
231
  value.phase = result.build.status === 'ready' ? 'ready' : 'failed'; value.error = result.build.error;
193
232
  } catch (error) { value.phase = 'failed'; value.error = (error as Error).message; }
@@ -1,6 +1,6 @@
1
1
  import type { ReviewSnapshot } from './ai-review.ts';
2
2
  import { execFile } from 'node:child_process';
3
- import { createHash } from 'node:crypto';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
4
  import { EventEmitter } from 'node:events';
5
5
  import fs from 'node:fs';
6
6
  import os from 'node:os';
@@ -223,14 +223,29 @@ async function gitAction(root: string, args: string[], timeout = 60_000) {
223
223
  }
224
224
 
225
225
  // The preview shows the manuscript as it is on disk, including accepted but
226
- // uncommitted review changes. A temporary index records it as a Git tree
227
- // without touching the real index, branches or files.
226
+ // uncommitted review changes. A private index records it as a Git tree
227
+ // without touching the real index, branches or files. It starts from the
228
+ // previous snapshot's index, or the repository's, so Git rehashes only files
229
+ // whose size or modification time changed.
228
230
  export async function workingTree(root: string) {
229
- const folder = fs.mkdtempSync(path.join(os.tmpdir(), 'papilot-index-')), env = { GIT_INDEX_FILE: path.join(folder, 'index') };
230
- try {
231
- await git(root, ['read-tree', 'HEAD'], env);
231
+ const dir = gitDir(root), saved = dir && path.join(dir, 'latex-review', 'working-index');
232
+ const folder = fs.mkdtempSync(path.join(os.tmpdir(), 'papilot-index-')), index = path.join(folder, 'index'), env = { GIT_INDEX_FILE: index };
233
+ const snapshot = async (seed: string | null | undefined) => {
234
+ if (seed && fs.existsSync(seed)) fs.copyFileSync(seed, index);
235
+ else { fs.rmSync(index, { force: true }); await git(root, ['read-tree', 'HEAD'], env).catch(() => git(root, ['read-tree', '--empty'], env)); }
232
236
  await git(root, ['add', '-A'], env);
233
237
  return (await git(root, ['write-tree'], env)).trim();
238
+ };
239
+ try {
240
+ let tree: string;
241
+ try { tree = await snapshot(saved && fs.existsSync(saved) ? saved : dir && path.join(dir, 'index')); }
242
+ catch { tree = await snapshot(null); }
243
+ if (saved) try {
244
+ fs.mkdirSync(path.dirname(saved), { recursive: true });
245
+ const temporary = `${saved}.${randomUUID()}.tmp`;
246
+ fs.copyFileSync(index, temporary); fs.renameSync(temporary, saved);
247
+ } catch { /* The next snapshot rehashes more files. */ }
248
+ return tree;
234
249
  } finally { fs.rmSync(folder, { recursive: true, force: true }); }
235
250
  }
236
251
 
@@ -380,7 +395,10 @@ export class Manuscripts extends EventEmitter {
380
395
  }
381
396
 
382
397
  async state(projectId: string, root: string, rebuild = false): Promise<{ git: GitStatus; build: ManuscriptBuild }> {
383
- const [status, tree] = await Promise.all([gitStatus(root), workingTree(root)]);
398
+ const current = this.builds.get(projectId);
399
+ // Polling an in-flight build needs its status, not another source snapshot.
400
+ // Once it finishes, the next read checks for edits made during compilation.
401
+ const [status, tree] = await Promise.all([gitStatus(root), current?.status === 'building' ? current.tree : workingTree(root)]);
384
402
  let build = this.builds.get(projectId);
385
403
  if (!build || (build.status !== 'building' && (build.tree !== tree || rebuild))) build = this.start(projectId, root, tree, build, rebuild);
386
404
  const { status: state, key, pages, builtAt, error } = build;
@@ -444,7 +462,7 @@ export class Manuscripts extends EventEmitter {
444
462
  async reanchor(projectId: string, root: string, notes: Annotation[]): Promise<Annotation[]> {
445
463
  const build = this.builds.get(projectId);
446
464
  if (!build?.pdf || !build.key || build.status !== 'ready' || !notes.some(note => note.key !== build.key)) return notes;
447
- const { PdfDocument, loadProject } = await engine();
465
+ const { PdfDocument } = await engine();
448
466
  const current = new PdfDocument(build.pdf), target = new AnnotationPages(current);
449
467
  const old = new Map<string, { document: InstanceType<typeof PdfDocument>; pages: AnnotationPages }>();
450
468
  try {
@@ -10,6 +10,7 @@ import { Manuscripts } from './manuscript.ts';
10
10
  import { Changes } from './changes.ts';
11
11
  import { buildApp, gitQueue } from './app.ts';
12
12
  import { Updates } from './updates.ts';
13
+ import { installRelease } from './update-install.ts';
13
14
  import { readJson } from '../../bin/installation.mjs';
14
15
  import { desktopDemo } from './desktop-demo.ts';
15
16
 
@@ -21,8 +22,14 @@ const accounts = new Accounts(store, ACCOUNT_ORIGIN || null);
21
22
  const manuscripts = new Manuscripts(), changes = new Changes();
22
23
  const desktop = process.env.PAPILOT_DESKTOP === '1';
23
24
  if (desktop) await desktopDemo(ROOT, DATA, store, workspace, manuscripts).catch(error => console.error('Could not prepare the demo paper:', error));
25
+ // A packaged desktop app installs newer releases and restarts them itself.
26
+ const shell = desktop && process.send ? Number(process.env.PAPILOT_DESKTOP_SHELL) || 0 : 0, downloadUrl = 'https://github.com/PapilotAI/papilot/releases/latest';
24
27
  const updates = new Updates({ root: ROOT, data: DATA, version: VERSION, port: PORT,
25
- ...(desktop ? { supported: false, downloadUrl: 'https://github.com/PapilotAI/papilot/releases/latest' } : {}),
28
+ ...(shell ? {
29
+ desktop: { shell, installer: process.env.PAPILOT_DESKTOP_INSTALLER || VERSION, downloadUrl },
30
+ install: (data: string, version: string) => installRelease(data, version, { shell, codex: process.env.PAPILOT_DESKTOP_CODEX }),
31
+ restart: async (root: string, version: string) => { process.send!({ type: 'papilot:activate', root, version }); },
32
+ } : desktop ? { supported: false, downloadUrl } : {}),
26
33
  busy: () => workspace.busyForUpdate || manuscripts.busyForUpdate || changes.busyForUpdate, shutdown: () => shutdown() });
27
34
  const activation = readJson(path.join(DATA, 'updates/active.json'));
28
35
  const updateInstance = typeof activation?.root === 'string' && fs.existsSync(activation.root) && fs.realpathSync(activation.root) === fs.realpathSync(ROOT) ? activation.id : undefined;
@@ -83,14 +83,6 @@ export class Store extends EventEmitter {
83
83
  });
84
84
  this.emit('change');
85
85
  }
86
- // Runs a one-off change the first time this database sees its key.
87
- migrate(key: string, fn: () => void) {
88
- this.transaction(() => {
89
- if (this.db.prepare('SELECT 1 FROM migrations WHERE key=?').get(key)) return;
90
- fn();
91
- this.db.prepare('INSERT INTO migrations VALUES(?,?)').run(key, Date.now());
92
- });
93
- }
94
86
  transaction<T>(fn: () => T): T { this.db.exec('BEGIN IMMEDIATE'); try { const result = fn(); this.db.exec('COMMIT'); return result; } catch (e) { this.db.exec('ROLLBACK'); throw e; } }
95
87
  recover() {
96
88
  this.transaction(() => {
@@ -4,7 +4,7 @@ import net from 'node:net';
4
4
  import { execFile, spawn } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
6
  import { createRequire } from 'node:module';
7
- import { devRevision, readJson, stableVersion } from '../../bin/installation.mjs';
7
+ import { devRevision, newerVersion, readJson, stableVersion } from '../../bin/installation.mjs';
8
8
 
9
9
  export const DEV_REPOSITORY = 'https://github.com/PapilotAI/papilot.git';
10
10
  const git = async (args: string[], cwd: string) => (await promisify(execFile)('git', args, { cwd, timeout: 120000, maxBuffer: 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })).stdout.trim();
@@ -18,6 +18,19 @@ export function npmCli(env = process.env, executable = process.execPath) {
18
18
  }
19
19
  throw new Error('npm is unavailable to Papilot.');
20
20
  }
21
+ // A desktop installer runs a newer release when it provides the shell level the
22
+ // release declares and its bundled Node meets the release's engine range.
23
+ export const NEEDS_INSTALLER = 'This version needs the latest desktop app. Download it from GitHub Releases.';
24
+ export function desktopSupports(pkg: any, shell: number, node = process.versions.node) {
25
+ if (!Number.isInteger(pkg?.desktopShell) || pkg.desktopShell < 1 || pkg.desktopShell > shell) return false;
26
+ // npm's --engine-strict checks other range forms during installation.
27
+ const minimum = typeof pkg.engines?.node === 'string' ? /^\s*>=\s*v?(\d+\.\d+\.\d+)\s*$/.exec(pkg.engines.node)?.[1] : undefined;
28
+ return !minimum || !newerVersion(minimum, node);
29
+ }
30
+ // The installer's Codex serves releases that test the same or an older one.
31
+ export function codexVersion(bin: string | undefined) {
32
+ try { return readJson(path.join(path.dirname(fs.realpathSync(bin!)), '../package.json'))?.version || null; } catch { return null; }
33
+ }
21
34
  export const loaderFor = (root: string) => createRequire(path.join(root, 'package.json')).resolve('tsx/esm');
22
35
  export function runInstall(args: string[], cwd: string, log: number, timeout = 600000) {
23
36
  return new Promise<void>((resolve, reject) => {
@@ -102,7 +115,7 @@ async function installDev(data: string, version: string) {
102
115
  } catch (error) { fs.rmSync(stage, { recursive: true, force: true }); throw error; }
103
116
  finally { fs.closeSync(log); }
104
117
  }
105
- export async function installRelease(data: string, version: string) {
118
+ export async function installRelease(data: string, version: string, desktop?: { shell: number; codex?: string }) {
106
119
  if (devRevision(version)) return installDev(data, version);
107
120
  if (!stableVersion(version)) throw new Error('Invalid release version.');
108
121
  const releases = path.join(data, 'updates/releases'); fs.mkdirSync(releases, { recursive: true, mode: 0o700 });
@@ -116,7 +129,14 @@ export async function installRelease(data: string, version: string) {
116
129
  for (const file of ['dist/index.html', 'src/server/start.ts', 'src/server/main.ts', 'bin/papilot.mjs', 'vendor/latex-review/package-lock.json']) {
117
130
  if (!fs.existsSync(path.join(root, file))) throw new Error('The downloaded release is incomplete.');
118
131
  }
132
+ if (desktop && !desktopSupports(pkg, desktop.shell)) throw new Error(NEEDS_INSTALLER);
119
133
  await runInstall(['ci', '--prefix', path.join(root, 'vendor/latex-review'), ...flags], stage, log);
134
+ // Like the CLI's first run, Codex installs with its own scripts. The app
135
+ // finds it beside the release.
136
+ const tested = pkg.devDependencies?.['@openai/codex'], bundled = desktop && codexVersion(desktop.codex);
137
+ if (desktop && stableVersion(tested) && (!bundled || newerVersion(tested, bundled))) {
138
+ await runInstall(['install', '--prefix', path.join(stage, 'codex'), '--registry=https://registry.npmjs.org', '--no-audit', '--no-fund', `@openai/codex@${tested}`], stage, log);
139
+ }
120
140
  await probeInstallation(root, version, stage, log);
121
141
  return root;
122
142
  } catch (error) { fs.rmSync(stage, { recursive: true, force: true }); throw error; }
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { devRevision, newerVersion, readJson, stableVersion, writeJson } from '../../bin/installation.mjs';
6
- import { installRelease, loaderFor, npmCli } from './update-install.ts';
6
+ import { desktopSupports, installRelease, loaderFor, NEEDS_INSTALLER, npmCli } from './update-install.ts';
7
7
  import type { UpdateChannel, UpdateStatus } from '../shared.ts';
8
8
 
9
9
  const activePhases = new Set(['installing', 'waiting', 'restarting']);
@@ -15,27 +15,32 @@ async function releaseInfo(url: string, fetcher: typeof fetch) {
15
15
  if (text.length > 256000) throw new Error('Invalid release information.');
16
16
  return JSON.parse(text);
17
17
  }
18
- export async function latestRelease(channel: UpdateChannel = 'stable', fetcher = fetch): Promise<string> {
18
+ // The package.json of the newest release on a channel.
19
+ export async function latestManifest(channel: UpdateChannel = 'stable', fetcher = fetch): Promise<Record<string, any>> {
19
20
  if (channel === 'dev') {
20
21
  const commit = await releaseInfo('https://api.github.com/repos/PapilotAI/papilot/git/ref/heads/main', fetcher);
21
22
  const revision = commit.object?.sha;
22
23
  if (!/^[a-f0-9]{40}$/.test(revision)) throw new Error('Invalid development revision.');
23
24
  const pkg = await releaseInfo(`https://raw.githubusercontent.com/PapilotAI/papilot/${revision}/package.json`, fetcher);
24
25
  if (pkg.name !== 'papilot' || !stableVersion(pkg.version)) throw new Error('Invalid development version.');
25
- return `${pkg.version}-dev.${revision}`;
26
+ return { ...pkg, version: `${pkg.version}-dev.${revision}` };
26
27
  }
27
28
  const pkg = await releaseInfo('https://registry.npmjs.org/papilot/latest', fetcher);
28
29
  if (pkg.name !== 'papilot' || !stableVersion(pkg.version)) throw new Error('Invalid release information.');
29
- return pkg.version;
30
+ return pkg;
30
31
  }
32
+ export const latestRelease = async (channel: UpdateChannel = 'stable', fetcher = fetch) => (await latestManifest(channel, fetcher)).version as string;
31
33
  const validTarget = (version: unknown, channel: UpdateChannel): version is string => channel === 'dev' ? Boolean(devRevision(version)) : stableVersion(version);
32
34
  const available = (next: string, current: string, channel: UpdateChannel) => validTarget(next, channel)
33
35
  && (channel === 'dev' || devRevision(current) ? next !== current : newerVersion(next, current));
34
36
  interface Options {
37
+ // Desktop builds that cannot install releases link to this download page.
35
38
  downloadUrl?: string;
39
+ // A desktop installer that runs newer releases from npm (see desktop/runtime.ts).
40
+ desktop?: { shell: number; installer: string; downloadUrl: string };
36
41
  root: string; data: string; version: string; port: number;
37
42
  busy: () => boolean; shutdown: () => void;
38
- latest?: (channel: UpdateChannel) => Promise<string>; install?: (data: string, version: string) => Promise<string>;
43
+ latest?: (channel: UpdateChannel) => Promise<string | Record<string, any>>; install?: (data: string, version: string) => Promise<string>;
39
44
  restart?: (root: string, version: string) => Promise<void>; supported?: boolean;
40
45
  }
41
46
  // Both HTTP listeners share one updater. Downloads do not interrupt editing;
@@ -43,7 +48,6 @@ interface Options {
43
48
  export class Updates {
44
49
  private state: UpdateStatus;
45
50
  private checking: Promise<UpdateStatus> | null = null;
46
- private operation: Promise<void> | null = null;
47
51
  private prepared = '';
48
52
  private inFlight = 0;
49
53
  private timer?: NodeJS.Timeout;
@@ -51,10 +55,12 @@ export class Updates {
51
55
  private file: string;
52
56
  private lock: string;
53
57
  private channel: UpdateChannel;
58
+ // The latest release needs a newer desktop installer.
59
+ private needsInstaller = false;
54
60
  constructor(private options: Options) {
55
61
  this.file = path.join(options.data, 'updates/status.json'); this.lock = path.join(options.data, 'updates/install.lock');
56
62
  const preference = readJson(path.join(options.data, 'updates/channel.json'))?.channel;
57
- this.channel = options.downloadUrl ? 'stable' : preference === 'dev' || preference === 'stable' ? preference : devRevision(options.version) ? 'dev' : 'stable';
63
+ this.channel = options.downloadUrl || options.desktop ? 'stable' : preference === 'dev' || preference === 'stable' ? preference : devRevision(options.version) ? 'dev' : 'stable';
58
64
  let supported = options.supported ?? true;
59
65
  if (options.supported === undefined) try { npmCli(); } catch { supported = false; }
60
66
  this.state = { currentVersion: options.version, latestVersion: null, phase: 'idle', checkedAt: null, error: null, canUpdate: supported };
@@ -66,7 +72,9 @@ export class Updates {
66
72
  if (this.options.downloadUrl) return { currentVersion: this.options.version, latestVersion: null, phase: 'idle', checkedAt: null, error: null, canUpdate: false, channel: 'stable', downloadUrl: this.options.downloadUrl };
67
73
  const saved = readJson(this.file);
68
74
  if (saved && activePhases.has(this.state.phase) && ['updated', 'failed'].includes(saved.phase)) this.state = { ...this.state, ...saved, currentVersion: this.options.version };
69
- const { currentVersion, latestVersion, phase, checkedAt, error, canUpdate } = this.state;
75
+ const { currentVersion, latestVersion, phase, checkedAt, error, canUpdate } = this.state, desktop = this.options.desktop;
76
+ if (desktop) return { currentVersion, latestVersion, phase, checkedAt, error, channel: this.channel, installerVersion: desktop.installer,
77
+ ...(this.needsInstaller ? { canUpdate: false, downloadUrl: desktop.downloadUrl } : { canUpdate }) };
70
78
  return { currentVersion, latestVersion, phase, checkedAt, error, canUpdate, channel: this.channel };
71
79
  }
72
80
  get restarting() { return this.snapshot().phase === 'restarting'; }
@@ -85,7 +93,7 @@ export class Updates {
85
93
  writeJson(this.file, { ...this.state, pid: process.pid });
86
94
  }
87
95
  setChannel(channel: unknown) {
88
- if (this.options.downloadUrl) throw new Error('Download desktop updates from GitHub Releases.');
96
+ if (this.options.downloadUrl || this.options.desktop) throw new Error('Download desktop updates from GitHub Releases.');
89
97
  if (channel !== 'stable' && channel !== 'dev') throw new Error('Choose a valid update channel.');
90
98
  if (activePhases.has(this.state.phase)) throw new Error('Wait for the current update to finish before changing channels.');
91
99
  if (channel === this.channel) return this.check();
@@ -101,9 +109,14 @@ export class Updates {
101
109
  if (activePhases.has(this.state.phase)) return Promise.resolve(this.snapshot());
102
110
  this.change({ phase: 'checking' });
103
111
  const channel = this.channel;
104
- const checking = (this.options.latest || latestRelease)(channel).then(version => {
112
+ const checking = (this.options.latest || latestManifest)(channel).then(found => {
113
+ const manifest = typeof found === 'string' ? { version: found } : found, version = manifest?.version;
105
114
  if (!validTarget(version, channel)) throw new Error('Invalid release version.');
106
- if (!this.closed && this.checking === checking && !activePhases.has(this.state.phase)) this.change({ latestVersion: version, checkedAt: Date.now(), error: null, phase: available(version, this.options.version, channel) ? 'available' : 'idle' });
115
+ if (!this.closed && this.checking === checking && !activePhases.has(this.state.phase)) {
116
+ const newer = available(version, this.options.version, channel);
117
+ this.needsInstaller = Boolean(this.options.desktop && newer && !desktopSupports(manifest, this.options.desktop.shell));
118
+ this.change({ latestVersion: version, checkedAt: Date.now(), error: null, phase: newer ? 'available' : 'idle' });
119
+ }
107
120
  return this.snapshot();
108
121
  }).catch(() => {
109
122
  if (!this.closed && this.checking === checking && !activePhases.has(this.state.phase)) this.change({ phase: this.state.latestVersion && available(this.state.latestVersion, this.options.version, channel) ? 'available' : 'idle', checkedAt: Date.now(), error: 'Could not check for updates. Try again when connected.' });
@@ -115,6 +128,7 @@ export class Updates {
115
128
  install(version: unknown) {
116
129
  if (this.options.downloadUrl) throw new Error('Download desktop updates from GitHub Releases.');
117
130
  if (activePhases.has(this.state.phase)) return this.snapshot();
131
+ if (this.needsInstaller) throw new Error(NEEDS_INSTALLER);
118
132
  if (!this.state.canUpdate) throw new Error('npm is unavailable to Papilot.');
119
133
  if (!validTarget(version, this.channel) || version !== this.state.latestVersion || !available(version, this.options.version, this.channel)) throw new Error('Check for a newer release before updating.');
120
134
  fs.mkdirSync(path.dirname(this.lock), { recursive: true, mode: 0o700 });
@@ -123,11 +137,11 @@ export class Updates {
123
137
  if (previous) fs.rmSync(this.lock);
124
138
  const fd = fs.openSync(this.lock, 'wx', 0o600); fs.writeFileSync(fd, JSON.stringify({ pid: process.pid })); fs.closeSync(fd);
125
139
  this.change({ phase: 'installing', error: null });
126
- this.operation = (this.options.install || installRelease)(this.options.data, version).then(root => {
140
+ void (this.options.install || installRelease)(this.options.data, version).then(root => {
127
141
  if (this.closed) return;
128
142
  this.prepared = root; this.change({ phase: 'waiting' });
129
143
  return this.activate();
130
- }).catch(error => { this.change({ phase: 'failed', error: error instanceof Error ? error.message : 'The update failed.' }); this.release(); }).finally(() => { this.operation = null; });
144
+ }).catch(error => { this.change({ phase: 'failed', error: error instanceof Error ? error.message : 'The update failed.' }); this.release(); });
131
145
  return this.snapshot();
132
146
  }
133
147
  // Public for deterministic tests; the periodic tick calls this in production.
@@ -451,13 +451,21 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
451
451
  }
452
452
  }
453
453
  if (this.active.get(task.id)?.id !== run.id) return;
454
- task = this.store.updateTask(task.id, { threadId: result.thread.id, model: result.model || task.model, ...(task.threadId ? {} : { viewPdf }) });
454
+ // Settings can change while the agent loads. Keep this turn's snapshot,
455
+ // and never overwrite a newer choice with the thread's resolved model.
456
+ const current = this.store.task(task.id);
457
+ this.store.updateTask(task.id, { threadId: result.thread.id, ...(current.model === task.model ? { model: result.model || task.model } : {}), ...(task.threadId ? {} : { viewPdf }) });
458
+ task = { ...task, threadId: result.thread.id, model: result.model || task.model };
455
459
  this.loaded.add(result.thread.id);
456
460
  }
457
461
  this.store.updateTask(task.id, { pendingRunId: run.id });
458
462
  const input = [{ type: 'text', text: run.prompt }, ...(run.attachments || []).filter(file => file.mime.startsWith('image/')).map(file => ({ type: 'localImage', path: this.attachments.path(file) }))];
459
463
  let result: any;
460
- try { result = await agent.request('turn/start', { threadId: task.threadId, input, cwd: task.worktree, clientUserMessageId: run.id, ...(task.effort ? { effort: task.effort } : {}), ...(this.agentOf(task) === 'codex' && task.serviceTier ? { serviceTier: task.serviceTier } : {}) }); }
464
+ const models = this.runtimes[this.agentOf(task)].models;
465
+ const model = task.model ? models.find(model => model.id === task.model) : models.find(model => model.isDefault);
466
+ // Explicit defaults replace a previous turn's reasoning override.
467
+ const effort = task.effort || (this.agentOf(task) === 'codex' ? model?.defaultEffort : undefined);
468
+ try { result = await agent.request('turn/start', { threadId: task.threadId, input, cwd: task.worktree, clientUserMessageId: run.id, ...(task.model ? { model: task.model } : {}), ...(effort ? { effort } : {}), ...(this.agentOf(task) === 'codex' && task.serviceTier ? { serviceTier: task.serviceTier } : {}) }); }
461
469
  catch (error) {
462
470
  if (this.active.get(task.id)?.id !== run.id) return;
463
471
  if (error instanceof RpcResponseError) throw error;
@@ -1081,6 +1089,20 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
1081
1089
  this.committing.add(projectId);
1082
1090
  try { return await work(); } finally { this.committing.delete(projectId); }
1083
1091
  }
1092
+ updateTaskModel(taskId: string, change: { model?: string; effort?: string; serviceTier?: string }) {
1093
+ const task = this.store.task(taskId), agent = this.agentOf(task);
1094
+ if (task.status === 'deleting') throw new Error('This task is being deleted.');
1095
+ const models = this.runtimes[agent].models;
1096
+ const id = change.model ?? task.model;
1097
+ const model = id ? models.find(model => model.id === id) : models.find(model => model.isDefault);
1098
+ if (change.model && change.model !== task.model && !model) throw new Error('Choose an available model for this agent.');
1099
+ const effort = change.effort ?? (change.model !== undefined && model && !model.efforts.includes(task.effort || '') ? '' : task.effort);
1100
+ if (change.effort && model && !model.efforts.includes(change.effort)) throw new Error('This model does not support that reasoning effort.');
1101
+ const serviceTier = change.serviceTier ?? (change.model !== undefined && task.serviceTier !== model?.fastMode?.serviceTier ? 'default' : task.serviceTier);
1102
+ if (change.serviceTier !== undefined && agent !== 'codex') throw new Error('Service tiers are only available for Codex.');
1103
+ if (serviceTier && serviceTier !== 'default' && serviceTier !== model?.fastMode?.serviceTier) throw new Error('Fast mode is not available for this model.');
1104
+ return this.store.updateTask(taskId, { model: id || null, effort: effort || null, ...(serviceTier ? { serviceTier } : {}) });
1105
+ }
1084
1106
  renameTask(taskId: string, title: string) {
1085
1107
  const task = this.store.task(taskId);
1086
1108
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
package/src/shared.ts CHANGED
@@ -9,7 +9,10 @@ export const MAX_ATTACHMENT_TOTAL = 100 * 1024 * 1024;
9
9
  export interface Attachment { id: string; projectId: string; name: string; size: number; mime: string }
10
10
  export type UpdateChannel = 'stable' | 'dev';
11
11
  export interface UpdateStatus {
12
+ // Present when the update must be downloaded as a new desktop app.
12
13
  downloadUrl?: string;
14
+ // The desktop app's own Papilot version; newer releases run on top of it.
15
+ installerVersion?: string;
13
16
  channel?: UpdateChannel;
14
17
  currentVersion: string; latestVersion: string | null;
15
18
  phase: 'idle' | 'checking' | 'available' | 'installing' | 'waiting' | 'restarting' | 'updated' | 'failed';