papilot 0.7.0 → 0.8.0

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 (63) hide show
  1. package/README.md +52 -30
  2. package/README.zh-CN.md +54 -30
  3. package/bin/doctor.mjs +53 -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-D7ro6ecN.js} +54 -3
  7. package/dist/assets/index-Cc-awdNn.css +1 -0
  8. package/dist/assets/index-D_VX51no.js +265 -0
  9. package/dist/assets/latex-BarqJFtQ.svg +1 -0
  10. package/dist/assets/{math-BsEl3u6P.js → math-V9uUaUzA.js} +1 -1
  11. package/dist/assets/pdf-view-Bnb2uIBE.js +598 -0
  12. package/dist/index.html +2 -2
  13. package/examples/papilot-paper/main.tex +1 -1
  14. package/examples/papilot-paper/sections/02-using.tex +3 -1
  15. package/examples/papilot-paper/sections/09-agents.tex +8 -5
  16. package/package.json +10 -6
  17. package/skills/ai-review.md +18 -0
  18. package/skills/latex-warnings.md +1 -1
  19. package/src/conversation.ts +6 -0
  20. package/src/server/acp.ts +354 -0
  21. package/src/server/agents.ts +220 -43
  22. package/src/server/ai-review-routes.ts +72 -0
  23. package/src/server/ai-review.ts +38 -0
  24. package/src/server/app.ts +53 -11
  25. package/src/server/build-problems.ts +329 -0
  26. package/src/server/changes.ts +25 -2
  27. package/src/server/codex.ts +2 -1
  28. package/src/server/cursor.ts +153 -0
  29. package/src/server/latex-installation.ts +43 -0
  30. package/src/server/manuscript.ts +165 -23
  31. package/src/server/paths.ts +1 -1
  32. package/src/server/references.ts +70 -0
  33. package/src/server/review.ts +3 -3
  34. package/src/server/skills.ts +5 -8
  35. package/src/server/store.ts +20 -2
  36. package/src/server/update-install.ts +53 -2
  37. package/src/server/update-worker.ts +6 -6
  38. package/src/server/updates.ts +43 -14
  39. package/src/server/workspace.ts +183 -29
  40. package/src/shared.ts +78 -7
  41. package/src/skill-icons.ts +18 -0
  42. package/src/skill-metadata.ts +45 -0
  43. package/vendor/latex-review/dist/web/assets/index-7VKAte5x.js +19 -0
  44. package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +1 -0
  45. package/vendor/latex-review/dist/web/index.html +2 -2
  46. package/vendor/latex-review/src/core/bib.ts +692 -0
  47. package/vendor/latex-review/src/core/changes.ts +39 -0
  48. package/vendor/latex-review/src/core/review.ts +1 -0
  49. package/vendor/latex-review/src/core/verify.ts +784 -0
  50. package/vendor/latex-review/src/core/view.ts +204 -0
  51. package/vendor/latex-review/src/render/round.ts +11 -0
  52. package/vendor/latex-review/src/server.ts +65 -0
  53. package/vendor/latex-review/src/types.ts +125 -0
  54. package/vendor/latex-review/src/web/effect-preferences.ts +10 -0
  55. package/vendor/latex-review/src/web/references.tsx +499 -0
  56. package/vendor/latex-review/src/web/review.tsx +259 -131
  57. package/vendor/latex-review/src/web/style.css +285 -0
  58. package/vendor/latex-review/src/web/zh-CN.ts +75 -0
  59. package/dist/assets/index-CIJEYlP7.js +0 -252
  60. package/dist/assets/index-hOJEruFc.css +0 -1
  61. package/dist/assets/pdf-view-Cz9_vK5U.js +0 -598
  62. package/vendor/latex-review/dist/web/assets/index-Be9CtgcZ.css +0 -1
  63. package/vendor/latex-review/dist/web/assets/index-TvMWVSaw.js +0 -19
@@ -1,13 +1,17 @@
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';
@@ -18,6 +22,7 @@ import { taskCaptionFrom, taskCaptionInstructions } from '../task-caption.ts';
18
22
  import { notesInstructions, reviewNotesInstructions, summarizeNotes } from '../notes.ts';
19
23
  import { canonicalGroup, groupsWithoutInstructions, parseGroupInstructions, type GroupInstructions } from '../note-groups.ts';
20
24
  import { commitChanges, conflictMarkers, gitCommit, gitRemote, gitStatus, mergeConflicts, mergeInProgress } from './manuscript.ts';
25
+ import { AGENT_IDS, isTaskReviewNote } from '../shared.ts';
21
26
  import type { Task, Run, TimelineItem, PendingRequest, RuntimeStatus, Snapshot, PendingReview, AgentModel, Usage, AgentId, MessageMeta, FeedbackSummary, BuildFailure, Author, Annotation, NoteSummary } from '../shared.ts';
22
27
 
23
28
  const execute = promisify(execFile);
@@ -53,8 +58,26 @@ const BUSY = new Set(['running', 'waiting_input', 'building', 'uncertain', 'dele
53
58
  // publish.ts), and the request keys of the turns that send them back to it.
54
59
  const FIXABLE = /^(Some edits are outside the task scope|The revised document could not be compiled)/;
55
60
  const BUILD_FIX = 'build-fix-';
61
+ // The tool Papilot gives Codex threads for looking at the typeset paper. The
62
+ // review engine compiles the task worktree as it is and draws the pages.
63
+ export const VIEW_PDF = 'view_pdf';
64
+ export const VIEW_PDF_TOOL = {
65
+ type: 'function', name: VIEW_PDF,
66
+ 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.',
67
+ inputSchema: {
68
+ type: 'object', additionalProperties: false,
69
+ properties: {
70
+ locations: { type: 'array', items: { type: 'string' }, maxItems: 4, description: 'Source lines as file:line, relative to the worktree.' },
71
+ 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.' },
72
+ accepted: { type: 'boolean', description: 'Show the accepted text instead of your worktree.' },
73
+ },
74
+ },
75
+ };
76
+ const VIEW_PDF_UNSUPPORTED = /dynamicTools|experimental/i;
77
+ // What a view_pdf call asked for, as the chat shows it.
78
+ 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
79
 
57
- const AGENTS: AgentId[] = ['codex', 'claude', 'antigravity'];
80
+ const AGENTS: AgentId[] = AGENT_IDS;
58
81
  // The small model an agent offers for short text such as a commit message,
59
82
  // by the names agents give them (GPT Luna and mini, Claude Haiku, Gemini Flash).
60
83
  const LIGHT = /\b(?:luna|mini|nano|haiku|flash|lite)\b/i;
@@ -67,11 +90,13 @@ const blankRuntime = (): RuntimeStatus => ({ connected: false, authenticated: fa
67
90
 
68
91
  export class Workspace extends EventEmitter {
69
92
  // 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() };
93
+ runtimes = Object.fromEntries(AGENTS.map(id => [id, blankRuntime()])) as Record<AgentId, RuntimeStatus>;
71
94
  runtime = this.runtimes.codex;
72
95
  agents: Record<AgentId, AgentRuntime>;
73
96
  requests = new Map<string, PendingRequest & { rpcId: string | number; agent: AgentId }>();
74
97
  private active = new Map<string, Run>();
98
+ // Async tools must never answer a request from an earlier connection.
99
+ private connectionEpochs = new Map<AgentId, number>();
75
100
  private loaded = new Set<string>();
76
101
  private preparing = new Set<string>();
77
102
  private preparingNotes = new Set<string>();
@@ -94,6 +119,8 @@ export class Workspace extends EventEmitter {
94
119
  private messages = new Map<string, Promise<{ message: string; agent: AgentId }>>();
95
120
  private titles = new Map<string, Promise<Task | null>>();
96
121
  private helperStops = new Set<() => void>();
122
+ // Set when Codex refuses the view_pdf tool, so later threads start without it.
123
+ private noViewPdf = false;
97
124
  private projectChanges = new Set<string>();
98
125
  private projectRequests = new Map<string, number>();
99
126
  importsBusy = () => false;
@@ -105,7 +132,9 @@ export class Workspace extends EventEmitter {
105
132
  constructor(public store: Store, public codex: AgentRuntime = new Codex(), private concurrency = 2, private connectReview = reviewClient, others: Partial<Record<AgentId, AgentRuntime>> = {}) {
106
133
  super();
107
134
  this.attachments = new Attachments(store);
108
- this.agents = { codex, claude: others.claude || new ClaudeAgent(), antigravity: others.antigravity || new AntigravityAgent() };
135
+ this.agents = { codex, claude: others.claude || new ClaudeAgent(), cursor: others.cursor || new CursorAgent(), antigravity: others.antigravity || new AntigravityAgent(),
136
+ kimi: others.kimi || new AcpAgent(ACP_PROFILES.kimi), glm: others.glm || new GlmAgent(), deepseek: others.deepseek || new AcpAgent(ACP_PROFILES.deepseek),
137
+ qwen: others.qwen || new AcpAgent(ACP_PROFILES.qwen), opencode: others.opencode || new AcpAgent(ACP_PROFILES.opencode) };
109
138
  store.on('change', () => this.emit('change'));
110
139
  for (const id of AGENTS) {
111
140
  const agent = this.agents[id];
@@ -116,6 +145,7 @@ export class Workspace extends EventEmitter {
116
145
  }
117
146
  private agentOf(task: Task): AgentId { return task.agent && AGENTS.includes(task.agent) ? task.agent : 'codex'; }
118
147
  private disconnected(id: AgentId, e: unknown) {
148
+ this.connectionEpochs.set(id, (this.connectionEpochs.get(id) || 0) + 1);
119
149
  this.runtimes[id].connected = false; this.runtimes[id].error = errorText(e);
120
150
  for (const [key, request] of this.requests) if (request.agent === id) this.requests.delete(key);
121
151
  for (const [taskId, run] of this.active) {
@@ -137,6 +167,10 @@ export class Workspace extends EventEmitter {
137
167
  }
138
168
  private async connectAgent(id: AgentId) {
139
169
  const agent = this.agents[id], runtime = this.runtimes[id], name = AGENT_NAMES[id];
170
+ // An agent that is not on this host is neither started nor offered;
171
+ // Reconnect looks again. A running agent stays connected.
172
+ runtime.installed = agent.ready || !agent.detect || agent.detect();
173
+ if (!runtime.installed) { Object.assign(runtime, blankRuntime(), { installed: false }); return; }
140
174
  try {
141
175
  await agent.start();
142
176
  const account = await agent.request('account/read', { refreshToken: false });
@@ -236,12 +270,14 @@ export class Workspace extends EventEmitter {
236
270
  }
237
271
  // A task combines the author's words, an optional skill and selected PDF notes.
238
272
  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 }) {
273
+ 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
274
  if (!/^[\w-]{16,64}$/.test(input.id)) throw new Error('Invalid task ID.');
241
275
  if (this.store.taskDeleted(input.id)) throw new Error('This task was deleted. Create a new task instead.');
242
276
  // A retried request returns its task; an ID from another paper is refused.
243
277
  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; }
278
+ if (existing) { if (existing.projectId !== input.projectId || (existing.mode === 'review') !== Boolean(input.reviewInput)) throw new Error('Invalid task ID.'); return existing; }
279
+ if (input.reviewInput && !REVIEW_AGENTS.includes(input.agent || 'codex')) throw new Error('Choose Codex, Claude or GLM for AI review.');
280
+ 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
281
  if (input.serviceTier !== undefined) {
246
282
  if (input.agent && input.agent !== 'codex') throw new Error('Service tiers are only available for Codex.');
247
283
  const models = this.runtimes.codex.models;
@@ -254,9 +290,10 @@ export class Workspace extends EventEmitter {
254
290
  let releaseAttachments = () => {};
255
291
  try {
256
292
  const project = this.store.project(input.projectId), skill = input.skill ? findSkill(project.path, input.skill) : undefined;
293
+ if (skill?.output === 'annotations' && !input.reviewInput) throw new Error('Start a new task to run the AI review skill on the current PDF.');
257
294
  const files = this.attachments.resolve(project.id, input.attachments || [], input.author?.id || 'owner', input.id);
258
295
  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]));
296
+ const available = new Map(this.store.annotations(project.id).filter(note => !note.taskId && !isTaskReviewNote(note)).map(note => [note.id, note]));
260
297
  notes = [...new Set(input.annotations || [])].map(id => {
261
298
  const note = available.get(id);
262
299
  if (!note) throw new Error('A selected PDF note is no longer available. Refresh the manuscript and try again.');
@@ -268,16 +305,20 @@ export class Workspace extends EventEmitter {
268
305
  if (Object.keys(groupInstructions).some(group => !selectedGroups.has(group as keyof GroupInstructions))) throw new Error('Group instructions must refer to selected PDF notes.');
269
306
  if (groupsWithoutInstructions(notes, groupInstructions).length && !input.instructions.trim() && !skill) throw new Error('Describe what to do with each selected color group.');
270
307
  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.');
308
+ 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
309
  const instructions = notes.length ? notesInstructions(notes, input.instructions, groupInstructions) : input.instructions;
273
310
  const client = await this.connectReview(project.path);
274
311
  const name = 'p-' + input.id.toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 48);
275
312
  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,
313
+ const reviewInput = input.reviewInput && (input.prepareReview ? await input.prepareReview(info.worktree) : input.reviewInput);
314
+ 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
315
  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
316
  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 });
317
+ this.store.transaction(() => {
318
+ this.store.saveTask(task);
319
+ if (reviewInput) this.store.saveReviewInput(task.id, reviewInput);
320
+ });
321
+ 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
322
  // Naming is independent of the editing turn. An unavailable helper must
282
323
  // not fail creation or prevent the queued task from running.
283
324
  try { this.requestTaskTitle(task.id); } catch { /* Keep the prompt-derived title. */ }
@@ -290,6 +331,8 @@ export class Workspace extends EventEmitter {
290
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 } = {}) {
291
332
  const task = this.store.task(taskId);
292
333
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
334
+ 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.');
335
+ 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
336
  const existing = this.store.runByKey(requestKey);
294
337
  if (existing) { if (existing.taskId !== taskId) throw new Error('This request ID is already in use by another task.'); return existing; }
295
338
  if (task.status === 'uncertain') throw new Error('Check the task status before sending more instructions.');
@@ -301,6 +344,7 @@ export class Workspace extends EventEmitter {
301
344
  const instructions = options.notes?.length ? notesInstructions(options.notes, text, options.groupInstructions) : options.reviewNotes?.length ? reviewNotesInstructions(options.reviewNotes, text) : text.trim();
302
345
  let prompt = options.build ? buildFixPrompt(options.build) : options.feedback ? reviewInstructions : options.skill ? skillPrompt(options.skill, instructions) : instructions, cursor = task.feedbackCursor;
303
346
  let summary: FeedbackSummary | undefined;
347
+ if (task.mode === 'review') includeFeedback = false;
304
348
  if (includeFeedback) {
305
349
  const client = await this.connectReview(this.store.project(task.projectId).path);
306
350
  const feedback = await client.get(`${taskRoute(task.name)}/feedback?after=${task.feedbackCursor}`);
@@ -310,8 +354,9 @@ export class Workspace extends EventEmitter {
310
354
  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
355
  }
312
356
  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);
357
+ if (task.mode === 'review') prompt = reviewPrompt(prompt, this.store.reviewInput(taskId));
313
358
  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 } : {}) };
359
+ 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
360
  if (options.groupInstructions && Object.keys(options.groupInstructions).length) meta.groupInstructions = options.groupInstructions;
316
361
  const saved = this.store.transaction(() => {
317
362
  // Feedback loading yields to other requests. Recheck identity, queue, and
@@ -334,6 +379,8 @@ export class Workspace extends EventEmitter {
334
379
  this.store.saveRun(run);
335
380
  for (const note of options.notes || []) this.store.saveAnnotation({ ...note, taskId });
336
381
  if (!BUSY.has(current.status)) this.store.updateTask(taskId, { status: 'queued', error: null });
382
+ // Writing to an archived task brings it back to the task list.
383
+ if (current.archivedAt && !options.build) this.store.updateTask(taskId, { archivedAt: null });
337
384
  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
385
  return run;
339
386
  });
@@ -365,14 +412,16 @@ export class Workspace extends EventEmitter {
365
412
  return files.some(file => file === '' || file === '.') ? [] : files;
366
413
  } catch { return []; }
367
414
  }
368
- private instructions(task: Task, scope: string[] = []) {
415
+ private instructions(task: Task, scope: string[] = [], viewPdf = false) {
416
+ if (task.mode === 'review') return aiReviewInstructions + '\n\nOriginal review skill and request:\n' + task.instructions;
369
417
  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
418
  Your task is already created. Your editable working directory is ${task.worktree}.
371
419
  Original task: ${task.instructions}
372
420
  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
421
  ${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
422
  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.`;
423
+ 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.
424
+ ${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
425
  }
377
426
  private async startRun(run: Run) {
378
427
  let task = this.store.task(run.taskId);
@@ -384,12 +433,23 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
384
433
  this.store.saveRun({ ...run, status: 'running' });
385
434
  this.store.updateTask(task.id, { status: 'running', turnId: null, error: null });
386
435
  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);
436
+ // Codex threads get the view_pdf tool when they start, and keep it when resumed.
437
+ const scope = task.mode === 'review' ? [] : await this.reviewScope(task), codex = this.agentOf(task) === 'codex';
438
+ let viewPdf = task.mode !== 'review' && codex && (task.threadId ? Boolean(task.viewPdf) : !this.noViewPdf);
439
+ 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 } : {}) });
440
+ let result;
441
+ if (task.threadId) result = await agent.request('thread/resume', { ...options(viewPdf), threadId: task.threadId, excludeTurns: true });
442
+ else {
443
+ try { result = await agent.request('thread/start', viewPdf ? { ...options(true), dynamicTools: [VIEW_PDF_TOOL] } : options(false)); }
444
+ catch (error) {
445
+ // A Codex without the experimental tool API starts its threads without it.
446
+ if (!viewPdf || !(error instanceof RpcResponseError) || !VIEW_PDF_UNSUPPORTED.test(error.message)) throw error;
447
+ this.noViewPdf = true; viewPdf = false;
448
+ result = await agent.request('thread/start', options(false));
449
+ }
450
+ }
391
451
  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 });
452
+ task = this.store.updateTask(task.id, { threadId: result.thread.id, model: result.model || task.model, ...(task.threadId ? {} : { viewPdf }) });
393
453
  this.loaded.add(result.thread.id);
394
454
  }
395
455
  this.store.updateTask(task.id, { pendingRunId: run.id });
@@ -453,7 +513,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
453
513
  if (!task || task.status === 'deleting') return;
454
514
  const eventTurn = p.turnId || p.turn?.id;
455
515
  if (eventTurn && task.turnId && eventTurn !== task.turnId) return;
456
- if (method === 'item/agentMessage/delta') this.appendDelta(task, p, 'assistant');
516
+ if (method === 'item/agentMessage/delta') { if (task.mode !== 'review') this.appendDelta(task, p, 'assistant'); }
457
517
  else if (method === 'item/commandExecution/outputDelta') this.appendDelta(task, p, 'command');
458
518
  else if ((method === 'item/started' || method === 'item/completed') && p.item) {
459
519
  const item = p.item, id = `${task.id}:${p.turnId}:${item.id}`;
@@ -461,11 +521,16 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
461
521
  this.flush();
462
522
  const previous = this.store.items(task.id).find(i => i.id === id);
463
523
  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';
524
+ if (item.type === 'agentMessage') entry.type = task.mode === 'review' ? 'reviewOutput' : 'assistant';
465
525
  if (item.type === 'commandExecution') { entry.type = 'command'; entry.detail = String(item.aggregatedOutput || previous?.detail || '').slice(-40000); }
466
526
  if (item.type === 'fileChange') { entry.type = 'files'; entry.text = (item.changes || []).map((change: any) => change.path).join('\n'); }
467
527
  if (item.type === 'mcpToolCall') { entry.type = 'tool'; entry.text = `${item.server} / ${item.tool}`; }
468
528
  if (item.type === 'toolCall') entry.type = 'tool';
529
+ if (item.type === 'dynamicToolCall') {
530
+ if (item.tool !== VIEW_PDF) { entry.type = 'tool'; entry.text = item.tool || ''; }
531
+ else { entry.type = 'pdf'; entry.text = viewedPlaces(item.arguments); }
532
+ if (item.success === false) entry.status = 'failed';
533
+ }
469
534
  if (entry.text || entry.detail) this.store.saveItem(entry);
470
535
  } else if (method === 'serverRequest/resolved') {
471
536
  const buffered = this.earlyRequests.get(task.id);
@@ -491,7 +556,10 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
491
556
  this.store.saveRun({ ...run, status, finishedAt: Date.now() });
492
557
  this.store.updateTask(task.id, { turnId: null, pendingRunId: null, status: status === 'completed' ? 'building' : status, error: p.turn.error?.message || null });
493
558
  this.schedule();
494
- if (status === 'completed') await this.publish(task.id, run);
559
+ if (status === 'completed') {
560
+ if (task.mode === 'review') this.finishAiReview(task, run, eventTurn);
561
+ else await this.publish(task.id, run);
562
+ }
495
563
  } else if (method === 'error' && !p.willRetry) {
496
564
  if (!this.active.has(task.id) || (eventTurn && !task.turnId)) return;
497
565
  this.store.updateTask(task.id, { error: p.error?.message || `${AGENT_NAMES[agent]} encountered an error.` });
@@ -499,7 +567,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
499
567
  }
500
568
  private serverRequest(message: any, agent: AgentId = 'codex') {
501
569
  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'];
570
+ const allowed = ['item/commandExecution/requestApproval', 'item/fileChange/requestApproval', 'item/permissions/requestApproval', 'item/tool/requestUserInput', 'item/tool/call'];
503
571
  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
572
  if (message.params?.turnId && task.turnId && message.params.turnId !== task.turnId) { runtime.reject(message.id, 'This turn is no longer active.'); return; }
505
573
  if (!task.turnId && message.params?.turnId) {
@@ -508,10 +576,50 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
508
576
  else runtime.reject(message.id, 'Too many pending requests.');
509
577
  return;
510
578
  }
579
+ if (task.mode === 'review') { runtime.reject(message.id, 'AI review is read-only and cannot request tools or additional permissions.'); return; }
580
+ // Papilot answers its own tools; the author is not asked.
581
+ if (message.method === 'item/tool/call') {
582
+ void this.toolCall(task, message, runtime).catch(error => console.error(`${AGENT_NAMES[agent]} tool:`, errorText(error)));
583
+ return;
584
+ }
511
585
  const id = randomUUID();
512
586
  this.requests.set(id, { id, taskId: task.id, rpcId: message.id, agent, method: message.method, params: message.params, createdAt: Date.now() });
513
587
  this.store.updateTask(task.id, { status: 'waiting_input' });
514
588
  }
589
+ // The agent looks at typeset pages: the review engine compiles the task
590
+ // worktree and draws them as files, which the agent opens with its own
591
+ // image viewer. Returning the images themselves would put them in front of
592
+ // Codex's code mode as base64 text.
593
+ private async toolCall(task: Task, message: any, runtime: AgentRuntime) {
594
+ const { tool, arguments: args } = message.params || {};
595
+ const agent = this.agentOf(task), run = this.active.get(task.id), epoch = this.connectionEpochs.get(agent);
596
+ const current = () => !this.closing && runtime.ready && this.agents[agent] === runtime
597
+ && this.connectionEpochs.get(agent) === epoch && run && this.active.get(task.id)?.id === run.id
598
+ && this.store.task(task.id).turnId === task.turnId;
599
+ const reply = (success: boolean, text: string) => {
600
+ if (!current()) return;
601
+ // A failed transport cannot receive an error reply either. Do not let
602
+ // it escape this detached async call or retry it against a new child.
603
+ try { runtime.respond(message.id, { success, contentItems: [{ type: 'inputText', text }] }); }
604
+ catch (error) { console.error(`${AGENT_NAMES[agent]} tool reply:`, errorText(error)); }
605
+ };
606
+ if (tool !== VIEW_PDF) { reply(false, `Papilot has no tool named ${tool}.`); return; }
607
+ const strings = (value: unknown) => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string').slice(0, 4) : [];
608
+ try {
609
+ const client = await this.connectReview(this.store.project(task.projectId).path);
610
+ if (!current()) return;
611
+ const shown = await client.post<{ pages: { index: number; label: string | null; shows: string[]; file: string }[]; missing: string[]; targets: string[]; error?: string; details?: string[] }>(
612
+ `${taskRoute(task.name)}/view`, { locations: strings(args?.locations), pages: strings(args?.pages), ...(args?.accepted === true ? { accepted: true } : {}) }, 30 * 60000);
613
+ if (shown.error) { reply(false, [shown.error, ...(shown.details || [])].join('\n')); return; }
614
+ 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`;
615
+ reply(true, [
616
+ `${args?.accepted === true ? 'The accepted text' : 'Your worktree'} was compiled (${compiled}).`,
617
+ ...shown.pages.map(page => `${page.label ? `Page ${page.label}` : `PDF page ${page.index} (printed page number unavailable)`} shows ${page.shows.join(', ')}: ${page.file}`),
618
+ ...shown.missing,
619
+ ...(shown.pages.length ? ['Open the page files you need with your image viewer (view_image).'] : []),
620
+ ].join('\n'));
621
+ } catch (error) { reply(false, 'Papilot could not show the pages: ' + errorText(error)); }
622
+ }
515
623
  answer(id: string, input: { decision?: string; answers?: Record<string, string> }) {
516
624
  const request = this.requests.get(id); if (!request) throw new Error('This request has ended or the connection has restarted.');
517
625
  if (this.store.task(request.taskId).status === 'deleting') throw new Error('This task is being deleted.');
@@ -520,6 +628,8 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
520
628
  const questions = request.params.questions || [];
521
629
  response = { answers: Object.fromEntries(questions.map((q: any) => {
522
630
  const answer = input.answers?.[q.id]?.trim(); if (!answer) throw new Error('Answer every question before submitting.');
631
+ // A question without an "Other" choice only takes one of its options.
632
+ 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
633
  return [q.id, { answers: [answer] }];
524
634
  })) };
525
635
  } else {
@@ -561,6 +671,10 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
561
671
  this.earlyCompletions.delete(taskId);
562
672
  this.active.set(taskId, run);
563
673
  this.store.updateTask(taskId, { turnId: turn.id, pendingRunId: null, error: null, feedbackCursor: Math.max(task.feedbackCursor, run.feedbackCursor || 0) });
674
+ // Recover the structured review reply when the completion notification was lost.
675
+ if (task.mode === 'review' && turn.status === 'completed') for (const item of turn.items || []) {
676
+ if (item.type === 'agentMessage') await this.notification({ method: 'item/completed', params: { threadId: task.threadId, turnId: turn.id, item } }, this.agentOf(task));
677
+ }
564
678
  if (completion) {
565
679
  await this.notification(completion, this.agentOf(task));
566
680
  } else if (turn.status === 'inProgress' && thread.status?.type !== 'notLoaded') {
@@ -593,6 +707,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
593
707
  async reviewWrite<T>(taskId: string, writesFiles: boolean, operation: () => Promise<T>, pending?: Omit<PendingReview, 'previousStatus'>): Promise<T> {
594
708
  const task = this.store.task(taskId);
595
709
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
710
+ if (task.mode === 'review') throw new Error('AI review uses manuscript PDF notes, not change decisions.');
596
711
  if (!writesFiles) {
597
712
  this.commentWrites.set(taskId, (this.commentWrites.get(taskId) || 0) + 1);
598
713
  try { return await operation(); }
@@ -621,6 +736,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
621
736
  // edits break the build, the agent gets the error in a new turn, once.
622
737
  publish(taskId: string, after?: Run): Promise<void> {
623
738
  const task = this.store.task(taskId), automatic = Boolean(after);
739
+ if (task.mode === 'review') throw new Error('AI review creates PDF notes only. It cannot publish manuscript changes.');
624
740
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
625
741
  if (this.active.has(taskId)) throw new Error('Pause Codex before building a review.');
626
742
  if (this.reviewWrites.has(taskId)) throw new Error('Wait for the current review action before building a review.');
@@ -661,10 +777,33 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
661
777
  await this.refresh(taskId);
662
778
  } catch (e) { this.store.updateTask(taskId, { status: 'failed', error: errorText(e) }); }
663
779
  }
780
+ private finishAiReview(task: Task, run: Run, turnId: string) {
781
+ try {
782
+ const snapshot = this.store.reviewInput(task.id);
783
+ const reply = this.store.items(task.id).filter(item => item.type === 'reviewOutput' && item.id.startsWith(`${task.id}:${turnId}:`)).at(-1);
784
+ const result = parseAiReview(reply?.text || '', snapshot);
785
+ this.store.transaction(() => {
786
+ result.notes.forEach((note, index) => {
787
+ const id = `${run.id}-${index}`;
788
+ // Deterministic IDs make completion replay safe, including deleted/sent notes.
789
+ try { this.store.annotation(task.projectId, id); return; } catch { /* New finding. */ }
790
+ this.store.saveAnnotation({ ...note, id, projectId: task.projectId, key: snapshot.key, source: null, createdAt: Date.now(), aiReview: { taskId: task.id, agent: this.agentOf(task) } });
791
+ });
792
+ this.store.saveRun({ ...run, status: 'completed', finishedAt: Date.now(), reviewRound: null });
793
+ if (reply) this.store.saveItem({ ...reply, type: 'assistant', text: result.summary });
794
+ const review = this.store.task(task.id).aiReview;
795
+ 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: [] } });
796
+ });
797
+ } catch (error) {
798
+ this.store.updateTask(task.id, { status: 'failed', error: errorText(error) });
799
+ }
800
+ this.schedule();
801
+ }
664
802
  async refresh(taskId: string) {
665
803
  let release: (() => void) | undefined;
666
804
  try {
667
805
  const task = this.store.task(taskId);
806
+ if (task.mode === 'review') return;
668
807
  release = this.enterProjectRequest(task.projectId);
669
808
  if (task.status === 'deleting') return;
670
809
  const project = this.store.project(task.projectId), client = await this.connectReview(project.path);
@@ -693,7 +832,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
693
832
  private helper(choice: { agent?: AgentId; model?: string }, task: string, light = false) {
694
833
  const usable = (id: AgentId) => this.agents[id].ready && this.runtimes[id].authenticated;
695
834
  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}.`);
835
+ if (!id) throw new Error(`Sign in to an agent, such as Codex, to ${task}.`);
697
836
  const models = this.runtimes[id].models;
698
837
  return { id, model: (light ? lightModel(models) : undefined) || models.find(item => item.id === choice.model) || models.find(item => item.isDefault) };
699
838
  }
@@ -707,6 +846,14 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
707
846
  // reply is returned and no chat shows it.
708
847
  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
848
  if (this.closing) throw new Error('The server is shutting down.');
849
+ // A read-only helper (a title, a commit message) has everything in its
850
+ // prompt. It runs in an empty folder, so an agent that does not enforce a
851
+ // read-only sandbox (Claude, Cursor, the ACP agents) cannot change the paper.
852
+ const scratch = options.sandbox === 'read-only' ? fs.mkdtempSync(path.join(os.tmpdir(), 'papilot-helper-')) : null;
853
+ try { return await this.helperTurn(id, { ...options, cwd: scratch || options.cwd }); }
854
+ finally { if (scratch) fs.rmSync(scratch, { recursive: true, force: true }); }
855
+ }
856
+ 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
857
  const agent = this.agents[id], name = AGENT_NAMES[id];
711
858
  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
859
  if (this.closing) throw new Error('The server is shutting down.');
@@ -821,6 +968,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
821
968
  }
822
969
  async deliverTask(taskId: string, mode: 'workspace' | 'branch', message = '') {
823
970
  const task = this.store.task(taskId), project = this.store.project(task.projectId);
971
+ if (task.mode === 'review') throw new Error('AI review has no manuscript changes to deliver.');
824
972
  if (!task.deferred) throw new Error('This task already applies accepted changes to the working directory.');
825
973
  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
974
  throw new Error('Wait for the task and review actions to finish before choosing a destination.');
@@ -872,11 +1020,15 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
872
1020
  const paths = (await gitStatus(project.path)).files.filter(file => files.includes(file.path)).map(file => file.path);
873
1021
  if (!paths.length) throw new Error('This task has no accepted changes left to commit.');
874
1022
  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);
1023
+ const committed = { hash, accepted: accepted!, at: Date.now() };
1024
+ this.store.updateTask(taskId, { committed });
877
1025
  // The commit stands even when the push fails; the author can push again.
878
1026
  let pushError: string | undefined;
879
- if (push) try { await gitRemote(project.path, 'push'); } catch (error) { pushError = errorText(error); }
1027
+ if (push) try {
1028
+ await gitRemote(project.path, 'push');
1029
+ this.store.updateTask(taskId, { committed: { ...committed, pushed: true } });
1030
+ } catch (error) { pushError = errorText(error); }
1031
+ await this.refresh(taskId);
880
1032
  return { commit: hash, git: await gitStatus(project.path), ...(pushError ? { pushError } : {}) };
881
1033
  });
882
1034
  }
@@ -893,11 +1045,13 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
893
1045
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
894
1046
  return this.store.updateTask(taskId, { title, titleSource: 'manual', titleRevision: (task.titleRevision || 0) + 1, titleGeneration: null });
895
1047
  }
896
- archiveTask(taskId: string, archived: boolean) {
1048
+ // Older tabs send finished=true when navigating away. Ignore those requests;
1049
+ // only an explicit archive action should change the task list.
1050
+ archiveTask(taskId: string, archived: boolean, finished = false) {
897
1051
  if (typeof archived !== 'boolean') throw new Error('Choose whether to archive or restore the task.');
898
1052
  const task = this.store.task(taskId);
899
1053
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
900
- if (Boolean(task.archivedAt) === archived) return task;
1054
+ if (Boolean(task.archivedAt) === archived || (archived && finished)) return task;
901
1055
  // Archiving only organizes the list; the conversation, review and any run remain intact.
902
1056
  return this.store.updateTask(taskId, { archivedAt: archived ? Date.now() : null });
903
1057
  }