papilot 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.de.md +110 -0
  2. package/README.es.md +110 -0
  3. package/README.fr.md +110 -0
  4. package/README.it.md +110 -0
  5. package/README.ja.md +110 -0
  6. package/README.ko.md +110 -0
  7. package/README.md +54 -121
  8. package/README.pt.md +110 -0
  9. package/README.sv.md +110 -0
  10. package/README.zh-CN.md +58 -117
  11. package/README.zh-Hant.md +110 -0
  12. package/bin/doctor.mjs +7 -5
  13. package/dist/assets/{_virtual_papilot-licenses-D7ro6ecN.js → _virtual_papilot-licenses-CMfCtEYQ.js} +1 -1
  14. package/dist/assets/index-oAIkxhfp.js +265 -0
  15. package/dist/assets/index-t_qCOF_B.css +1 -0
  16. package/dist/assets/{math-V9uUaUzA.js → math-CEU4m4-H.js} +1 -1
  17. package/dist/assets/{pdf-view-Bnb2uIBE.js → pdf-view-HWRGBLsZ.js} +1 -1
  18. package/dist/index.html +2 -2
  19. package/examples/papilot-paper/preview/main.pdf +0 -0
  20. package/examples/papilot-paper/preview/main.synctex.gz +0 -0
  21. package/package.json +18 -5
  22. package/src/server/acp.ts +44 -7
  23. package/src/server/agents.ts +5 -3
  24. package/src/server/app.ts +10 -3
  25. package/src/server/desktop-demo.ts +38 -0
  26. package/src/server/imports.ts +60 -21
  27. package/src/server/manuscript.ts +44 -8
  28. package/src/server/reconcile.ts +20 -0
  29. package/src/server/review.ts +3 -2
  30. package/src/server/start.ts +26 -4
  31. package/src/server/store.ts +0 -8
  32. package/src/server/update-install.ts +22 -2
  33. package/src/server/updates.ts +32 -12
  34. package/src/server/workspace.ts +78 -15
  35. package/src/shared.ts +20 -9
  36. package/src/skill-metadata.ts +1 -1
  37. package/vendor/latex-review/dist/web/assets/index-DVs7cTBl.js +19 -0
  38. package/vendor/latex-review/dist/web/assets/index-ysOpu05D.css +1 -0
  39. package/vendor/latex-review/dist/web/index.html +2 -2
  40. package/vendor/latex-review/src/core/build.ts +180 -97
  41. package/vendor/latex-review/src/core/current-commit.ts +104 -0
  42. package/vendor/latex-review/src/core/delivery.ts +62 -16
  43. package/vendor/latex-review/src/core/git.ts +135 -29
  44. package/vendor/latex-review/src/core/outline.ts +0 -9
  45. package/vendor/latex-review/src/core/publish.ts +7 -1
  46. package/vendor/latex-review/src/core/reconcile.ts +101 -0
  47. package/vendor/latex-review/src/core/review.ts +5 -2
  48. package/vendor/latex-review/src/core/store.ts +1 -0
  49. package/vendor/latex-review/src/core/tasks.ts +1 -1
  50. package/vendor/latex-review/src/locales.ts +47 -0
  51. package/vendor/latex-review/src/server.ts +12 -1
  52. package/vendor/latex-review/src/types.ts +5 -2
  53. package/vendor/latex-review/src/web/i18n.ts +9 -21
  54. package/vendor/latex-review/src/web/locales/de.json +250 -0
  55. package/vendor/latex-review/src/web/locales/es.json +250 -0
  56. package/vendor/latex-review/src/web/locales/fr.json +250 -0
  57. package/vendor/latex-review/src/web/locales/index.ts +16 -0
  58. package/vendor/latex-review/src/web/locales/it.json +250 -0
  59. package/vendor/latex-review/src/web/locales/ja.json +250 -0
  60. package/vendor/latex-review/src/web/locales/ko.json +250 -0
  61. package/vendor/latex-review/src/web/locales/pt.json +250 -0
  62. package/vendor/latex-review/src/web/locales/sv.json +250 -0
  63. package/vendor/latex-review/src/web/locales/zh-Hant.json +250 -0
  64. package/vendor/latex-review/src/web/references.tsx +1 -1
  65. package/vendor/latex-review/src/web/review.tsx +8 -6
  66. package/vendor/latex-review/src/web/style.css +1 -11
  67. package/vendor/latex-review/src/web/zh-CN.ts +4 -2
  68. package/dist/assets/index-Cc-awdNn.css +0 -1
  69. package/dist/assets/index-D_VX51no.js +0 -265
  70. package/vendor/latex-review/dist/web/assets/index-7VKAte5x.js +0 -19
  71. package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +0 -1
@@ -83,14 +83,6 @@ export class Store extends EventEmitter {
83
83
  });
84
84
  this.emit('change');
85
85
  }
86
- // Runs a one-off change the first time this database sees its key.
87
- migrate(key: string, fn: () => void) {
88
- this.transaction(() => {
89
- if (this.db.prepare('SELECT 1 FROM migrations WHERE key=?').get(key)) return;
90
- fn();
91
- this.db.prepare('INSERT INTO migrations VALUES(?,?)').run(key, Date.now());
92
- });
93
- }
94
86
  transaction<T>(fn: () => T): T { this.db.exec('BEGIN IMMEDIATE'); try { const result = fn(); this.db.exec('COMMIT'); return result; } catch (e) { this.db.exec('ROLLBACK'); throw e; } }
95
87
  recover() {
96
88
  this.transaction(() => {
@@ -4,7 +4,7 @@ import net from 'node:net';
4
4
  import { execFile, spawn } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
6
  import { createRequire } from 'node:module';
7
- import { devRevision, readJson, stableVersion } from '../../bin/installation.mjs';
7
+ import { devRevision, newerVersion, readJson, stableVersion } from '../../bin/installation.mjs';
8
8
 
9
9
  export const DEV_REPOSITORY = 'https://github.com/PapilotAI/papilot.git';
10
10
  const git = async (args: string[], cwd: string) => (await promisify(execFile)('git', args, { cwd, timeout: 120000, maxBuffer: 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })).stdout.trim();
@@ -18,6 +18,19 @@ export function npmCli(env = process.env, executable = process.execPath) {
18
18
  }
19
19
  throw new Error('npm is unavailable to Papilot.');
20
20
  }
21
+ // A desktop installer runs a newer release when it provides the shell level the
22
+ // release declares and its bundled Node meets the release's engine range.
23
+ export const NEEDS_INSTALLER = 'This version needs the latest desktop app. Download it from GitHub Releases.';
24
+ export function desktopSupports(pkg: any, shell: number, node = process.versions.node) {
25
+ if (!Number.isInteger(pkg?.desktopShell) || pkg.desktopShell < 1 || pkg.desktopShell > shell) return false;
26
+ // npm's --engine-strict checks other range forms during installation.
27
+ const minimum = typeof pkg.engines?.node === 'string' ? /^\s*>=\s*v?(\d+\.\d+\.\d+)\s*$/.exec(pkg.engines.node)?.[1] : undefined;
28
+ return !minimum || !newerVersion(minimum, node);
29
+ }
30
+ // The installer's Codex serves releases that test the same or an older one.
31
+ export function codexVersion(bin: string | undefined) {
32
+ try { return readJson(path.join(path.dirname(fs.realpathSync(bin!)), '../package.json'))?.version || null; } catch { return null; }
33
+ }
21
34
  export const loaderFor = (root: string) => createRequire(path.join(root, 'package.json')).resolve('tsx/esm');
22
35
  export function runInstall(args: string[], cwd: string, log: number, timeout = 600000) {
23
36
  return new Promise<void>((resolve, reject) => {
@@ -102,7 +115,7 @@ async function installDev(data: string, version: string) {
102
115
  } catch (error) { fs.rmSync(stage, { recursive: true, force: true }); throw error; }
103
116
  finally { fs.closeSync(log); }
104
117
  }
105
- export async function installRelease(data: string, version: string) {
118
+ export async function installRelease(data: string, version: string, desktop?: { shell: number; codex?: string }) {
106
119
  if (devRevision(version)) return installDev(data, version);
107
120
  if (!stableVersion(version)) throw new Error('Invalid release version.');
108
121
  const releases = path.join(data, 'updates/releases'); fs.mkdirSync(releases, { recursive: true, mode: 0o700 });
@@ -116,7 +129,14 @@ export async function installRelease(data: string, version: string) {
116
129
  for (const file of ['dist/index.html', 'src/server/start.ts', 'src/server/main.ts', 'bin/papilot.mjs', 'vendor/latex-review/package-lock.json']) {
117
130
  if (!fs.existsSync(path.join(root, file))) throw new Error('The downloaded release is incomplete.');
118
131
  }
132
+ if (desktop && !desktopSupports(pkg, desktop.shell)) throw new Error(NEEDS_INSTALLER);
119
133
  await runInstall(['ci', '--prefix', path.join(root, 'vendor/latex-review'), ...flags], stage, log);
134
+ // Like the CLI's first run, Codex installs with its own scripts. The app
135
+ // finds it beside the release.
136
+ const tested = pkg.devDependencies?.['@openai/codex'], bundled = desktop && codexVersion(desktop.codex);
137
+ if (desktop && stableVersion(tested) && (!bundled || newerVersion(tested, bundled))) {
138
+ await runInstall(['install', '--prefix', path.join(stage, 'codex'), '--registry=https://registry.npmjs.org', '--no-audit', '--no-fund', `@openai/codex@${tested}`], stage, log);
139
+ }
120
140
  await probeInstallation(root, version, stage, log);
121
141
  return root;
122
142
  } catch (error) { fs.rmSync(stage, { recursive: true, force: true }); throw error; }
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { devRevision, newerVersion, readJson, stableVersion, writeJson } from '../../bin/installation.mjs';
6
- import { installRelease, loaderFor, npmCli } from './update-install.ts';
6
+ import { desktopSupports, installRelease, loaderFor, NEEDS_INSTALLER, npmCli } from './update-install.ts';
7
7
  import type { UpdateChannel, UpdateStatus } from '../shared.ts';
8
8
 
9
9
  const activePhases = new Set(['installing', 'waiting', 'restarting']);
@@ -15,26 +15,32 @@ async function releaseInfo(url: string, fetcher: typeof fetch) {
15
15
  if (text.length > 256000) throw new Error('Invalid release information.');
16
16
  return JSON.parse(text);
17
17
  }
18
- export async function latestRelease(channel: UpdateChannel = 'stable', fetcher = fetch): Promise<string> {
18
+ // The package.json of the newest release on a channel.
19
+ export async function latestManifest(channel: UpdateChannel = 'stable', fetcher = fetch): Promise<Record<string, any>> {
19
20
  if (channel === 'dev') {
20
21
  const commit = await releaseInfo('https://api.github.com/repos/PapilotAI/papilot/git/ref/heads/main', fetcher);
21
22
  const revision = commit.object?.sha;
22
23
  if (!/^[a-f0-9]{40}$/.test(revision)) throw new Error('Invalid development revision.');
23
24
  const pkg = await releaseInfo(`https://raw.githubusercontent.com/PapilotAI/papilot/${revision}/package.json`, fetcher);
24
25
  if (pkg.name !== 'papilot' || !stableVersion(pkg.version)) throw new Error('Invalid development version.');
25
- return `${pkg.version}-dev.${revision}`;
26
+ return { ...pkg, version: `${pkg.version}-dev.${revision}` };
26
27
  }
27
28
  const pkg = await releaseInfo('https://registry.npmjs.org/papilot/latest', fetcher);
28
29
  if (pkg.name !== 'papilot' || !stableVersion(pkg.version)) throw new Error('Invalid release information.');
29
- return pkg.version;
30
+ return pkg;
30
31
  }
32
+ export const latestRelease = async (channel: UpdateChannel = 'stable', fetcher = fetch) => (await latestManifest(channel, fetcher)).version as string;
31
33
  const validTarget = (version: unknown, channel: UpdateChannel): version is string => channel === 'dev' ? Boolean(devRevision(version)) : stableVersion(version);
32
34
  const available = (next: string, current: string, channel: UpdateChannel) => validTarget(next, channel)
33
35
  && (channel === 'dev' || devRevision(current) ? next !== current : newerVersion(next, current));
34
36
  interface Options {
37
+ // Desktop builds that cannot install releases link to this download page.
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 };
35
41
  root: string; data: string; version: string; port: number;
36
42
  busy: () => boolean; shutdown: () => void;
37
- latest?: (channel: UpdateChannel) => Promise<string>; install?: (data: string, version: string) => Promise<string>;
43
+ latest?: (channel: UpdateChannel) => Promise<string | Record<string, any>>; install?: (data: string, version: string) => Promise<string>;
38
44
  restart?: (root: string, version: string) => Promise<void>; supported?: boolean;
39
45
  }
40
46
  // Both HTTP listeners share one updater. Downloads do not interrupt editing;
@@ -42,7 +48,6 @@ interface Options {
42
48
  export class Updates {
43
49
  private state: UpdateStatus;
44
50
  private checking: Promise<UpdateStatus> | null = null;
45
- private operation: Promise<void> | null = null;
46
51
  private prepared = '';
47
52
  private inFlight = 0;
48
53
  private timer?: NodeJS.Timeout;
@@ -50,10 +55,12 @@ export class Updates {
50
55
  private file: string;
51
56
  private lock: string;
52
57
  private channel: UpdateChannel;
58
+ // The latest release needs a newer desktop installer.
59
+ private needsInstaller = false;
53
60
  constructor(private options: Options) {
54
61
  this.file = path.join(options.data, 'updates/status.json'); this.lock = path.join(options.data, 'updates/install.lock');
55
62
  const preference = readJson(path.join(options.data, 'updates/channel.json'))?.channel;
56
- this.channel = 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';
57
64
  let supported = options.supported ?? true;
58
65
  if (options.supported === undefined) try { npmCli(); } catch { supported = false; }
59
66
  this.state = { currentVersion: options.version, latestVersion: null, phase: 'idle', checkedAt: null, error: null, canUpdate: supported };
@@ -62,14 +69,18 @@ export class Updates {
62
69
  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.' });
63
70
  }
64
71
  snapshot(): UpdateStatus {
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 };
65
73
  const saved = readJson(this.file);
66
74
  if (saved && activePhases.has(this.state.phase) && ['updated', 'failed'].includes(saved.phase)) this.state = { ...this.state, ...saved, currentVersion: this.options.version };
67
- 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 }) };
68
78
  return { currentVersion, latestVersion, phase, checkedAt, error, canUpdate, channel: this.channel };
69
79
  }
70
80
  get restarting() { return this.snapshot().phase === 'restarting'; }
71
81
  enter() { this.inFlight++; let released = false; return () => { if (!released) { released = true; this.inFlight--; } }; }
72
82
  start() {
83
+ if (this.options.downloadUrl) return;
73
84
  if (this.state.phase !== 'failed') void this.check();
74
85
  this.timer = setInterval(() => {
75
86
  if (this.state.phase === 'waiting') void this.activate();
@@ -82,6 +93,7 @@ export class Updates {
82
93
  writeJson(this.file, { ...this.state, pid: process.pid });
83
94
  }
84
95
  setChannel(channel: unknown) {
96
+ if (this.options.downloadUrl || this.options.desktop) throw new Error('Download desktop updates from GitHub Releases.');
85
97
  if (channel !== 'stable' && channel !== 'dev') throw new Error('Choose a valid update channel.');
86
98
  if (activePhases.has(this.state.phase)) throw new Error('Wait for the current update to finish before changing channels.');
87
99
  if (channel === this.channel) return this.check();
@@ -92,13 +104,19 @@ export class Updates {
92
104
  return this.check();
93
105
  }
94
106
  check(): Promise<UpdateStatus> {
107
+ if (this.options.downloadUrl) return Promise.resolve(this.snapshot());
95
108
  if (this.checking) return this.checking;
96
109
  if (activePhases.has(this.state.phase)) return Promise.resolve(this.snapshot());
97
110
  this.change({ phase: 'checking' });
98
111
  const channel = this.channel;
99
- const checking = (this.options.latest || latestRelease)(channel).then(version => {
112
+ const checking = (this.options.latest || latestManifest)(channel).then(found => {
113
+ const manifest = typeof found === 'string' ? { version: found } : found, version = manifest?.version;
100
114
  if (!validTarget(version, channel)) throw new Error('Invalid release version.');
101
- if (!this.closed && this.checking === checking && !activePhases.has(this.state.phase)) this.change({ latestVersion: version, checkedAt: Date.now(), error: null, phase: available(version, this.options.version, channel) ? 'available' : 'idle' });
115
+ if (!this.closed && this.checking === checking && !activePhases.has(this.state.phase)) {
116
+ const newer = available(version, this.options.version, channel);
117
+ this.needsInstaller = Boolean(this.options.desktop && newer && !desktopSupports(manifest, this.options.desktop.shell));
118
+ this.change({ latestVersion: version, checkedAt: Date.now(), error: null, phase: newer ? 'available' : 'idle' });
119
+ }
102
120
  return this.snapshot();
103
121
  }).catch(() => {
104
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.' });
@@ -108,7 +126,9 @@ export class Updates {
108
126
  return this.checking;
109
127
  }
110
128
  install(version: unknown) {
129
+ if (this.options.downloadUrl) throw new Error('Download desktop updates from GitHub Releases.');
111
130
  if (activePhases.has(this.state.phase)) return this.snapshot();
131
+ if (this.needsInstaller) throw new Error(NEEDS_INSTALLER);
112
132
  if (!this.state.canUpdate) throw new Error('npm is unavailable to Papilot.');
113
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.');
114
134
  fs.mkdirSync(path.dirname(this.lock), { recursive: true, mode: 0o700 });
@@ -117,11 +137,11 @@ export class Updates {
117
137
  if (previous) fs.rmSync(this.lock);
118
138
  const fd = fs.openSync(this.lock, 'wx', 0o600); fs.writeFileSync(fd, JSON.stringify({ pid: process.pid })); fs.closeSync(fd);
119
139
  this.change({ phase: 'installing', error: null });
120
- this.operation = (this.options.install || installRelease)(this.options.data, version).then(root => {
140
+ void (this.options.install || installRelease)(this.options.data, version).then(root => {
121
141
  if (this.closed) return;
122
142
  this.prepared = root; this.change({ phase: 'waiting' });
123
143
  return this.activate();
124
- }).catch(error => { this.change({ phase: 'failed', error: error instanceof Error ? error.message : 'The update failed.' }); this.release(); }).finally(() => { this.operation = null; });
144
+ }).catch(error => { this.change({ phase: 'failed', error: error instanceof Error ? error.message : 'The update failed.' }); this.release(); });
125
145
  return this.snapshot();
126
146
  }
127
147
  // Public for deterministic tests; the periodic tick calls this in production.
@@ -17,13 +17,14 @@ import { VERSION } from './paths.ts';
17
17
  import { prepareRepository } from './imports.ts';
18
18
  import { findSkill, type SkillFile } from './skills.ts';
19
19
  import { buildFixPrompt, reviewInstructions, skillPrompt, summarizeFeedback } from '../conversation.ts';
20
+ import { reconciliationPrompt } from './reconcile.ts';
20
21
  import { commitMessageFrom } from '../commit.ts';
21
22
  import { taskCaptionFrom, taskCaptionInstructions } from '../task-caption.ts';
22
23
  import { notesInstructions, reviewNotesInstructions, summarizeNotes } from '../notes.ts';
23
24
  import { canonicalGroup, groupsWithoutInstructions, parseGroupInstructions, type GroupInstructions } from '../note-groups.ts';
24
25
  import { commitChanges, conflictMarkers, gitCommit, gitRemote, gitStatus, mergeConflicts, mergeInProgress } from './manuscript.ts';
25
26
  import { AGENT_IDS, isTaskReviewNote } from '../shared.ts';
26
- import type { Task, Run, TimelineItem, PendingRequest, RuntimeStatus, Snapshot, PendingReview, AgentModel, Usage, AgentId, MessageMeta, FeedbackSummary, BuildFailure, Author, Annotation, NoteSummary } from '../shared.ts';
27
+ import type { Task, Run, TimelineItem, PendingRequest, RuntimeStatus, Snapshot, PendingReview, AgentModel, Usage, AgentId, MessageMeta, FeedbackSummary, BuildFailure, Author, Annotation, NoteSummary, TaskReconciliation, TaskCommitReview } from '../shared.ts';
27
28
 
28
29
  const execute = promisify(execFile);
29
30
  const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
@@ -132,7 +133,7 @@ export class Workspace extends EventEmitter {
132
133
  constructor(public store: Store, public codex: AgentRuntime = new Codex(), private concurrency = 2, private connectReview = reviewClient, others: Partial<Record<AgentId, AgentRuntime>> = {}) {
133
134
  super();
134
135
  this.attachments = new Attachments(store);
135
- this.agents = { codex, claude: others.claude || new ClaudeAgent(), cursor: others.cursor || new CursorAgent(), antigravity: others.antigravity || new AntigravityAgent(),
136
+ this.agents = { codex, claude: others.claude || new ClaudeAgent(), cursor: others.cursor || new CursorAgent(), copilot: others.copilot || new AcpAgent(ACP_PROFILES.copilot), grok: others.grok || new AcpAgent(ACP_PROFILES.grok), antigravity: others.antigravity || new AntigravityAgent(),
136
137
  kimi: others.kimi || new AcpAgent(ACP_PROFILES.kimi), glm: others.glm || new GlmAgent(), deepseek: others.deepseek || new AcpAgent(ACP_PROFILES.deepseek),
137
138
  qwen: others.qwen || new AcpAgent(ACP_PROFILES.qwen), opencode: others.opencode || new AcpAgent(ACP_PROFILES.opencode) };
138
139
  store.on('change', () => this.emit('change'));
@@ -328,7 +329,7 @@ export class Workspace extends EventEmitter {
328
329
  // The author's message, a skill run with or without one, the review
329
330
  // feedback alone, or a build the agent broke. The chat keeps what happened;
330
331
  // the agent gets the prompt.
331
- async enqueue(taskId: string, text: string, requestKey: string, includeFeedback = true, options: { attachments?: string[]; skill?: SkillFile; notes?: Annotation[]; groupInstructions?: GroupInstructions; reviewNotes?: NoteSummary[]; feedback?: boolean; build?: BuildFailure; author?: Author } = {}) {
332
+ async enqueue(taskId: string, text: string, requestKey: string, includeFeedback = true, options: { attachments?: string[]; skill?: SkillFile; notes?: Annotation[]; groupInstructions?: GroupInstructions; reviewNotes?: NoteSummary[]; feedback?: boolean; build?: BuildFailure; author?: Author; merge?: TaskReconciliation } = {}) {
332
333
  const task = this.store.task(taskId);
333
334
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
334
335
  if (task.mode === 'review' && ((options.skill && options.skill.output !== 'annotations') || options.notes?.length || options.reviewNotes?.length || options.attachments?.length || options.feedback || options.build)) throw new Error('AI review accepts review instructions only. Start an editing task to send notes, skills or attachments.');
@@ -342,7 +343,7 @@ export class Workspace extends EventEmitter {
342
343
  const pauseVersion = task.pauseVersion || 0;
343
344
  if (this.store.runs().some(r => r.taskId === taskId && r.status === 'queued')) throw new Error('This task already has queued instructions. Wait or pause it first.');
344
345
  const instructions = options.notes?.length ? notesInstructions(options.notes, text, options.groupInstructions) : options.reviewNotes?.length ? reviewNotesInstructions(options.reviewNotes, text) : text.trim();
345
- let prompt = options.build ? buildFixPrompt(options.build) : options.feedback ? reviewInstructions : options.skill ? skillPrompt(options.skill, instructions) : instructions, cursor = task.feedbackCursor;
346
+ let prompt = options.merge ? reconciliationPrompt(options.merge) : options.build ? buildFixPrompt(options.build) : options.feedback ? reviewInstructions : options.skill ? skillPrompt(options.skill, instructions) : instructions, cursor = task.feedbackCursor;
346
347
  let summary: FeedbackSummary | undefined;
347
348
  if (task.mode === 'review') includeFeedback = false;
348
349
  if (includeFeedback) {
@@ -417,6 +418,7 @@ export class Workspace extends EventEmitter {
417
418
  return `You are ${this.agentOf(task) === 'codex' ? 'the official Codex agent' : AGENT_NAMES[this.agentOf(task)]} running inside Papilot, a paper editing and review workspace.
418
419
  Your task is already created. Your editable working directory is ${task.worktree}.
419
420
  Original task: ${task.instructions}
421
+ Papilot offers two ways to save accepted changes: Apply to workspace leaves them uncommitted; Commit to manuscript creates one commit containing only this task on the current manuscript branch. Both update the manuscript directly, including for tasks previously saved to a task branch. Do not tell the author to export patches or run Git commands to apply a task. If application conflicts, Papilot automatically asks AI to prepare another review in this task. Ask the author only when the instructions and manuscript context cannot determine the intended content; continue after their answer.
420
422
  Only edit this isolated task worktree. Never edit the manuscript checkout, other task worktrees, or Papilot's own source/state. Never commit, push, stash, or accept/reject/undo review decisions.
421
423
  ${scope.length ? `The review covers only ${scope.join(', ')} in this worktree, and an edit to any other file stops the PDF build. Leave other files unchanged even when project instructions ask for a matching update; mention that update in your summary instead.\n` : ''}Papilot owns the LaTeX review lifecycle: it compiles and publishes automatically AFTER your turn finishes, and delivers review feedback in a new turn. Do not create another review task, invoke publish, or wait on feedback --wait. This application workflow replaces the standalone latex-review skill's publish-and-wait loop. End your turn after completing the requested edits, with a concise summary for the author of what you changed and why; do not describe Papilot's review workflow.
422
424
  Preserve meaning and mathematical correctness. Read relevant project instructions. Treat reviewer comments as instructions tied to their round, file and changes. Rejected edits must not be reintroduced unless explicitly requested. If there is a conflict with accepted text, explain it and propose a revision for a new review round; never overwrite the accepted manuscript.
@@ -449,13 +451,21 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
449
451
  }
450
452
  }
451
453
  if (this.active.get(task.id)?.id !== run.id) return;
452
- 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 };
453
459
  this.loaded.add(result.thread.id);
454
460
  }
455
461
  this.store.updateTask(task.id, { pendingRunId: run.id });
456
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) }))];
457
463
  let result: any;
458
- 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 } : {}) }); }
459
469
  catch (error) {
460
470
  if (this.active.get(task.id)?.id !== run.id) return;
461
471
  if (error instanceof RpcResponseError) throw error;
@@ -811,9 +821,16 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
811
821
  const { unsent, comments } = await this.unsentReviewFeedback(client, this.store.task(taskId));
812
822
  const deferred = summary.deferred === true || task.deferred;
813
823
  const delivery = summary.delivery ?? task.delivery;
814
- const files = await acceptedFiles(project.path, task.name, deferred ? delivery?.accepted : task.committed?.accepted);
824
+ const reconciliation = summary.reconciliation ?? null;
825
+ const files = await acceptedFiles(project.path, task.name, deferred ? reconciliation?.base ?? (delivery && delivery.mode !== 'branch' ? delivery.accepted : undefined) : task.committed?.accepted);
815
826
  const current = this.store.task(taskId);
816
- if (current.status !== 'deleting') this.store.updateTask(taskId, { deferred, delivery, review: { state: summary.state, round: summary.round, counts: summary.counts, job: current.review?.job, unsent, comments, files } });
827
+ if (current.status !== 'deleting') this.store.updateTask(taskId, { deferred, delivery, reconciliation, review: { state: summary.state, round: summary.round, counts: summary.counts, job: current.review?.job, unsent, comments, files } });
828
+ // Recover an application interrupted after the engine prepared its
829
+ // proposal but before Papilot persisted the agent run.
830
+ if (reconciliation?.ready && (summary.round ?? 0) <= reconciliation.round && current.status === 'reviewing' && !this.committing.has(task.projectId) && !this.reviewWrites.has(taskId)
831
+ && !current.pendingReview && !this.store.runByKey('merge-review-' + reconciliation.id)
832
+ && !this.store.runs().some(run => run.taskId === taskId && run.status === 'queued'))
833
+ await this.queueMergeSuggestion(taskId, reconciliation);
817
834
  } catch { /* The task remains visible even if its project is temporarily unavailable. */ }
818
835
  finally { release?.(); }
819
836
  }
@@ -951,7 +968,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
951
968
  try {
952
969
  const task = this.store.task(taskId), files = task.review?.files || [];
953
970
  if (task.status !== 'reviewing' || task.review?.counts?.pending || !files.length) return;
954
- if (task.deferred) { await this.commitMessage(task.projectId, files, { agent: this.agentOf(task), taskId }); return; }
971
+ if (task.deferred) return;
955
972
  const project = this.store.project(task.projectId), git = await gitStatus(project.path);
956
973
  const paths = git.merging ? [] : files.filter(file => git.files.some(item => item.path === file));
957
974
  if (paths.length) await this.commitMessage(project.id, paths, { agent: this.agentOf(task) });
@@ -959,9 +976,10 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
959
976
  }
960
977
  private async taskCommitChanges(task: Task) {
961
978
  const root = this.store.project(task.projectId).path;
962
- const before = task.delivery?.accepted ?? `refs/latex-review/${task.name}/base`;
979
+ const delivered = task.reconciliation?.base ?? (task.delivery?.mode !== 'branch' ? task.delivery?.accepted : undefined);
980
+ const before = delivered ?? `refs/latex-review/${task.name}/base`;
963
981
  const after = `refs/latex-review/${task.name}/accepted`;
964
- const paths = await acceptedFiles(root, task.name, task.delivery?.accepted);
982
+ const paths = await acceptedFiles(root, task.name, delivered);
965
983
  if (!paths.length) throw new Error('This task has no accepted changes left to commit.');
966
984
  const { stdout: patch } = await execute('git', ['-C', root, 'diff', '--no-ext-diff', '--no-textconv', before, after, '--'], { maxBuffer: 8 * 1024 * 1024 });
967
985
  return { files: paths.map(path => ({ path, status: 'M', from: undefined })), patch: patch.slice(0, 80000), recent: [] as string[] };
@@ -982,6 +1000,38 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
982
1000
  } finally { await this.refresh(taskId); this.reviewWrites.delete(taskId); this.schedule(); }
983
1001
  });
984
1002
  }
1003
+ async applyTask(taskId: string, keepCurrent = false, commit?: { message: string; confirmation?: string }) {
1004
+ const task = this.store.task(taskId), project = this.store.project(task.projectId);
1005
+ if (task.mode === 'review') throw new Error('AI review has no manuscript changes to apply.');
1006
+ if (!task.deferred) throw new Error('Accepted changes are already in the manuscript.');
1007
+ if (task.status !== 'reviewing' || this.active.has(taskId) || this.publishing.has(taskId) || task.pendingReview || this.store.runs().some(run => run.taskId === taskId && run.status === 'queued'))
1008
+ throw new Error('Wait for the task and review actions to finish before applying changes.');
1009
+ const result = await this.exclusiveCommit(project.id, async () => {
1010
+ this.reviewWrites.add(taskId);
1011
+ try {
1012
+ const client = await this.connectReview(project.path);
1013
+ const result = await client.post<{ delivery?: NonNullable<Task['delivery']>; reconciliation?: TaskReconciliation; commitReview?: TaskCommitReview }>(`${taskRoute(task.name)}/apply`, { keepCurrent, mode: commit ? 'commit' : 'workspace', ...(commit ? { message: commit.message, confirmation: commit.confirmation } : {}) });
1014
+ if (result.delivery) this.store.updateTask(taskId, { delivery: result.delivery, reconciliation: null });
1015
+ if (result.reconciliation) this.store.updateTask(taskId, { reconciliation: result.reconciliation });
1016
+ return { ...result, git: await gitStatus(project.path) };
1017
+ } finally { await this.refresh(taskId); this.reviewWrites.delete(taskId); }
1018
+ }).catch(async error => {
1019
+ // A lost response is not a failed application. refresh above checks the
1020
+ // engine's persisted result before we retry or start the suggestion.
1021
+ const confirmed = this.store.task(taskId);
1022
+ if (confirmed.reconciliation?.ready && confirmed.reconciliation.id !== task.reconciliation?.id)
1023
+ return { reconciliation: confirmed.reconciliation, delivery: undefined, commitReview: undefined, git: await gitStatus(project.path) };
1024
+ if (confirmed.delivery && confirmed.delivery.mode !== 'branch' && !confirmed.delivery.pending && (confirmed.delivery.mode !== task.delivery?.mode || confirmed.delivery.accepted !== task.delivery?.accepted))
1025
+ return { delivery: confirmed.delivery, reconciliation: undefined, commitReview: undefined, git: await gitStatus(project.path) };
1026
+ throw error;
1027
+ });
1028
+ if (result.reconciliation?.ready) await this.queueMergeSuggestion(taskId, result.reconciliation);
1029
+ this.schedule();
1030
+ return result;
1031
+ }
1032
+ private queueMergeSuggestion(taskId: string, merge: TaskReconciliation) {
1033
+ return this.enqueue(taskId, 'The manuscript has changed in overlapping passages. Prepare a merge suggestion for me to review; keep the manuscript unchanged.', 'merge-review-' + merge.id, false, { merge });
1034
+ }
985
1035
  // Resolves a merge's conflicts in the manuscript checkout itself: the agent
986
1036
  // edits only the conflicted files, at the model's default reasoning, and
987
1037
  // leaves committing the merge to the author once they have read the diffs.
@@ -1006,10 +1056,9 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
1006
1056
  async commitTask(taskId: string, message: string, push = false) {
1007
1057
  const task = this.store.task(taskId);
1008
1058
  if (task.deferred) {
1009
- if (push) throw new Error('Task branch commits are kept locally.');
1010
- const result = await this.deliverTask(taskId, 'branch', message);
1011
- if (!result.commit) throw new Error('The review engine did not confirm the commit. Retry the task commit.');
1012
- return { ...result, commit: result.commit, pushError: undefined as string | undefined };
1059
+ if (push) throw new Error('Commit the task first, then push from Git.');
1060
+ const result = await this.applyTask(taskId, false, { message });
1061
+ return { ...result, commit: result.delivery?.commit, pushError: undefined };
1013
1062
  }
1014
1063
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
1015
1064
  if (task.pendingReview) throw new Error('Wait for the current review action before committing.');
@@ -1040,6 +1089,20 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
1040
1089
  this.committing.add(projectId);
1041
1090
  try { return await work(); } finally { this.committing.delete(projectId); }
1042
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
+ }
1043
1106
  renameTask(taskId: string, title: string) {
1044
1107
  const task = this.store.task(taskId);
1045
1108
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
package/src/shared.ts CHANGED
@@ -9,6 +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.
13
+ downloadUrl?: string;
14
+ // The desktop app's own Papilot version; newer releases run on top of it.
15
+ installerVersion?: string;
12
16
  channel?: UpdateChannel;
13
17
  currentVersion: string; latestVersion: string | null;
14
18
  phase: 'idle' | 'checking' | 'available' | 'installing' | 'waiting' | 'restarting' | 'updated' | 'failed';
@@ -19,12 +23,19 @@ export interface UpdateStatus {
19
23
  // accepted changes touched since its last commit, relative to the repository root.
20
24
  export interface ReviewSummary { state: string; round: number | null; counts: Record<string, number>; unsent?: number; comments?: number; files?: string[]; next?: string; job?: { phase: string; status: string; error?: string; details?: string[] } | null }
21
25
  export interface PendingReview { route: string; body: Record<string, unknown>; previousStatus: TaskStatus }
26
+ export interface TaskCommitReview { token: string; files: string[]; diff: string }
27
+ export interface TaskReconciliation {
28
+ id: string; round: number; files: string[];
29
+ sourceBase: string; sourceAccepted: string; base: string; ready: boolean;
30
+ intent?: { mode: 'workspace' | 'commit'; message: string };
31
+ }
22
32
  export interface Task {
23
33
  mode?: 'edit' | 'review';
24
34
  aiReview?: { key: string; notes?: number; revision?: number };
25
- // New tasks keep acceptance isolated until the author chooses a destination.
35
+ // Accepted changes remain isolated until the author applies them.
36
+ reconciliation?: TaskReconciliation | null;
26
37
  deferred?: boolean;
27
- delivery?: { mode: 'workspace' | 'branch'; accepted: string; branch?: string; commit?: string; pending?: boolean } | null;
38
+ delivery?: { mode: 'workspace' | 'commit' | 'branch'; accepted: string; branch?: string; commit?: string; pending?: boolean } | null;
28
39
  id: string; projectId: string; name: string; title: string; instructions: string;
29
40
  titleSource?: 'prompt' | 'generated' | 'manual'; titleRevision?: number; titleGeneration?: 'pending' | 'failed' | null;
30
41
  worktree: string; threadId: string | null; turnId: string | null; agent?: AgentId; model: string | null; effort?: string | null; serviceTier?: string;
@@ -38,18 +49,18 @@ export interface Task {
38
49
  // The task's last commit from its review, and the accepted text it included.
39
50
  committed?: { hash: string; accepted: string; at: number; pushed?: boolean } | null;
40
51
  }
41
- // A task the author is through with, which Papilot archives once they leave
42
- // it: every change of its last result is decided, no comment or rejection
52
+ // A task the author is through with, so the saved result can offer archiving:
53
+ // every change of its last result is decided, no comment or rejection
43
54
  // waits to go back to the agent, and what was accepted has left the task
44
55
  // (applied or committed; older tasks accept straight into the working
45
56
  // directory). An answer without manuscript changes is finished too.
46
57
  export function taskFinished(task: Task) {
47
58
  const review = task.review;
48
- if (task.status !== 'reviewing' || !review || task.pendingReview || task.delivery?.pending || review.counts?.pending || review.state === 'build_failed') return false;
59
+ if (task.status !== 'reviewing' || !review || task.reconciliation || task.pendingReview || task.delivery?.pending || review.counts?.pending || review.state === 'build_failed') return false;
49
60
  const unsent = review.unsent ?? ['accepted', 'rejected', 'commented'].reduce((count, kind) => count + (review.counts?.[kind] || 0), 0);
50
61
  if ((review.comments ?? unsent) > 0 || (unsent > 0 && !review.counts?.accepted)) return false;
51
62
  if (review.round === null) return true;
52
- return review.state === 'done' && (!task.deferred || review.files?.length === 0);
63
+ return review.state === 'done' && (!task.deferred || (review.files?.length === 0 && task.delivery?.mode !== 'branch'));
53
64
  }
54
65
  // reviewRound is 0 while awaiting a result, null when no changes were produced,
55
66
  // or the published round. Older runs omit it.
@@ -70,10 +81,10 @@ export interface BuildFailure { error: string; details: string[]; log?: string }
70
81
  export interface FeedbackSummary { round: number | null; accepted: number; rejected: number; undone: number; comments: { where: string; text: string }[]; buildError?: boolean }
71
82
  export interface Skill { output?: 'annotations'; id: string; name: string; description: string; hint: string; icon?: SkillIcon; source: 'builtin' | 'user' | 'paper' }
72
83
  export interface PendingRequest { id: string; taskId: string; method: string; params: Record<string, any>; createdAt: number }
73
- export type AgentId = 'codex' | 'claude' | 'antigravity' | 'kimi' | 'cursor' | 'opencode' | 'qwen' | 'glm' | 'deepseek';
84
+ export type AgentId = 'codex' | 'claude' | 'antigravity' | 'kimi' | 'cursor' | 'copilot' | 'grok' | 'opencode' | 'qwen' | 'glm' | 'deepseek';
74
85
  // Every agent Papilot can drive, in menu order. Only the ones found on the host are offered.
75
- export const AGENT_IDS: AgentId[] = ['codex', 'claude', 'cursor', 'antigravity', 'kimi', 'glm', 'deepseek', 'qwen', 'opencode'];
76
- export const AGENT_NAMES: Record<AgentId, string> = { codex: 'Codex', claude: 'Claude', cursor: 'Cursor', antigravity: 'Antigravity', kimi: 'Kimi', glm: 'GLM', deepseek: 'DeepSeek', qwen: 'Qwen', opencode: 'OpenCode' };
86
+ export const AGENT_IDS: AgentId[] = ['codex', 'claude', 'cursor', 'copilot', 'grok', 'antigravity', 'kimi', 'glm', 'deepseek', 'qwen', 'opencode'];
87
+ export const AGENT_NAMES: Record<AgentId, string> = { codex: 'Codex', claude: 'Claude', cursor: 'Cursor', copilot: 'Copilot', grok: 'Grok', antigravity: 'Antigravity', kimi: 'Kimi', glm: 'GLM', deepseek: 'DeepSeek', qwen: 'Qwen', opencode: 'OpenCode' };
77
88
  export const isAgentId = (value: unknown): value is AgentId => AGENT_IDS.includes(value as AgentId);
78
89
  export interface AgentModel { id: string; name: string; isDefault: boolean; efforts: string[]; defaultEffort: string | null; fastMode?: { serviceTier: string; description: string } }
79
90
  // The account's included usage: rolling windows (minutes long, reset time in ms) and credits.
@@ -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
  }