papilot 0.2.0 → 0.4.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 (34) hide show
  1. package/README.md +3 -1
  2. package/README.zh-CN.md +3 -1
  3. package/bin/doctor.mjs +11 -0
  4. package/dist/assets/index-BB0zC0wj.css +1 -0
  5. package/dist/assets/index-DTScOfwx.js +206 -0
  6. package/dist/assets/{math-Ebyrobf9.js → math-C3k2egGJ.js} +7 -7
  7. package/dist/assets/{pdf-view-BhJZr4qw.js → pdf-view-Df0q8hSk.js} +1 -1
  8. package/dist/index.html +2 -2
  9. package/package.json +5 -2
  10. package/src/commit.ts +23 -0
  11. package/src/conversation.ts +12 -1
  12. package/src/notes.ts +1 -1
  13. package/src/server/access.ts +170 -0
  14. package/src/server/accounts.ts +226 -0
  15. package/src/server/app.ts +298 -0
  16. package/src/server/frontend.ts +1 -1
  17. package/src/server/main.ts +21 -194
  18. package/src/server/manuscript.ts +95 -14
  19. package/src/server/paths.ts +4 -0
  20. package/src/server/review.ts +2 -1
  21. package/src/server/security.ts +20 -0
  22. package/src/server/store.ts +5 -0
  23. package/src/server/workspace.ts +192 -25
  24. package/src/shared.ts +23 -5
  25. package/vendor/latex-review/dist/web/assets/index-Bg-VqnPa.css +1 -0
  26. package/vendor/latex-review/dist/web/assets/{index-DqkrTw0t.js → index-xLeqYBJt.js} +3 -3
  27. package/vendor/latex-review/dist/web/index.html +2 -2
  28. package/vendor/latex-review/src/render/pdf.ts +35 -1
  29. package/vendor/latex-review/src/web/review.tsx +18 -8
  30. package/vendor/latex-review/src/web/style.css +24 -0
  31. package/vendor/latex-review/src/web/zh-CN.ts +2 -0
  32. package/dist/assets/index-CqhlgnOu.css +0 -1
  33. package/dist/assets/index-Czf1eCno.js +0 -185
  34. package/vendor/latex-review/dist/web/assets/index-Db7pq4Cj.css +0 -1
@@ -7,8 +7,9 @@ import path from 'node:path';
7
7
  import { promisify } from 'node:util';
8
8
  import { pathToFileURL } from 'node:url';
9
9
  import { ENGINE } from './paths.ts';
10
- import type { Annotation, GitStatus, ManuscriptBuild } from '../shared.ts';
10
+ import type { Annotation, GitFile, GitStatus, ManuscriptBuild } from '../shared.ts';
11
11
  import { notesInstructions } from '../notes.ts';
12
+ import { ignorePattern } from '../commit.ts';
12
13
 
13
14
  type Rect = [number, number, number, number];
14
15
 
@@ -57,8 +58,26 @@ export async function gitStatus(root: string): Promise<GitStatus> {
57
58
  git(root, ['remote']).catch(() => ''),
58
59
  ]);
59
60
  const [hash, subject, time] = head.trim().split('\0');
60
- return { ...parseStatus(status, numstat), head: hash ? { hash, subject, date: Number(time) * 1000 } : null, remote: Boolean(remotes.trim()) };
61
+ const parsed = parseStatus(status, numstat), dir = gitDir(root);
62
+ // A merge in progress names the files whose conflict markers remain.
63
+ const merging = dir && fs.existsSync(path.join(dir, 'MERGE_HEAD'))
64
+ ? { message: readText(path.join(dir, 'MERGE_MSG')).split('\n')[0] || 'Merge', conflicts: parsed.files.filter(file => file.status === 'U' && conflictMarkers(root, file.path)).map(file => file.path) }
65
+ : null;
66
+ return { ...parsed, head: hash ? { hash, subject, date: Number(time) * 1000 } : null, remote: Boolean(remotes.trim()), merging };
61
67
  }
68
+ const readText = (file: string) => { try { return fs.readFileSync(file, 'utf8'); } catch { return ''; } };
69
+ // The repository's Git directory: .git, or where a .git file points.
70
+ function gitDir(root: string) {
71
+ try {
72
+ const dot = path.join(root, '.git');
73
+ return fs.statSync(dot).isFile() ? path.resolve(root, readText(dot).replace(/^gitdir:\s*/, '').trim()) : dot;
74
+ } catch { return null; }
75
+ }
76
+ export function mergeInProgress(root: string) {
77
+ const dir = gitDir(root);
78
+ return Boolean(dir && fs.existsSync(path.join(dir, 'MERGE_HEAD')));
79
+ }
80
+ export const conflictMarkers = (root: string, file: string) => /^(?:<{7}|>{7})(?: |$)/m.test(readText(path.join(root, file)));
62
81
 
63
82
  // Paths from the browser are matched against the changed files, and Git reads
64
83
  // them literally, so a name cannot act as a pathspec pattern.
@@ -69,17 +88,51 @@ const DIFF_LIMIT = 400_000;
69
88
  export async function gitDiff(root: string, file: string) {
70
89
  const entry = (await gitStatus(root)).files.find(item => item.path === file);
71
90
  if (!entry) throw Object.assign(new Error('That file has no uncommitted changes.'), { statusCode: 404 });
72
- const diff = entry.status === '?'
73
- // `git diff --no-index` exits with 1 when the files differ.
74
- ? await exec('git', ['diff', '--no-index', '--no-color', '--', '/dev/null', file], { cwd: root, maxBuffer: 64 * 1024 * 1024 }).then(r => r.stdout, (e: { stdout?: string }) => e.stdout || '')
75
- : await git(root, ['diff', '--no-color', '--find-renames', 'HEAD', '--', ...(entry.from ? [entry.from] : []), file], literal);
91
+ const diff = await fileDiff(root, entry);
76
92
  return { path: file, diff: diff.slice(0, DIFF_LIMIT), truncated: diff.length > DIFF_LIMIT };
77
93
  }
94
+ function fileDiff(root: string, entry: GitFile) {
95
+ return entry.status === '?'
96
+ // `git diff --no-index` exits with 1 when the files differ.
97
+ ? exec('git', ['diff', '--no-index', '--no-color', '--', '/dev/null', entry.path], { cwd: root, maxBuffer: 64 * 1024 * 1024 }).then(r => r.stdout, (e: { stdout?: string }) => e.stdout || '')
98
+ : git(root, ['diff', '--no-color', '--find-renames', 'HEAD', '--', ...(entry.from ? [entry.from] : []), entry.path], literal);
99
+ }
100
+
101
+ // What a commit message is written from: the chosen files' diffs, each
102
+ // shortened so that a large change still fits, and the repository's recent
103
+ // commit subjects, whose style and language the message follows.
104
+ export async function commitChanges(root: string, paths: string[]) {
105
+ const files = (await gitStatus(root)).files.filter(item => paths.includes(item.path));
106
+ if (!files.length) throw new Error('Select at least one changed file.');
107
+ let patch = '';
108
+ for (const [index, file] of files.entries()) {
109
+ if (patch.length > 40_000) { patch += `\n[${files.length - index} more changed files are not shown.]\n`; break; }
110
+ const diff = await fileDiff(root, file);
111
+ patch += diff.length > 8_000 ? `${diff.slice(0, 8_000)}\n[The rest of this file's diff is not shown.]\n` : diff;
112
+ }
113
+ const recent = (await git(root, ['log', '-8', '--format=%s']).catch(() => '')).split('\n').filter(Boolean);
114
+ return { files, patch, recent };
115
+ }
116
+
117
+ // Adds the patterns for the chosen untracked build files to the repository's
118
+ // .gitignore, one per kind of file and each only once, so they leave the list
119
+ // of changes. The .gitignore is then a change to commit like any other.
120
+ export async function gitIgnore(root: string, paths: string[]) {
121
+ const patterns = [...new Set((await gitStatus(root)).files.filter(item => item.status === '?' && paths.includes(item.path)).map(item => ignorePattern(item.path)).filter((pattern): pattern is string => Boolean(pattern)))];
122
+ if (!patterns.length) throw new Error('None of the chosen files is build output that can be ignored.');
123
+ const file = path.join(root, '.gitignore');
124
+ const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
125
+ const present = new Set(current.split(/\r?\n/).map(line => line.trim()));
126
+ const missing = patterns.filter(pattern => !present.has(pattern) && !present.has('/' + pattern));
127
+ if (missing.length) fs.writeFileSync(file, `${current}${current && !current.endsWith('\n') ? '\n' : ''}${current.trim() ? '\n' : ''}# Build output\n${missing.join('\n')}\n`);
128
+ return gitStatus(root);
129
+ }
78
130
 
79
131
  // Commit exactly the chosen files as they are on disk. Changes staged
80
132
  // elsewhere for other files stay staged and out of this commit.
81
133
  export async function gitCommit(root: string, message: string, paths: string[]) {
82
- const files = (await gitStatus(root)).files.filter(item => paths.includes(item.path));
134
+ const status = await gitStatus(root), files = status.files.filter(item => paths.includes(item.path));
135
+ if (status.merging) throw new Error('Finish or abort the merge before committing other changes.');
83
136
  if (!files.length) throw new Error('Select at least one changed file to commit.');
84
137
  if (files.some(item => item.status === 'U')) throw new Error('Resolve the merge conflicts in the selected files before committing.');
85
138
  // A rename is already staged, so only its new path is added; the commit
@@ -91,13 +144,17 @@ export async function gitCommit(root: string, message: string, paths: string[])
91
144
 
92
145
  // Fetch, fast-forward pull and push use the host's Git credentials. Nothing
93
146
  // may prompt, so a missing credential fails instead of hanging.
94
- export async function gitRemote(root: string, action: 'fetch' | 'pull' | 'push') {
147
+ export async function gitRemote(root: string, action: 'fetch' | 'pull' | 'merge' | 'push') {
95
148
  const status = await gitStatus(root);
96
149
  if (!status.remote) throw new Error('This repository has no remote.');
150
+ if (status.merging && action !== 'fetch') throw new Error('Finish or abort the merge first.');
97
151
  if (action === 'fetch') await gitAction(root, ['fetch', '--prune'], 120_000);
98
- else if (action === 'pull') {
152
+ else if (action === 'pull' || action === 'merge') {
99
153
  if (!status.upstream) throw new Error('This branch does not track a remote branch yet.');
100
- await gitAction(root, ['pull', '--ff-only'], 120_000);
154
+ // Pull only fast-forwards. Merge also joins a branch that has diverged
155
+ // from its upstream; conflicts leave the merge in progress to resolve.
156
+ if (action === 'pull') await gitAction(root, ['pull', '--ff-only'], 120_000);
157
+ else await gitAction(root, ['pull', '--no-rebase', '--ff', '--no-edit'], 120_000).catch(error => { if (!mergeInProgress(root)) throw error; });
101
158
  } else if (status.upstream) await gitAction(root, ['push'], 120_000);
102
159
  else {
103
160
  if (!status.branch) throw new Error('Check out a branch before pushing.');
@@ -106,6 +163,30 @@ export async function gitRemote(root: string, action: 'fetch' | 'pull' | 'push')
106
163
  }
107
164
  }
108
165
 
166
+ // A merge in progress ends with its commit once no conflict markers remain,
167
+ // or is abandoned, restoring the files as they were before it.
168
+ export async function gitFinishMerge(root: string) {
169
+ const status = await gitStatus(root);
170
+ if (!status.merging) throw new Error('No merge is in progress.');
171
+ if (status.merging.conflicts.length) throw new Error(`Resolve the conflicts in ${status.merging.conflicts.join(', ')} first.`);
172
+ const resolved = status.files.filter(item => item.status === 'U').map(item => item.path);
173
+ if (resolved.length) await gitAction(root, ['add', '-A', '--', ...resolved]);
174
+ await gitAction(root, ['commit', '--no-edit']);
175
+ return (await git(root, ['rev-parse', '--short', 'HEAD'])).trim();
176
+ }
177
+ export async function gitAbortMerge(root: string) {
178
+ if (!mergeInProgress(root)) throw new Error('No merge is in progress.');
179
+ await gitAction(root, ['merge', '--abort']);
180
+ }
181
+ // What an agent needs to resolve a merge's conflicts: the files with markers
182
+ // and the commits on each side.
183
+ export async function mergeConflicts(root: string) {
184
+ const status = await gitStatus(root);
185
+ if (!status.merging) throw new Error('No merge is in progress.');
186
+ const subjects = async (range: string) => (await git(root, ['log', '-12', '--format=%s', range]).catch(() => '')).split('\n').filter(Boolean);
187
+ return { files: status.merging.conflicts, branch: status.branch || 'HEAD', upstream: status.upstream || 'the remote branch', ours: await subjects('MERGE_HEAD..HEAD'), theirs: await subjects('HEAD..MERGE_HEAD') };
188
+ }
189
+
109
190
  // Git's own message, without the usage noise, becomes the error the user sees.
110
191
  async function gitAction(root: string, args: string[], timeout = 60_000) {
111
192
  try { return (await exec('git', args, { cwd: root, timeout, env: { ...process.env, ...literal, GIT_TERMINAL_PROMPT: '0' }, maxBuffer: 64 * 1024 * 1024 })).stdout; }
@@ -129,13 +210,13 @@ export async function workingTree(root: string) {
129
210
  }
130
211
 
131
212
  async function engine() {
132
- const [{ Builder }, { Repo }, { loadProject }, { mupdf, PdfDocument, containsCenter }] = await Promise.all([
213
+ const [{ Builder }, { Repo }, { loadProject }, { mupdf, PdfDocument, containsCenter, walkText }] = await Promise.all([
133
214
  import(pathToFileURL(path.join(ENGINE, 'src/core/build.ts')).href),
134
215
  import(pathToFileURL(path.join(ENGINE, 'src/core/git.ts')).href),
135
216
  import(pathToFileURL(path.join(ENGINE, 'src/core/config.ts')).href),
136
217
  import(pathToFileURL(path.join(ENGINE, 'src/render/pdf.ts')).href),
137
218
  ]);
138
- return { Builder, Repo, loadProject, mupdf, PdfDocument, containsCenter };
219
+ return { Builder, Repo, loadProject, mupdf, PdfDocument, containsCenter, walkText };
139
220
  }
140
221
 
141
222
  // `synctex edit` maps a PDF position (big points from the top-left corner) to
@@ -212,7 +293,7 @@ export function sourceExcerpt(file: string, [start, end]: [number, number], limi
212
293
  // the page's top left. pdf.js lays a whole line out in a stand-in font and
213
294
  // drifts from the printed words mid-line; per-word runs cannot drift.
214
295
  export async function pdfWords(pdf: string, page: number) {
215
- const { PdfDocument } = await engine();
296
+ const { PdfDocument, walkText } = await engine();
216
297
  const document = new PdfDocument(pdf);
217
298
  try {
218
299
  if (!Number.isInteger(page) || page < 0 || page >= document.length) throw Object.assign(new Error('That page is not part of the manuscript.'), { statusCode: 400 });
@@ -220,7 +301,7 @@ export async function pdfWords(pdf: string, page: number) {
220
301
  const words: { text: string; left: number; right: number; baseline: number; size: number; eol: boolean }[] = [];
221
302
  let word: (typeof words)[number] | null = null;
222
303
  const text = raw.toStructuredText('preserve-whitespace');
223
- text.walk({
304
+ walkText(text, {
224
305
  beginLine: () => { word = null; },
225
306
  onChar: (c: string, origin: [number, number], _font: unknown, size: number, quad: number[]) => {
226
307
  if (/\s/u.test(c)) { word = null; return; }
@@ -8,4 +8,8 @@ export const VERSION: string = JSON.parse(fs.readFileSync(path.join(ROOT, 'packa
8
8
  export const PORT = Number(process.env.PAPILOT_PORT || 7274);
9
9
  export const PUBLIC_ORIGIN = process.env.PAPILOT_PUBLIC_ORIGIN || '';
10
10
  export const OWNER = process.env.PAPILOT_TAILSCALE_LOGIN || '';
11
+ // The public URL where invited people sign in with passkeys, and the local
12
+ // port its proxy (such as Tailscale Funnel) forwards to.
13
+ export const ACCOUNT_ORIGIN = (process.env.PAPILOT_ACCOUNT_ORIGIN || '').replace(/\/+$/, '');
14
+ export const ACCOUNT_PORT = Number(process.env.PAPILOT_ACCOUNT_PORT || 7275);
11
15
  export const CODEX = process.env.PAPILOT_CODEX_BIN || (fs.existsSync(path.join(ROOT, 'node_modules/.bin/codex')) ? path.join(ROOT, 'node_modules/.bin/codex') : 'codex');
@@ -34,7 +34,8 @@ export function annotateReviewState(value: unknown, tasks: readonly Task[]): unk
34
34
  else if (task.status === 'building') decisionLock = 'Wait for the PDF build before accepting, rejecting, or undoing changes. You can still add comments.';
35
35
  else if (['running', 'waiting_input', 'queued'].includes(task.status)) decisionLock = 'Pause this task in Papilot before accepting, rejecting, or undoing changes. You can still add comments.';
36
36
  else if (task.pendingReview) decisionLock = 'Wait for the current review action before accepting, rejecting, or undoing changes. You can still add comments.';
37
- return { ...state, decisionLock, commentLock, ...(task ? { workspace: { status: task.status, unsent: task.review?.unsent ?? 0, comments: task.review?.comments ?? 0, accepted: task.review?.files?.length ?? 0 } } : {}) };
37
+ // Changes the task has committed are in the manuscript too.
38
+ return { ...state, decisionLock, commentLock, ...(task ? { workspace: { status: task.status, unsent: task.review?.unsent ?? 0, comments: task.review?.comments ?? 0, accepted: task.review?.files?.length || (task.committed ? 1 : 0) } } : {}) };
38
39
  }
39
40
 
40
41
  export function allowedReviewRoute(route: string, method: string): boolean {
@@ -13,3 +13,23 @@ export function authorize(headers: IncomingHttpHeaders, remoteAddress: string |
13
13
  if (headers.origin && headers.origin !== publicOrigin) return 'The request origin does not match.';
14
14
  return null;
15
15
  }
16
+
17
+ // The public sign-in URL needs its own port, an exact HTTPS origin (plain HTTP
18
+ // only on localhost, for testing), and must not be the tailnet endpoint, whose
19
+ // requests are trusted as the owner.
20
+ export function accountProblem(accountOrigin: string, accountPort: number, port: number, publicOrigin: string): string | null {
21
+ if (!accountOrigin) return null;
22
+ let url: URL;
23
+ try { url = new URL(accountOrigin); } catch { return `PAPILOT_ACCOUNT_ORIGIN is not a URL: ${accountOrigin}`; }
24
+ if (url.origin !== accountOrigin) return `PAPILOT_ACCOUNT_ORIGIN must be an origin without a path, such as ${url.origin}.`;
25
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && url.hostname === 'localhost')) return 'PAPILOT_ACCOUNT_ORIGIN must use https://.';
26
+ if (!Number.isInteger(accountPort) || accountPort < 1 || accountPort > 65535 || accountPort === port) return `PAPILOT_ACCOUNT_PORT must be a free port other than ${port}.`;
27
+ if (publicOrigin && new URL(publicOrigin).host === url.host) return 'PAPILOT_ACCOUNT_ORIGIN must differ from PAPILOT_PUBLIC_ORIGIN, the tailnet address; use another port, such as :10000.';
28
+ return null;
29
+ }
30
+ // HSTS on the public address covers every port of its host name, so it is
31
+ // left off when the tailnet endpoint on that host uses plain HTTP.
32
+ export function accountHsts(accountOrigin: string, publicOrigin: string) {
33
+ const account = new URL(accountOrigin);
34
+ return account.protocol === 'https:' && !(publicOrigin.startsWith('http:') && new URL(publicOrigin).hostname === account.hostname);
35
+ }
@@ -17,6 +17,11 @@ export class Store extends EventEmitter {
17
17
  CREATE TABLE IF NOT EXISTS items(id TEXT PRIMARY KEY, task_id TEXT NOT NULL, created INTEGER NOT NULL, data TEXT NOT NULL);
18
18
  CREATE TABLE IF NOT EXISTS deleted_tasks(id TEXT PRIMARY KEY, deleted INTEGER NOT NULL);
19
19
  CREATE TABLE IF NOT EXISTS annotations(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, data TEXT NOT NULL);
20
+ CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY, data TEXT NOT NULL);
21
+ CREATE TABLE IF NOT EXISTS credentials(id TEXT PRIMARY KEY, user_id TEXT NOT NULL, data TEXT NOT NULL);
22
+ CREATE TABLE IF NOT EXISTS invites(token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires INTEGER NOT NULL, data TEXT NOT NULL);
23
+ CREATE TABLE IF NOT EXISTS sessions(token_hash TEXT PRIMARY KEY, id TEXT UNIQUE NOT NULL, user_id TEXT NOT NULL, expires INTEGER NOT NULL, data TEXT NOT NULL);
24
+ CREATE TABLE IF NOT EXISTS members(project_id TEXT NOT NULL, user_id TEXT NOT NULL, PRIMARY KEY(project_id, user_id));
20
25
  CREATE INDEX IF NOT EXISTS task_items ON items(task_id,created);
21
26
  CREATE INDEX IF NOT EXISTS task_runs ON runs(task_id,status);`);
22
27
  }
@@ -1,4 +1,4 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { execFile } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
4
  import fs from 'node:fs';
@@ -10,8 +10,10 @@ import { AGENT_NAMES, AntigravityAgent, ClaudeAgent, type AgentRuntime } from '.
10
10
  import { reviewClient, taskRoute, confirmedReviewResponse, type ReviewClient } from './review.ts';
11
11
  import { VERSION } from './paths.ts';
12
12
  import { findSkill, type SkillFile } from './skills.ts';
13
- import { reviewInstructions, skillPrompt, summarizeFeedback } from '../conversation.ts';
14
- import type { Task, Run, TimelineItem, PendingRequest, RuntimeStatus, Snapshot, PendingReview, AgentModel, Usage, AgentId, MessageMeta, FeedbackSummary } from '../shared.ts';
13
+ import { buildFixPrompt, reviewInstructions, skillPrompt, summarizeFeedback } from '../conversation.ts';
14
+ import { commitMessageFrom } from '../commit.ts';
15
+ import { commitChanges, conflictMarkers, gitCommit, gitRemote, gitStatus, mergeConflicts, mergeInProgress } from './manuscript.ts';
16
+ import type { Task, Run, TimelineItem, PendingRequest, RuntimeStatus, Snapshot, PendingReview, AgentModel, Usage, AgentId, MessageMeta, FeedbackSummary, BuildFailure, Author } from '../shared.ts';
15
17
 
16
18
  const execute = promisify(execFile);
17
19
  const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
@@ -35,8 +37,20 @@ export function accountUsage(response: any): Usage | null {
35
37
  return { plan: typeof limits.planType === 'string' ? limits.planType : null, windows, credits, reached: Boolean(limits.rateLimitReachedType) || response.ordinaryUsageAllowed === false };
36
38
  }
37
39
  const BUSY = new Set(['running', 'waiting_input', 'building', 'uncertain', 'deleting']);
40
+ // Review builds that fail on the agent's own edits (see the review engine's
41
+ // publish.ts), and the request keys of the turns that send them back to it.
42
+ const FIXABLE = /^(Some edits are outside the task scope|The revised document could not be compiled)/;
43
+ const BUILD_FIX = 'build-fix-';
38
44
 
39
45
  const AGENTS: AgentId[] = ['codex', 'claude', 'antigravity'];
46
+ // The small model an agent offers for short text such as a commit message,
47
+ // by the names agents give them (GPT Luna and mini, Claude Haiku, Gemini Flash).
48
+ const LIGHT = /\b(?:luna|mini|nano|haiku|flash|lite)\b/i;
49
+ export const lightModel = (models: AgentModel[]) => models.find(model => LIGHT.test(model.id) || LIGHT.test(model.name));
50
+ // A commit message's agent sees the changes in its prompt and needs no tools.
51
+ const commitInstructions = 'You write Git commit messages for the repository of a LaTeX paper. Reply with the commit message only, without a preamble, quotes or a code fence: a subject line in the imperative mood under 72 characters and, only when the change needs more explanation, a blank line and a body of a few short plain sentences. Describe what changed in the paper, such as its wording, sections, equations, figures or references, rather than listing files. Follow the style and language of the recent commit subjects, or write in English when there are none. Everything you need is in the message: do not run commands, read files or edit anything.';
52
+ // A merge's agent edits the conflicted files in the manuscript checkout and nothing else.
53
+ const mergeInstructions = 'You resolve Git merge conflicts in the repository of a LaTeX paper. Edit only the files listed as conflicted, and in each one remove every conflict marker (<<<<<<<, ||||||| , =======, >>>>>>>) so that the text reads as one coherent version. Keep the changes from both sides wherever they are compatible; where both sides rewrote the same passage, combine their intent, and where they truly contradict each other, prefer the version that is more complete and correct and say so. Keep the LaTeX valid: balanced environments and braces, labels, references and citations. You may read any file to understand the context. Do not edit other files, and do not run Git commands that change the repository, such as add, commit, merge, checkout, reset, restore or stash: the author reviews the result and commits the merge.';
40
54
  const blankRuntime = (): RuntimeStatus => ({ connected: false, authenticated: false, account: null, error: null, models: [] });
41
55
 
42
56
  export class Workspace extends EventEmitter {
@@ -49,6 +63,8 @@ export class Workspace extends EventEmitter {
49
63
  private loaded = new Set<string>();
50
64
  private preparing = new Set<string>();
51
65
  private reviewWrites = new Set<string>();
66
+ // Papers with a commit in progress: no review write may change their files meanwhile.
67
+ private committing = new Set<string>();
52
68
  private commentWrites = new Map<string, number>();
53
69
  private deletions = new Set<string>();
54
70
  private publishing = new Set<string>();
@@ -60,6 +76,9 @@ export class Workspace extends EventEmitter {
60
76
  private flushTimer: NodeJS.Timeout | null = null;
61
77
  private connecting: Promise<void> | null = null;
62
78
  private usageTimers = new Map<AgentId, NodeJS.Timeout>();
79
+ // Commit messages by agent, model and prompt, kept or still being written,
80
+ // so one written ahead for a finished review is there when the dialog opens.
81
+ private messages = new Map<string, Promise<{ message: string; agent: AgentId }>>();
63
82
  constructor(public store: Store, public codex: AgentRuntime = new Codex(), private concurrency = 2, private connectReview = reviewClient, others: Partial<Record<AgentId, AgentRuntime>> = {}) {
64
83
  super();
65
84
  this.agents = { codex, claude: others.claude || new ClaudeAgent(), antigravity: others.antigravity || new AntigravityAgent() };
@@ -125,10 +144,12 @@ export class Workspace extends EventEmitter {
125
144
  this.store.saveProject(project); return project;
126
145
  }
127
146
  // A task can start from a skill; its instructions are then the author's own words, possibly none.
128
- async createTask(input: { projectId: string; title: string; instructions: string; skill?: string; agent?: AgentId; model?: string; effort?: string; id: string }) {
147
+ async createTask(input: { projectId: string; title: string; instructions: string; skill?: string; agent?: AgentId; model?: string; effort?: string; id: string; author?: Author }) {
129
148
  if (!/^[\w-]{16,64}$/.test(input.id)) throw new Error('Invalid task ID.');
130
149
  if (this.store.taskDeleted(input.id)) throw new Error('This task was deleted. Create a new task instead.');
131
- const existing = this.store.tasks().find(t => t.id === input.id); if (existing) return existing;
150
+ // A retried request returns its task; an ID from another paper is refused.
151
+ const existing = this.store.tasks().find(t => t.id === input.id);
152
+ if (existing) { if (existing.projectId !== input.projectId) throw new Error('Invalid task ID.'); return existing; }
132
153
  if (this.preparing.has(input.id)) throw new Error('This task is being created. Refresh in a moment.');
133
154
  this.preparing.add(input.id);
134
155
  try {
@@ -141,13 +162,14 @@ export class Workspace extends EventEmitter {
141
162
  worktree: info.worktree, threadId: null, turnId: null, agent: input.agent || 'codex', model: input.model || null, effort: input.effort || null, status: 'idle',
142
163
  createdAt: Date.now(), updatedAt: Date.now(), feedbackCursor: 0, error: null, review: null };
143
164
  this.store.saveTask(task);
144
- await this.enqueue(task.id, input.instructions, 'initial-' + task.id, false, { skill });
165
+ await this.enqueue(task.id, input.instructions, 'initial-' + task.id, false, { skill, author: input.author });
145
166
  return this.store.task(task.id);
146
167
  } finally { this.preparing.delete(input.id); }
147
168
  }
148
- // The author's message, a skill run with or without one, or the review
149
- // feedback alone. The chat keeps what the author did; the agent gets the prompt.
150
- async enqueue(taskId: string, text: string, requestKey: string, includeFeedback = true, options: { skill?: SkillFile; feedback?: boolean } = {}) {
169
+ // The author's message, a skill run with or without one, the review
170
+ // feedback alone, or a build the agent broke. The chat keeps what happened;
171
+ // the agent gets the prompt.
172
+ async enqueue(taskId: string, text: string, requestKey: string, includeFeedback = true, options: { skill?: SkillFile; feedback?: boolean; build?: BuildFailure; author?: Author } = {}) {
151
173
  const task = this.store.task(taskId);
152
174
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
153
175
  const existing = this.store.runByKey(requestKey);
@@ -155,7 +177,7 @@ export class Workspace extends EventEmitter {
155
177
  if (task.status === 'uncertain') throw new Error('Check the task status before sending more instructions.');
156
178
  const pauseVersion = task.pauseVersion || 0;
157
179
  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.');
158
- let prompt = options.feedback ? reviewInstructions : options.skill ? skillPrompt(options.skill, text) : text.trim(), cursor = task.feedbackCursor;
180
+ let prompt = options.build ? buildFixPrompt(options.build) : options.feedback ? reviewInstructions : options.skill ? skillPrompt(options.skill, text) : text.trim(), cursor = task.feedbackCursor;
159
181
  let summary: FeedbackSummary | undefined;
160
182
  if (includeFeedback) {
161
183
  const client = await this.connectReview(this.store.project(task.projectId).path);
@@ -166,7 +188,7 @@ export class Workspace extends EventEmitter {
166
188
  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.';
167
189
  }
168
190
  const run: Run = { id: randomUUID(), taskId, requestKey, prompt, feedbackCursor: cursor, status: 'queued', createdAt: Date.now(), finishedAt: null };
169
- const meta: MessageMeta = { ...(options.skill ? { skill: { id: options.skill.id, name: options.skill.name } } : {}), ...(summary ? { feedback: summary } : {}), ...(options.feedback ? { sendsFeedback: true } : {}) };
191
+ const meta: MessageMeta = { ...(options.skill ? { skill: { id: options.skill.id, name: options.skill.name } } : {}), ...(summary ? { feedback: summary } : {}), ...(options.feedback ? { sendsFeedback: true } : {}), ...(options.build ? { build: options.build } : {}), ...(options.author ? { author: options.author } : {}) };
170
192
  const saved = this.store.transaction(() => {
171
193
  // Feedback loading yields to other requests. Recheck identity, queue, and
172
194
  // task status together before committing; none of the earlier reads own a lock.
@@ -182,7 +204,7 @@ export class Workspace extends EventEmitter {
182
204
  if (current.status === 'uncertain') throw new Error('Check the task status before sending more instructions.');
183
205
  this.store.saveRun(run);
184
206
  if (!BUSY.has(current.status)) this.store.updateTask(taskId, { status: 'queued', error: null });
185
- this.store.saveItem({ id: run.id, taskId, type: 'user', text: options.feedback ? '' : text.trim(), createdAt: run.createdAt, ...(Object.keys(meta).length ? { meta } : {}) });
207
+ this.store.saveItem({ id: run.id, taskId, type: 'user', text: options.feedback || options.build ? '' : text.trim(), createdAt: run.createdAt, ...(Object.keys(meta).length ? { meta } : {}) });
186
208
  return run;
187
209
  });
188
210
  this.schedule();
@@ -205,12 +227,20 @@ export class Workspace extends EventEmitter {
205
227
  void this.startRun(run).catch(e => this.failRun(run, e));
206
228
  }
207
229
  }
208
- private instructions(task: Task) {
230
+ // The paths a task's review covers, or none when it covers the whole worktree.
231
+ private async reviewScope(task: Task): Promise<string[]> {
232
+ try {
233
+ const client = await this.connectReview(this.store.project(task.projectId).path);
234
+ const files: string[] = (await client.get(taskRoute(task.name))).scope?.files || [];
235
+ return files.some(file => file === '' || file === '.') ? [] : files;
236
+ } catch { return []; }
237
+ }
238
+ private instructions(task: Task, scope: string[] = []) {
209
239
  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.
210
240
  Your task is already created. Your editable working directory is ${task.worktree}.
211
241
  Original task: ${task.instructions}
212
242
  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.
213
- 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.
243
+ ${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.
214
244
  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.
215
245
  ${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.`;
216
246
  }
@@ -221,7 +251,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
221
251
  this.store.saveRun({ ...run, status: 'running' });
222
252
  this.store.updateTask(task.id, { status: 'running', turnId: null, error: null });
223
253
  if (!task.threadId || !this.loaded.has(task.threadId)) {
224
- const options = { cwd: task.worktree, approvalPolicy: 'on-request', sandbox: 'workspace-write', developerInstructions: this.instructions(task), ...(task.model ? { model: task.model } : {}) };
254
+ const options = { cwd: task.worktree, approvalPolicy: 'on-request', sandbox: 'workspace-write', developerInstructions: this.instructions(task, await this.reviewScope(task)), ...(task.model ? { model: task.model } : {}) };
225
255
  const result = task.threadId
226
256
  ? await agent.request('thread/resume', { ...options, threadId: task.threadId, excludeTurns: true })
227
257
  : await agent.request('thread/start', options);
@@ -327,7 +357,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
327
357
  this.store.saveRun({ ...run, status, finishedAt: Date.now() });
328
358
  this.store.updateTask(task.id, { turnId: null, pendingRunId: null, status: status === 'completed' ? 'building' : status, error: p.turn.error?.message || null });
329
359
  this.schedule();
330
- if (status === 'completed') await this.publish(task.id, true);
360
+ if (status === 'completed') await this.publish(task.id, run);
331
361
  } else if (method === 'error' && !p.willRetry) {
332
362
  if (!this.active.has(task.id) || (eventTurn && !task.turnId)) return;
333
363
  this.store.updateTask(task.id, { error: p.error?.message || `${AGENT_NAMES[agent]} encountered an error.` });
@@ -438,6 +468,8 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
438
468
  }
439
469
  }
440
470
  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.');
471
+ if (this.committing.has(task.projectId)) throw new Error('A commit is in progress. Try again when it finishes.');
472
+ if (mergeInProgress(this.store.project(task.projectId).path)) throw new Error('Finish or abort the merge in the Git panel before accepting, rejecting, or undoing changes.');
441
473
  if (this.reviewWrites.has(taskId)) throw new Error('A review action is already in progress. Wait for it to finish.');
442
474
  this.reviewWrites.add(taskId);
443
475
  if (pending) this.store.updateTask(taskId, { pendingReview: { ...pending, previousStatus: task.status } });
@@ -451,17 +483,19 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
451
483
  }
452
484
  finally { this.reviewWrites.delete(taskId); this.schedule(); }
453
485
  }
454
- publish(taskId: string, automatic = false): Promise<void> {
455
- const task = this.store.task(taskId);
486
+ // A build is automatic after the agent's run `after` completes. When its
487
+ // edits break the build, the agent gets the error in a new turn, once.
488
+ publish(taskId: string, after?: Run): Promise<void> {
489
+ const task = this.store.task(taskId), automatic = Boolean(after);
456
490
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
457
491
  if (this.active.has(taskId)) throw new Error('Pause Codex before building a review.');
458
492
  if (this.reviewWrites.has(taskId)) throw new Error('Wait for the current review action before building a review.');
459
493
  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.');
460
494
  this.publishing.add(taskId);
461
495
  this.store.updateTask(taskId, { status: 'building', error: null });
462
- return this.buildReview(task).finally(() => { this.publishing.delete(taskId); this.schedule(); });
496
+ return this.buildReview(task, Boolean(after && !after.requestKey.startsWith(BUILD_FIX))).finally(() => { this.publishing.delete(taskId); this.schedule(); });
463
497
  }
464
- private async buildReview(task: Task) {
498
+ private async buildReview(task: Task, sendBack: boolean) {
465
499
  const taskId = task.id;
466
500
  try {
467
501
  const client = await this.connectReview(this.store.project(task.projectId).path);
@@ -472,7 +506,15 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
472
506
  const summary = await client.get(taskRoute(task.name));
473
507
  this.store.updateTask(taskId, { review: { ...this.store.task(taskId).review, state: summary.state, round: summary.round, counts: summary.counts, job: current } });
474
508
  if (current.status === 'done' || current.status === 'completed' || current.status === 'succeeded') break;
475
- if (current.status === 'failed') throw new Error([current.error, ...(current.details || [])].filter(Boolean).join('\n') || 'PDF build failed.');
509
+ if (current.status === 'failed') {
510
+ const failure: BuildFailure = { error: current.error || 'PDF build failed.', details: current.details || [], ...(current.log ? { log: current.log } : {}) };
511
+ // Instructions the author queued meanwhile go first; the build after them sends any error that remains.
512
+ if (sendBack && FIXABLE.test(failure.error) && await this.enqueue(taskId, '', BUILD_FIX + job.job, false, { build: failure }).then(() => true, () => false)) {
513
+ this.store.updateTask(taskId, { status: 'queued', error: null });
514
+ return;
515
+ }
516
+ throw new Error([failure.error, ...failure.details].join('\n'));
517
+ }
476
518
  await delay(1500);
477
519
  }
478
520
  this.store.updateTask(taskId, { status: this.store.runs().some(r => r.taskId === taskId && r.status === 'queued') ? 'queued' : 'reviewing', error: null });
@@ -486,7 +528,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
486
528
  const project = this.store.project(task.projectId), client = await this.connectReview(project.path);
487
529
  const summary = await client.get(taskRoute(task.name));
488
530
  const { unsent, comments } = await this.unsentReviewFeedback(client, this.store.task(taskId));
489
- const files = await acceptedFiles(project.path, task.name);
531
+ const files = await acceptedFiles(project.path, task.name, task.committed?.accepted);
490
532
  const current = this.store.task(taskId);
491
533
  if (current.status !== 'deleting') this.store.updateTask(taskId, { review: { state: summary.state, round: summary.round, counts: summary.counts, job: current.review?.job, unsent, comments, files } });
492
534
  } catch { /* The task remains visible even if its project is temporarily unavailable. */ }
@@ -500,6 +542,126 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
500
542
  const items = feedback.feedback || [];
501
543
  return { unsent: items.length, comments: items.filter(item => item.kind === 'comment' || item.kind === 'note').length };
502
544
  }
545
+ // The agent that writes a commit message or resolves a merge: the one the
546
+ // author chose, or else the first that is signed in, with the chosen model
547
+ // or the default one; light asks for the agent's small model when it has one.
548
+ private helper(choice: { agent?: AgentId; model?: string }, task: string, light = false) {
549
+ const usable = (id: AgentId) => this.agents[id].ready && this.runtimes[id].authenticated;
550
+ const id = choice.agent && usable(choice.agent) ? choice.agent : AGENTS.find(usable);
551
+ if (!id) throw new Error(`Sign in to Codex, Claude or Antigravity to ${task}.`);
552
+ const models = this.runtimes[id].models;
553
+ return { id, model: (light ? lightModel(models) : undefined) || models.find(item => item.id === choice.model) || models.find(item => item.isDefault) };
554
+ }
555
+ // One turn on a thread the agent does not keep, outside any task: its
556
+ // reply is returned and no chat shows it.
557
+ 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 }) {
558
+ const agent = this.agents[id], name = AGENT_NAMES[id];
559
+ 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 } : {}) });
560
+ const threadId = thread.thread.id;
561
+ return new Promise<string>((resolve, reject) => {
562
+ let text = '', turnId = '';
563
+ const listen = (message: any) => {
564
+ const p = message.params || {};
565
+ if (p.threadId !== threadId) return;
566
+ if (message.method === 'item/completed' && p.item?.type === 'agentMessage') text = p.item.text || text;
567
+ else if (message.method === 'turn/completed') finish(p.turn?.status === 'completed' ? null : new Error(p.turn?.error?.message || `${name} stopped before it could ${options.what}.`));
568
+ };
569
+ const timer = setTimeout(() => {
570
+ if (turnId) agent.request('turn/interrupt', { threadId, turnId }).catch(() => undefined);
571
+ finish(new Error(`${name} did not ${options.what} in time. Try again.`));
572
+ }, options.timeout);
573
+ const finish = (error: Error | null) => { clearTimeout(timer); agent.off('notification', listen); if (error) reject(error); else resolve(text); };
574
+ agent.on('notification', listen);
575
+ agent.request('turn/start', { threadId, input: [{ type: 'text', text: options.prompt }], cwd: options.cwd, ...(options.effort ? { effort: options.effort } : {}) })
576
+ .then(result => { turnId = result?.turn?.id || ''; }, error => finish(error instanceof Error ? error : new Error(String(error))));
577
+ });
578
+ }
579
+ // Writes a commit message for the chosen manuscript files, like Cursor's
580
+ // generate button: one read-only turn on the agent's small model, with the
581
+ // lightest reasoning it offers. The same changes get the message already
582
+ // written, or being written, for them unless the author asks for a fresh one.
583
+ async commitMessage(projectId: string, paths: string[], choice: { agent?: AgentId; fresh?: boolean } = {}) {
584
+ const project = this.store.project(projectId), { id, model } = this.helper(choice, 'write a commit message', true);
585
+ const { files, patch, recent } = await commitChanges(project.path, paths);
586
+ const tasks = this.store.tasks().filter(task => task.projectId === projectId && task.review?.files?.some(file => paths.includes(file))).map(task => task.title);
587
+ const prompt = [
588
+ recent.length ? `Recent commit subjects in this repository:\n${recent.map(subject => `- ${subject}`).join('\n')}` : 'This repository has no commits yet.',
589
+ tasks.length ? `Papilot tasks whose accepted changes are in these files:\n${tasks.map(title => `- ${title}`).join('\n')}` : '',
590
+ `Changed files:\n${files.map(file => `${file.status === '?' ? 'A' : file.status} ${file.from ? `${file.from} -> ` : ''}${file.path}`).join('\n')}`,
591
+ `Diff:\n${patch}`,
592
+ ].filter(Boolean).join('\n\n');
593
+ const key = createHash('sha256').update(`${id}\0${model?.id ?? ''}\0${prompt}`).digest('hex'), kept = this.messages.get(key);
594
+ if (kept && !choice.fresh) return kept;
595
+ const written = this.oneOff(id, { cwd: project.path, sandbox: 'read-only', instructions: commitInstructions, prompt, model, effort: model?.efforts[0], timeout: 120000, what: 'write a commit message' }).then(reply => {
596
+ const message = commitMessageFrom(reply);
597
+ if (!message) throw new Error(`${AGENT_NAMES[id]} did not return a commit message. Try again.`);
598
+ return { message, agent: id };
599
+ });
600
+ // Only the latest few messages are kept, and never a failed one.
601
+ this.messages.delete(key); this.messages.set(key, written);
602
+ for (const old of this.messages.keys()) { if (this.messages.size <= 20) break; this.messages.delete(old); }
603
+ written.catch(() => { if (this.messages.get(key) === written) this.messages.delete(key); });
604
+ return written;
605
+ }
606
+ // A finished review offers to commit what it accepted, so after the
607
+ // author's last decision its commit message is written ahead.
608
+ async prepareCommitMessage(taskId: string) {
609
+ try {
610
+ const task = this.store.task(taskId), files = task.review?.files || [];
611
+ if (task.status !== 'reviewing' || task.review?.counts?.pending || !files.length) return;
612
+ const project = this.store.project(task.projectId), git = await gitStatus(project.path);
613
+ const paths = git.merging ? [] : files.filter(file => git.files.some(item => item.path === file));
614
+ if (paths.length) await this.commitMessage(project.id, paths, { agent: this.agentOf(task) });
615
+ } catch { /* The commit dialog writes the message when it opens. */ }
616
+ }
617
+ // Resolves a merge's conflicts in the manuscript checkout itself: the agent
618
+ // edits only the conflicted files, at the model's default reasoning, and
619
+ // leaves committing the merge to the author once they have read the diffs.
620
+ async resolveConflicts(projectId: string, choice: { agent?: AgentId; model?: string } = {}) {
621
+ const project = this.store.project(projectId), { id, model } = this.helper(choice, 'resolve conflicts');
622
+ const { files, branch, upstream, ours, theirs } = await mergeConflicts(project.path);
623
+ if (!files.length) throw new Error('No file has conflict markers left. Commit the merge.');
624
+ const list = (items: string[]) => items.length ? items.map(item => `- ${item}`).join('\n') : '- (none listed)';
625
+ const prompt = [
626
+ `${branch} is being merged with ${upstream} in ${project.path}, and these files have conflicts:\n${list(files)}`,
627
+ `Commits on ${branch} since the two diverged:\n${list(ours)}`,
628
+ `Commits from ${upstream} being merged in:\n${list(theirs)}`,
629
+ 'Resolve every conflict in those files, then reply with a short summary for the author: how you resolved each conflict, and any place where you had to choose one side.',
630
+ ].join('\n\n');
631
+ const summary = await this.oneOff(id, { cwd: project.path, sandbox: 'workspace-write', instructions: mergeInstructions, prompt, model, effort: model?.defaultEffort, timeout: 600000, what: 'resolve the conflicts' });
632
+ return { summary: summary.trim(), remaining: files.filter(file => conflictMarkers(project.path, file)), agent: id, git: await gitStatus(project.path) };
633
+ }
634
+ // Commits what the author accepted from this task since its last commit,
635
+ // with the message they confirmed, and pushes it if they asked. Other
636
+ // uncommitted files stay as they are; a file another task also changed is
637
+ // committed as it is on disk.
638
+ async commitTask(taskId: string, message: string, push = false) {
639
+ const task = this.store.task(taskId);
640
+ if (task.status === 'deleting') throw new Error('This task is being deleted.');
641
+ if (task.pendingReview) throw new Error('Wait for the current review action before committing.');
642
+ const project = this.store.project(task.projectId);
643
+ return this.exclusiveCommit(project.id, async () => {
644
+ const accepted = await acceptedCommit(project.path, task.name);
645
+ const files = accepted ? await acceptedFiles(project.path, task.name, this.store.task(taskId).committed?.accepted) : [];
646
+ const paths = (await gitStatus(project.path)).files.filter(file => files.includes(file.path)).map(file => file.path);
647
+ if (!paths.length) throw new Error('This task has no accepted changes left to commit.');
648
+ const hash = await gitCommit(project.path, message, paths);
649
+ this.store.updateTask(taskId, { committed: { hash, accepted: accepted!, at: Date.now() } });
650
+ void this.refresh(taskId);
651
+ // The commit stands even when the push fails; the author can push again.
652
+ let pushError: string | undefined;
653
+ if (push) try { await gitRemote(project.path, 'push'); } catch (error) { pushError = errorText(error); }
654
+ return { commit: hash, git: await gitStatus(project.path), ...(pushError ? { pushError } : {}) };
655
+ });
656
+ }
657
+ // A commit reads the working tree that accepting, rejecting and undoing
658
+ // write, so neither starts while the other runs on the same paper.
659
+ async exclusiveCommit<T>(projectId: string, work: () => Promise<T>): Promise<T> {
660
+ if (this.committing.has(projectId)) throw new Error('A commit is already in progress. Wait for it to finish.');
661
+ if ([...this.reviewWrites].some(id => this.store.task(id).projectId === projectId)) throw new Error('Wait for the current review action before committing.');
662
+ this.committing.add(projectId);
663
+ try { return await work(); } finally { this.committing.delete(projectId); }
664
+ }
503
665
  renameTask(taskId: string, title: string) {
504
666
  if (this.store.task(taskId).status === 'deleting') throw new Error('This task is being deleted.');
505
667
  return this.store.updateTask(taskId, { title });
@@ -544,10 +706,15 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
544
706
 
545
707
  // The review engine records each task's accepted text as a commit beside the
546
708
  // one it started from; their difference is what the task put in the
547
- // manuscript. A rename counts as both of its paths.
548
- export async function acceptedFiles(root: string, name: string) {
709
+ // manuscript, and the difference from the accepted text at the task's last
710
+ // commit is what it has added since. A rename counts as both of its paths.
711
+ export async function acceptedFiles(root: string, name: string, since?: string) {
549
712
  try {
550
- const { stdout } = await execute('git', ['-C', root, 'diff', '--name-only', '--no-renames', '-z', `refs/latex-review/${name}/base`, `refs/latex-review/${name}/accepted`, '--']);
713
+ const { stdout } = await execute('git', ['-C', root, 'diff', '--name-only', '--no-renames', '-z', since || `refs/latex-review/${name}/base`, `refs/latex-review/${name}/accepted`, '--']);
551
714
  return stdout.split('\0').filter(Boolean);
552
715
  } catch { return []; }
553
716
  }
717
+ export async function acceptedCommit(root: string, name: string) {
718
+ try { return (await execute('git', ['-C', root, 'rev-parse', '--verify', '-q', `refs/latex-review/${name}/accepted^{commit}`])).stdout.trim() || undefined; }
719
+ catch { return undefined; }
720
+ }