papilot 0.8.1 → 0.8.3

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 (112) hide show
  1. package/README.md +4 -2
  2. package/bin/doctor.mjs +1 -1
  3. package/dist/assets/{_virtual_papilot-licenses-CMfCtEYQ.js → _virtual_papilot-licenses-YJbh9c1G.js} +1 -1
  4. package/dist/assets/index-0siHHtfz.js +267 -0
  5. package/dist/assets/index-CxdYm81a.css +1 -0
  6. package/dist/assets/{math-Bu3xEyrC.js → math-ibBpTslk.js} +1 -1
  7. package/dist/assets/{pdf-view-PbkCuTZ-.js → pdf-view-CD1kNKYH.js} +1 -1
  8. package/dist/index.html +2 -2
  9. package/engine/dist/web/assets/FullPaper-DUgLb01O.js +54 -0
  10. package/engine/dist/web/assets/index-D3_ZFdT8.css +1 -0
  11. package/engine/dist/web/assets/index-DJywe0wU.js +19 -0
  12. package/engine/dist/web/assets/pdf.worker.min-CjEcRF4W.mjs +28 -0
  13. package/{vendor/latex-review → engine}/dist/web/index.html +2 -2
  14. package/engine/package-lock.json +3390 -0
  15. package/engine/package.json +50 -0
  16. package/{vendor/latex-review → engine}/src/core/build.ts +198 -99
  17. package/{vendor/latex-review → engine}/src/core/git.ts +133 -28
  18. package/{vendor/latex-review → engine}/src/core/outline.ts +0 -9
  19. package/engine/src/core/progress.ts +67 -0
  20. package/{vendor/latex-review → engine}/src/core/review.ts +32 -0
  21. package/engine/src/locales.ts +47 -0
  22. package/{vendor/latex-review → engine}/src/render/inline.ts +1 -0
  23. package/{vendor/latex-review → engine}/src/render/locate.ts +26 -16
  24. package/engine/src/render/positions.ts +34 -0
  25. package/engine/src/render/synctex.ts +188 -0
  26. package/engine/src/render/synctex.wasm +0 -0
  27. package/engine/src/render/synctex.wasm.LICENSE +50 -0
  28. package/{vendor/latex-review → engine}/src/server.ts +5 -3
  29. package/{vendor/latex-review → engine}/src/types.ts +13 -0
  30. package/engine/src/web/FullPaper.tsx +318 -0
  31. package/engine/src/web/i18n.ts +36 -0
  32. package/engine/src/web/locales/de.json +253 -0
  33. package/engine/src/web/locales/es.json +253 -0
  34. package/engine/src/web/locales/fr.json +253 -0
  35. package/engine/src/web/locales/index.ts +16 -0
  36. package/engine/src/web/locales/it.json +253 -0
  37. package/engine/src/web/locales/ja.json +253 -0
  38. package/engine/src/web/locales/ko.json +253 -0
  39. package/engine/src/web/locales/pt.json +253 -0
  40. package/engine/src/web/locales/sv.json +253 -0
  41. package/engine/src/web/locales/zh-Hant.json +253 -0
  42. package/engine/src/web/paper.ts +13 -0
  43. package/{vendor/latex-review → engine}/src/web/references.tsx +1 -1
  44. package/{vendor/latex-review → engine}/src/web/review.tsx +99 -2
  45. package/{vendor/latex-review → engine}/src/web/style.css +71 -11
  46. package/engine/src/web/view-preferences.ts +13 -0
  47. package/{vendor/latex-review → engine}/src/web/zh-CN.ts +3 -0
  48. package/package.json +18 -9
  49. package/src/conversation.ts +1 -1
  50. package/src/server/acp.ts +9 -5
  51. package/src/server/agents.ts +4 -2
  52. package/src/server/app.ts +23 -0
  53. package/src/server/changes.ts +12 -3
  54. package/src/server/imports.ts +69 -25
  55. package/src/server/main.ts +3 -2
  56. package/src/server/manuscript.ts +31 -12
  57. package/src/server/paths.ts +1 -1
  58. package/src/server/start.ts +3 -3
  59. package/src/server/store.ts +0 -8
  60. package/src/server/update-install.ts +6 -3
  61. package/src/server/updates.ts +55 -23
  62. package/src/server/workspace.ts +24 -2
  63. package/src/shared.ts +7 -1
  64. package/src/skill-metadata.ts +1 -1
  65. package/vendor/latex-review/package-lock.json +4 -3106
  66. package/vendor/latex-review/package.json +3 -46
  67. package/README.zh-CN.md +0 -100
  68. package/dist/assets/index-BlLyIqT9.js +0 -265
  69. package/dist/assets/index-DVZtyZvu.css +0 -1
  70. package/vendor/latex-review/dist/web/assets/index-CXdnUjct.js +0 -19
  71. package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +0 -1
  72. package/vendor/latex-review/src/web/i18n.ts +0 -48
  73. /package/{vendor/latex-review → engine}/LICENSE +0 -0
  74. /package/{vendor/latex-review → engine}/dist/web/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
  75. /package/{vendor/latex-review → engine}/dist/web/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
  76. /package/{vendor/latex-review → engine}/src/cli.ts +0 -0
  77. /package/{vendor/latex-review → engine}/src/client.ts +0 -0
  78. /package/{vendor/latex-review → engine}/src/core/bib.ts +0 -0
  79. /package/{vendor/latex-review → engine}/src/core/changes.ts +0 -0
  80. /package/{vendor/latex-review → engine}/src/core/config.ts +0 -0
  81. /package/{vendor/latex-review → engine}/src/core/current-commit.ts +0 -0
  82. /package/{vendor/latex-review → engine}/src/core/delivery.ts +0 -0
  83. /package/{vendor/latex-review → engine}/src/core/diff.ts +0 -0
  84. /package/{vendor/latex-review → engine}/src/core/files.ts +0 -0
  85. /package/{vendor/latex-review → engine}/src/core/process.ts +0 -0
  86. /package/{vendor/latex-review → engine}/src/core/publish.ts +0 -0
  87. /package/{vendor/latex-review → engine}/src/core/reconcile.ts +0 -0
  88. /package/{vendor/latex-review → engine}/src/core/store.ts +0 -0
  89. /package/{vendor/latex-review → engine}/src/core/tasks.ts +0 -0
  90. /package/{vendor/latex-review → engine}/src/core/verify.ts +0 -0
  91. /package/{vendor/latex-review → engine}/src/core/view.ts +0 -0
  92. /package/{vendor/latex-review → engine}/src/render/effects.ts +0 -0
  93. /package/{vendor/latex-review → engine}/src/render/pdf.ts +0 -0
  94. /package/{vendor/latex-review → engine}/src/render/pdfdiff.ts +0 -0
  95. /package/{vendor/latex-review → engine}/src/render/round.ts +0 -0
  96. /package/{vendor/latex-review → engine}/src/render/worker.ts +0 -0
  97. /package/{vendor/latex-review → engine}/src/runtime.ts +0 -0
  98. /package/{vendor/latex-review → engine}/src/web/ChangeMap.tsx +0 -0
  99. /package/{vendor/latex-review → engine}/src/web/PdfStrip.tsx +0 -0
  100. /package/{vendor/latex-review → engine}/src/web/effect-preferences.ts +0 -0
  101. /package/{vendor/latex-review → engine}/src/web/fonts/GUST-FONT-LICENSE.txt +0 -0
  102. /package/{vendor/latex-review → engine}/src/web/fonts/LPPL.txt +0 -0
  103. /package/{vendor/latex-review → engine}/src/web/fonts/MANIFEST-newpx.txt +0 -0
  104. /package/{vendor/latex-review → engine}/src/web/fonts/README-newpx.txt +0 -0
  105. /package/{vendor/latex-review → engine}/src/web/fonts/README.md +0 -0
  106. /package/{vendor/latex-review → engine}/src/web/fonts/TeXGyrePagellaX-Bold.otf +0 -0
  107. /package/{vendor/latex-review → engine}/src/web/fonts/TeXGyrePagellaX-Regular.otf +0 -0
  108. /package/{vendor/latex-review → engine}/src/web/index.html +0 -0
  109. /package/{vendor/latex-review → engine}/src/web/main.tsx +0 -0
  110. /package/{vendor/latex-review → engine}/src/web/model.ts +0 -0
  111. /package/{vendor/latex-review → engine}/src/web/overview.tsx +0 -0
  112. /package/{vendor/latex-review → engine}/src/web/shortcuts.ts +0 -0
@@ -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;
@@ -117,7 +149,9 @@ export class PaperImports {
117
149
  private save(value: PaperImport) {
118
150
  fs.mkdirSync(path.join(this.directory, 'imports'), { recursive: true, mode: 0o700 });
119
151
  const file = path.join(this.directory, 'imports', value.id + '.json');
120
- fs.writeFileSync(file + '.tmp', JSON.stringify(value), { mode: 0o600 }); fs.renameSync(file + '.tmp', file);
152
+ // Progress is live state; a restarted server fails a running import anyway.
153
+ const { progress: _progress, ...stored } = value;
154
+ fs.writeFileSync(file + '.tmp', JSON.stringify(stored), { mode: 0o600 }); fs.renameSync(file + '.tmp', file);
121
155
  this.sessions.set(value.id, value); return { ...value };
122
156
  }
123
157
  get(id: string) {
@@ -170,28 +204,38 @@ export class PaperImports {
170
204
  if (this.active.has(id)) return value;
171
205
  if (this.registered(value.path)) throw new Error('This paper is already added. Open it from the paper list.');
172
206
  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.');
207
+ if (!existingTex(value.path, main)) throw new Error('Choose an existing TeX main file.');
174
208
  if (!['pdflatex', 'xelatex', 'lualatex'].includes(engine as string)) throw new Error('Choose a supported LaTeX engine.');
175
209
  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
210
  this.active.add(id);
177
211
  const previousError = value.error || '';
178
- Object.assign(value, { main, engine, ...(input.name ? { name: input.name } : {}), phase: repair ? 'repairing' : 'building', error: undefined });
212
+ Object.assign(value, { main, engine, ...(input.name ? { name: input.name } : {}), phase: repair ? 'repairing' : 'building', error: undefined, startedAt: Date.now(), progress: undefined });
179
213
  this.save(value);
180
214
  void (async () => {
181
215
  try {
182
216
  await repositoryRoot(value.path);
183
217
  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');
218
+ // Keep the author's settings, including a source folder that still
219
+ // contains the main file; without one, the whole folder compiles.
220
+ const settings = config(value.path), folder = typeof settings.source_dir === 'string' ? settings.source_dir.replace(/\/+$/, '') : null;
221
+ const contains = folder === '' || (folder !== null && (main as string).startsWith(folder + '/'));
222
+ const next = { ...settings, main, engine, ...(contains ? {} : { source_dir: '' }) };
223
+ if (JSON.stringify(next) !== JSON.stringify(settings)) fs.writeFileSync(path.join(value.path, CONFIG), JSON.stringify(next, null, 2) + '\n');
186
224
  }
187
225
  await prepareRepository(value.path); value.versioned = true;
188
226
  if (repair) { value.summary = await repair(value.path, previousError); Object.assign(value, await inspectPaper(value.path)); }
189
227
  value.phase = 'building'; this.save(value);
190
- let result = await this.manuscripts.state('import-' + id, value.path, true);
191
- while (result.build.status === 'building') { await new Promise(resolve => setTimeout(resolve, 400)); result = await this.manuscripts.state('import-' + id, value.path); }
228
+ // A retry starts a new build without forcing a clean one, so the
229
+ // opened paper finds this PDF in the cache instead of compiling again.
230
+ this.manuscripts.forgetProject('import-' + id);
231
+ let result = await this.manuscripts.state('import-' + id, value.path);
232
+ while (result.build.status === 'building') {
233
+ value.progress = result.build.progress;
234
+ await new Promise(resolve => setTimeout(resolve, 400)); result = await this.manuscripts.state('import-' + id, value.path);
235
+ }
192
236
  value.phase = result.build.status === 'ready' ? 'ready' : 'failed'; value.error = result.build.error;
193
237
  } catch (error) { value.phase = 'failed'; value.error = (error as Error).message; }
194
- finally { this.active.delete(id); this.save(value); }
238
+ finally { this.active.delete(id); value.progress = undefined; this.save(value); }
195
239
  })();
196
240
  return this.get(id);
197
241
  }
@@ -3,8 +3,9 @@ import { pathToFileURL } from 'node:url';
3
3
  import { ROOT, DATA } from './paths.ts';
4
4
  import { activeInstallation, waitForActivation } from '../../bin/installation.mjs';
5
5
 
6
- await waitForActivation(DATA);
7
- const root = activeInstallation(ROOT, DATA);
6
+ const desktop = process.env.PAPILOT_DESKTOP === '1';
7
+ if (!desktop) await waitForActivation(DATA);
8
+ const root = desktop ? ROOT : activeInstallation(ROOT, DATA);
8
9
  // A source checkout's default data directory must not move with the release.
9
10
  process.env.PAPILOT_DATA = DATA;
10
11
  await import(pathToFileURL(path.join(root, 'src/server/start.ts')).href);
@@ -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';
@@ -8,7 +8,7 @@ import path from 'node:path';
8
8
  import { promisify } from 'node:util';
9
9
  import { pathToFileURL } from 'node:url';
10
10
  import { ENGINE } from './paths.ts';
11
- import type { Annotation, BuildProblem, GitFile, GitStatus, ManuscriptBuild, ManuscriptProblems } from '../shared.ts';
11
+ import type { Annotation, BuildProblem, BuildProgress, GitFile, GitStatus, ManuscriptBuild, ManuscriptProblems } from '../shared.ts';
12
12
  import { notesInstructions } from '../notes.ts';
13
13
  import { ignorePattern } from '../commit.ts';
14
14
  import { AnnotationPages, placeAnnotation } from './annotation-placement.ts';
@@ -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
 
@@ -345,7 +360,7 @@ export async function pdfWords(pdf: string, page: number) {
345
360
  // `problems` come from the last finished build. They are placed on the PDF
346
361
  // named `problemsKey`; a failed build has no PDF of its own to place them on.
347
362
  interface Build {
348
- tree: string; status: ManuscriptBuild['status']; key?: string; pdf?: string; root?: string; sourceDir?: string; pages?: [number, number][]; builtAt?: number; error?: string;
363
+ tree: string; status: ManuscriptBuild['status']; key?: string; pdf?: string; root?: string; sourceDir?: string; pages?: [number, number][]; builtAt?: number; error?: string; progress?: BuildProgress;
349
364
  problems?: StoredProblem[]; problemsKey?: string; log?: string; logFolder?: string;
350
365
  }
351
366
 
@@ -380,11 +395,14 @@ 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;
387
- return { git: status, build: { status: state, key, pages, builtAt, error, current: build.tree === tree && state === 'ready', problems: build.problems && countProblems(build.problems) } };
405
+ return { git: status, build: { status: state, key, pages, builtAt, error, current: build.tree === tree && state === 'ready', problems: build.problems && countProblems(build.problems), ...(state === 'building' && build.progress ? { progress: build.progress } : {}) } };
388
406
  }
389
407
 
390
408
  // Keep the previous pages visible while a newer working tree compiles.
@@ -397,7 +415,8 @@ export class Manuscripts extends EventEmitter {
397
415
  try {
398
416
  const { Builder, Repo, loadProject, mupdf } = await engine();
399
417
  project = loadProject(root);
400
- const result = await new Builder(project, new Repo(root)).build(tree, ['full'], null, { force });
418
+ const startedAt = Date.now();
419
+ const result = await new Builder(project, new Repo(root)).build(tree, ['full'], null, { force, progress: (progress: Omit<BuildProgress, 'startedAt'>) => { if (this.builds.get(projectId) === build) build.progress = { ...progress, startedAt }; } });
401
420
  const document = mupdf.Document.openDocument(fs.readFileSync(result.pdf), 'application/pdf');
402
421
  const pages: [number, number][] = [];
403
422
  for (let i = 0; i < document.countPages(); i++) { const [x0, y0, x1, y1] = document.loadPage(i).getBounds(); pages.push([Math.round(x1 - x0), Math.round(y1 - y0)]); }
@@ -444,7 +463,7 @@ export class Manuscripts extends EventEmitter {
444
463
  async reanchor(projectId: string, root: string, notes: Annotation[]): Promise<Annotation[]> {
445
464
  const build = this.builds.get(projectId);
446
465
  if (!build?.pdf || !build.key || build.status !== 'ready' || !notes.some(note => note.key !== build.key)) return notes;
447
- const { PdfDocument, loadProject } = await engine();
466
+ const { PdfDocument } = await engine();
448
467
  const current = new PdfDocument(build.pdf), target = new AnnotationPages(current);
449
468
  const old = new Map<string, { document: InstanceType<typeof PdfDocument>; pages: AnnotationPages }>();
450
469
  try {
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
5
5
  export const DATA = path.resolve(process.env.PAPILOT_DATA || path.join(ROOT, '.papilot'));
6
- export const ENGINE = path.resolve(process.env.PAPILOT_REVIEW_ENGINE || path.join(ROOT, 'vendor/latex-review'));
6
+ export const ENGINE = path.resolve(process.env.PAPILOT_REVIEW_ENGINE || path.join(ROOT, 'engine'));
7
7
  export const VERSION: string = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
8
8
  export const PORT = Number(process.env.PAPILOT_PORT || 7274);
9
9
  export const PUBLIC_ORIGIN = process.env.PAPILOT_PUBLIC_ORIGIN || '';
@@ -21,10 +21,10 @@ const accounts = new Accounts(store, ACCOUNT_ORIGIN || null);
21
21
  const manuscripts = new Manuscripts(), changes = new Changes();
22
22
  const desktop = process.env.PAPILOT_DESKTOP === '1';
23
23
  if (desktop) await desktopDemo(ROOT, DATA, store, workspace, manuscripts).catch(error => console.error('Could not prepare the demo paper:', error));
24
- const updates = new Updates({ root: ROOT, data: DATA, version: VERSION, port: PORT,
25
- ...(desktop ? { supported: false, downloadUrl: 'https://github.com/PapilotAI/papilot/releases/latest' } : {}),
24
+ // Desktop releases are complete installers; npm updates belong to the web server.
25
+ const updates = new Updates({ root: ROOT, data: DATA, version: VERSION, port: PORT, desktop,
26
26
  busy: () => workspace.busyForUpdate || manuscripts.busyForUpdate || changes.busyForUpdate, shutdown: () => shutdown() });
27
- const activation = readJson(path.join(DATA, 'updates/active.json'));
27
+ const activation = desktop ? null : readJson(path.join(DATA, 'updates/active.json'));
28
28
  const updateInstance = typeof activation?.root === 'string' && fs.existsSync(activation.root) && fs.realpathSync(activation.root) === fs.realpathSync(ROOT) ? activation.id : undefined;
29
29
  const shared = {
30
30
  store, workspace, accounts, manuscripts, changes, updates, updateInstance, csrf: randomBytes(32).toString('base64url'), serialGit: gitQueue(),
@@ -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(() => {
@@ -71,7 +71,7 @@ export async function prepareDev(root: string, version: string, log: number, run
71
71
  // Test-only tools can exclude a Node version that supports the application.
72
72
  // Build first, then enforce engine compatibility for the retained runtime.
73
73
  await run(['ci', '--include=dev', '--engine-strict=false', ...flags], root, log);
74
- const engine = path.join(root, 'vendor/latex-review');
74
+ const engine = path.join(root, 'engine');
75
75
  await run(['ci', '--include=dev', '--engine-strict=false', ...flags], engine, log);
76
76
  // Only this installed snapshot gets a dev version; the repository stays unchanged.
77
77
  fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ ...pkg, version }, null, 2) + '\n');
@@ -103,6 +103,7 @@ async function installDev(data: string, version: string) {
103
103
  finally { fs.closeSync(log); }
104
104
  }
105
105
  export async function installRelease(data: string, version: string) {
106
+ if (process.env.PAPILOT_DESKTOP === '1') throw new Error('Download desktop updates from GitHub Releases.');
106
107
  if (devRevision(version)) return installDev(data, version);
107
108
  if (!stableVersion(version)) throw new Error('Invalid release version.');
108
109
  const releases = path.join(data, 'updates/releases'); fs.mkdirSync(releases, { recursive: true, mode: 0o700 });
@@ -113,10 +114,12 @@ export async function installRelease(data: string, version: string) {
113
114
  await runInstall(['install', '--prefix', stage, '--save-exact', ...flags, `papilot@${version}`], stage, log);
114
115
  const pkg = readJson(path.join(root, 'package.json'));
115
116
  if (pkg?.name !== 'papilot' || pkg.version !== version) throw new Error('The downloaded package does not match the requested release.');
116
- 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
+ // Releases also ship an empty vendor/latex-review package, where 0.8.2 and
118
+ // earlier look for the engine when they install an update.
119
+ for (const file of ['dist/index.html', 'src/server/start.ts', 'src/server/main.ts', 'bin/papilot.mjs', 'engine/package-lock.json']) {
117
120
  if (!fs.existsSync(path.join(root, file))) throw new Error('The downloaded release is incomplete.');
118
121
  }
119
- await runInstall(['ci', '--prefix', path.join(root, 'vendor/latex-review'), ...flags], stage, log);
122
+ await runInstall(['ci', '--prefix', path.join(root, 'engine'), ...flags], stage, log);
120
123
  await probeInstallation(root, version, stage, log);
121
124
  return root;
122
125
  } catch (error) { fs.rmSync(stage, { recursive: true, force: true }); throw error; }
@@ -8,34 +8,54 @@ import type { UpdateChannel, UpdateStatus } from '../shared.ts';
8
8
 
9
9
  const activePhases = new Set(['installing', 'waiting', 'restarting']);
10
10
  export const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } };
11
- async function releaseInfo(url: string, fetcher: typeof fetch) {
11
+ async function releaseInfo(url: string, fetcher: typeof fetch, limit = 256000) {
12
12
  const response = await fetcher(url, { signal: AbortSignal.timeout(10000), redirect: 'error', headers: { Accept: 'application/json', 'User-Agent': 'Papilot' } });
13
13
  if (!response.ok) throw new Error('Could not check for updates.');
14
14
  const text = await response.text();
15
- if (text.length > 256000) throw new Error('Invalid release information.');
15
+ if (text.length > limit) 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;
31
+ }
32
+ export const latestRelease = async (channel: UpdateChannel = 'stable', fetcher = fetch) => (await latestManifest(channel, fetcher)).version as string;
33
+ export const DESKTOP_RELEASES = 'https://github.com/PapilotAI/papilot/releases';
34
+ // npm can be published before desktop builds finish. Only advertise releases
35
+ // with an uploaded installer for this OS and CPU, and link to that exact release.
36
+ export async function latestDesktopRelease(platform = process.platform, arch = process.arch, fetcher = fetch) {
37
+ const releases = await releaseInfo('https://api.github.com/repos/PapilotAI/papilot/releases?per_page=100', fetcher, 4 * 1024 * 1024);
38
+ if (!Array.isArray(releases)) throw new Error('Invalid release information.');
39
+ let latest: { version: string; downloadUrl: string } | null = null;
40
+ for (const release of releases) {
41
+ const version = typeof release.tag_name === 'string' ? release.tag_name.replace(/^v/, '') : '';
42
+ if (release.draft !== false || release.prerelease !== false || !stableVersion(version) || !Array.isArray(release.assets)) continue;
43
+ const names = platform === 'darwin' && ['arm64', 'x64'].includes(arch) ? [`Papilot-${version}-mac-${arch}.dmg`, `Papilot-${version}-mac-${arch}.zip`]
44
+ : platform === 'linux' && ['arm64', 'x64'].includes(arch) ? [`Papilot-${version}-linux-${arch === 'x64' ? 'x86_64' : arch}.AppImage`, `Papilot-${version}-linux-${arch === 'x64' ? 'amd64' : arch}.deb`] : [];
45
+ if (!release.assets.some((asset: any) => asset.state === 'uploaded' && asset.size > 0 && names.includes(asset.name))) continue;
46
+ if (!latest || newerVersion(version, latest.version)) latest = { version, downloadUrl: `${DESKTOP_RELEASES}/tag/${release.tag_name}` };
47
+ }
48
+ return latest;
30
49
  }
31
50
  const validTarget = (version: unknown, channel: UpdateChannel): version is string => channel === 'dev' ? Boolean(devRevision(version)) : stableVersion(version);
32
51
  const available = (next: string, current: string, channel: UpdateChannel) => validTarget(next, channel)
33
52
  && (channel === 'dev' || devRevision(current) ? next !== current : newerVersion(next, current));
34
53
  interface Options {
35
- downloadUrl?: string;
54
+ desktop?: boolean;
55
+ latestDesktop?: () => Promise<{ version: string; downloadUrl: string } | null>;
36
56
  root: string; data: string; version: string; port: number;
37
57
  busy: () => boolean; shutdown: () => void;
38
- latest?: (channel: UpdateChannel) => Promise<string>; install?: (data: string, version: string) => Promise<string>;
58
+ latest?: (channel: UpdateChannel) => Promise<string | Record<string, any>>; install?: (data: string, version: string) => Promise<string>;
39
59
  restart?: (root: string, version: string) => Promise<void>; supported?: boolean;
40
60
  }
41
61
  // Both HTTP listeners share one updater. Downloads do not interrupt editing;
@@ -43,7 +63,6 @@ interface Options {
43
63
  export class Updates {
44
64
  private state: UpdateStatus;
45
65
  private checking: Promise<UpdateStatus> | null = null;
46
- private operation: Promise<void> | null = null;
47
66
  private prepared = '';
48
67
  private inFlight = 0;
49
68
  private timer?: NodeJS.Timeout;
@@ -51,28 +70,28 @@ export class Updates {
51
70
  private file: string;
52
71
  private lock: string;
53
72
  private channel: UpdateChannel;
73
+ private downloadUrl = DESKTOP_RELEASES;
54
74
  constructor(private options: Options) {
55
- this.file = path.join(options.data, 'updates/status.json'); this.lock = path.join(options.data, 'updates/install.lock');
75
+ this.file = path.join(options.data, options.desktop ? 'updates/desktop/status.json' : 'updates/status.json'); this.lock = path.join(options.data, 'updates/install.lock');
56
76
  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';
58
- let supported = options.supported ?? true;
59
- if (options.supported === undefined) try { npmCli(); } catch { supported = false; }
77
+ this.channel = options.desktop ? 'stable' : preference === 'dev' || preference === 'stable' ? preference : devRevision(options.version) ? 'dev' : 'stable';
78
+ let supported = options.desktop ? false : options.supported ?? true;
79
+ if (!options.desktop && options.supported === undefined) try { npmCli(); } catch { supported = false; }
60
80
  this.state = { currentVersion: options.version, latestVersion: null, phase: 'idle', checkedAt: null, error: null, canUpdate: supported };
61
- const saved = readJson(this.file);
81
+ const saved = options.desktop ? null : readJson(this.file);
62
82
  if (saved && ['failed', 'updated', 'installing', 'waiting', 'restarting'].includes(saved.phase)) this.state = { ...this.state, ...saved, currentVersion: options.version, canUpdate: supported };
63
83
  if (saved && activePhases.has(saved.phase) && !alive(saved.pid)) this.change({ phase: 'failed', checkedAt: Date.now(), error: 'The update was interrupted. Your current installation is still available; retry the update.' });
64
84
  }
65
85
  snapshot(): UpdateStatus {
66
- 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
86
  const saved = readJson(this.file);
68
87
  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;
88
+ const { currentVersion, latestVersion, phase, checkedAt, error, canUpdate } = this.state, desktop = this.options.desktop;
89
+ if (desktop) return { currentVersion, latestVersion, phase, checkedAt, error, channel: 'stable', canUpdate: false, downloadUrl: this.downloadUrl };
70
90
  return { currentVersion, latestVersion, phase, checkedAt, error, canUpdate, channel: this.channel };
71
91
  }
72
92
  get restarting() { return this.snapshot().phase === 'restarting'; }
73
93
  enter() { this.inFlight++; let released = false; return () => { if (!released) { released = true; this.inFlight--; } }; }
74
94
  start() {
75
- if (this.options.downloadUrl) return;
76
95
  if (this.state.phase !== 'failed') void this.check();
77
96
  this.timer = setInterval(() => {
78
97
  if (this.state.phase === 'waiting') void this.activate();
@@ -85,7 +104,7 @@ export class Updates {
85
104
  writeJson(this.file, { ...this.state, pid: process.pid });
86
105
  }
87
106
  setChannel(channel: unknown) {
88
- if (this.options.downloadUrl) throw new Error('Download desktop updates from GitHub Releases.');
107
+ if (this.options.desktop) throw new Error('Download desktop updates from GitHub Releases.');
89
108
  if (channel !== 'stable' && channel !== 'dev') throw new Error('Choose a valid update channel.');
90
109
  if (activePhases.has(this.state.phase)) throw new Error('Wait for the current update to finish before changing channels.');
91
110
  if (channel === this.channel) return this.check();
@@ -96,14 +115,27 @@ export class Updates {
96
115
  return this.check();
97
116
  }
98
117
  check(): Promise<UpdateStatus> {
99
- if (this.options.downloadUrl) return Promise.resolve(this.snapshot());
100
118
  if (this.checking) return this.checking;
101
119
  if (activePhases.has(this.state.phase)) return Promise.resolve(this.snapshot());
102
120
  this.change({ phase: 'checking' });
103
121
  const channel = this.channel;
104
- const checking = (this.options.latest || latestRelease)(channel).then(version => {
122
+ const discovery = this.options.desktop ? (this.options.latestDesktop || latestDesktopRelease)() : (this.options.latest || latestManifest)(channel);
123
+ const checking = discovery.then(found => {
124
+ const manifest = typeof found === 'string' ? { version: found } : found;
125
+ if (!manifest && this.options.desktop) {
126
+ if (!this.closed && this.checking === checking) {
127
+ this.downloadUrl = DESKTOP_RELEASES;
128
+ this.change({ latestVersion: null, checkedAt: Date.now(), error: null, phase: 'idle' });
129
+ }
130
+ return this.snapshot();
131
+ }
132
+ const version = manifest?.version;
105
133
  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' });
134
+ if (!this.closed && this.checking === checking && !activePhases.has(this.state.phase)) {
135
+ const newer = available(version, this.options.version, channel);
136
+ if (this.options.desktop) this.downloadUrl = manifest!.downloadUrl;
137
+ this.change({ latestVersion: version, checkedAt: Date.now(), error: null, phase: newer ? 'available' : 'idle' });
138
+ }
107
139
  return this.snapshot();
108
140
  }).catch(() => {
109
141
  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.' });
@@ -113,7 +145,7 @@ export class Updates {
113
145
  return this.checking;
114
146
  }
115
147
  install(version: unknown) {
116
- if (this.options.downloadUrl) throw new Error('Download desktop updates from GitHub Releases.');
148
+ if (this.options.desktop) throw new Error('Download desktop updates from GitHub Releases.');
117
149
  if (activePhases.has(this.state.phase)) return this.snapshot();
118
150
  if (!this.state.canUpdate) throw new Error('npm is unavailable to Papilot.');
119
151
  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.');
@@ -123,11 +155,11 @@ export class Updates {
123
155
  if (previous) fs.rmSync(this.lock);
124
156
  const fd = fs.openSync(this.lock, 'wx', 0o600); fs.writeFileSync(fd, JSON.stringify({ pid: process.pid })); fs.closeSync(fd);
125
157
  this.change({ phase: 'installing', error: null });
126
- this.operation = (this.options.install || installRelease)(this.options.data, version).then(root => {
158
+ void (this.options.install || installRelease)(this.options.data, version).then(root => {
127
159
  if (this.closed) return;
128
160
  this.prepared = root; this.change({ phase: 'waiting' });
129
161
  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; });
162
+ }).catch(error => { this.change({ phase: 'failed', error: error instanceof Error ? error.message : 'The update failed.' }); this.release(); });
131
163
  return this.snapshot();
132
164
  }
133
165
  // 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,6 +9,7 @@ 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;
13
14
  channel?: UpdateChannel;
14
15
  currentVersion: string; latestVersion: string | null;
@@ -123,7 +124,10 @@ export interface GitWorktree {
123
124
  }
124
125
  export interface GitGraphCommit { hash: string; parents: string[]; subject: string; author: string; date: number; refs: string[] }
125
126
  export interface GitOverview { worktrees: GitWorktree[]; commits: GitGraphCommit[]; truncated: boolean }
126
- export interface ManuscriptBuild { status: 'building' | 'ready' | 'failed'; key?: string; pages?: [number, number][]; builtAt?: number; error?: string; current: boolean; problems?: BuildProblemCounts }
127
+ // A running compilation, from latexmk's output: its step, the TeX pass, and
128
+ // the pages typeset in that pass against the last known page count.
129
+ export interface BuildProgress { step: 'tex' | 'bibliography' | 'pdf'; pass: number; pages: number; total: number | null; startedAt: number }
130
+ export interface ManuscriptBuild { status: 'building' | 'ready' | 'failed'; key?: string; pages?: [number, number][]; builtAt?: number; error?: string; current: boolean; problems?: BuildProblemCounts; progress?: BuildProgress }
127
131
  // What the last finished build reported. Errors stop the build; warnings are
128
132
  // mistakes in the PDF (?? and missing citations); minor problems are
129
133
  // typesetting (overfull lines) and package remarks, listed but never counted.
@@ -153,6 +157,8 @@ export interface PaperImport {
153
157
  id: string; path: string; name: string; copied: boolean; candidates: string[]; main: string; engine: string;
154
158
  phase: 'choose' | 'building' | 'failed' | 'ready' | 'repairing';
155
159
  error?: string; summary?: string; versioned?: boolean;
160
+ // While building: when it started, and the compilation's progress.
161
+ startedAt?: number; progress?: BuildProgress;
156
162
  }
157
163
  // The paper's bibliography as the References panel reads it: the review
158
164
  // engine's report of every entry, with online checks and the entries the
@@ -40,6 +40,6 @@ export function setSkillMetadata(content: string, field: 'name' | 'description'
40
40
  return `${bom}---${newline}${next.join(newline)}${newline}---${closingNewline}${source.slice(header[0].length)}`;
41
41
  }
42
42
  export function setSkillInstructions(content: string, body: string) {
43
- const { bom, source, header, newline } = parts(content);
43
+ const { bom, header, newline } = parts(content);
44
44
  return header ? `${bom}${header[0]}${header[0].endsWith('\n') ? '' : newline}${body}` : `${bom}${body}`;
45
45
  }