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.
- package/README.de.md +110 -0
- package/README.es.md +110 -0
- package/README.fr.md +110 -0
- package/README.it.md +110 -0
- package/README.ja.md +110 -0
- package/README.ko.md +110 -0
- package/README.md +3 -1
- package/README.pt.md +110 -0
- package/README.sv.md +110 -0
- package/README.zh-CN.md +39 -29
- package/README.zh-Hant.md +110 -0
- package/dist/assets/index-oAIkxhfp.js +265 -0
- package/dist/assets/index-t_qCOF_B.css +1 -0
- package/dist/assets/{math-Bu3xEyrC.js → math-CEU4m4-H.js} +1 -1
- package/dist/assets/{pdf-view-PbkCuTZ-.js → pdf-view-HWRGBLsZ.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +11 -4
- package/src/server/acp.ts +9 -5
- package/src/server/agents.ts +4 -2
- package/src/server/app.ts +7 -0
- package/src/server/imports.ts +60 -21
- package/src/server/manuscript.ts +26 -8
- package/src/server/start.ts +8 -1
- package/src/server/store.ts +0 -8
- package/src/server/update-install.ts +22 -2
- package/src/server/updates.ts +27 -13
- package/src/server/workspace.ts +24 -2
- package/src/shared.ts +3 -0
- package/src/skill-metadata.ts +1 -1
- package/vendor/latex-review/dist/web/assets/index-DVs7cTBl.js +19 -0
- package/vendor/latex-review/dist/web/assets/index-ysOpu05D.css +1 -0
- package/vendor/latex-review/dist/web/index.html +2 -2
- package/vendor/latex-review/src/core/build.ts +180 -97
- package/vendor/latex-review/src/core/git.ts +133 -28
- package/vendor/latex-review/src/core/outline.ts +0 -9
- package/vendor/latex-review/src/locales.ts +47 -0
- package/vendor/latex-review/src/web/i18n.ts +9 -21
- package/vendor/latex-review/src/web/locales/de.json +250 -0
- package/vendor/latex-review/src/web/locales/es.json +250 -0
- package/vendor/latex-review/src/web/locales/fr.json +250 -0
- package/vendor/latex-review/src/web/locales/index.ts +16 -0
- package/vendor/latex-review/src/web/locales/it.json +250 -0
- package/vendor/latex-review/src/web/locales/ja.json +250 -0
- package/vendor/latex-review/src/web/locales/ko.json +250 -0
- package/vendor/latex-review/src/web/locales/pt.json +250 -0
- package/vendor/latex-review/src/web/locales/sv.json +250 -0
- package/vendor/latex-review/src/web/locales/zh-Hant.json +250 -0
- package/vendor/latex-review/src/web/references.tsx +1 -1
- package/vendor/latex-review/src/web/review.tsx +1 -1
- package/vendor/latex-review/src/web/style.css +1 -11
- package/dist/assets/index-BlLyIqT9.js +0 -265
- package/dist/assets/index-DVZtyZvu.css +0 -1
- package/vendor/latex-review/dist/web/assets/index-CXdnUjct.js +0 -19
- 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-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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.`);
|
package/src/server/agents.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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));
|
package/src/server/imports.ts
CHANGED
|
@@ -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[] = []
|
|
37
|
-
|
|
38
|
-
|
|
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())
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 =
|
|
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
|
|
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 (
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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; }
|
package/src/server/manuscript.ts
CHANGED
|
@@ -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
|
|
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
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
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
|
|
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 {
|
package/src/server/start.ts
CHANGED
|
@@ -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
|
-
...(
|
|
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;
|
package/src/server/store.ts
CHANGED
|
@@ -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; }
|
package/src/server/updates.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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 ||
|
|
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))
|
|
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
|
-
|
|
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(); })
|
|
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.
|
package/src/server/workspace.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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';
|