papilot 0.7.0 → 0.8.1

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 (75) hide show
  1. package/README.md +53 -100
  2. package/README.zh-CN.md +52 -97
  3. package/bin/doctor.mjs +55 -6
  4. package/bin/installation.d.mts +2 -0
  5. package/bin/installation.mjs +7 -1
  6. package/dist/assets/{_virtual_papilot-licenses-BWIsfrQh.js → _virtual_papilot-licenses-CMfCtEYQ.js} +54 -3
  7. package/dist/assets/index-BlLyIqT9.js +265 -0
  8. package/dist/assets/index-DVZtyZvu.css +1 -0
  9. package/dist/assets/latex-BarqJFtQ.svg +1 -0
  10. package/dist/assets/{math-BsEl3u6P.js → math-Bu3xEyrC.js} +1 -1
  11. package/dist/assets/pdf-view-PbkCuTZ-.js +598 -0
  12. package/dist/index.html +2 -2
  13. package/examples/papilot-paper/main.tex +1 -1
  14. package/examples/papilot-paper/preview/main.pdf +0 -0
  15. package/examples/papilot-paper/preview/main.synctex.gz +0 -0
  16. package/examples/papilot-paper/sections/02-using.tex +3 -1
  17. package/examples/papilot-paper/sections/09-agents.tex +8 -5
  18. package/package.json +18 -8
  19. package/skills/ai-review.md +18 -0
  20. package/skills/latex-warnings.md +1 -1
  21. package/src/conversation.ts +6 -0
  22. package/src/server/acp.ts +387 -0
  23. package/src/server/agents.ts +220 -43
  24. package/src/server/ai-review-routes.ts +72 -0
  25. package/src/server/ai-review.ts +38 -0
  26. package/src/server/app.ts +56 -14
  27. package/src/server/build-problems.ts +329 -0
  28. package/src/server/changes.ts +25 -2
  29. package/src/server/codex.ts +2 -1
  30. package/src/server/cursor.ts +153 -0
  31. package/src/server/desktop-demo.ts +38 -0
  32. package/src/server/latex-installation.ts +43 -0
  33. package/src/server/manuscript.ts +183 -23
  34. package/src/server/paths.ts +1 -1
  35. package/src/server/reconcile.ts +20 -0
  36. package/src/server/references.ts +70 -0
  37. package/src/server/review.ts +6 -5
  38. package/src/server/skills.ts +5 -8
  39. package/src/server/start.ts +19 -4
  40. package/src/server/store.ts +20 -2
  41. package/src/server/update-install.ts +53 -2
  42. package/src/server/update-worker.ts +6 -6
  43. package/src/server/updates.ts +49 -14
  44. package/src/server/workspace.ts +236 -41
  45. package/src/shared.ts +88 -9
  46. package/src/skill-icons.ts +18 -0
  47. package/src/skill-metadata.ts +45 -0
  48. package/vendor/latex-review/dist/web/assets/index-CXdnUjct.js +19 -0
  49. package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +1 -0
  50. package/vendor/latex-review/dist/web/index.html +2 -2
  51. package/vendor/latex-review/src/core/bib.ts +692 -0
  52. package/vendor/latex-review/src/core/changes.ts +39 -0
  53. package/vendor/latex-review/src/core/current-commit.ts +104 -0
  54. package/vendor/latex-review/src/core/delivery.ts +62 -16
  55. package/vendor/latex-review/src/core/git.ts +2 -1
  56. package/vendor/latex-review/src/core/publish.ts +7 -1
  57. package/vendor/latex-review/src/core/reconcile.ts +101 -0
  58. package/vendor/latex-review/src/core/review.ts +6 -2
  59. package/vendor/latex-review/src/core/store.ts +1 -0
  60. package/vendor/latex-review/src/core/tasks.ts +1 -1
  61. package/vendor/latex-review/src/core/verify.ts +784 -0
  62. package/vendor/latex-review/src/core/view.ts +204 -0
  63. package/vendor/latex-review/src/render/round.ts +11 -0
  64. package/vendor/latex-review/src/server.ts +77 -1
  65. package/vendor/latex-review/src/types.ts +130 -2
  66. package/vendor/latex-review/src/web/effect-preferences.ts +10 -0
  67. package/vendor/latex-review/src/web/references.tsx +499 -0
  68. package/vendor/latex-review/src/web/review.tsx +263 -133
  69. package/vendor/latex-review/src/web/style.css +285 -0
  70. package/vendor/latex-review/src/web/zh-CN.ts +79 -2
  71. package/dist/assets/index-CIJEYlP7.js +0 -252
  72. package/dist/assets/index-hOJEruFc.css +0 -1
  73. package/dist/assets/pdf-view-Cz9_vK5U.js +0 -598
  74. package/vendor/latex-review/dist/web/assets/index-Be9CtgcZ.css +0 -1
  75. package/vendor/latex-review/dist/web/assets/index-TvMWVSaw.js +0 -19
@@ -1,24 +1,30 @@
1
+ import { aiReviewInstructions, parseAiReview, reviewPrompt, REVIEW_AGENTS, type ReviewSnapshot } from './ai-review.ts';
1
2
  import { createHash, randomUUID } from 'node:crypto';
2
3
  import { execFile } from 'node:child_process';
3
4
  import { promisify } from 'node:util';
4
5
  import fs from 'node:fs';
6
+ import os from 'node:os';
5
7
  import path from 'node:path';
6
8
  import { EventEmitter } from 'node:events';
7
9
  import { Store } from './store.ts';
8
10
  import { Attachments } from './attachments.ts';
9
11
  import { Codex, RpcResponseError } from './codex.ts';
10
- import { AGENT_NAMES, AntigravityAgent, ClaudeAgent, type AgentRuntime } from './agents.ts';
12
+ import { AGENT_NAMES, AntigravityAgent, ClaudeAgent, GlmAgent, type AgentRuntime } from './agents.ts';
13
+ import { ACP_PROFILES, AcpAgent } from './acp.ts';
14
+ import { CursorAgent } from './cursor.ts';
11
15
  import { reviewClient, taskRoute, confirmedReviewResponse, type ReviewClient } from './review.ts';
12
16
  import { VERSION } from './paths.ts';
13
17
  import { prepareRepository } from './imports.ts';
14
18
  import { findSkill, type SkillFile } from './skills.ts';
15
19
  import { buildFixPrompt, reviewInstructions, skillPrompt, summarizeFeedback } from '../conversation.ts';
20
+ import { reconciliationPrompt } from './reconcile.ts';
16
21
  import { commitMessageFrom } from '../commit.ts';
17
22
  import { taskCaptionFrom, taskCaptionInstructions } from '../task-caption.ts';
18
23
  import { notesInstructions, reviewNotesInstructions, summarizeNotes } from '../notes.ts';
19
24
  import { canonicalGroup, groupsWithoutInstructions, parseGroupInstructions, type GroupInstructions } from '../note-groups.ts';
20
25
  import { commitChanges, conflictMarkers, gitCommit, gitRemote, gitStatus, mergeConflicts, mergeInProgress } from './manuscript.ts';
21
- import type { Task, Run, TimelineItem, PendingRequest, RuntimeStatus, Snapshot, PendingReview, AgentModel, Usage, AgentId, MessageMeta, FeedbackSummary, BuildFailure, Author, Annotation, NoteSummary } from '../shared.ts';
26
+ import { AGENT_IDS, isTaskReviewNote } 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';
22
28
 
23
29
  const execute = promisify(execFile);
24
30
  const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
@@ -53,8 +59,26 @@ const BUSY = new Set(['running', 'waiting_input', 'building', 'uncertain', 'dele
53
59
  // publish.ts), and the request keys of the turns that send them back to it.
54
60
  const FIXABLE = /^(Some edits are outside the task scope|The revised document could not be compiled)/;
55
61
  const BUILD_FIX = 'build-fix-';
62
+ // The tool Papilot gives Codex threads for looking at the typeset paper. The
63
+ // review engine compiles the task worktree as it is and draws the pages.
64
+ export const VIEW_PDF = 'view_pdf';
65
+ export const VIEW_PDF_TOOL = {
66
+ type: 'function', name: VIEW_PDF,
67
+ description: 'Compile the paper in your task worktree as it is now and draw typeset pages as PNG files. Ask for the pages where source lines are typeset (locations, such as "main/chapters/intro.tex:42") or for page numbers as printed on the pages; up to 4 pages per call. The result lists each page\'s file: open the ones you need with your image viewer (view_image) to see them. Set accepted to true to draw the author\'s accepted text, before your edits, instead. Only the chapters needed are compiled when the paper allows it, but this can still take a minute or more.',
68
+ inputSchema: {
69
+ type: 'object', additionalProperties: false,
70
+ properties: {
71
+ locations: { type: 'array', items: { type: 'string' }, maxItems: 4, description: 'Source lines as file:line, relative to the worktree.' },
72
+ pages: { type: 'array', items: { type: 'string' }, maxItems: 4, description: 'Printed page numbers, such as "12" or "iv", when the PDF provides page labels. Use locations if numbering metadata is unavailable.' },
73
+ accepted: { type: 'boolean', description: 'Show the accepted text instead of your worktree.' },
74
+ },
75
+ },
76
+ };
77
+ const VIEW_PDF_UNSUPPORTED = /dynamicTools|experimental/i;
78
+ // What a view_pdf call asked for, as the chat shows it.
79
+ const viewedPlaces = (args: any) => [...(Array.isArray(args?.locations) ? args.locations : []), ...(Array.isArray(args?.pages) ? args.pages.map((page: unknown) => `p. ${page}`) : [])].filter(place => typeof place === 'string').join(', ') + (args?.accepted === true ? ' (accepted)' : '');
56
80
 
57
- const AGENTS: AgentId[] = ['codex', 'claude', 'antigravity'];
81
+ const AGENTS: AgentId[] = AGENT_IDS;
58
82
  // The small model an agent offers for short text such as a commit message,
59
83
  // by the names agents give them (GPT Luna and mini, Claude Haiku, Gemini Flash).
60
84
  const LIGHT = /\b(?:luna|mini|nano|haiku|flash|lite)\b/i;
@@ -67,11 +91,13 @@ const blankRuntime = (): RuntimeStatus => ({ connected: false, authenticated: fa
67
91
 
68
92
  export class Workspace extends EventEmitter {
69
93
  // Each task runs on one agent; `runtime` is Codex's status, kept for older clients.
70
- runtimes: Record<AgentId, RuntimeStatus> = { codex: blankRuntime(), claude: blankRuntime(), antigravity: blankRuntime() };
94
+ runtimes = Object.fromEntries(AGENTS.map(id => [id, blankRuntime()])) as Record<AgentId, RuntimeStatus>;
71
95
  runtime = this.runtimes.codex;
72
96
  agents: Record<AgentId, AgentRuntime>;
73
97
  requests = new Map<string, PendingRequest & { rpcId: string | number; agent: AgentId }>();
74
98
  private active = new Map<string, Run>();
99
+ // Async tools must never answer a request from an earlier connection.
100
+ private connectionEpochs = new Map<AgentId, number>();
75
101
  private loaded = new Set<string>();
76
102
  private preparing = new Set<string>();
77
103
  private preparingNotes = new Set<string>();
@@ -94,6 +120,8 @@ export class Workspace extends EventEmitter {
94
120
  private messages = new Map<string, Promise<{ message: string; agent: AgentId }>>();
95
121
  private titles = new Map<string, Promise<Task | null>>();
96
122
  private helperStops = new Set<() => void>();
123
+ // Set when Codex refuses the view_pdf tool, so later threads start without it.
124
+ private noViewPdf = false;
97
125
  private projectChanges = new Set<string>();
98
126
  private projectRequests = new Map<string, number>();
99
127
  importsBusy = () => false;
@@ -105,7 +133,9 @@ export class Workspace extends EventEmitter {
105
133
  constructor(public store: Store, public codex: AgentRuntime = new Codex(), private concurrency = 2, private connectReview = reviewClient, others: Partial<Record<AgentId, AgentRuntime>> = {}) {
106
134
  super();
107
135
  this.attachments = new Attachments(store);
108
- this.agents = { codex, claude: others.claude || new ClaudeAgent(), 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(),
137
+ kimi: others.kimi || new AcpAgent(ACP_PROFILES.kimi), glm: others.glm || new GlmAgent(), deepseek: others.deepseek || new AcpAgent(ACP_PROFILES.deepseek),
138
+ qwen: others.qwen || new AcpAgent(ACP_PROFILES.qwen), opencode: others.opencode || new AcpAgent(ACP_PROFILES.opencode) };
109
139
  store.on('change', () => this.emit('change'));
110
140
  for (const id of AGENTS) {
111
141
  const agent = this.agents[id];
@@ -116,6 +146,7 @@ export class Workspace extends EventEmitter {
116
146
  }
117
147
  private agentOf(task: Task): AgentId { return task.agent && AGENTS.includes(task.agent) ? task.agent : 'codex'; }
118
148
  private disconnected(id: AgentId, e: unknown) {
149
+ this.connectionEpochs.set(id, (this.connectionEpochs.get(id) || 0) + 1);
119
150
  this.runtimes[id].connected = false; this.runtimes[id].error = errorText(e);
120
151
  for (const [key, request] of this.requests) if (request.agent === id) this.requests.delete(key);
121
152
  for (const [taskId, run] of this.active) {
@@ -137,6 +168,10 @@ export class Workspace extends EventEmitter {
137
168
  }
138
169
  private async connectAgent(id: AgentId) {
139
170
  const agent = this.agents[id], runtime = this.runtimes[id], name = AGENT_NAMES[id];
171
+ // An agent that is not on this host is neither started nor offered;
172
+ // Reconnect looks again. A running agent stays connected.
173
+ runtime.installed = agent.ready || !agent.detect || agent.detect();
174
+ if (!runtime.installed) { Object.assign(runtime, blankRuntime(), { installed: false }); return; }
140
175
  try {
141
176
  await agent.start();
142
177
  const account = await agent.request('account/read', { refreshToken: false });
@@ -236,12 +271,14 @@ export class Workspace extends EventEmitter {
236
271
  }
237
272
  // A task combines the author's words, an optional skill and selected PDF notes.
238
273
  annotationSending(id: string) { return this.preparingNotes.has(id); }
239
- async createTask(input: { projectId: string; title: string; instructions: string; skill?: string; annotations?: string[]; groupInstructions?: GroupInstructions; attachments?: string[]; agent?: AgentId; model?: string; effort?: string; serviceTier?: string; id: string; author?: Author }) {
274
+ async createTask(input: { reviewInput?: ReviewSnapshot; prepareReview?: (worktree: string) => Promise<ReviewSnapshot>; projectId: string; title: string; instructions: string; skill?: string; annotations?: string[]; groupInstructions?: GroupInstructions; attachments?: string[]; agent?: AgentId; model?: string; effort?: string; serviceTier?: string; id: string; author?: Author }) {
240
275
  if (!/^[\w-]{16,64}$/.test(input.id)) throw new Error('Invalid task ID.');
241
276
  if (this.store.taskDeleted(input.id)) throw new Error('This task was deleted. Create a new task instead.');
242
277
  // A retried request returns its task; an ID from another paper is refused.
243
278
  const existing = this.store.tasks().find(t => t.id === input.id);
244
- if (existing) { if (existing.projectId !== input.projectId) throw new Error('Invalid task ID.'); return existing; }
279
+ if (existing) { if (existing.projectId !== input.projectId || (existing.mode === 'review') !== Boolean(input.reviewInput)) throw new Error('Invalid task ID.'); return existing; }
280
+ if (input.reviewInput && !REVIEW_AGENTS.includes(input.agent || 'codex')) throw new Error('Choose Codex, Claude or GLM for AI review.');
281
+ if (input.reviewInput && (input.annotations?.length || input.attachments?.length)) throw new Error('AI review uses the manuscript PDF. Start a separate editing task to send notes or attachments.');
245
282
  if (input.serviceTier !== undefined) {
246
283
  if (input.agent && input.agent !== 'codex') throw new Error('Service tiers are only available for Codex.');
247
284
  const models = this.runtimes.codex.models;
@@ -254,9 +291,10 @@ export class Workspace extends EventEmitter {
254
291
  let releaseAttachments = () => {};
255
292
  try {
256
293
  const project = this.store.project(input.projectId), skill = input.skill ? findSkill(project.path, input.skill) : undefined;
294
+ if (skill?.output === 'annotations' && !input.reviewInput) throw new Error('Start a new task to run the AI review skill on the current PDF.');
257
295
  const files = this.attachments.resolve(project.id, input.attachments || [], input.author?.id || 'owner', input.id);
258
296
  releaseAttachments = this.attachments.reserve(project.id, input.attachments || [], input.author?.id || 'owner', input.id);
259
- const available = new Map(this.store.annotations(project.id).filter(note => !note.taskId).map(note => [note.id, note]));
297
+ const available = new Map(this.store.annotations(project.id).filter(note => !note.taskId && !isTaskReviewNote(note)).map(note => [note.id, note]));
260
298
  notes = [...new Set(input.annotations || [])].map(id => {
261
299
  const note = available.get(id);
262
300
  if (!note) throw new Error('A selected PDF note is no longer available. Refresh the manuscript and try again.');
@@ -268,16 +306,20 @@ export class Workspace extends EventEmitter {
268
306
  if (Object.keys(groupInstructions).some(group => !selectedGroups.has(group as keyof GroupInstructions))) throw new Error('Group instructions must refer to selected PDF notes.');
269
307
  if (groupsWithoutInstructions(notes, groupInstructions).length && !input.instructions.trim() && !skill) throw new Error('Describe what to do with each selected color group.');
270
308
  claimed = notes.map(note => note.id); claimed.forEach(id => this.preparingNotes.add(id));
271
- if (!skill && !notes.length && !files.length && !input.instructions.trim()) throw new Error('Describe the revision, choose a skill, or add a PDF note.');
309
+ if (!input.reviewInput && !skill && !notes.length && !files.length && !input.instructions.trim()) throw new Error('Describe the revision, choose a skill, or add a PDF note.');
272
310
  const instructions = notes.length ? notesInstructions(notes, input.instructions, groupInstructions) : input.instructions;
273
311
  const client = await this.connectReview(project.path);
274
312
  const name = 'p-' + input.id.toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 48);
275
313
  const info = await client.post('/api/tasks', { name, title: input.title, deferred: true });
276
- const task: Task = { id: input.id, projectId: project.id, name, title: input.title, titleSource: 'prompt', instructions: skill ? skillPrompt(skill, instructions) : instructions,
314
+ const reviewInput = input.reviewInput && (input.prepareReview ? await input.prepareReview(info.worktree) : input.reviewInput);
315
+ const task: Task = { ...(reviewInput ? { mode: 'review' as const, aiReview: { key: reviewInput.key } } : {}), id: input.id, projectId: project.id, name, title: input.title, titleSource: 'prompt', instructions: skill ? skillPrompt(skill, instructions) : instructions,
277
316
  worktree: info.worktree, deferred: info.deferred === true, threadId: null, turnId: null, agent: input.agent || 'codex', model: input.model || null, effort: input.effort || null, ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), status: 'idle',
278
317
  createdAt: Date.now(), updatedAt: Date.now(), feedbackCursor: 0, error: null, review: null };
279
- this.store.saveTask(task);
280
- await this.enqueue(task.id, input.instructions, 'initial-' + task.id, false, { skill, notes, groupInstructions, attachments: files.map(file => file.id), author: input.author });
318
+ this.store.transaction(() => {
319
+ this.store.saveTask(task);
320
+ if (reviewInput) this.store.saveReviewInput(task.id, reviewInput);
321
+ });
322
+ await this.enqueue(task.id, input.instructions || (input.reviewInput && !skill ? 'Review this manuscript and suggest improvements.' : ''), 'initial-' + task.id, false, { skill, notes, groupInstructions, attachments: files.map(file => file.id), author: input.author });
281
323
  // Naming is independent of the editing turn. An unavailable helper must
282
324
  // not fail creation or prevent the queued task from running.
283
325
  try { this.requestTaskTitle(task.id); } catch { /* Keep the prompt-derived title. */ }
@@ -287,9 +329,11 @@ export class Workspace extends EventEmitter {
287
329
  // The author's message, a skill run with or without one, the review
288
330
  // feedback alone, or a build the agent broke. The chat keeps what happened;
289
331
  // the agent gets the prompt.
290
- 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 } = {}) {
291
333
  const task = this.store.task(taskId);
292
334
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
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.');
336
+ if (task.mode !== 'review' && options.skill?.output === 'annotations') throw new Error('Start a new task to run the AI review skill on the current PDF.');
293
337
  const existing = this.store.runByKey(requestKey);
294
338
  if (existing) { if (existing.taskId !== taskId) throw new Error('This request ID is already in use by another task.'); return existing; }
295
339
  if (task.status === 'uncertain') throw new Error('Check the task status before sending more instructions.');
@@ -299,8 +343,9 @@ export class Workspace extends EventEmitter {
299
343
  const pauseVersion = task.pauseVersion || 0;
300
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.');
301
345
  const instructions = options.notes?.length ? notesInstructions(options.notes, text, options.groupInstructions) : options.reviewNotes?.length ? reviewNotesInstructions(options.reviewNotes, text) : text.trim();
302
- 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;
303
347
  let summary: FeedbackSummary | undefined;
348
+ if (task.mode === 'review') includeFeedback = false;
304
349
  if (includeFeedback) {
305
350
  const client = await this.connectReview(this.store.project(task.projectId).path);
306
351
  const feedback = await client.get(`${taskRoute(task.name)}/feedback?after=${task.feedbackCursor}`);
@@ -310,8 +355,9 @@ export class Workspace extends EventEmitter {
310
355
  if (task.error) prompt += '\n\nLatest task/review error:\n' + task.error + '\nThe latest accepted manuscript is at ' + this.store.project(task.projectId).path + '. You may read it to understand accepted changes, but must only edit your isolated task worktree. Prepare a revised proposal for another review; never write to or accept changes in the manuscript checkout.';
311
356
  }
312
357
  if (files.length) prompt = (prompt || 'The user attached reference material without an instruction. Briefly identify the material and ask what they would like to do with it. Do not change the manuscript yet.') + this.attachments.prompt(files);
358
+ if (task.mode === 'review') prompt = reviewPrompt(prompt, this.store.reviewInput(taskId));
313
359
  const run: Run = { id: randomUUID(), taskId, requestKey, prompt, ...(files.length ? { attachments: files } : {}), feedbackCursor: cursor, status: 'queued', createdAt: Date.now(), finishedAt: null, reviewRound: 0 };
314
- const meta: MessageMeta = { ...(options.skill ? { skill: { id: options.skill.id, name: options.skill.name } } : {}), ...(options.notes?.length ? { notes: summarizeNotes(options.notes) } : options.reviewNotes?.length ? { notes: options.reviewNotes } : {}), ...(summary ? { feedback: summary } : {}), ...(options.feedback ? { sendsFeedback: true } : {}), ...(options.build ? { build: options.build } : {}), ...(options.author ? { author: options.author } : {}) };
360
+ const meta: MessageMeta = { ...(options.skill ? { skill: { id: options.skill.id, name: options.skill.name, ...(options.skill.icon ? { icon: options.skill.icon } : {}) } } : {}), ...(options.notes?.length ? { notes: summarizeNotes(options.notes) } : options.reviewNotes?.length ? { notes: options.reviewNotes } : {}), ...(summary ? { feedback: summary } : {}), ...(options.feedback ? { sendsFeedback: true } : {}), ...(options.build ? { build: options.build } : {}), ...(options.author ? { author: options.author } : {}) };
315
361
  if (options.groupInstructions && Object.keys(options.groupInstructions).length) meta.groupInstructions = options.groupInstructions;
316
362
  const saved = this.store.transaction(() => {
317
363
  // Feedback loading yields to other requests. Recheck identity, queue, and
@@ -334,6 +380,8 @@ export class Workspace extends EventEmitter {
334
380
  this.store.saveRun(run);
335
381
  for (const note of options.notes || []) this.store.saveAnnotation({ ...note, taskId });
336
382
  if (!BUSY.has(current.status)) this.store.updateTask(taskId, { status: 'queued', error: null });
383
+ // Writing to an archived task brings it back to the task list.
384
+ if (current.archivedAt && !options.build) this.store.updateTask(taskId, { archivedAt: null });
337
385
  this.store.saveItem({ id: run.id, taskId, type: 'user', text: options.feedback || options.build ? '' : text.trim(), createdAt: run.createdAt, ...(Object.keys(meta).length ? { meta } : {}) });
338
386
  return run;
339
387
  });
@@ -365,14 +413,17 @@ export class Workspace extends EventEmitter {
365
413
  return files.some(file => file === '' || file === '.') ? [] : files;
366
414
  } catch { return []; }
367
415
  }
368
- private instructions(task: Task, scope: string[] = []) {
416
+ private instructions(task: Task, scope: string[] = [], viewPdf = false) {
417
+ if (task.mode === 'review') return aiReviewInstructions + '\n\nOriginal review skill and request:\n' + task.instructions;
369
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.
370
419
  Your task is already created. Your editable working directory is ${task.worktree}.
371
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.
372
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.
373
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.
374
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.
375
- ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to you here and any attempt is refused, which ends your turn: read, search and edit files in the task worktree with your file tools only.' : 'You may run tools in the task worktree to inspect and validate your changes.'} If a decision requires the user, ask through the available user-input tool or explain it in your final response. Use English for your progress updates, questions, and final summaries unless the user explicitly requests another language. Preserve the manuscript language unless asked to translate it.`;
425
+ Never invent a bibliography entry or any of its details. Add a .bib entry only for a work you can identify, take its authors, title, year and venue from the published record, and include its doi or arXiv eprint when it has one. Papilot looks up every added or changed entry in Crossref, DBLP and arXiv and shows the author any it cannot confirm. Name the entries you added or changed in your summary.
426
+ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to you here and any attempt is refused, which ends your turn: read, search and edit files in the task worktree with your file tools only.' : 'You may run tools in the task worktree to inspect and validate your changes.'} ${viewPdf ? `When the look of the pages matters, such as line breaks, spacing, figures, tables or displayed formulas, use the ${VIEW_PDF} tool and open the pages it draws with your image viewer to see them as the author does. It compiles the paper, so do not call it for wording alone. ` : ''}If a decision requires the user, ask through the available user-input tool or explain it in your final response. Use English for your progress updates, questions, and final summaries unless the user explicitly requests another language. Preserve the manuscript language unless asked to translate it.`;
376
427
  }
377
428
  private async startRun(run: Run) {
378
429
  let task = this.store.task(run.taskId);
@@ -384,12 +435,23 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
384
435
  this.store.saveRun({ ...run, status: 'running' });
385
436
  this.store.updateTask(task.id, { status: 'running', turnId: null, error: null });
386
437
  if (!task.threadId || !this.loaded.has(task.threadId)) {
387
- const options = { cwd: task.worktree, approvalPolicy: 'on-request', sandbox: 'workspace-write', developerInstructions: this.instructions(task, await this.reviewScope(task)), ...(task.model ? { model: task.model } : {}) };
388
- const result = task.threadId
389
- ? await agent.request('thread/resume', { ...options, threadId: task.threadId, excludeTurns: true })
390
- : await agent.request('thread/start', options);
438
+ // Codex threads get the view_pdf tool when they start, and keep it when resumed.
439
+ const scope = task.mode === 'review' ? [] : await this.reviewScope(task), codex = this.agentOf(task) === 'codex';
440
+ let viewPdf = task.mode !== 'review' && codex && (task.threadId ? Boolean(task.viewPdf) : !this.noViewPdf);
441
+ const options = (tool: boolean) => ({ cwd: task.worktree, approvalPolicy: task.mode === 'review' ? 'never' : 'on-request', sandbox: task.mode === 'review' ? 'read-only' : 'workspace-write', developerInstructions: this.instructions(task, scope, tool), ...(task.model ? { model: task.model } : {}) });
442
+ let result;
443
+ if (task.threadId) result = await agent.request('thread/resume', { ...options(viewPdf), threadId: task.threadId, excludeTurns: true });
444
+ else {
445
+ try { result = await agent.request('thread/start', viewPdf ? { ...options(true), dynamicTools: [VIEW_PDF_TOOL] } : options(false)); }
446
+ catch (error) {
447
+ // A Codex without the experimental tool API starts its threads without it.
448
+ if (!viewPdf || !(error instanceof RpcResponseError) || !VIEW_PDF_UNSUPPORTED.test(error.message)) throw error;
449
+ this.noViewPdf = true; viewPdf = false;
450
+ result = await agent.request('thread/start', options(false));
451
+ }
452
+ }
391
453
  if (this.active.get(task.id)?.id !== run.id) return;
392
- task = this.store.updateTask(task.id, { threadId: result.thread.id, model: result.model || task.model });
454
+ task = this.store.updateTask(task.id, { threadId: result.thread.id, model: result.model || task.model, ...(task.threadId ? {} : { viewPdf }) });
393
455
  this.loaded.add(result.thread.id);
394
456
  }
395
457
  this.store.updateTask(task.id, { pendingRunId: run.id });
@@ -453,7 +515,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
453
515
  if (!task || task.status === 'deleting') return;
454
516
  const eventTurn = p.turnId || p.turn?.id;
455
517
  if (eventTurn && task.turnId && eventTurn !== task.turnId) return;
456
- if (method === 'item/agentMessage/delta') this.appendDelta(task, p, 'assistant');
518
+ if (method === 'item/agentMessage/delta') { if (task.mode !== 'review') this.appendDelta(task, p, 'assistant'); }
457
519
  else if (method === 'item/commandExecution/outputDelta') this.appendDelta(task, p, 'command');
458
520
  else if ((method === 'item/started' || method === 'item/completed') && p.item) {
459
521
  const item = p.item, id = `${task.id}:${p.turnId}:${item.id}`;
@@ -461,11 +523,16 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
461
523
  this.flush();
462
524
  const previous = this.store.items(task.id).find(i => i.id === id);
463
525
  const entry: TimelineItem = { id, taskId: task.id, createdAt: previous?.createdAt || Date.now(), type: item.type, text: item.text || item.command || '', status: item.status || (method === 'item/completed' ? 'completed' : 'inProgress') };
464
- if (item.type === 'agentMessage') entry.type = 'assistant';
526
+ if (item.type === 'agentMessage') entry.type = task.mode === 'review' ? 'reviewOutput' : 'assistant';
465
527
  if (item.type === 'commandExecution') { entry.type = 'command'; entry.detail = String(item.aggregatedOutput || previous?.detail || '').slice(-40000); }
466
528
  if (item.type === 'fileChange') { entry.type = 'files'; entry.text = (item.changes || []).map((change: any) => change.path).join('\n'); }
467
529
  if (item.type === 'mcpToolCall') { entry.type = 'tool'; entry.text = `${item.server} / ${item.tool}`; }
468
530
  if (item.type === 'toolCall') entry.type = 'tool';
531
+ if (item.type === 'dynamicToolCall') {
532
+ if (item.tool !== VIEW_PDF) { entry.type = 'tool'; entry.text = item.tool || ''; }
533
+ else { entry.type = 'pdf'; entry.text = viewedPlaces(item.arguments); }
534
+ if (item.success === false) entry.status = 'failed';
535
+ }
469
536
  if (entry.text || entry.detail) this.store.saveItem(entry);
470
537
  } else if (method === 'serverRequest/resolved') {
471
538
  const buffered = this.earlyRequests.get(task.id);
@@ -491,7 +558,10 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
491
558
  this.store.saveRun({ ...run, status, finishedAt: Date.now() });
492
559
  this.store.updateTask(task.id, { turnId: null, pendingRunId: null, status: status === 'completed' ? 'building' : status, error: p.turn.error?.message || null });
493
560
  this.schedule();
494
- if (status === 'completed') await this.publish(task.id, run);
561
+ if (status === 'completed') {
562
+ if (task.mode === 'review') this.finishAiReview(task, run, eventTurn);
563
+ else await this.publish(task.id, run);
564
+ }
495
565
  } else if (method === 'error' && !p.willRetry) {
496
566
  if (!this.active.has(task.id) || (eventTurn && !task.turnId)) return;
497
567
  this.store.updateTask(task.id, { error: p.error?.message || `${AGENT_NAMES[agent]} encountered an error.` });
@@ -499,7 +569,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
499
569
  }
500
570
  private serverRequest(message: any, agent: AgentId = 'codex') {
501
571
  const task = this.store.tasks().find(t => t.threadId === message.params?.threadId), runtime = this.agents[agent];
502
- const allowed = ['item/commandExecution/requestApproval', 'item/fileChange/requestApproval', 'item/permissions/requestApproval', 'item/tool/requestUserInput'];
572
+ const allowed = ['item/commandExecution/requestApproval', 'item/fileChange/requestApproval', 'item/permissions/requestApproval', 'item/tool/requestUserInput', 'item/tool/call'];
503
573
  if (!task || this.agentOf(task) !== agent || !this.active.has(task.id) || !allowed.includes(message.method)) { runtime.reject(message.id, 'This request is not supported or its turn is no longer active.'); return; }
504
574
  if (message.params?.turnId && task.turnId && message.params.turnId !== task.turnId) { runtime.reject(message.id, 'This turn is no longer active.'); return; }
505
575
  if (!task.turnId && message.params?.turnId) {
@@ -508,10 +578,50 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
508
578
  else runtime.reject(message.id, 'Too many pending requests.');
509
579
  return;
510
580
  }
581
+ if (task.mode === 'review') { runtime.reject(message.id, 'AI review is read-only and cannot request tools or additional permissions.'); return; }
582
+ // Papilot answers its own tools; the author is not asked.
583
+ if (message.method === 'item/tool/call') {
584
+ void this.toolCall(task, message, runtime).catch(error => console.error(`${AGENT_NAMES[agent]} tool:`, errorText(error)));
585
+ return;
586
+ }
511
587
  const id = randomUUID();
512
588
  this.requests.set(id, { id, taskId: task.id, rpcId: message.id, agent, method: message.method, params: message.params, createdAt: Date.now() });
513
589
  this.store.updateTask(task.id, { status: 'waiting_input' });
514
590
  }
591
+ // The agent looks at typeset pages: the review engine compiles the task
592
+ // worktree and draws them as files, which the agent opens with its own
593
+ // image viewer. Returning the images themselves would put them in front of
594
+ // Codex's code mode as base64 text.
595
+ private async toolCall(task: Task, message: any, runtime: AgentRuntime) {
596
+ const { tool, arguments: args } = message.params || {};
597
+ const agent = this.agentOf(task), run = this.active.get(task.id), epoch = this.connectionEpochs.get(agent);
598
+ const current = () => !this.closing && runtime.ready && this.agents[agent] === runtime
599
+ && this.connectionEpochs.get(agent) === epoch && run && this.active.get(task.id)?.id === run.id
600
+ && this.store.task(task.id).turnId === task.turnId;
601
+ const reply = (success: boolean, text: string) => {
602
+ if (!current()) return;
603
+ // A failed transport cannot receive an error reply either. Do not let
604
+ // it escape this detached async call or retry it against a new child.
605
+ try { runtime.respond(message.id, { success, contentItems: [{ type: 'inputText', text }] }); }
606
+ catch (error) { console.error(`${AGENT_NAMES[agent]} tool reply:`, errorText(error)); }
607
+ };
608
+ if (tool !== VIEW_PDF) { reply(false, `Papilot has no tool named ${tool}.`); return; }
609
+ const strings = (value: unknown) => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string').slice(0, 4) : [];
610
+ try {
611
+ const client = await this.connectReview(this.store.project(task.projectId).path);
612
+ if (!current()) return;
613
+ const shown = await client.post<{ pages: { index: number; label: string | null; shows: string[]; file: string }[]; missing: string[]; targets: string[]; error?: string; details?: string[] }>(
614
+ `${taskRoute(task.name)}/view`, { locations: strings(args?.locations), pages: strings(args?.pages), ...(args?.accepted === true ? { accepted: true } : {}) }, 30 * 60000);
615
+ if (shown.error) { reply(false, [shown.error, ...(shown.details || [])].join('\n')); return; }
616
+ const compiled = shown.targets[0] === 'full' ? 'the whole paper' : `only ${shown.targets.join(', ')}, so page positions differ from the full PDF while printed page numbers match`;
617
+ reply(true, [
618
+ `${args?.accepted === true ? 'The accepted text' : 'Your worktree'} was compiled (${compiled}).`,
619
+ ...shown.pages.map(page => `${page.label ? `Page ${page.label}` : `PDF page ${page.index} (printed page number unavailable)`} shows ${page.shows.join(', ')}: ${page.file}`),
620
+ ...shown.missing,
621
+ ...(shown.pages.length ? ['Open the page files you need with your image viewer (view_image).'] : []),
622
+ ].join('\n'));
623
+ } catch (error) { reply(false, 'Papilot could not show the pages: ' + errorText(error)); }
624
+ }
515
625
  answer(id: string, input: { decision?: string; answers?: Record<string, string> }) {
516
626
  const request = this.requests.get(id); if (!request) throw new Error('This request has ended or the connection has restarted.');
517
627
  if (this.store.task(request.taskId).status === 'deleting') throw new Error('This task is being deleted.');
@@ -520,6 +630,8 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
520
630
  const questions = request.params.questions || [];
521
631
  response = { answers: Object.fromEntries(questions.map((q: any) => {
522
632
  const answer = input.answers?.[q.id]?.trim(); if (!answer) throw new Error('Answer every question before submitting.');
633
+ // A question without an "Other" choice only takes one of its options.
634
+ if (q.isOther === false && q.options?.length && !q.options.some((option: any) => option.label === answer)) throw new Error('Choose one of the offered answers.');
523
635
  return [q.id, { answers: [answer] }];
524
636
  })) };
525
637
  } else {
@@ -561,6 +673,10 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
561
673
  this.earlyCompletions.delete(taskId);
562
674
  this.active.set(taskId, run);
563
675
  this.store.updateTask(taskId, { turnId: turn.id, pendingRunId: null, error: null, feedbackCursor: Math.max(task.feedbackCursor, run.feedbackCursor || 0) });
676
+ // Recover the structured review reply when the completion notification was lost.
677
+ if (task.mode === 'review' && turn.status === 'completed') for (const item of turn.items || []) {
678
+ if (item.type === 'agentMessage') await this.notification({ method: 'item/completed', params: { threadId: task.threadId, turnId: turn.id, item } }, this.agentOf(task));
679
+ }
564
680
  if (completion) {
565
681
  await this.notification(completion, this.agentOf(task));
566
682
  } else if (turn.status === 'inProgress' && thread.status?.type !== 'notLoaded') {
@@ -593,6 +709,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
593
709
  async reviewWrite<T>(taskId: string, writesFiles: boolean, operation: () => Promise<T>, pending?: Omit<PendingReview, 'previousStatus'>): Promise<T> {
594
710
  const task = this.store.task(taskId);
595
711
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
712
+ if (task.mode === 'review') throw new Error('AI review uses manuscript PDF notes, not change decisions.');
596
713
  if (!writesFiles) {
597
714
  this.commentWrites.set(taskId, (this.commentWrites.get(taskId) || 0) + 1);
598
715
  try { return await operation(); }
@@ -621,6 +738,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
621
738
  // edits break the build, the agent gets the error in a new turn, once.
622
739
  publish(taskId: string, after?: Run): Promise<void> {
623
740
  const task = this.store.task(taskId), automatic = Boolean(after);
741
+ if (task.mode === 'review') throw new Error('AI review creates PDF notes only. It cannot publish manuscript changes.');
624
742
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
625
743
  if (this.active.has(taskId)) throw new Error('Pause Codex before building a review.');
626
744
  if (this.reviewWrites.has(taskId)) throw new Error('Wait for the current review action before building a review.');
@@ -661,10 +779,33 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
661
779
  await this.refresh(taskId);
662
780
  } catch (e) { this.store.updateTask(taskId, { status: 'failed', error: errorText(e) }); }
663
781
  }
782
+ private finishAiReview(task: Task, run: Run, turnId: string) {
783
+ try {
784
+ const snapshot = this.store.reviewInput(task.id);
785
+ const reply = this.store.items(task.id).filter(item => item.type === 'reviewOutput' && item.id.startsWith(`${task.id}:${turnId}:`)).at(-1);
786
+ const result = parseAiReview(reply?.text || '', snapshot);
787
+ this.store.transaction(() => {
788
+ result.notes.forEach((note, index) => {
789
+ const id = `${run.id}-${index}`;
790
+ // Deterministic IDs make completion replay safe, including deleted/sent notes.
791
+ try { this.store.annotation(task.projectId, id); return; } catch { /* New finding. */ }
792
+ this.store.saveAnnotation({ ...note, id, projectId: task.projectId, key: snapshot.key, source: null, createdAt: Date.now(), aiReview: { taskId: task.id, agent: this.agentOf(task) } });
793
+ });
794
+ this.store.saveRun({ ...run, status: 'completed', finishedAt: Date.now(), reviewRound: null });
795
+ if (reply) this.store.saveItem({ ...reply, type: 'assistant', text: result.summary });
796
+ const review = this.store.task(task.id).aiReview;
797
+ this.store.updateTask(task.id, { status: this.store.runs().some(r => r.taskId === task.id && r.status === 'queued') ? 'queued' : 'reviewing', error: null, aiReview: { ...review, key: snapshot.key, notes: (review?.notes || 0) + result.notes.length }, review: { state: 'done', round: null, counts: {}, unsent: 0, comments: 0, files: [] } });
798
+ });
799
+ } catch (error) {
800
+ this.store.updateTask(task.id, { status: 'failed', error: errorText(error) });
801
+ }
802
+ this.schedule();
803
+ }
664
804
  async refresh(taskId: string) {
665
805
  let release: (() => void) | undefined;
666
806
  try {
667
807
  const task = this.store.task(taskId);
808
+ if (task.mode === 'review') return;
668
809
  release = this.enterProjectRequest(task.projectId);
669
810
  if (task.status === 'deleting') return;
670
811
  const project = this.store.project(task.projectId), client = await this.connectReview(project.path);
@@ -672,9 +813,16 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
672
813
  const { unsent, comments } = await this.unsentReviewFeedback(client, this.store.task(taskId));
673
814
  const deferred = summary.deferred === true || task.deferred;
674
815
  const delivery = summary.delivery ?? task.delivery;
675
- const files = await acceptedFiles(project.path, task.name, deferred ? delivery?.accepted : task.committed?.accepted);
816
+ const reconciliation = summary.reconciliation ?? null;
817
+ const files = await acceptedFiles(project.path, task.name, deferred ? reconciliation?.base ?? (delivery && delivery.mode !== 'branch' ? delivery.accepted : undefined) : task.committed?.accepted);
676
818
  const current = this.store.task(taskId);
677
- 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 } });
819
+ 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 } });
820
+ // Recover an application interrupted after the engine prepared its
821
+ // proposal but before Papilot persisted the agent run.
822
+ if (reconciliation?.ready && (summary.round ?? 0) <= reconciliation.round && current.status === 'reviewing' && !this.committing.has(task.projectId) && !this.reviewWrites.has(taskId)
823
+ && !current.pendingReview && !this.store.runByKey('merge-review-' + reconciliation.id)
824
+ && !this.store.runs().some(run => run.taskId === taskId && run.status === 'queued'))
825
+ await this.queueMergeSuggestion(taskId, reconciliation);
678
826
  } catch { /* The task remains visible even if its project is temporarily unavailable. */ }
679
827
  finally { release?.(); }
680
828
  }
@@ -693,7 +841,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
693
841
  private helper(choice: { agent?: AgentId; model?: string }, task: string, light = false) {
694
842
  const usable = (id: AgentId) => this.agents[id].ready && this.runtimes[id].authenticated;
695
843
  const id = choice.agent && usable(choice.agent) ? choice.agent : AGENTS.find(usable);
696
- if (!id) throw new Error(`Sign in to Codex, Claude or Antigravity to ${task}.`);
844
+ if (!id) throw new Error(`Sign in to an agent, such as Codex, to ${task}.`);
697
845
  const models = this.runtimes[id].models;
698
846
  return { id, model: (light ? lightModel(models) : undefined) || models.find(item => item.id === choice.model) || models.find(item => item.isDefault) };
699
847
  }
@@ -707,6 +855,14 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
707
855
  // reply is returned and no chat shows it.
708
856
  private async oneOff(id: AgentId, options: { cwd: string; sandbox: 'read-only' | 'workspace-write'; instructions: string; prompt: string; model?: AgentModel; effort?: string | null; timeout: number; what: string }) {
709
857
  if (this.closing) throw new Error('The server is shutting down.');
858
+ // A read-only helper (a title, a commit message) has everything in its
859
+ // prompt. It runs in an empty folder, so an agent that does not enforce a
860
+ // read-only sandbox (Claude, Cursor, the ACP agents) cannot change the paper.
861
+ const scratch = options.sandbox === 'read-only' ? fs.mkdtempSync(path.join(os.tmpdir(), 'papilot-helper-')) : null;
862
+ try { return await this.helperTurn(id, { ...options, cwd: scratch || options.cwd }); }
863
+ finally { if (scratch) fs.rmSync(scratch, { recursive: true, force: true }); }
864
+ }
865
+ private async helperTurn(id: AgentId, options: { cwd: string; sandbox: 'read-only' | 'workspace-write'; instructions: string; prompt: string; model?: AgentModel; effort?: string | null; timeout: number; what: string }) {
710
866
  const agent = this.agents[id], name = AGENT_NAMES[id];
711
867
  const thread = await agent.request('thread/start', { cwd: options.cwd, approvalPolicy: 'never', sandbox: options.sandbox, ephemeral: true, developerInstructions: options.instructions, ...(options.model ? { model: options.model.id } : {}) }, options.timeout);
712
868
  if (this.closing) throw new Error('The server is shutting down.');
@@ -804,7 +960,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
804
960
  try {
805
961
  const task = this.store.task(taskId), files = task.review?.files || [];
806
962
  if (task.status !== 'reviewing' || task.review?.counts?.pending || !files.length) return;
807
- if (task.deferred) { await this.commitMessage(task.projectId, files, { agent: this.agentOf(task), taskId }); return; }
963
+ if (task.deferred) return;
808
964
  const project = this.store.project(task.projectId), git = await gitStatus(project.path);
809
965
  const paths = git.merging ? [] : files.filter(file => git.files.some(item => item.path === file));
810
966
  if (paths.length) await this.commitMessage(project.id, paths, { agent: this.agentOf(task) });
@@ -812,15 +968,17 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
812
968
  }
813
969
  private async taskCommitChanges(task: Task) {
814
970
  const root = this.store.project(task.projectId).path;
815
- const before = task.delivery?.accepted ?? `refs/latex-review/${task.name}/base`;
971
+ const delivered = task.reconciliation?.base ?? (task.delivery?.mode !== 'branch' ? task.delivery?.accepted : undefined);
972
+ const before = delivered ?? `refs/latex-review/${task.name}/base`;
816
973
  const after = `refs/latex-review/${task.name}/accepted`;
817
- const paths = await acceptedFiles(root, task.name, task.delivery?.accepted);
974
+ const paths = await acceptedFiles(root, task.name, delivered);
818
975
  if (!paths.length) throw new Error('This task has no accepted changes left to commit.');
819
976
  const { stdout: patch } = await execute('git', ['-C', root, 'diff', '--no-ext-diff', '--no-textconv', before, after, '--'], { maxBuffer: 8 * 1024 * 1024 });
820
977
  return { files: paths.map(path => ({ path, status: 'M', from: undefined })), patch: patch.slice(0, 80000), recent: [] as string[] };
821
978
  }
822
979
  async deliverTask(taskId: string, mode: 'workspace' | 'branch', message = '') {
823
980
  const task = this.store.task(taskId), project = this.store.project(task.projectId);
981
+ if (task.mode === 'review') throw new Error('AI review has no manuscript changes to deliver.');
824
982
  if (!task.deferred) throw new Error('This task already applies accepted changes to the working directory.');
825
983
  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'))
826
984
  throw new Error('Wait for the task and review actions to finish before choosing a destination.');
@@ -834,6 +992,38 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
834
992
  } finally { await this.refresh(taskId); this.reviewWrites.delete(taskId); this.schedule(); }
835
993
  });
836
994
  }
995
+ async applyTask(taskId: string, keepCurrent = false, commit?: { message: string; confirmation?: string }) {
996
+ const task = this.store.task(taskId), project = this.store.project(task.projectId);
997
+ if (task.mode === 'review') throw new Error('AI review has no manuscript changes to apply.');
998
+ if (!task.deferred) throw new Error('Accepted changes are already in the manuscript.');
999
+ 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'))
1000
+ throw new Error('Wait for the task and review actions to finish before applying changes.');
1001
+ const result = await this.exclusiveCommit(project.id, async () => {
1002
+ this.reviewWrites.add(taskId);
1003
+ try {
1004
+ const client = await this.connectReview(project.path);
1005
+ 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 } : {}) });
1006
+ if (result.delivery) this.store.updateTask(taskId, { delivery: result.delivery, reconciliation: null });
1007
+ if (result.reconciliation) this.store.updateTask(taskId, { reconciliation: result.reconciliation });
1008
+ return { ...result, git: await gitStatus(project.path) };
1009
+ } finally { await this.refresh(taskId); this.reviewWrites.delete(taskId); }
1010
+ }).catch(async error => {
1011
+ // A lost response is not a failed application. refresh above checks the
1012
+ // engine's persisted result before we retry or start the suggestion.
1013
+ const confirmed = this.store.task(taskId);
1014
+ if (confirmed.reconciliation?.ready && confirmed.reconciliation.id !== task.reconciliation?.id)
1015
+ return { reconciliation: confirmed.reconciliation, delivery: undefined, commitReview: undefined, git: await gitStatus(project.path) };
1016
+ if (confirmed.delivery && confirmed.delivery.mode !== 'branch' && !confirmed.delivery.pending && (confirmed.delivery.mode !== task.delivery?.mode || confirmed.delivery.accepted !== task.delivery?.accepted))
1017
+ return { delivery: confirmed.delivery, reconciliation: undefined, commitReview: undefined, git: await gitStatus(project.path) };
1018
+ throw error;
1019
+ });
1020
+ if (result.reconciliation?.ready) await this.queueMergeSuggestion(taskId, result.reconciliation);
1021
+ this.schedule();
1022
+ return result;
1023
+ }
1024
+ private queueMergeSuggestion(taskId: string, merge: TaskReconciliation) {
1025
+ 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 });
1026
+ }
837
1027
  // Resolves a merge's conflicts in the manuscript checkout itself: the agent
838
1028
  // edits only the conflicted files, at the model's default reasoning, and
839
1029
  // leaves committing the merge to the author once they have read the diffs.
@@ -858,10 +1048,9 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
858
1048
  async commitTask(taskId: string, message: string, push = false) {
859
1049
  const task = this.store.task(taskId);
860
1050
  if (task.deferred) {
861
- if (push) throw new Error('Task branch commits are kept locally.');
862
- const result = await this.deliverTask(taskId, 'branch', message);
863
- if (!result.commit) throw new Error('The review engine did not confirm the commit. Retry the task commit.');
864
- return { ...result, commit: result.commit, pushError: undefined as string | undefined };
1051
+ if (push) throw new Error('Commit the task first, then push from Git.');
1052
+ const result = await this.applyTask(taskId, false, { message });
1053
+ return { ...result, commit: result.delivery?.commit, pushError: undefined };
865
1054
  }
866
1055
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
867
1056
  if (task.pendingReview) throw new Error('Wait for the current review action before committing.');
@@ -872,11 +1061,15 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
872
1061
  const paths = (await gitStatus(project.path)).files.filter(file => files.includes(file.path)).map(file => file.path);
873
1062
  if (!paths.length) throw new Error('This task has no accepted changes left to commit.');
874
1063
  const hash = await gitCommit(project.path, message, paths);
875
- this.store.updateTask(taskId, { committed: { hash, accepted: accepted!, at: Date.now() } });
876
- void this.refresh(taskId);
1064
+ const committed = { hash, accepted: accepted!, at: Date.now() };
1065
+ this.store.updateTask(taskId, { committed });
877
1066
  // The commit stands even when the push fails; the author can push again.
878
1067
  let pushError: string | undefined;
879
- if (push) try { await gitRemote(project.path, 'push'); } catch (error) { pushError = errorText(error); }
1068
+ if (push) try {
1069
+ await gitRemote(project.path, 'push');
1070
+ this.store.updateTask(taskId, { committed: { ...committed, pushed: true } });
1071
+ } catch (error) { pushError = errorText(error); }
1072
+ await this.refresh(taskId);
880
1073
  return { commit: hash, git: await gitStatus(project.path), ...(pushError ? { pushError } : {}) };
881
1074
  });
882
1075
  }
@@ -893,11 +1086,13 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
893
1086
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
894
1087
  return this.store.updateTask(taskId, { title, titleSource: 'manual', titleRevision: (task.titleRevision || 0) + 1, titleGeneration: null });
895
1088
  }
896
- archiveTask(taskId: string, archived: boolean) {
1089
+ // Older tabs send finished=true when navigating away. Ignore those requests;
1090
+ // only an explicit archive action should change the task list.
1091
+ archiveTask(taskId: string, archived: boolean, finished = false) {
897
1092
  if (typeof archived !== 'boolean') throw new Error('Choose whether to archive or restore the task.');
898
1093
  const task = this.store.task(taskId);
899
1094
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
900
- if (Boolean(task.archivedAt) === archived) return task;
1095
+ if (Boolean(task.archivedAt) === archived || (archived && finished)) return task;
901
1096
  // Archiving only organizes the list; the conversation, review and any run remain intact.
902
1097
  return this.store.updateTask(taskId, { archivedAt: archived ? Date.now() : null });
903
1098
  }