Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ src/
protocol.ts Socket.IO 0.9 frame encode/decode (pure)
ot.ts ShareJS ops, offset<->rowcol, diff, wire encoding, git-blob hash (pure)
logParser.ts parse pdfTeX/LaTeX output.log into errors/warnings/bad-boxes (pure)
trackedChanges.ts parse ranges.changes; find the suggestions an op would touch (pure)
document.ts Document: local mirror of one open doc (text + version + generation)
types.ts shared types (Entity, ProjectSummary, ProjectInfo, CompileResult, ...)
realtime.ts RealtimeConnection(projectId): WS transport, acks, heartbeat, reconnect
Expand Down Expand Up @@ -150,6 +151,27 @@ UTF-8 (do not decode). The resolve/reopen/delete thread routes return 404 on ove
Claudeleaf ships only create + list. Exposed as `addComment`/`listComments` (SDK) and
`overleaf_add_comment`/`overleaf_list_comments` (MCP).

**Reading tracked changes and the overwrite guard.** `ranges.changes` entries are
`{id, op:{p,i}|{p,d}, metadata:{user_id,ts}}`: a tracked insertion is snapshot text at
`[p, p+i.length)`, a tracked deletion is text no longer in the snapshot, anchored at the point
`p`; the text is the realtime byte-view (decode it). `trackedChanges.ts` (pure) parses them and
`findConflicts(op, changes)` returns the ones an op would touch, carrying positions through the
op's components. Rules: an insert conflicts strictly inside an insertion or exactly on a
deletion anchor; a delete conflicts when it overlaps an insertion or has a deletion anchor
strictly inside it; edges are allowed so the neighbouring word stays editable (per Overleaf's
change tracker an edge insert shifts or leaves a suggestion and merges only into a tracked
insert of the same user; verified live that direct inserts at both edges of a tracked insertion
leave its text unchanged, with suggestion and edit from one account - a second author not yet
tested); comment ops never conflict. The live compile test skips itself when `main.tex` has
pending suggestions, since its break-and-restore would drop them. `submit` re-joins
the doc before every attempt unless `EditOptions.overwriteTrackedChanges` is set (the mirror
tracks text, not ranges, so earlier ranges may be stale) and throws `TrackedChangeConflictError`
(with `.changes`) before sending. Why: an edit inside a co-author's pending suggestion silently
rewrites or removes it, and Overleaf mails its author that the suggestion was rejected.
`listTrackedChanges` names authors from the project's owner/members. The MCP exposes
`overleaf_list_tracked_changes` but deliberately no override, so an agent asks a human instead;
the CLI has `changes` and `--overwrite-tracked-changes`.

**Hash.** Overleaf's optional ShareJS hash is git-blob SHA-1 (`ot.gitBlobHash`). We **omit
it**: the server treats it as optional, and a mismatch is fatal (`otUpdateError "Invalid
hash"` + disconnect), so omitting it is strictly more reliable.
Expand Down Expand Up @@ -233,7 +255,8 @@ state; instead:
- **MCP server stays minimal** — every tool is a one-line wrapper; the server stays alive
until the client disconnects (stdin close), then exits. Each tool carries MCP
`annotations`. `readOnlyHint` is `true` for the reads (`list_projects`, `project_info`,
`list_documents`, `read_document`, `search`, `connected_users`, `list_comments`, `compile`)
`list_documents`, `read_document`, `search`, `connected_users`, `list_comments`,
`list_tracked_changes`, `compile`)
and `false` for the writes. `compile` is read-only (it never mutates project content) but
also `idempotentHint: false`, since each call kicks off a fresh build. The writes set
`destructiveHint` explicitly (it would otherwise default to `true` whenever `readOnlyHint`
Expand Down Expand Up @@ -265,7 +288,7 @@ No configuration is required. Overrides (mainly for self-hosted / tuning):
`project(idOrName)` → `ProjectSession`. All document methods take a `project` (id or name) as
the first argument and are `async`: `projectInfo`, `listDocuments`, `readDocument`, `getLines`,
`documentVersion`, `search`, `insertAt`, `insert`, `append`, `deleteRange`, `replaceRange`,
`replaceText`, `setText`, `deleteLines`, `setCursor`, `getConnectedUsers`, `createDocument`,
`replaceText`, `setText`, `deleteLines`, `setCursor`, `getConnectedUsers`, `listTrackedChanges`, `createDocument`,
`uploadFile`, `createFolder`, `deleteDocument`, `renameDocument`, `resync`, `compile`. `ProjectSession`
exposes the same methods without the leading `project` argument. Every edit method takes an
optional trailing `EditOptions` (`{trackChanges?}`) — set `trackChanges` to submit the edit as
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Bring Claude (and any agent) into Overleaf. Claudeleaf is a reliable TypeScript
- **Full document control** — list documents, read text, insert at a position or line/column, replace ranges or literal text, delete ranges or lines, replace a whole document, create/delete/rename files, and **upload images** (e.g. a local W&B plot PNG) for `\includegraphics`: `claudeleaf upload <project> plot.png --folder figures` or the `overleaf_upload_file` MCP tool.
- **Review mode (track changes).** Submit any edit as a tracked change (a suggestion) with `trackChanges` instead of a direct edit, so a human accepts or rejects it in Overleaf. Requires track changes on the project (a premium feature).
- **Comments.** Anchor a comment to a span of text (like selecting text and commenting in the editor) and list a document's comments with their threads.
- **Collaborators' suggestions are protected.** List a document's pending tracked changes with their authors (`claudeleaf changes <project> <path>`, `overleaf_list_tracked_changes`). Any edit that would change text inside someone's pending suggestion is refused before it is sent, instead of silently rewriting it (which Overleaf reports to the author as a rejected suggestion). Override in the SDK/CLI with `overwriteTrackedChanges` / `--overwrite-tracked-changes`; the MCP server has no override, so an agent asks a human.
- **Compile & error logs** — trigger a LaTeX recompile and get back the status plus the parsed errors and warnings (with file and line), or the full raw log.
- **Reliable by design** — automatic reconnect with document re-join, per-document edit serialization, and server-confirmed edits (no silent loss).
- **Zero config, easy sign-in.** No credentials stored and nothing to configure: run `claudeleaf login` once, sign in by hand in the browser that opens (handles reCAPTCHA / SSO / 2FA), and the session is cached.
Expand Down
23 changes: 21 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ Usage: claudeleaf <command> [args]
comment <project> <path> <start> <end> [message|-]
comment on the [start,end) span
comments <project> <path> list a document's comments
changes <project> <path> list a document's pending tracked changes
upload <project> <localFile> [--folder DIR] [--name NAME]
upload an image/PDF for \includegraphics
compile <project> [--draft] [--stop-on-first-error] [--log] [--warnings]
Expand All @@ -89,7 +90,9 @@ project is an id or name. line/column are 0-based; start/end are character offse

The editing commands (append, insert, replace, set) accept --track-changes to
submit the edit as a review-mode suggestion instead of a direct edit (requires
track changes on the project, a premium feature).`;
track changes on the project, a premium feature). They refuse, sending nothing,
an edit that would touch a pending tracked change; --overwrite-tracked-changes
skips that check.`;

async function run(argv: string[]): Promise<number> {
const command = argv[0];
Expand Down Expand Up @@ -149,7 +152,10 @@ async function run(argv: string[]): Promise<number> {
return 1;
}
// Editing commands share this: submit as a review-mode tracked change instead of a direct edit.
const editOpts = { trackChanges: boolFlag(flags["track-changes"]) };
const editOpts = {
trackChanges: boolFlag(flags["track-changes"]),
overwriteTrackedChanges: boolFlag(flags["overwrite-tracked-changes"]),
};
const editedHow = editOpts.trackChanges ? " (as a tracked change)" : "";
try {
switch (command) {
Expand Down Expand Up @@ -231,6 +237,19 @@ async function run(argv: string[]): Promise<number> {
}
return 0;
}
case "changes": {
const changes = await client.listTrackedChanges(project, positionals[1]);
if (changes.length === 0) {
console.log("No tracked changes.");
return 0;
}
for (const c of changes) {
const who = c.author ?? c.email ?? c.userId ?? "?";
const when = c.timestamp ? ` ${c.timestamp}` : "";
console.log(`${c.line}:${c.column} ${c.kind.padEnd(6)} ${who}${when} ${JSON.stringify(c.text)}`);
}
return 0;
}
case "upload": {
const file = positionals[1];
if (!file) {
Expand Down
6 changes: 6 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
Entity,
ProjectInfo,
ProjectSummary,
TrackedChange,
} from "./types.js";
import { Mutex } from "./util.js";

Expand Down Expand Up @@ -214,6 +215,11 @@ export class OverleafClient {
return (await this.project(project)).listComments(path);
}

// -- tracked changes --------------------------------------------------
async listTrackedChanges(project: string, path: string): Promise<TrackedChange[]> {
return (await this.project(project)).listTrackedChanges(path);
}

// -- presence & structure ---------------------------------------------
async setCursor(project: string, path: string, line: number, column: number): Promise<void> {
await (await this.project(project)).setCursor(path, line, column);
Expand Down
17 changes: 17 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* All errors derive from {@link ClaudeleafError}, so callers can catch the whole family.
*/

import type { TrackedChange } from "./types.js";

export class ClaudeleafError extends Error {
constructor(message: string) {
super(message);
Expand Down Expand Up @@ -47,3 +49,18 @@ export class EditError extends ClaudeleafError {}
* in most cases; this surfaces only when retries are exhausted.
*/
export class EditConflictError extends EditError {}

/**
* The edit would change text inside or at the edge of a pending tracked change (a review-mode
* suggestion), which would silently alter or remove that suggestion. Nothing was sent. Ask the
* suggestion's author to accept or reject it first, or retry with `overwriteTrackedChanges`.
*/
export class TrackedChangeConflictError extends EditError {
constructor(
message: string,
/** The suggestions the edit would have touched. */
readonly changes: TrackedChange[],
) {
super(message);
}
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export {
DocumentNotFoundError,
EditError,
EditConflictError,
TrackedChangeConflictError,
} from "./errors.js";
export type {
Cookies,
Expand All @@ -35,6 +36,7 @@ export type {
ConnectedUser,
Comment,
CommentMessage,
TrackedChange,
OutputFile,
LogLevel,
LogEntry,
Expand Down
18 changes: 17 additions & 1 deletion src/mcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ const trackChanges = z
.describe(
"Submit as a tracked change (review-mode suggestion) instead of a direct edit, for a " +
"human to accept or reject. Inserts become tracked insertions; deletions keep the removed " +
"text struck through. Requires track changes to be available on the project (premium).",
"text struck through. Requires track changes to be available on the project (premium). " +
"Every text edit is refused, with nothing sent, if it would touch a pending suggestion " +
"(see overleaf_list_tracked_changes); then ask the human instead of working around it.",
);

export function createServer(): McpServer {
Expand Down Expand Up @@ -285,6 +287,20 @@ export function createServer(): McpServer {
async ({ project, path }) => result(await getClient().listComments(project, path)),
);

server.registerTool(
"overleaf_list_tracked_changes",
{
description:
"List a document's pending tracked changes (review-mode suggestions) — each with kind " +
"(insert: text in the document; delete: text removed but kept as a suggestion), the text, " +
"position (offset/line/column), author and time. Edits that would touch one are refused, " +
"so check here before editing text a collaborator may have suggested changes to.",
inputSchema: { project: z.string(), path: z.string() },
annotations: { readOnlyHint: true },
},
async ({ project, path }) => result(await getClient().listTrackedChanges(project, path)),
);

server.registerTool(
"overleaf_compile",
{
Expand Down
80 changes: 78 additions & 2 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
EditConflictError,
EditError,
NotTransmittedError,
TrackedChangeConflictError,
} from "./errors.js";
import { parseLatexLog } from "./logParser.js";
import * as ot from "./ot.js";
Expand All @@ -43,7 +44,9 @@ import type {
Entity,
OutputFile,
ProjectInfo,
TrackedChange,
} from "./types.js";
import { findConflicts, parseChanges, type ParsedChange } from "./trackedChanges.js";
import { Deferred, Mutex, sleep } from "./util.js";

const TREE_EVENTS = new Set([
Expand All @@ -68,6 +71,14 @@ type ConfirmResult = { status: "confirmed"; version?: number } | { status: "drop
*/
export interface EditOptions {
trackChanges?: boolean;
/**
* Allow the edit to change text inside or at the edge of a pending tracked change. By
* default an edit that would touch someone's suggestion is refused with a
* {@link TrackedChangeConflictError} before anything is sent, because it would silently
* alter or remove that suggestion (Overleaf reports a removed insertion to its author as
* "rejected"). The check re-joins the document before each edit to read fresh ranges.
*/
overwriteTrackedChanges?: boolean;
}

interface PendingEdit {
Expand All @@ -84,7 +95,7 @@ interface RawCommentRange {
/** The ranges a `joinDoc {encodeRanges:true}` ack carries alongside the document. */
interface DocRanges {
comments?: RawCommentRange[];
changes?: unknown[];
changes?: unknown;
}

export class ProjectSession {
Expand Down Expand Up @@ -366,6 +377,46 @@ export class ProjectSession {
});
}

// -- tracked changes --------------------------------------------------
/**
* List a document's pending tracked changes (review-mode suggestions), in document order,
* with their author where the author is a member of the project. Read-only.
*/
async listTrackedChanges(path: string): Promise<TrackedChange[]> {
const doc = await this.openDoc(path);
// Through joinDoc for the same reasons as listComments: a failed join raises, and the
// offsets are mapped against the text of this same join.
const ranges = await this.mutex(doc.id).runExclusive(() => this.joinDoc(doc));
return parseChanges(ranges.changes)
.sort((a, b) => a.offset - b.offset)
.map((c) => this.describeChange(doc, c));
}

private describeChange(doc: Document, c: ParsedChange): TrackedChange {
const [line, column] = doc.offsetToRowcol(c.offset);
const user = c.userId ? this.projectUser(c.userId) : undefined;
const name = user ? [user.first_name, user.last_name].filter(Boolean).join(" ") : "";
return {
id: c.id,
kind: c.kind,
text: c.text,
offset: c.offset,
line,
column,
userId: c.userId,
author: name || undefined,
email: user?.email as string | undefined,
timestamp: c.timestamp,
};
}

/** The owner or member with this user id, from the joinProject payload. */
private projectUser(userId: string): Record<string, any> | undefined {
const owner = this.project.owner as Record<string, any> | undefined;
const people = [owner, ...((this.project.members as Record<string, any>[]) ?? [])];
return people.find((u) => u && (u._id === userId || u.id === userId));
}

// -- presence & structure ---------------------------------------------
async setCursor(path: string, line: number, column: number): Promise<void> {
const doc = await this.openDoc(path);
Expand Down Expand Up @@ -665,9 +716,20 @@ export class ProjectSession {
return this.mutex(doc.id).runExclusive(async () => {
let lastError: Error | undefined;
for (let attempt = 0; attempt < 4; attempt++) {
await this.ensureJoined(doc);
// Unless overwriting suggestions is allowed, re-join to get the current ranges: the
// local mirror tracks text, not ranges, so ranges from an earlier join may be stale.
let ranges: DocRanges | undefined;
if (opts.overwriteTrackedChanges) await this.ensureJoined(doc);
else ranges = await this.joinDoc(doc);
const { op, cursor } = build(doc);
if (op.length === 0) return doc;
if (ranges) {
const touched = findConflicts(op, parseChanges(ranges.changes));
if (touched.length > 0) {
const described = touched.map((c) => this.describeChange(doc, c));
throw new TrackedChangeConflictError(conflictMessage(doc.path, described), described);
}
}

let newText: string;
try {
Expand Down Expand Up @@ -888,6 +950,20 @@ export class ProjectSession {
}
}

function conflictMessage(path: string, changes: TrackedChange[]): string {
const shown = changes.slice(0, 3).map((c) => {
const who = c.author ?? c.email ?? c.userId ?? "unknown author";
const text = c.text.length > 60 ? c.text.slice(0, 57) + "..." : c.text;
return `${c.kind === "insert" ? "insertion" : "deletion"} by ${who} at ${c.line}:${c.column} ${JSON.stringify(text)}`;
});
const more = changes.length > 3 ? ` and ${changes.length - 3} more` : "";
return (
`edit to ${path} not sent: it would touch ${changes.length} pending tracked change(s) - ` +
`${shown.join("; ")}${more}. Ask the author to accept or reject the suggestion first ` +
"(or retry with overwriteTrackedChanges)."
);
}

function clamp(value: number, length: number): number {
return Math.max(0, Math.min(value, length));
}
Expand Down
Loading