papilot 0.0.0-stage → 0.1.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 (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -2
  3. package/README.zh-CN.md +127 -0
  4. package/bin/papilot.mjs +113 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/Inconsolatazi4-Regular-Dbtq7t_r.otf +0 -0
  7. package/dist/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
  8. package/dist/assets/TeXGyrePagellaX-Italic-CSuYIstn.otf +0 -0
  9. package/dist/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
  10. package/dist/assets/cursor-editorTextHighlight-kG3uUOwL.svg +8 -0
  11. package/dist/assets/index-BhQFCMTx.js +131 -0
  12. package/dist/assets/index-CAV3-87U.css +1 -0
  13. package/dist/assets/pdf-view-DJo0X38w.js +598 -0
  14. package/dist/assets/pdf.worker.min-CjEcRF4W.mjs +28 -0
  15. package/dist/favicon.png +0 -0
  16. package/dist/index.html +4 -0
  17. package/examples/papilot-paper/.latex-review.json +4 -0
  18. package/examples/papilot-paper/appendix/proofs.tex +60 -0
  19. package/examples/papilot-paper/appendix/reference.tex +54 -0
  20. package/examples/papilot-paper/main.tex +57 -0
  21. package/examples/papilot-paper/preamble.tex +53 -0
  22. package/examples/papilot-paper/references.bib +158 -0
  23. package/examples/papilot-paper/sections/01-introduction.tex +66 -0
  24. package/examples/papilot-paper/sections/02-using.tex +134 -0
  25. package/examples/papilot-paper/sections/03-background.tex +97 -0
  26. package/examples/papilot-paper/sections/04-model.tex +185 -0
  27. package/examples/papilot-paper/sections/05-conflicts.tex +99 -0
  28. package/examples/papilot-paper/sections/06-building.tex +70 -0
  29. package/examples/papilot-paper/sections/07-geometry.tex +193 -0
  30. package/examples/papilot-paper/sections/08-notes.tex +104 -0
  31. package/examples/papilot-paper/sections/09-agents.tex +106 -0
  32. package/examples/papilot-paper/sections/10-usage.tex +56 -0
  33. package/examples/papilot-paper/sections/11-git.tex +62 -0
  34. package/examples/papilot-paper/sections/12-security.tex +41 -0
  35. package/examples/papilot-paper/sections/13-evaluation.tex +106 -0
  36. package/examples/papilot-paper/sections/14-related.tex +38 -0
  37. package/examples/papilot-paper/sections/15-conclusion.tex +27 -0
  38. package/package.json +85 -4
  39. package/scripts/demo.ts +21 -0
  40. package/scripts/service.ts +35 -0
  41. package/src/notes.ts +40 -0
  42. package/src/server/agents.ts +343 -0
  43. package/src/server/codex.ts +63 -0
  44. package/src/server/frontend.ts +15 -0
  45. package/src/server/main.ts +202 -0
  46. package/src/server/manuscript.ts +337 -0
  47. package/src/server/paths.ts +10 -0
  48. package/src/server/pdf.ts +27 -0
  49. package/src/server/review.ts +53 -0
  50. package/src/server/security.ts +15 -0
  51. package/src/server/store.ts +60 -0
  52. package/src/server/workspace.ts +529 -0
  53. package/src/shared.ts +28 -0
  54. package/vendor/latex-review/LICENSE +21 -0
  55. package/vendor/latex-review/dist/web/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
  56. package/vendor/latex-review/dist/web/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
  57. package/vendor/latex-review/dist/web/assets/index-DyzYnIGk.css +1 -0
  58. package/vendor/latex-review/dist/web/assets/index-HxBK-dls.js +19 -0
  59. package/vendor/latex-review/dist/web/index.html +18 -0
  60. package/vendor/latex-review/package-lock.json +3114 -0
  61. package/vendor/latex-review/package.json +49 -0
  62. package/vendor/latex-review/src/cli.ts +494 -0
  63. package/vendor/latex-review/src/client.ts +198 -0
  64. package/vendor/latex-review/src/core/build.ts +296 -0
  65. package/vendor/latex-review/src/core/changes.ts +126 -0
  66. package/vendor/latex-review/src/core/config.ts +270 -0
  67. package/vendor/latex-review/src/core/diff.ts +54 -0
  68. package/vendor/latex-review/src/core/files.ts +96 -0
  69. package/vendor/latex-review/src/core/git.ts +317 -0
  70. package/vendor/latex-review/src/core/outline.ts +314 -0
  71. package/vendor/latex-review/src/core/process.ts +111 -0
  72. package/vendor/latex-review/src/core/publish.ts +339 -0
  73. package/vendor/latex-review/src/core/review.ts +734 -0
  74. package/vendor/latex-review/src/core/store.ts +66 -0
  75. package/vendor/latex-review/src/core/tasks.ts +237 -0
  76. package/vendor/latex-review/src/render/inline.ts +805 -0
  77. package/vendor/latex-review/src/render/locate.ts +481 -0
  78. package/vendor/latex-review/src/render/pdf.ts +281 -0
  79. package/vendor/latex-review/src/render/pdfdiff.ts +272 -0
  80. package/vendor/latex-review/src/render/round.ts +236 -0
  81. package/vendor/latex-review/src/render/worker.ts +10 -0
  82. package/vendor/latex-review/src/runtime.ts +23 -0
  83. package/vendor/latex-review/src/server.ts +401 -0
  84. package/vendor/latex-review/src/types.ts +283 -0
  85. package/vendor/latex-review/src/web/ChangeMap.tsx +212 -0
  86. package/vendor/latex-review/src/web/PdfStrip.tsx +77 -0
  87. package/vendor/latex-review/src/web/fonts/GUST-FONT-LICENSE.txt +29 -0
  88. package/vendor/latex-review/src/web/fonts/LPPL.txt +415 -0
  89. package/vendor/latex-review/src/web/fonts/MANIFEST-newpx.txt +20 -0
  90. package/vendor/latex-review/src/web/fonts/README-newpx.txt +473 -0
  91. package/vendor/latex-review/src/web/fonts/README.md +12 -0
  92. package/vendor/latex-review/src/web/fonts/TeXGyrePagellaX-Bold.otf +0 -0
  93. package/vendor/latex-review/src/web/fonts/TeXGyrePagellaX-Regular.otf +0 -0
  94. package/vendor/latex-review/src/web/index.html +17 -0
  95. package/vendor/latex-review/src/web/main.tsx +10 -0
  96. package/vendor/latex-review/src/web/model.ts +153 -0
  97. package/vendor/latex-review/src/web/overview.tsx +146 -0
  98. package/vendor/latex-review/src/web/review.tsx +991 -0
  99. package/vendor/latex-review/src/web/style.css +1249 -0
@@ -0,0 +1,529 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { EventEmitter } from 'node:events';
7
+ import { Store } from './store.ts';
8
+ import { Codex, RpcResponseError } from './codex.ts';
9
+ import { AGENT_NAMES, AntigravityAgent, ClaudeAgent, type AgentRuntime } from './agents.ts';
10
+ import { reviewClient, taskRoute, confirmedReviewResponse, type ReviewClient } from './review.ts';
11
+ import type { Task, Run, TimelineItem, PendingRequest, RuntimeStatus, Snapshot, PendingReview, AgentModel, Usage, AgentId } from '../shared.ts';
12
+
13
+ const execute = promisify(execFile);
14
+ const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
15
+ const errorText = (e: unknown) => e instanceof Error ? e.message : String(e);
16
+ // A Codex model/list entry with the reasoning efforts it supports.
17
+ export const agentModel = (m: any): AgentModel => ({
18
+ id: m.model || m.id, name: m.displayName || m.model || m.id, isDefault: Boolean(m.isDefault),
19
+ efforts: (m.supportedReasoningEfforts || []).map((e: any) => e?.reasoningEffort).filter((e: unknown) => typeof e === 'string'),
20
+ defaultEffort: typeof m.defaultReasoningEffort === 'string' ? m.defaultReasoningEffort : null,
21
+ });
22
+ // An account/rateLimits/read response as the usage Papilot shows; null when
23
+ // the account reports neither limits nor credits (for example, an API key).
24
+ export function accountUsage(response: any): Usage | null {
25
+ const limits = response?.rateLimits;
26
+ if (!limits) return null;
27
+ const windows = [limits.primary, limits.secondary].filter((w: any) => typeof w?.usedPercent === 'number')
28
+ .map((w: any) => ({ usedPercent: Math.min(100, Math.max(0, w.usedPercent)), minutes: typeof w.windowDurationMins === 'number' ? w.windowDurationMins : null, resetsAt: typeof w.resetsAt === 'number' ? w.resetsAt * 1000 : null }));
29
+ const balance = Number(limits.credits?.balance);
30
+ const credits = limits.credits?.hasCredits || limits.credits?.unlimited ? { unlimited: Boolean(limits.credits.unlimited), balance: Number.isFinite(balance) ? balance : null } : null;
31
+ if (!windows.length && !credits) return null;
32
+ return { plan: typeof limits.planType === 'string' ? limits.planType : null, windows, credits, reached: Boolean(limits.rateLimitReachedType) || response.ordinaryUsageAllowed === false };
33
+ }
34
+ const BUSY = new Set(['running', 'waiting_input', 'building', 'uncertain', 'deleting']);
35
+
36
+ const AGENTS: AgentId[] = ['codex', 'claude', 'antigravity'];
37
+ const blankRuntime = (): RuntimeStatus => ({ connected: false, authenticated: false, account: null, error: null, models: [] });
38
+
39
+ export class Workspace extends EventEmitter {
40
+ // Each task runs on one agent; `runtime` is Codex's status, kept for older clients.
41
+ runtimes: Record<AgentId, RuntimeStatus> = { codex: blankRuntime(), claude: blankRuntime(), antigravity: blankRuntime() };
42
+ runtime = this.runtimes.codex;
43
+ agents: Record<AgentId, AgentRuntime>;
44
+ requests = new Map<string, PendingRequest & { rpcId: string | number; agent: AgentId }>();
45
+ private active = new Map<string, Run>();
46
+ private loaded = new Set<string>();
47
+ private preparing = new Set<string>();
48
+ private reviewWrites = new Set<string>();
49
+ private commentWrites = new Map<string, number>();
50
+ private deletions = new Set<string>();
51
+ private publishing = new Set<string>();
52
+ private earlyCompletions = new Map<string, Map<string, any>>();
53
+ private earlyRequests = new Map<string, any[]>();
54
+ private queued = false;
55
+ private closing = false;
56
+ private liveItems = new Map<string, TimelineItem>();
57
+ private flushTimer: NodeJS.Timeout | null = null;
58
+ private connecting: Promise<void> | null = null;
59
+ private usageTimers = new Map<AgentId, NodeJS.Timeout>();
60
+ constructor(public store: Store, public codex: AgentRuntime = new Codex(), private concurrency = 2, private connectReview = reviewClient, others: Partial<Record<AgentId, AgentRuntime>> = {}) {
61
+ super();
62
+ this.agents = { codex, claude: others.claude || new ClaudeAgent(), antigravity: others.antigravity || new AntigravityAgent() };
63
+ store.on('change', () => this.emit('change'));
64
+ for (const id of AGENTS) {
65
+ const agent = this.agents[id];
66
+ agent.on('notification', message => void this.notification(message, id).catch(e => console.error(`${AGENT_NAMES[id]} event:`, errorText(e))));
67
+ agent.on('request', message => this.serverRequest(message, id));
68
+ agent.on('disconnected', e => this.disconnected(id, e));
69
+ }
70
+ }
71
+ private agentOf(task: Task): AgentId { return task.agent && AGENTS.includes(task.agent) ? task.agent : 'codex'; }
72
+ private disconnected(id: AgentId, e: unknown) {
73
+ this.runtimes[id].connected = false; this.runtimes[id].error = errorText(e);
74
+ for (const [key, request] of this.requests) if (request.agent === id) this.requests.delete(key);
75
+ for (const [taskId, run] of this.active) {
76
+ const task = this.store.task(taskId);
77
+ if (this.agentOf(task) !== id) continue;
78
+ if (task.threadId) this.loaded.delete(task.threadId);
79
+ this.earlyCompletions.delete(taskId); this.earlyRequests.delete(taskId); this.active.delete(taskId);
80
+ this.store.saveRun({ ...run, status: 'interrupted', finishedAt: Date.now() });
81
+ this.store.updateTask(taskId, { status: 'interrupted', turnId: null, error: `${AGENT_NAMES[id]} disconnected. You can resume the same task.` });
82
+ }
83
+ if (id === 'codex') this.loaded.clear();
84
+ this.emit('change');
85
+ }
86
+ snapshot(): Snapshot { return { version: '0.1.0', projects: this.store.projects(), tasks: this.store.tasks(), requests: [...this.requests.values()].map(({ rpcId: _, agent: __, ...r }) => r), runtime: this.runtime, runtimes: this.runtimes }; }
87
+ async connect() {
88
+ if (this.connecting) return this.connecting;
89
+ this.connecting = Promise.all(AGENTS.map(id => this.connectAgent(id))).then(() => { this.emit('change'); this.schedule(); }).finally(() => { this.connecting = null; });
90
+ return this.connecting;
91
+ }
92
+ private async connectAgent(id: AgentId) {
93
+ const agent = this.agents[id], runtime = this.runtimes[id], name = AGENT_NAMES[id];
94
+ try {
95
+ await agent.start();
96
+ const account = await agent.request('account/read', { refreshToken: false });
97
+ runtime.connected = true;
98
+ runtime.authenticated = Boolean(account.account) || account.requiresOpenaiAuth === false;
99
+ runtime.account = account.account?.email || account.account?.type || (runtime.authenticated ? `Local ${name} configuration` : null);
100
+ runtime.error = runtime.authenticated ? null : account.reason || `Sign in to ${name} on this host, then select Reconnect.`;
101
+ try {
102
+ const models = await agent.request('model/list', {}, 20000);
103
+ runtime.models = (models.data || []).map(agentModel);
104
+ } catch { /* Tasks can still use the account's configured default model. */ }
105
+ await this.readUsage(id);
106
+ } catch (e) { runtime.error = errorText(e); runtime.connected = false; }
107
+ }
108
+ async addProject(input: string, name?: string) {
109
+ const expanded = input.startsWith('~/') ? path.join(process.env.HOME!, input.slice(2)) : input;
110
+ const root = fs.realpathSync(expanded);
111
+ const git = await execute('git', ['-C', root, 'rev-parse', '--show-toplevel']);
112
+ if (fs.realpathSync(git.stdout.trim()) !== root) throw new Error('Select the root directory of the Git repository.');
113
+ await execute('git', ['-C', root, 'rev-parse', '--verify', 'HEAD']);
114
+ const existing = this.store.projectByPath(root); if (existing) return existing;
115
+ const client = await this.connectReview(root);
116
+ await client.get('/api/tasks');
117
+ const project = { id: randomUUID(), name: name?.trim() || path.basename(root), path: root, createdAt: Date.now() };
118
+ this.store.saveProject(project); return project;
119
+ }
120
+ renameProject(projectId: string, name: string) {
121
+ const project = { ...this.store.project(projectId), name };
122
+ this.store.saveProject(project); return project;
123
+ }
124
+ async createTask(input: { projectId: string; title: string; instructions: string; agent?: AgentId; model?: string; effort?: string; id: string }) {
125
+ if (!/^[\w-]{16,64}$/.test(input.id)) throw new Error('Invalid task ID.');
126
+ if (this.store.taskDeleted(input.id)) throw new Error('This task was deleted. Create a new task instead.');
127
+ const existing = this.store.tasks().find(t => t.id === input.id); if (existing) return existing;
128
+ if (this.preparing.has(input.id)) throw new Error('This task is being created. Refresh in a moment.');
129
+ this.preparing.add(input.id);
130
+ try {
131
+ const project = this.store.project(input.projectId), client = await this.connectReview(project.path);
132
+ const name = 'p-' + input.id.toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 48);
133
+ const info = await client.post('/api/tasks', { name, title: input.title });
134
+ const task: Task = { id: input.id, projectId: project.id, name, title: input.title, instructions: input.instructions,
135
+ worktree: info.worktree, threadId: null, turnId: null, agent: input.agent || 'codex', model: input.model || null, effort: input.effort || null, status: 'idle',
136
+ createdAt: Date.now(), updatedAt: Date.now(), feedbackCursor: 0, error: null, review: null };
137
+ this.store.saveTask(task);
138
+ await this.enqueue(task.id, input.instructions, 'initial-' + task.id, false);
139
+ return this.store.task(task.id);
140
+ } finally { this.preparing.delete(input.id); }
141
+ }
142
+ async enqueue(taskId: string, text: string, requestKey: string, includeFeedback = true) {
143
+ const task = this.store.task(taskId);
144
+ if (task.status === 'deleting') throw new Error('This task is being deleted.');
145
+ const existing = this.store.runByKey(requestKey);
146
+ if (existing) { if (existing.taskId !== taskId) throw new Error('This request ID is already in use by another task.'); return existing; }
147
+ if (task.status === 'uncertain') throw new Error('Check the task status before sending more instructions.');
148
+ const pauseVersion = task.pauseVersion || 0;
149
+ if (this.store.runs().some(r => r.taskId === taskId && r.status === 'queued')) throw new Error('This task already has queued instructions. Wait or pause it first.');
150
+ let prompt = text.trim(), cursor = task.feedbackCursor;
151
+ if (includeFeedback) {
152
+ const client = await this.connectReview(this.store.project(task.projectId).path);
153
+ const feedback = await client.get(`${taskRoute(task.name)}/feedback?after=${task.feedbackCursor}`);
154
+ cursor = feedback.cursor;
155
+ if (feedback.feedback?.length || feedback.build_error) prompt += '\n\nPapilot review feedback (bound to the specified review round and source changes):\n' + JSON.stringify(feedback, null, 2);
156
+ 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.';
157
+ }
158
+ const run: Run = { id: randomUUID(), taskId, requestKey, prompt, feedbackCursor: cursor, status: 'queued', createdAt: Date.now(), finishedAt: null };
159
+ const saved = this.store.transaction(() => {
160
+ // Feedback loading yields to other requests. Recheck identity, queue, and
161
+ // task status together before committing; none of the earlier reads own a lock.
162
+ const duplicate = this.store.runByKey(requestKey);
163
+ if (duplicate) {
164
+ if (duplicate.taskId !== taskId) throw new Error('This request ID is already in use by another task.');
165
+ return duplicate;
166
+ }
167
+ if (this.store.runs().some(r => r.taskId === taskId && r.status === 'queued')) throw new Error('This task already has queued instructions. Wait or pause it first.');
168
+ const current = this.store.task(taskId);
169
+ if (current.status === 'deleting') throw new Error('This task is being deleted. Your instructions were not queued.');
170
+ if ((current.pauseVersion || 0) !== pauseVersion) throw new Error('The task was paused before these instructions were queued. Your draft has been kept.');
171
+ if (current.status === 'uncertain') throw new Error('Check the task status before sending more instructions.');
172
+ this.store.saveRun(run);
173
+ if (!BUSY.has(current.status)) this.store.updateTask(taskId, { status: 'queued', error: null });
174
+ this.store.saveItem({ id: run.id, taskId, type: 'user', text: text.trim(), createdAt: run.createdAt });
175
+ return run;
176
+ });
177
+ this.schedule();
178
+ if (includeFeedback) void this.refresh(taskId);
179
+ return saved;
180
+ }
181
+ private schedule() {
182
+ if (this.queued || this.closing) return;
183
+ this.queued = true;
184
+ setImmediate(() => { this.queued = false; void this.drain(); });
185
+ }
186
+ private async drain() {
187
+ if (this.closing) return;
188
+ for (const run of this.store.runs().filter(r => r.status === 'queued')) {
189
+ if (this.active.size >= this.concurrency) break;
190
+ const agent = this.agentOf(this.store.task(run.taskId));
191
+ if (!this.agents[agent].ready || !this.runtimes[agent].authenticated) continue;
192
+ if (this.active.has(run.taskId) || this.reviewWrites.has(run.taskId) || this.publishing.has(run.taskId) || BUSY.has(this.store.task(run.taskId).status)) continue;
193
+ this.active.set(run.taskId, run);
194
+ void this.startRun(run).catch(e => this.failRun(run, e));
195
+ }
196
+ }
197
+ private instructions(task: Task) {
198
+ 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.
199
+ Your task is already created. Your editable working directory is ${task.worktree}.
200
+ Original task: ${task.instructions}
201
+ 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.
202
+ 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.
203
+ 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.
204
+ ${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.`;
205
+ }
206
+ private async startRun(run: Run) {
207
+ let task = this.store.task(run.taskId);
208
+ const agent = this.agents[this.agentOf(task)], name = AGENT_NAMES[this.agentOf(task)];
209
+ this.earlyCompletions.delete(task.id); this.earlyRequests.delete(task.id);
210
+ this.store.saveRun({ ...run, status: 'running' });
211
+ this.store.updateTask(task.id, { status: 'running', turnId: null, error: null });
212
+ if (!task.threadId || !this.loaded.has(task.threadId)) {
213
+ const options = { cwd: task.worktree, approvalPolicy: 'on-request', sandbox: 'workspace-write', developerInstructions: this.instructions(task), ...(task.model ? { model: task.model } : {}) };
214
+ const result = task.threadId
215
+ ? await agent.request('thread/resume', { ...options, threadId: task.threadId, excludeTurns: true })
216
+ : await agent.request('thread/start', options);
217
+ if (this.active.get(task.id)?.id !== run.id) return;
218
+ task = this.store.updateTask(task.id, { threadId: result.thread.id, model: result.model || task.model });
219
+ this.loaded.add(result.thread.id);
220
+ }
221
+ this.store.updateTask(task.id, { pendingRunId: run.id });
222
+ let result: any;
223
+ try { result = await agent.request('turn/start', { threadId: task.threadId, input: [{ type: 'text', text: run.prompt }], cwd: task.worktree, clientUserMessageId: run.id, ...(task.effort ? { effort: task.effort } : {}) }); }
224
+ catch (error) {
225
+ if (this.active.get(task.id)?.id !== run.id) return;
226
+ if (error instanceof RpcResponseError) throw error;
227
+ this.store.saveRun({ ...run, status: 'uncertain' });
228
+ this.store.updateTask(task.id, { status: 'uncertain', error: `${name} did not confirm whether this turn started. Select Check status before continuing. ` + errorText(error) });
229
+ return;
230
+ }
231
+ if (this.active.get(task.id)?.id !== run.id) return;
232
+ // A cancelled queued message has not delivered its comments. Advance only
233
+ // after Codex acknowledges starting the turn that contains that feedback.
234
+ if (run.feedbackCursor !== undefined) this.store.updateTask(task.id, { feedbackCursor: Math.max(this.store.task(task.id).feedbackCursor, run.feedbackCursor) });
235
+ this.store.updateTask(task.id, { turnId: result.turn.id, pendingRunId: null });
236
+ const requests = this.earlyRequests.get(task.id) || [];
237
+ const completion = this.earlyCompletions.get(task.id)?.get(result.turn.id);
238
+ this.earlyRequests.delete(task.id); this.earlyCompletions.delete(task.id);
239
+ for (const request of requests) this.serverRequest(request, this.agentOf(task));
240
+ if (completion) await this.notification(completion, this.agentOf(task));
241
+ }
242
+ private failRun(run: Run, error: unknown) {
243
+ if (this.active.get(run.taskId)?.id !== run.id) return;
244
+ this.active.delete(run.taskId);
245
+ this.earlyCompletions.delete(run.taskId); this.earlyRequests.delete(run.taskId);
246
+ this.store.saveRun({ ...run, status: 'failed', finishedAt: Date.now() });
247
+ this.store.updateTask(run.taskId, { status: 'failed', turnId: null, pendingRunId: null, error: errorText(error) });
248
+ this.schedule();
249
+ }
250
+ private flush() {
251
+ if (this.flushTimer) clearTimeout(this.flushTimer); this.flushTimer = null;
252
+ for (const item of this.liveItems.values()) this.store.saveItem(item);
253
+ this.liveItems.clear();
254
+ }
255
+ private appendDelta(task: Task, params: any, type: string) {
256
+ const id = `${task.id}:${params.turnId}:${params.itemId}`;
257
+ const item = this.liveItems.get(id) || this.store.items(task.id).find(item => item.id === id) || { id, taskId: task.id, type, text: '', createdAt: Date.now() };
258
+ if (type === 'command') item.detail = ((item.detail || '') + (params.delta || '')).slice(-40000);
259
+ else item.text += params.delta || '';
260
+ this.liveItems.set(id, item);
261
+ this.flushTimer ??= setTimeout(() => this.flush(), 350);
262
+ }
263
+ // Usage is shown on the new-task page. Agents report changes after turns;
264
+ // read the full snapshot again rather than merging their sparse updates.
265
+ async readUsage(id: AgentId = 'codex') {
266
+ try { this.runtimes[id].usage = accountUsage(await this.agents[id].request('account/rateLimits/read', { excludeResetCreditDetails: true }, 20000)); }
267
+ catch { this.runtimes[id].usage = null; }
268
+ this.emit('change');
269
+ }
270
+ private async notification(message: any, agent: AgentId = 'codex') {
271
+ const p = message.params || {}, method = message.method;
272
+ if (method === 'account/rateLimits/updated') {
273
+ clearTimeout(this.usageTimers.get(agent));
274
+ this.usageTimers.set(agent, setTimeout(() => { this.usageTimers.delete(agent); void this.readUsage(agent); }, 1500));
275
+ return;
276
+ }
277
+ const task = this.store.tasks().find(t => t.threadId === p.threadId);
278
+ if (!task || task.status === 'deleting') return;
279
+ const eventTurn = p.turnId || p.turn?.id;
280
+ if (eventTurn && task.turnId && eventTurn !== task.turnId) return;
281
+ if (method === 'item/agentMessage/delta') this.appendDelta(task, p, 'assistant');
282
+ else if (method === 'item/commandExecution/outputDelta') this.appendDelta(task, p, 'command');
283
+ else if ((method === 'item/started' || method === 'item/completed') && p.item) {
284
+ const item = p.item, id = `${task.id}:${p.turnId}:${item.id}`;
285
+ if (['userMessage', 'reasoning'].includes(item.type)) return;
286
+ this.flush();
287
+ const previous = this.store.items(task.id).find(i => i.id === id);
288
+ 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') };
289
+ if (item.type === 'agentMessage') entry.type = 'assistant';
290
+ if (item.type === 'commandExecution') { entry.type = 'command'; entry.detail = String(item.aggregatedOutput || previous?.detail || '').slice(-40000); }
291
+ if (item.type === 'fileChange') { entry.type = 'files'; entry.text = (item.changes || []).map((change: any) => change.path).join('\n'); }
292
+ if (item.type === 'mcpToolCall') { entry.type = 'tool'; entry.text = `${item.server} / ${item.tool}`; }
293
+ if (item.type === 'toolCall') entry.type = 'tool';
294
+ if (entry.text || entry.detail) this.store.saveItem(entry);
295
+ } else if (method === 'serverRequest/resolved') {
296
+ const buffered = this.earlyRequests.get(task.id);
297
+ if (buffered) this.earlyRequests.set(task.id, buffered.filter(request => String(request.id) !== String(p.requestId)));
298
+ let removed = false;
299
+ for (const [key, request] of this.requests) if (request.taskId === task.id && String(request.rpcId) === String(p.requestId)) { this.requests.delete(key); removed = true; }
300
+ if (removed && task.status === 'waiting_input' && ![...this.requests.values()].some(r => r.taskId === task.id) && this.active.has(task.id)) this.store.updateTask(task.id, { status: 'running' });
301
+ this.emit('change');
302
+ } else if (method === 'turn/completed') {
303
+ if (!eventTurn || !this.active.has(task.id)) return;
304
+ if (!task.turnId) {
305
+ // Very short turns can finish before the turn/start response arrives.
306
+ const pending = this.earlyCompletions.get(task.id) || new Map<string, any>();
307
+ if (pending.size < 32) pending.set(eventTurn, message);
308
+ this.earlyCompletions.set(task.id, pending);
309
+ return;
310
+ }
311
+ this.flush();
312
+ const run = this.active.get(task.id); if (!run) return;
313
+ this.active.delete(task.id);
314
+ for (const [key, r] of this.requests) if (r.taskId === task.id) this.requests.delete(key);
315
+ const status = p.turn.status === 'completed' ? 'completed' : p.turn.status === 'interrupted' ? 'interrupted' : 'failed';
316
+ this.store.saveRun({ ...run, status, finishedAt: Date.now() });
317
+ this.store.updateTask(task.id, { turnId: null, pendingRunId: null, status: status === 'completed' ? 'building' : status, error: p.turn.error?.message || null });
318
+ this.schedule();
319
+ if (status === 'completed') await this.publish(task.id, true);
320
+ } else if (method === 'error' && !p.willRetry) {
321
+ if (!this.active.has(task.id) || (eventTurn && !task.turnId)) return;
322
+ this.store.updateTask(task.id, { error: p.error?.message || `${AGENT_NAMES[agent]} encountered an error.` });
323
+ }
324
+ }
325
+ private serverRequest(message: any, agent: AgentId = 'codex') {
326
+ const task = this.store.tasks().find(t => t.threadId === message.params?.threadId), runtime = this.agents[agent];
327
+ const allowed = ['item/commandExecution/requestApproval', 'item/fileChange/requestApproval', 'item/permissions/requestApproval', 'item/tool/requestUserInput'];
328
+ 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; }
329
+ if (message.params?.turnId && task.turnId && message.params.turnId !== task.turnId) { runtime.reject(message.id, 'This turn is no longer active.'); return; }
330
+ if (!task.turnId && message.params?.turnId) {
331
+ const pending = this.earlyRequests.get(task.id) || [];
332
+ if (pending.length < 32) { pending.push(message); this.earlyRequests.set(task.id, pending); }
333
+ else runtime.reject(message.id, 'Too many pending requests.');
334
+ return;
335
+ }
336
+ const id = randomUUID();
337
+ this.requests.set(id, { id, taskId: task.id, rpcId: message.id, agent, method: message.method, params: message.params, createdAt: Date.now() });
338
+ this.store.updateTask(task.id, { status: 'waiting_input' });
339
+ }
340
+ answer(id: string, input: { decision?: string; answers?: Record<string, string> }) {
341
+ const request = this.requests.get(id); if (!request) throw new Error('This request has ended or the connection has restarted.');
342
+ if (this.store.task(request.taskId).status === 'deleting') throw new Error('This task is being deleted.');
343
+ let response: unknown;
344
+ if (request.method === 'item/tool/requestUserInput') {
345
+ const questions = request.params.questions || [];
346
+ response = { answers: Object.fromEntries(questions.map((q: any) => {
347
+ const answer = input.answers?.[q.id]?.trim(); if (!answer) throw new Error('Answer every question before submitting.');
348
+ return [q.id, { answers: [answer] }];
349
+ })) };
350
+ } else {
351
+ if (!['accept', 'decline', 'cancel'].includes(input.decision || '')) throw new Error('Invalid approval decision.');
352
+ response = request.method === 'item/permissions/requestApproval'
353
+ ? { permissions: input.decision === 'accept' ? request.params.permissions : {}, scope: 'turn' }
354
+ : { decision: input.decision };
355
+ }
356
+ this.agents[request.agent].respond(request.rpcId, response);
357
+ this.requests.delete(id);
358
+ if (![...this.requests.values()].some(r => r.taskId === request.taskId)) this.store.updateTask(request.taskId, { status: 'running' });
359
+ }
360
+ async interrupt(taskId: string) {
361
+ const task = this.store.task(taskId);
362
+ if (task.status === 'deleting') throw new Error('This task is being deleted. Retry deletion to finish cleanup.');
363
+ if (task.status === 'building') throw new Error('The PDF is building. Wait for this build to finish.');
364
+ this.store.updateTask(taskId, { pauseVersion: (task.pauseVersion || 0) + 1 });
365
+ for (const run of this.store.runs().filter(r => r.taskId === taskId && r.status === 'queued')) this.store.saveRun({ ...run, status: 'interrupted', finishedAt: Date.now() });
366
+ void this.refresh(taskId);
367
+ if (this.active.has(taskId)) {
368
+ if (!task.turnId) throw new Error(`${AGENT_NAMES[this.agentOf(task)]} is starting. Try pausing again in a moment.`);
369
+ await this.agents[this.agentOf(task)].request('turn/interrupt', { threadId: task.threadId, turnId: task.turnId });
370
+ } else if (task.status === 'uncertain') throw new Error('Check the task status before pausing this turn.');
371
+ else this.store.updateTask(taskId, { status: 'interrupted' });
372
+ }
373
+ async reconcile(taskId: string) {
374
+ const task = this.store.task(taskId);
375
+ if (task.status === 'deleting') throw new Error('Retry deletion to confirm this task was removed.');
376
+ if (task.status !== 'uncertain') return task;
377
+ if (task.pendingReview) return this.reconcileReview(task);
378
+ const run = this.active.get(taskId) || this.store.runs().find(r => r.id === task.pendingRunId && r.taskId === taskId);
379
+ const name = AGENT_NAMES[this.agentOf(task)];
380
+ if (!run || !task.threadId) throw new Error(`The pending turn could not be identified. Reconnect ${name} and check again.`);
381
+ const { thread } = await this.agents[this.agentOf(task)].request('thread/read', { threadId: task.threadId, includeTurns: true }, 20000);
382
+ if (this.store.task(taskId).status !== 'uncertain') return this.store.task(taskId);
383
+ const turn = thread.turns?.find((turn: any) => turn.id === task.turnId || turn.items?.some((item: any) => item.type === 'userMessage' && item.clientId === run.id));
384
+ if (!turn) throw new Error(`${name} has not confirmed this turn yet. Check again in a moment.`);
385
+ const completion = this.earlyCompletions.get(taskId)?.get(turn.id);
386
+ this.earlyCompletions.delete(taskId);
387
+ this.active.set(taskId, run);
388
+ this.store.updateTask(taskId, { turnId: turn.id, pendingRunId: null, error: null, feedbackCursor: Math.max(task.feedbackCursor, run.feedbackCursor || 0) });
389
+ if (completion) {
390
+ await this.notification(completion, this.agentOf(task));
391
+ } else if (turn.status === 'inProgress' && thread.status?.type !== 'notLoaded') {
392
+ this.store.saveRun({ ...run, status: 'running' });
393
+ this.store.updateTask(taskId, { status: 'running' });
394
+ const requests = this.earlyRequests.get(taskId) || [];
395
+ this.earlyRequests.delete(taskId);
396
+ for (const request of requests) this.serverRequest(request, this.agentOf(task));
397
+ } else {
398
+ await this.notification({ method: 'turn/completed', params: { threadId: task.threadId, turn: turn.status === 'inProgress' ? { ...turn, status: 'interrupted' } : turn } }, this.agentOf(task));
399
+ }
400
+ return this.store.task(taskId);
401
+ }
402
+ private async reconcileReview(task: Task) {
403
+ if (this.reviewWrites.has(task.id)) throw new Error('The review action is still being checked. Wait for it to finish.');
404
+ this.reviewWrites.add(task.id);
405
+ try {
406
+ const pending = task.pendingReview!;
407
+ const client = await this.connectReview(this.store.project(task.projectId).path);
408
+ // Replay exactly the original action identity. Decisions are idempotent;
409
+ // undo is bound to its original action ID and cannot undo a different one.
410
+ const response = await fetch(client.url + pending.route, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Review-Token': client.token }, body: JSON.stringify(pending.body), signal: AbortSignal.timeout(120000), redirect: 'error' });
411
+ const value = await response.json() as { error?: string };
412
+ if (!confirmedReviewResponse(response.status, value, task.name)) throw new Error('The review engine could not confirm this action. Check again when it is available.');
413
+ await this.refresh(task.id);
414
+ this.store.updateTask(task.id, { pendingReview: null, status: this.store.runs().some(r => r.taskId === task.id && r.status === 'queued') ? 'queued' : pending.previousStatus, error: response.ok ? null : value.error || 'Check the review result before continuing.' });
415
+ return this.store.task(task.id);
416
+ } finally { this.reviewWrites.delete(task.id); this.schedule(); }
417
+ }
418
+ async reviewWrite<T>(taskId: string, writesFiles: boolean, operation: () => Promise<T>, pending?: Omit<PendingReview, 'previousStatus'>): Promise<T> {
419
+ const task = this.store.task(taskId);
420
+ if (task.status === 'deleting') throw new Error('This task is being deleted.');
421
+ if (!writesFiles) {
422
+ this.commentWrites.set(taskId, (this.commentWrites.get(taskId) || 0) + 1);
423
+ try { return await operation(); }
424
+ finally {
425
+ const remaining = (this.commentWrites.get(taskId) || 1) - 1;
426
+ if (remaining) this.commentWrites.set(taskId, remaining); else this.commentWrites.delete(taskId);
427
+ }
428
+ }
429
+ if (this.active.has(taskId) || this.publishing.has(taskId) || BUSY.has(task.status) || task.status === 'queued') throw new Error('Pause the task before accepting, rejecting, or undoing changes. Wait for any PDF build to finish.');
430
+ if (this.reviewWrites.has(taskId)) throw new Error('A review action is already in progress. Wait for it to finish.');
431
+ this.reviewWrites.add(taskId);
432
+ if (pending) this.store.updateTask(taskId, { pendingReview: { ...pending, previousStatus: task.status } });
433
+ try {
434
+ const result = await operation();
435
+ if (pending) this.store.updateTask(taskId, { pendingReview: null });
436
+ return result;
437
+ } catch (error) {
438
+ if (pending) this.store.updateTask(taskId, { status: 'uncertain', error: 'The review action could not be confirmed. Select Check status before continuing. ' + errorText(error) });
439
+ throw error;
440
+ }
441
+ finally { this.reviewWrites.delete(taskId); this.schedule(); }
442
+ }
443
+ publish(taskId: string, automatic = false): Promise<void> {
444
+ const task = this.store.task(taskId);
445
+ if (task.status === 'deleting') throw new Error('This task is being deleted.');
446
+ if (this.active.has(taskId)) throw new Error('Pause Codex before building a review.');
447
+ if (this.reviewWrites.has(taskId)) throw new Error('Wait for the current review action before building a review.');
448
+ if (this.publishing.has(taskId) || (!automatic && (BUSY.has(task.status) || task.status === 'queued'))) throw new Error('This task is busy. Wait for it to finish or pause it first.');
449
+ this.publishing.add(taskId);
450
+ this.store.updateTask(taskId, { status: 'building', error: null });
451
+ return this.buildReview(task).finally(() => { this.publishing.delete(taskId); this.schedule(); });
452
+ }
453
+ private async buildReview(task: Task) {
454
+ const taskId = task.id;
455
+ try {
456
+ const client = await this.connectReview(this.store.project(task.projectId).path);
457
+ const job = await client.post(`${taskRoute(task.name)}/publish`, {});
458
+ for (;;) {
459
+ if (this.closing) return;
460
+ const current = await client.get(`/api/jobs/${job.job}`);
461
+ const summary = await client.get(taskRoute(task.name));
462
+ this.store.updateTask(taskId, { review: { state: summary.state, round: summary.round, counts: summary.counts, job: current, unsent: this.store.task(taskId).review?.unsent } });
463
+ if (current.status === 'done' || current.status === 'completed' || current.status === 'succeeded') break;
464
+ if (current.status === 'failed') throw new Error([current.error, ...(current.details || [])].filter(Boolean).join('\n') || 'PDF build failed.');
465
+ await delay(1500);
466
+ }
467
+ this.store.updateTask(taskId, { status: this.store.runs().some(r => r.taskId === taskId && r.status === 'queued') ? 'queued' : 'reviewing', error: null });
468
+ await this.refresh(taskId);
469
+ } catch (e) { this.store.updateTask(taskId, { status: 'failed', error: errorText(e) }); }
470
+ }
471
+ async refresh(taskId: string) {
472
+ try {
473
+ const task = this.store.task(taskId);
474
+ if (task.status === 'deleting') return;
475
+ const client = await this.connectReview(this.store.project(task.projectId).path);
476
+ const summary = await client.get(taskRoute(task.name));
477
+ const unsent = await this.unsentComments(client, this.store.task(taskId));
478
+ const current = this.store.task(taskId);
479
+ if (current.status !== 'deleting') this.store.updateTask(taskId, { review: { state: summary.state, round: summary.round, counts: summary.counts, job: current.review?.job, unsent } });
480
+ } catch { /* The task remains visible even if its project is temporarily unavailable. */ }
481
+ }
482
+ // Review comments Codex has not received yet. A queued or in-flight message
483
+ // already carries the feedback up to its own cursor.
484
+ private async unsentComments(client: ReviewClient, task: Task) {
485
+ const after = Math.max(task.feedbackCursor, ...this.store.runs().filter(r => r.taskId === task.id && ['queued', 'running', 'uncertain'].includes(r.status)).map(r => r.feedbackCursor || 0));
486
+ const feedback = await client.get<{ feedback?: { kind?: string }[] }>(`${taskRoute(task.name)}/feedback?after=${after}`);
487
+ return (feedback.feedback || []).filter(item => item.kind === 'comment').length;
488
+ }
489
+ renameTask(taskId: string, title: string) {
490
+ if (this.store.task(taskId).status === 'deleting') throw new Error('This task is being deleted.');
491
+ return this.store.updateTask(taskId, { title });
492
+ }
493
+ async deleteTask(taskId: string): Promise<{ ok: true }> {
494
+ if (this.preparing.has(taskId)) throw new Error('Wait for task creation to finish before deleting it.');
495
+ if (this.deletions.has(taskId)) throw new Error('Deletion is already in progress. Wait for it to finish.');
496
+ const task = this.store.tasks().find(t => t.id === taskId);
497
+ if (!task) return { ok: true };
498
+ if (!['idle', 'interrupted', 'reviewing', 'failed', 'deleting'].includes(task.status)
499
+ || this.active.has(taskId) || this.publishing.has(taskId) || this.reviewWrites.has(taskId) || this.commentWrites.has(taskId)
500
+ || task.pendingReview || this.store.runs().some(run => run.taskId === taskId && run.status === 'queued')) {
501
+ throw new Error('Pause the task and wait for its current work or review action to finish before deleting it. Resolve any uncertain action first.');
502
+ }
503
+ // Persist the reservation before connecting to the engine. A restart or
504
+ // lost response must not let queued instructions reuse a removed worktree.
505
+ this.deletions.add(taskId);
506
+ this.store.updateTask(taskId, { status: 'deleting', pauseVersion: (task.pauseVersion || 0) + 1, error: null });
507
+ try {
508
+ const client = await this.connectReview(this.store.project(task.projectId).path);
509
+ const state = await client.get(`${taskRoute(task.name)}/state`);
510
+ if (!confirmedReviewResponse(200, state, task.name)) throw new Error('The review engine could not confirm this task’s cleanup status.');
511
+ if (!state.closed) {
512
+ const result = await client.post(`${taskRoute(task.name)}/finish`, { force: true });
513
+ if (result?.task !== task.name || result.finished !== true) throw new Error('The review engine did not confirm task deletion.');
514
+ }
515
+ // All file cleanup belongs to the review engine. Drop only Papilot data
516
+ // after an identified closed state or explicit finish acknowledgement.
517
+ for (const [id, item] of this.liveItems) if (item.taskId === taskId) this.liveItems.delete(id);
518
+ for (const [id, request] of this.requests) if (request.taskId === taskId) this.requests.delete(id);
519
+ this.earlyCompletions.delete(taskId); this.earlyRequests.delete(taskId);
520
+ if (task.threadId) this.loaded.delete(task.threadId);
521
+ this.store.deleteTask(taskId);
522
+ return { ok: true };
523
+ } catch (error) {
524
+ this.store.updateTask(taskId, { status: 'deleting', error: 'Deletion has not been confirmed. Retry deletion to finish cleanup. ' + errorText(error) });
525
+ throw error;
526
+ } finally { this.deletions.delete(taskId); this.schedule(); }
527
+ }
528
+ close() { this.closing = true; for (const timer of this.usageTimers.values()) clearTimeout(timer); this.flush(); for (const id of AGENTS) this.agents[id].close(); }
529
+ }
package/src/shared.ts ADDED
@@ -0,0 +1,28 @@
1
+ export type TaskStatus = 'idle' | 'queued' | 'running' | 'waiting_input' | 'building' | 'reviewing' | 'interrupted' | 'failed' | 'uncertain' | 'deleting';
2
+ export interface Project { id: string; name: string; path: string; createdAt: number }
3
+ export interface ReviewSummary { state: string; round: number | null; counts: Record<string, number>; unsent?: number; next?: string; job?: { phase: string; status: string; error?: string; details?: string[] } | null }
4
+ export interface PendingReview { route: string; body: Record<string, unknown>; previousStatus: TaskStatus }
5
+ export interface Task {
6
+ id: string; projectId: string; name: string; title: string; instructions: string;
7
+ worktree: string; threadId: string | null; turnId: string | null; agent?: AgentId; model: string | null; effort?: string | null;
8
+ status: TaskStatus; createdAt: number; updatedAt: number; feedbackCursor: number;
9
+ error: string | null; review: ReviewSummary | null;
10
+ pauseVersion?: number; pendingRunId?: string | null;
11
+ pendingReview?: PendingReview | null;
12
+ }
13
+ export interface Run { id: string; taskId: string; requestKey: string; prompt: string; feedbackCursor?: number; status: 'queued' | 'running' | 'completed' | 'failed' | 'interrupted' | 'uncertain'; createdAt: number; finishedAt: number | null }
14
+ export interface TimelineItem { id: string; taskId: string; type: string; text: string; detail?: string; status?: string; createdAt: number }
15
+ export interface PendingRequest { id: string; taskId: string; method: string; params: Record<string, any>; createdAt: number }
16
+ export type AgentId = 'codex' | 'claude' | 'antigravity';
17
+ export interface AgentModel { id: string; name: string; isDefault: boolean; efforts: string[]; defaultEffort: string | null }
18
+ // The account's included usage: rolling windows (minutes long, reset time in ms) and credits.
19
+ export interface UsageWindow { usedPercent: number; minutes: number | null; resetsAt: number | null }
20
+ export interface Usage { plan: string | null; windows: UsageWindow[]; credits: { unlimited: boolean; balance: number | null } | null; reached: boolean }
21
+ export interface RuntimeStatus { connected: boolean; authenticated: boolean; account: string | null; error: string | null; models: AgentModel[]; usage?: Usage | null }
22
+ export interface Snapshot { projects: Project[]; tasks: Task[]; requests: PendingRequest[]; runtime: RuntimeStatus; runtimes?: Partial<Record<AgentId, RuntimeStatus>>; version: string }
23
+ export interface GitFile { path: string; status: string; staged: boolean; lines?: [number, number]; from?: string }
24
+ export interface GitStatus { branch: string | null; upstream: string | null; ahead: number; behind: number; files: GitFile[]; head: { hash: string; subject: string; date: number } | null; remote?: boolean }
25
+ export interface GitDiff { path: string; diff: string; truncated: boolean }
26
+ export interface ManuscriptBuild { status: 'building' | 'ready' | 'failed'; key?: string; pages?: [number, number][]; builtAt?: number; error?: string; current: boolean }
27
+ export interface Annotation { id: string; projectId: string; key: string; page: number; rect: [number, number, number, number]; rects?: [number, number, number, number][]; comment: string; quote: string; source: { file: string; lines: [number, number]; excerpt?: string } | null; createdAt: number; taskId?: string }
28
+ export interface Manuscript { git: GitStatus; build: ManuscriptBuild; annotations: Annotation[] }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cui Chen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ @font-face{font-family:Review Serif;src:url(/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf)format("opentype");font-weight:400;font-display:swap}@font-face{font-family:Review Serif;src:url(/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf)format("opentype");font-weight:700;font-display:swap}:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--serif:"Review Serif", Palatino, "Palatino Linotype", "Songti SC", Georgia, serif;--sans:-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Noto Sans", sans-serif;--frame:#fff;--bar:#fff;--ink:#171717;--muted:#666;--line:#e5e5e5;--paper:#fff;--accent:#356aa0;--accept:#1d7448;--reject:#b0313b;--comment:#356aa0;--before:#fbeeee;--before-edge:#d17c82;--after:#eef8f1;--after-edge:#68ab82;--head:#fafafa;--shadow:0 1px 2px #0000000f, 0 4px 18px #0000000d;--paper-top:24px;font:14px/1.5 var(--sans);color:var(--ink);background:var(--frame)}*{box-sizing:border-box}body{background:var(--frame);text-rendering:optimizelegibility;grid-template-rows:auto 1fr;height:100dvh;margin:0;display:grid;overflow:hidden}button,input,select,textarea{font:inherit;color:inherit}button{cursor:pointer}button:disabled{cursor:default;opacity:.38}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:3px}[hidden]{display:none!important}.muted{color:var(--muted)}a{color:var(--accent);text-underline-offset:.18em}h1,h2,.brand{font-family:var(--serif);font-weight:400;line-height:1.3}.brand{letter-spacing:.02em;color:var(--ink);font-size:20px;font-variant-caps:small-caps;text-decoration:none}kbd{border:1px solid var(--line);color:var(--muted);background:var(--frame);vertical-align:1px;border-radius:4px;margin-left:6px;padding:2px 4px;font:600 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace}.bar{background:var(--bar);border-bottom:1px solid var(--line);z-index:3;align-items:center;gap:18px;min-height:62px;padding:10px 24px;display:flex}.identity{flex:1;align-items:baseline;gap:10px;min-width:0;display:flex}.identity h1{letter-spacing:.02em;white-space:nowrap;text-overflow:ellipsis;margin:0;font-size:20px;font-variant-caps:small-caps;overflow:hidden}.identity span{white-space:nowrap;font-size:13px}.meter{color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums;align-items:center;gap:10px;font-size:12px;display:flex}.track{background:var(--line);width:120px;height:2px;overflow:hidden}.track i{background:#999;width:0;height:100%;transition:width .3s;display:block}.bar-end{align-items:center;gap:8px;display:flex}.status:empty{display:none}.status{background:var(--head);color:var(--muted);white-space:nowrap;border-radius:999px;padding:3px 9px;font-size:12px}.status[data-state=build_failed],.status[data-state=offline]{color:var(--reject)}.ghost{color:var(--muted);background:0 0;border:1px solid #0000;border-radius:7px;padding:5px 9px;font-size:13px}.ghost:hover{border-color:var(--line);color:var(--ink);text-underline-offset:.18em;text-decoration:underline}.accept-all,.reject-all{color:var(--ink);border-color:var(--line);border-radius:999px;box-shadow:0 1px 3px #0000000d}.overview-link{color:var(--accent);flex:none;text-decoration:none}.ghost.round{border-color:var(--line);border-radius:50%;width:30px;height:30px;padding:0}#paper{padding:var(--paper-top) 24px 90px;overflow-anchor:none;outline:none;scroll-padding:70px 0 90px;overflow:auto}.document{max-width:980px;margin:0 auto}.empty{text-align:center;color:var(--muted);padding:60px 16px}.part+.part{margin-top:30px}.part-title{color:var(--muted);letter-spacing:.02em;margin:14px 0 18px;font-size:19px;font-variant-caps:small-caps;font-weight:400}.part-title .number{font-variant-numeric:tabular-nums;margin-right:8px}.sheet{background:var(--paper);box-shadow:var(--shadow);border-radius:3px;padding:10px 0}.context,.side{line-height:0}.document img{width:100%;height:auto;display:block}.fold{color:#9a9d98;letter-spacing:4px;background:repeating-linear-gradient(90deg,#0000 0 6px,#ececec 6px 12px) 50%/100% 1px no-repeat;justify-content:center;align-items:center;height:22px;margin:4px 0;font-size:16px;display:flex}.change{color:var(--ink);outline:none;scroll-margin:70px;position:relative}.change.standalone{background:var(--paper);box-shadow:var(--shadow);border-radius:3px;margin-bottom:26px}.change.inline{margin:6px 0}.change-head{background:var(--head);color:var(--muted);border-top:1px solid var(--line);border-bottom:1px solid var(--line);align-items:baseline;gap:10px;padding:8px 12px;font-size:12px;line-height:1.6;display:flex}.change-number{color:var(--ink);white-space:nowrap;font-weight:400}.change-where{text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;font:11px/1.8 ui-monospace,SFMono-Regular,Menlo,monospace;overflow:hidden}.change-kind{white-space:nowrap}.badge{white-space:nowrap;font-weight:600}.badge:empty{display:none}.link{color:var(--accent);background:0 0;border:0;padding:0 2px;font-size:12px}.link.active{font-weight:600;text-decoration:underline}.edits{white-space:nowrap}.side{border-left:2px solid #0000;position:relative}.side.before{background:var(--before);border-left-color:var(--before-edge)}.side.after{background:var(--after);border-left-color:var(--after-edge)}.side.after.identical{border-left-color:var(--line);background:0 0}.side img{mix-blend-mode:multiply}.side:after{font:10px/1 var(--sans);letter-spacing:.06em;text-transform:uppercase;color:var(--before-edge);opacity:.9;pointer-events:none;position:absolute;top:4px;right:8px}.side.before:after{content:"before"}.side.after:after{content:"after";color:var(--after-edge)}.side.after.identical:after,.change[data-resolved] .side:after{content:none}.caption{color:var(--muted);margin:0;padding:4px 12px 8px;font-size:12px}.change.current{box-shadow:0 0 0 2px var(--accent);z-index:1}.change.current .change-head{background:color-mix(in srgb, var(--accent) 8%, var(--head));color:var(--ink)}.change[data-state=accepted] .badge{color:var(--accept)}.change[data-state=rejected] .badge{color:var(--reject)}.change[data-state=commented] .badge,.change[data-state=mixed] .badge{color:var(--comment)}.change[data-resolved=after] .side.before,.change[data-resolved=before] .side.after:not(.identical){display:none}.change[data-resolved] .side{background:0 0;border-left-color:#0000}.change[data-resolved] .caption{display:none}.remark,.histories{padding:8px 14px;font-size:13px;line-height:1.55}.remark{background:color-mix(in srgb, var(--comment) 8%, #fff);border-top:1px solid var(--line);color:var(--ink)}.remark p,.histories p{white-space:pre-wrap;margin:0}.histories{border-bottom:1px solid var(--line);color:var(--muted);background:#f6f7fb}.source{background:var(--paper);border-top:1px solid var(--line);padding:10px 14px 12px;line-height:1.5}.source-unit+.source-unit{margin-top:12px}.source-title{color:#555;align-items:center;gap:8px;margin-bottom:6px;font:11px ui-monospace,SFMono-Regular,Menlo,monospace;display:flex}.unit-actions{font:13px var(--sans);align-items:center;gap:2px;margin-left:auto;display:flex}.unit-actions button{border:1px solid var(--line);background:var(--paper);border-radius:0;padding:1px 8px;font-weight:600}.unit-actions button:not(:disabled):hover{background:var(--frame)}.unit-actions .accept{color:var(--accept)}.unit-actions .reject{color:var(--reject)}.unit-actions .comment{color:var(--comment)}.unit-state{font-weight:600}.source-unit[data-state=accepted] .unit-state{color:var(--accept)}.source-unit[data-state=rejected] .unit-state{color:var(--reject)}.source-unit[data-state=commented] .unit-state{color:var(--comment)}.source-code{border:1px solid var(--line);white-space:pre-wrap;overflow-wrap:anywhere;color:#2b2f33;background:#fff;border-radius:5px;max-height:420px;margin:0;padding:10px 12px;font:12.5px/1.65 ui-monospace,SFMono-Regular,Menlo,monospace;overflow:auto}.source-code del{color:#8e1d28;background:#fadfe1;-webkit-text-decoration:line-through #c9636b;text-decoration:line-through #c9636b}.source-code ins{color:#11603a;background:#d6f0df;text-decoration:none}.banner{background:color-mix(in srgb, var(--accent) 6%, var(--paper));border:1px solid var(--line);border-radius:8px;max-width:980px;margin:0 auto 14px;padding:10px 14px;font-size:13px}.banner p{margin:3px 0}.banner .details{background:var(--bar);white-space:pre-wrap;border-radius:5px;max-height:220px;margin:6px 0 2px;padding:8px 10px;font:11.5px/1.5 ui-monospace,Menlo,monospace;overflow:auto}.banner summary{cursor:pointer;color:var(--muted)}.waiting{text-align:center;padding:90px 16px}.waiting p{margin:6px 0}.pulse{background:var(--accent);border-radius:50%;width:12px;height:12px;margin:0 auto 18px;animation:1.6s ease-in-out infinite pulse}@keyframes pulse{0%,to{opacity:.25;transform:scale(.8)}50%{opacity:1;transform:scale(1.15)}}.task-list{background:var(--bar);border:1px solid var(--line);box-shadow:var(--shadow);border-radius:8px;margin:0;padding:0;list-style:none}.task-list li+li{border-top:1px solid var(--line)}.index .document{max-width:880px}.overview-heading{margin:32px 0}.overview-heading h1{letter-spacing:.02em;margin:0 0 6px;font-size:34px;font-variant-caps:small-caps}.overview-heading p{margin:6px 0}.overview-summary{color:var(--muted);font-size:13px}.task-tools{gap:10px;margin-bottom:16px;display:flex}.task-tools input,.task-tools select{font:inherit;color:var(--ink);background:var(--bar);border:1px solid var(--line);border-radius:7px;padding:9px 12px}.task-tools input{flex:1;min-width:0}.task-tools input::placeholder{color:var(--muted)}.task-card{color:var(--ink);border-radius:7px;padding:24px 20px;text-decoration:none;display:block}.task-card:hover{background:var(--head)}.task-card:focus-visible,.overview-link:focus-visible,.task-tools :focus-visible{outline:2px solid var(--accent);outline-offset:2px}.task-heading{justify-content:space-between;align-items:baseline;gap:10px;display:flex}.task-heading h2{overflow-wrap:anywhere;margin:0;font-size:21px;font-weight:400}.task-meta{color:var(--muted);flex-wrap:wrap;gap:4px 14px;margin-top:5px;font-size:12px;display:flex}.task-name{overflow-wrap:anywhere;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.task-progress{color:var(--muted);align-items:center;gap:10px;margin-top:14px;font-size:12px;display:flex}.task-progress .track{width:140px}.task-footer{justify-content:space-between;align-items:baseline;gap:10px;margin-top:9px;font-size:12px;display:flex}.task-counts{color:var(--muted);flex-wrap:wrap;gap:4px 14px;display:flex}.task-counts [data-kind=pending]{color:var(--accent);font-weight:600}.task-counts [data-kind=commented]{color:var(--comment)}.task-open{color:var(--accent);flex:none;font-weight:600}.actions{z-index:4;background:var(--bar);border:1px solid var(--line);max-width:calc(100vw - 24px);box-shadow:var(--shadow);border-radius:12px;align-items:center;gap:2px;padding:5px;display:flex;position:fixed;bottom:22px;left:50%;transform:translate(-50%)}.actions button{white-space:nowrap;background:0 0;border:0;border-radius:8px;padding:9px 14px;font-size:14px}.actions button:not(:disabled):hover{background:var(--head)}.actions .nav{color:var(--muted);padding:6px 12px;font-size:20px;line-height:1}.actions .accept{color:var(--accept)}.actions .reject{color:var(--reject)}.actions .comment{color:var(--comment)}.actions .skip{font-weight:600}.actions .undo{color:var(--muted);border-left:1px solid var(--line);border-radius:0 8px 8px 0;margin-left:4px;font-size:13px}.actions .working{opacity:.55}.position{text-align:center;min-width:52px;color:var(--muted);font-variant-numeric:tabular-nums;font-size:12px}.complete{color:var(--muted);white-space:nowrap;padding:0 14px;font-size:13px}.change.current>.change-head{top:calc(-1 * var(--paper-top));z-index:4;position:sticky}.change-head .dock{flex:none;align-items:center;margin:0;display:flex}.change-head .actions{background:var(--bar);border:1px solid var(--line);border-radius:10px;gap:0;max-width:none;padding:3px;position:static;transform:none;box-shadow:0 1px 2px #00000014,0 6px 20px #0000001f}.change-head .actions button{padding:4px 8px;font-size:13px;line-height:1.5}.change-head .actions .nav{padding:2px 8px;font-size:17px}.change-head .actions .undo{margin-left:3px;padding:4px 10px;font-size:12px}.change-head .position{min-width:40px;font-size:11.5px}.change-head .complete{padding:0 8px;font-size:12px}.change-head .composer{width:min(380px,100vw - 24px);position:absolute;inset:calc(100% + 6px) 8px auto auto;transform:none}.composer{z-index:5;background:var(--bar);border:1px solid var(--line);border-radius:12px;width:min(540px,100vw - 24px);padding:12px;position:fixed;bottom:84px;left:50%;transform:translate(-50%);box-shadow:0 6px 24px #00000014}.composer label{color:var(--ink);margin-bottom:6px;font-size:17px;font-weight:400;display:block}.composer textarea{resize:vertical;border:1px solid var(--line);background:var(--paper);color:#1f2328;border-radius:7px;width:100%;min-height:84px;padding:8px 10px}.composer textarea:focus{outline:2px solid color-mix(in srgb, var(--comment) 45%, transparent);border-color:#0000}.composer-row{justify-content:space-between;align-items:center;margin-top:8px;font-size:12px;display:flex}.composer-row kbd{margin-left:0}.composer button{border:1px solid var(--line);background:var(--paper);color:var(--ink);border-radius:7px;padding:6px 16px}.toast{z-index:6;border:1px solid var(--line);background:var(--paper);max-width:min(640px,100vw - 24px);color:var(--ink);white-space:pre-wrap;padding:9px 14px;font-size:13px;position:fixed;top:74px;left:50%;transform:translate(-50%);box-shadow:0 3px 12px #0000000f}.toast.error{background:#7f2b31}dialog{border:1px solid var(--line);box-shadow:var(--shadow);background:var(--bar);color:var(--ink);border-radius:12px;max-width:min(460px,100vw - 32px);padding:20px 24px}dialog::backdrop{background:#00000040}dialog h2{margin:0 0 12px;font-size:23px}dialog dl{grid-template-columns:auto 1fr;gap:8px 16px;margin:0 0 12px;font-size:13px;display:grid}dialog dt{white-space:nowrap}dialog dt kbd{margin:0 2px 0 0}dialog dd{margin:0}dialog form{text-align:right}dialog button{border:1px solid var(--line);background:var(--frame);border-radius:7px;padding:5px 14px}@media (width<=720px){.bar{grid-template-columns:auto 1fr auto;gap:8px;padding:12px 16px;display:grid}.identity{grid-area:1/1/auto/-1}.bar .meter{justify-self:center;font-size:11px}.bar-end{gap:2px}.bar .ghost{padding:4px 2px;font-size:12px}.side{min-height:20px}.track{width:70px}.actions button{padding:9px}.change-head{flex-wrap:wrap}.change-head .dock{flex-basis:100%;order:9;justify-content:center;margin:4px 0 2px}.change-head .composer{width:calc(100vw - 24px);right:0}:root{--paper-top:10px}#paper{padding:var(--paper-top) 6px 90px}#paper.index{padding:10px 14px 40px}.overview-heading{margin-top:16px}.task-tools{flex-wrap:wrap}.task-tools input{flex-basis:100%}.task-tools select{width:100%}.task-card{padding:14px}.task-heading{flex-direction:column;gap:6px}}@media (prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important;animation:none!important}}body{display:block}#root{grid-template-rows:auto 1fr;height:100dvh;display:grid;overflow:hidden}.review-body{min-height:0;position:relative}.review-body>#paper{scrollbar-width:none;height:100%;padding-right:54px}.review-body>#paper::-webkit-scrollbar{display:none}.change-map{z-index:5;border:1px solid var(--line);touch-action:none;-webkit-user-select:none;user-select:none;background:#fafafa;border-radius:7px;width:22px;position:absolute;top:12px;bottom:12px;right:18px;box-shadow:0 1px 4px #0000000d}.change-map-viewport{cursor:grab;background:#6a6f741c;border:1px solid #c6c9cc;border-radius:5px;position:absolute;left:0;right:0}.change-map-viewport:active{cursor:grabbing}.change-map-mark{--mark:#b18a3c;background:var(--mark);border:0;border-radius:2px;width:calc(100% - 10px);min-height:0;padding:0;position:absolute;left:5px}.change-map-mark[data-state=accepted]{--mark:var(--accept)}.change-map-mark[data-state=rejected]{--mark:var(--reject)}.change-map-mark[data-state=commented]{--mark:var(--comment)}.change-map-mark[data-state=mixed]{--mark:#796195}.change-map-mark[aria-current]{outline:2px solid var(--ink);outline-offset:2px}.change-map-mark:hover,.change-map-mark:focus-visible{z-index:1;outline:2px solid var(--accent);outline-offset:2px}.change-map-tooltip{border:1px solid var(--line);background:var(--paper);width:max-content;max-width:min(360px,100vw - 64px);box-shadow:var(--shadow);color:var(--ink);font:12px/1.5 var(--sans);text-align:left;overflow-wrap:anywhere;pointer-events:none;visibility:hidden;border-radius:7px;padding:7px 10px;position:absolute;top:0;right:calc(100% + 12px)}.change-map-mark:hover .change-map-tooltip,.change-map-mark:focus-visible .change-map-tooltip{visibility:visible}@media (width<=720px){.review-body>#paper{padding-right:46px}.change-map{width:18px;right:16px}.change-map-mark{width:calc(100% - 6px);left:3px}.change-head .actions{flex-wrap:wrap;justify-content:center;max-width:100%}.change-head .actions button{padding:4px 6px;font-size:12px}.change-head .actions .nav{padding:2px 6px}.change-head .position{min-width:32px}}.pdf-strip{isolation:isolate;width:100%;position:relative;container-type:inline-size}.pdf-strip img{-webkit-user-select:none;user-select:none;pointer-events:none;position:absolute;inset:0}.pdf-text{color:#0000;-webkit-user-select:text;user-select:text;cursor:text;line-height:1;position:absolute;inset:0;overflow:hidden}.pdf-text-line{white-space:pre}.pdf-glyph{white-space:pre;transform-origin:0 0;color:#0000;font-family:Times New Roman,serif;font-weight:400;line-height:1;position:absolute}.pdf-glyph::selection{color:#0000;background:#437ee759}.copy-reference{white-space:nowrap}.change-head{flex-wrap:wrap}.change-head .change-where{min-width:160px}.document .source-unit button{line-height:1.5}.help-backdrop{z-index:8;background:#00000040;place-items:center;display:grid;position:fixed;inset:0}.help-backdrop dialog{margin:0;position:static}.composer{line-height:1.5}@media (width<=900px){.change-head .dock{flex-basis:100%;order:9;justify-content:center;margin:4px 0 2px}}