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.
- package/README.md +52 -30
- package/README.zh-CN.md +54 -30
- package/bin/doctor.mjs +53 -6
- package/bin/installation.d.mts +2 -0
- package/bin/installation.mjs +7 -1
- package/dist/assets/{_virtual_papilot-licenses-BWIsfrQh.js → _virtual_papilot-licenses-D7ro6ecN.js} +54 -3
- package/dist/assets/index-Cc-awdNn.css +1 -0
- package/dist/assets/index-D_VX51no.js +265 -0
- package/dist/assets/latex-BarqJFtQ.svg +1 -0
- package/dist/assets/{math-BsEl3u6P.js → math-V9uUaUzA.js} +1 -1
- package/dist/assets/pdf-view-Bnb2uIBE.js +598 -0
- package/dist/index.html +2 -2
- package/examples/papilot-paper/main.tex +1 -1
- package/examples/papilot-paper/sections/02-using.tex +3 -1
- package/examples/papilot-paper/sections/09-agents.tex +8 -5
- package/package.json +10 -6
- package/skills/ai-review.md +18 -0
- package/skills/latex-warnings.md +1 -1
- package/src/conversation.ts +6 -0
- package/src/server/acp.ts +354 -0
- package/src/server/agents.ts +220 -43
- package/src/server/ai-review-routes.ts +72 -0
- package/src/server/ai-review.ts +38 -0
- package/src/server/app.ts +53 -11
- package/src/server/build-problems.ts +329 -0
- package/src/server/changes.ts +25 -2
- package/src/server/codex.ts +2 -1
- package/src/server/cursor.ts +153 -0
- package/src/server/latex-installation.ts +43 -0
- package/src/server/manuscript.ts +165 -23
- package/src/server/paths.ts +1 -1
- package/src/server/references.ts +70 -0
- package/src/server/review.ts +3 -3
- package/src/server/skills.ts +5 -8
- package/src/server/store.ts +20 -2
- package/src/server/update-install.ts +53 -2
- package/src/server/update-worker.ts +6 -6
- package/src/server/updates.ts +43 -14
- package/src/server/workspace.ts +183 -29
- package/src/shared.ts +78 -7
- package/src/skill-icons.ts +18 -0
- package/src/skill-metadata.ts +45 -0
- package/vendor/latex-review/dist/web/assets/index-7VKAte5x.js +19 -0
- package/vendor/latex-review/dist/web/assets/index-DEz-LsGl.css +1 -0
- package/vendor/latex-review/dist/web/index.html +2 -2
- package/vendor/latex-review/src/core/bib.ts +692 -0
- package/vendor/latex-review/src/core/changes.ts +39 -0
- package/vendor/latex-review/src/core/review.ts +1 -0
- package/vendor/latex-review/src/core/verify.ts +784 -0
- package/vendor/latex-review/src/core/view.ts +204 -0
- package/vendor/latex-review/src/render/round.ts +11 -0
- package/vendor/latex-review/src/server.ts +65 -0
- package/vendor/latex-review/src/types.ts +125 -0
- package/vendor/latex-review/src/web/effect-preferences.ts +10 -0
- package/vendor/latex-review/src/web/references.tsx +499 -0
- package/vendor/latex-review/src/web/review.tsx +259 -131
- package/vendor/latex-review/src/web/style.css +285 -0
- package/vendor/latex-review/src/web/zh-CN.ts +75 -0
- package/dist/assets/index-CIJEYlP7.js +0 -252
- package/dist/assets/index-hOJEruFc.css +0 -1
- package/dist/assets/pdf-view-Cz9_vK5U.js +0 -598
- package/vendor/latex-review/dist/web/assets/index-Be9CtgcZ.css +0 -1
- package/vendor/latex-review/dist/web/assets/index-TvMWVSaw.js +0 -19
package/src/server/workspace.ts
CHANGED
|
@@ -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[] =
|
|
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
|
|
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
|
|
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.
|
|
280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
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')
|
|
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
|
|
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
|
-
|
|
876
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|