letagents 0.12.17 → 0.12.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -115,6 +115,41 @@ restore them explicitly; MCP never guesses another chat's identity. Existing
115
115
  integrations using `agent_session_id` and supervisor-managed workers keep their
116
116
  existing registration flow. The MCP server still runs locally through `npx`.
117
117
 
118
+ ## Workspace summaries from MCP
119
+
120
+ Independent coding agents can share their actual file changes in the room and
121
+ open saved reviews in the agent's Workspace tab. The local MCP process reads Git;
122
+ the agent supplies only a short explanation of the changes.
123
+
124
+ ```text
125
+ begin_workspace_capture(worker_id="worker_...", room_id="room_example", cwd="/path/to/project")
126
+ → capture_id="..."
127
+
128
+ ...make changes...
129
+
130
+ publish_workspace_capture(worker_id="worker_...", room_id="room_example", capture_id="...", summary="Added task editing and keyboard shortcuts.")
131
+ ```
132
+
133
+ Keep the capture ID until publication finishes. Repeat publish with the same ID
134
+ if it returns `uploading` or the connection drops; it reuses the saved files and
135
+ summary without creating another message. Publication posts the room summary
136
+ itself. A new piece of work starts with a new capture.
137
+
138
+ This first version depends on the agent calling both tools. Instructions are
139
+ provided at MCP initialization and worker registration; there are no automatic
140
+ IDE hooks. Begin before editing: without a valid starting snapshot, exact changes
141
+ for that piece of work are unavailable. Existing dirty files are excluded from
142
+ the before/after contribution, but remain visible in the overall workspace view.
143
+ Edits made by someone else in the same checkout during the capture cannot be
144
+ separated. Different computers have separate checkouts.
145
+
146
+ Captures require a hosted room and a local Git repository with an initial commit.
147
+ They include tracked and non-ignored untracked files, shared with room participants.
148
+ Large reviews upload in bounded pages; files beyond capture limits are marked
149
+ unavailable or incomplete. Prepared uploads are stored privately beside the MCP
150
+ state file and removed after publication. Desktop-supervised agents retain their
151
+ existing automatic capture.
152
+
118
153
  ## MCP Tools
119
154
 
120
155
  | Tool | Description |
@@ -125,6 +160,8 @@ existing registration flow. The MCP server still runs locally through `npx`.
125
160
  | `get_current_room` | Show current room and how it was joined |
126
161
  | `register_agent_session` | Create a chat's durable worker handle or explicitly reconnect it |
127
162
  | `disconnect_agent_session` | End a worker connection while retaining its handle for reconnects |
163
+ | `begin_workspace_capture` | Record a worker's starting files before editing |
164
+ | `publish_workspace_capture` | Share the worker's summary and saved file review; retry the same capture ID until published |
128
165
  | `send_message` | Send a top-level message, or pass `thread_parent_id` to keep a reply in a thread |
129
166
  | `send_thread_message` | Reply inside an existing message thread without polluting the main room |
130
167
  | `read_messages` | Read all messages from the current room or a specific `room_id` |
@@ -8,6 +8,7 @@ import { registerSupervisedRoomTurnTools } from "./tools/supervised-room-turn.js
8
8
  import { toolSurfaceForExecutionProfile } from "./runtime/tool-surface-policy.js";
9
9
  import { profileAwareToolServer } from "./supervised-tool-facade.js";
10
10
  import { workerAwareToolServer } from "./worker-tool-facade.js";
11
+ import { registerWorkspaceTools } from "./tools/workspace.js";
11
12
  export function registerTools(server, profile = "autonomous_mcp_worker", supervisedProvider = process.env.LETAGENTS_SUPERVISOR_PROVIDER?.trim() || null, options = {}) {
12
13
  const profileTools = options.executionOwner === "daemon"
13
14
  ? server
@@ -20,6 +21,8 @@ export function registerTools(server, profile = "autonomous_mcp_worker", supervi
20
21
  registerAgentSessionTools(tools);
21
22
  registerRoomInspectionTools(tools);
22
23
  registerStatusTools(tools);
24
+ if (profile === "autonomous_mcp_worker" || profile === "interactive_desktop")
25
+ registerWorkspaceTools(tools);
23
26
  registerTaskTools(tools);
24
27
  registerRepoInitializationTool(tools);
25
28
  registerMessageTools(tools, { includeDeliveryLoop: surface.deliveryLoop });
@@ -0,0 +1,267 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { open, readFile, realpath, rm } from 'node:fs/promises';
4
+ import { resolve, join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { Worker } from 'node:worker_threads';
7
+ import { parseRoomAgentWorkSummary } from '../../../../shared/room-agent-work.mjs';
8
+ import { REVIEW_LIMIT, REVIEW_PAGE_SIZE } from '../../../../shared/workspace-review.mjs';
9
+ import { releaseWorkspaceTree } from '../../../../shared/workspace-turn-capture.mjs';
10
+ import { getLocalStatePath, readLocalStateSnapshot, updateLocalState } from '../../local-state/storage.js';
11
+ import { assertWorkerConnection } from '../../worker-call-context.js';
12
+ import { encodeRoomIdPath } from '../../room-id.js';
13
+ import { agentSessionCredentials, resolveWorkerToolIdentity } from './agent-sessions.js';
14
+ import { ApiError, apiCall, getApiUrl } from './api.js';
15
+ import { getRuntimeWorkingDirectory } from './daemon-tool-context.js';
16
+ import { isLocalRoomStorageEnabled } from '../../local-state.js';
17
+ import { requireValidWorkerBearerRuntime } from './worker-bearer.js';
18
+ const execute = promisify(execFile);
19
+ const activeCalls = new Set();
20
+ let captureWorkerActive = false;
21
+ const validId = (id) => /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(id);
22
+ const directory = (id) => join(`${getLocalStatePath()}.workspaces`, id);
23
+ const preparationDirectory = (capture) => {
24
+ if (!capture.preparation_id || !validId(capture.preparation_id))
25
+ throw new Error('Saved preparation is unavailable.');
26
+ return join(directory(capture.capture_id), capture.preparation_id);
27
+ };
28
+ const sameWorker = (capture, session) => capture.api_url === getApiUrl()
29
+ && capture.room_id === session.room_id && capture.agent_key === session.agent_key && capture.agent_instance_id === session.agent_instance_id;
30
+ async function identity(roomId, agentSessionId) {
31
+ if (requireValidWorkerBearerRuntime().mode !== 'owner')
32
+ throw new Error('Desktop-supervised agents already have automatic capture. These tools require an independent MCP worker.');
33
+ const { agentSession } = await resolveWorkerToolIdentity({ roomId, agentSessionId });
34
+ if (!agentSession.agent_instance_id || !agentSession.session_token || agentSession.ended_at)
35
+ throw new Error('Reconnect this worker with register_agent_session first.');
36
+ if (await isLocalRoomStorageEnabled(agentSession.room_id))
37
+ throw new Error('Workspace sharing requires a hosted LetAgents room. Join the shared room before capturing.');
38
+ assertWorkerConnection(agentSession);
39
+ return agentSession;
40
+ }
41
+ function captures() {
42
+ const snapshot = readLocalStateSnapshot();
43
+ if (!snapshot.complete)
44
+ throw new Error('Local capture state is unavailable. Restore it before continuing.');
45
+ return snapshot.state.workspace_captures ?? {};
46
+ }
47
+ function readCapture(id, session) {
48
+ const capture = validId(id) ? captures()[id] : null;
49
+ if (!capture || !sameWorker(capture, session))
50
+ throw new Error('This worker has no such capture. Call begin_workspace_capture before editing and retain its capture_id.');
51
+ return capture;
52
+ }
53
+ function save(session, update) {
54
+ updateLocalState(state => {
55
+ const stored = state.agent_sessions?.[session.session_id];
56
+ if (!stored || stored.ended_at || stored.session_token !== session.session_token)
57
+ throw new Error('Worker connection changed. Reconnect explicitly before retrying.');
58
+ state.workspace_captures ??= {};
59
+ update(state.workspace_captures);
60
+ });
61
+ }
62
+ async function exclusive(key, operation) {
63
+ if (activeCalls.has(key))
64
+ throw new Error('This worker already has a capture operation in progress. Retry when it finishes.');
65
+ activeCalls.add(key);
66
+ try {
67
+ return await operation();
68
+ }
69
+ finally {
70
+ activeCalls.delete(key);
71
+ }
72
+ }
73
+ async function runCapture(operation, capture, text) {
74
+ if (captureWorkerActive)
75
+ throw new Error('Another workspace is being captured. Retry shortly; the original baseline is preserved.');
76
+ captureWorkerActive = true;
77
+ let worker;
78
+ try {
79
+ return await new Promise((resolve, reject) => {
80
+ worker = new Worker(new URL('../../../../shared/mcp-workspace-capture-worker.mjs', import.meta.url), {
81
+ execArgv: [], workerData: { operation, capture, text, directory: operation === 'finish' ? preparationDirectory(capture) : null },
82
+ });
83
+ const timeout = setTimeout(() => { reject(new Error('Capture exceeded its time limit. Retry using the same capture_id.')); void worker?.terminate(); }, 120_000);
84
+ worker.once('message', result => { clearTimeout(timeout); result.error ? reject(new Error(result.error)) : resolve(result); });
85
+ worker.once('error', error => { clearTimeout(timeout); reject(error); });
86
+ worker.once('exit', () => { clearTimeout(timeout); reject(new Error('Capture stopped before completion.')); });
87
+ });
88
+ }
89
+ finally {
90
+ await worker?.terminate();
91
+ captureWorkerActive = false;
92
+ }
93
+ }
94
+ export async function beginWorkspaceCapture(input) {
95
+ const session = await identity(input.room_id, input.agent_session_id);
96
+ return exclusive(`${getLocalStatePath()}:${session.agent_instance_id}:${session.room_id}`, async () => {
97
+ const prior = Object.values(captures()).find(record => sameWorker(record, session) && !['published', 'blocked'].includes(record.phase));
98
+ // Reject contention before recording a start that could never be captured.
99
+ if (captureWorkerActive)
100
+ throw new Error('Another workspace is being captured. Retry shortly before editing.');
101
+ const cwd = await realpath(resolve(input.cwd || prior?.workspace || getRuntimeWorkingDirectory()));
102
+ const git = async (args) => (await execute('git', ['--no-optional-locks', ...args], {
103
+ cwd, timeout: 5_000, maxBuffer: 16 * 1024,
104
+ env: Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))),
105
+ })).stdout.trim();
106
+ const workspace = await realpath(await git(['rev-parse', '--show-toplevel']));
107
+ if (prior) {
108
+ if (prior.workspace !== workspace)
109
+ throw new Error('This worker has an unfinished capture in another repository. Publish it before beginning a different workspace.');
110
+ return { capture_id: prior.capture_id, workspace: prior.workspace, baseline_available: Boolean(prior.baseline),
111
+ instruction: 'Continue this capture. Keep its capture_id and call publish_workspace_capture when finished.',
112
+ ...(prior.baseline ? {} : { warning: 'The starting snapshot is unavailable. Exact changes for this piece of work cannot be attributed.' }) };
113
+ }
114
+ const baseRevision = await git(['rev-parse', '--verify', 'HEAD']);
115
+ const capture = { capture_id: randomUUID(), api_url: getApiUrl(), room_id: session.room_id,
116
+ agent_key: session.agent_key, agent_instance_id: session.agent_instance_id, workspace, base_revision: baseRevision,
117
+ baseline: null, phase: 'starting', next_page: 0 };
118
+ const obsolete = Object.values(captures()).filter(record => sameWorker(record, session));
119
+ save(session, records => {
120
+ if (Object.values(records).some(record => sameWorker(record, session) && !['published', 'blocked'].includes(record.phase))) {
121
+ throw new Error('This worker already began a capture. Retry begin to retrieve it.');
122
+ }
123
+ for (const [id, record] of Object.entries(records))
124
+ if (sameWorker(record, session))
125
+ delete records[id];
126
+ records[capture.capture_id] = capture;
127
+ });
128
+ for (const record of obsolete)
129
+ await rm(directory(record.capture_id), { recursive: true, force: true });
130
+ try {
131
+ const result = await runCapture('begin', capture);
132
+ capture.baseline = result.baseline ?? null;
133
+ }
134
+ finally {
135
+ // A failed/interrupted start is never silently recaptured after editing.
136
+ capture.phase = 'ready';
137
+ try {
138
+ save(session, records => { records[capture.capture_id] = capture; });
139
+ }
140
+ catch (error) {
141
+ await releaseWorkspaceTree(capture.workspace, `mcp-workspace:${capture.capture_id}`);
142
+ throw error;
143
+ }
144
+ }
145
+ return { capture_id: capture.capture_id, workspace, baseline_available: Boolean(capture.baseline),
146
+ ...(capture.baseline ? {} : { warning: 'A complete starting snapshot could not be captured. Exact changes for this piece of work will be unavailable.' }),
147
+ instruction: 'Make your changes, then call publish_workspace_capture with this capture_id and a short summary. It posts the summary to the room; do not send a duplicate summary message.' };
148
+ });
149
+ }
150
+ async function preparedCapture(capture) {
151
+ if (!capture.preparation_id)
152
+ return null;
153
+ let raw;
154
+ raw = await readFile(join(preparationDirectory(capture), 'prepared.json'), 'utf8');
155
+ const value = JSON.parse(raw);
156
+ const summary = parseRoomAgentWorkSummary(value.summary);
157
+ if (!summary || summary.version !== 3 || typeof value.text !== 'string' || value.text.length > 400
158
+ || (value.archive && (!/^[a-f0-9]{64}$/.test(value.archive.digest) || !Number.isSafeInteger(value.archive.length)
159
+ || value.archive.length < 1 || value.archive.length > REVIEW_LIMIT || value.archive.total !== Math.ceil(value.archive.length / REVIEW_PAGE_SIZE)))) {
160
+ throw new Error('Saved capture is invalid. It cannot be published.');
161
+ }
162
+ return { ...value, summary };
163
+ }
164
+ export async function publishWorkspaceCapture(input) {
165
+ const session = await identity(input.room_id, input.agent_session_id);
166
+ return exclusive(`${getLocalStatePath()}:${session.agent_instance_id}:${session.room_id}`, async () => {
167
+ let capture = readCapture(input.capture_id, session);
168
+ if (capture.phase === 'blocked')
169
+ throw new Error('This capture was deleted or cleared. It will not be republished. Begin a new capture for new work.');
170
+ if (capture.phase === 'published')
171
+ return { status: 'published', capture_id: capture.capture_id, source_message_id: capture.source_message_id, attempt_id: capture.attempt_id };
172
+ let prepared = await preparedCapture(capture);
173
+ if (!prepared) {
174
+ const text = input.summary.trim();
175
+ if (!text || text.length > 400)
176
+ throw new Error('Supply a short summary of at most 400 characters.');
177
+ const candidate = { ...capture, preparation_id: randomUUID() };
178
+ try {
179
+ await runCapture('finish', candidate, text);
180
+ await preparedCapture(candidate);
181
+ // Commit one immutable preparation under the same lock that fences
182
+ // reconnects. Stale processes only write their own disposable directory.
183
+ save(session, records => {
184
+ const current = records[capture.capture_id];
185
+ if (!current || !sameWorker(current, session) || ['published', 'blocked'].includes(current.phase))
186
+ throw new Error('Capture is no longer pending.');
187
+ capture = current.preparation_id ? current : { ...current, preparation_id: candidate.preparation_id };
188
+ records[capture.capture_id] = capture;
189
+ });
190
+ }
191
+ finally {
192
+ if (capture.preparation_id !== candidate.preparation_id)
193
+ await rm(preparationDirectory(candidate), { recursive: true, force: true });
194
+ }
195
+ prepared = await preparedCapture(capture);
196
+ if (!prepared)
197
+ throw new Error('Capture did not finish. Retry with the same capture_id.');
198
+ }
199
+ await releaseWorkspaceTree(capture.workspace, `mcp-workspace:${capture.capture_id}`);
200
+ const payload = prepared;
201
+ const credentials = agentSessionCredentials(session);
202
+ const request = (path, body) => {
203
+ assertWorkerConnection(session);
204
+ return apiCall(`/rooms/${encodeRoomIdPath(session.room_id)}/${path}`, {
205
+ method: 'POST', signal: AbortSignal.timeout(30_000), body: JSON.stringify({ ...body, ...credentials }),
206
+ });
207
+ };
208
+ const persist = () => save(session, records => { records[capture.capture_id] = capture; });
209
+ try {
210
+ if (!capture.source_message_id) {
211
+ const message = await request('messages', { sender: session.actor_label, text: payload.text,
212
+ client_message_id: `mcp-workspace:${capture.capture_id}` });
213
+ if (!/^msg_[1-9]\d{0,9}$/.test(message?.id))
214
+ throw new Error('The server did not confirm the summary message. Retry this capture.');
215
+ capture = { ...capture, source_message_id: message.id, phase: 'publishing' };
216
+ persist();
217
+ }
218
+ const total = payload.archive?.total ?? 1;
219
+ const review = payload.archive ? await open(join(preparationDirectory(capture), 'review'), 'r') : null;
220
+ try {
221
+ // At most 1 MiB per tool call; large uploads resume from the saved page.
222
+ // Each request carries the same immutable summary and 64 KiB archive page.
223
+ const end = Math.min(total, capture.next_page + 16);
224
+ for (let index = capture.next_page; index < end; index++) {
225
+ let page;
226
+ if (review && payload.archive) {
227
+ const length = Math.min(REVIEW_PAGE_SIZE, payload.archive.length - index * REVIEW_PAGE_SIZE);
228
+ const buffer = Buffer.alloc(length);
229
+ const { bytesRead } = await review.read(buffer, 0, length, index * REVIEW_PAGE_SIZE);
230
+ if (bytesRead !== length)
231
+ throw new Error('Saved review is incomplete. It cannot be published.');
232
+ page = { digest: payload.archive.digest, index, total, data: buffer.toString('ascii') };
233
+ }
234
+ const result = await request('agent-work', {
235
+ source_message_id: capture.source_message_id, summary: payload.summary, ...(page ? { review_page: page } : {}),
236
+ });
237
+ if (!validId(result.work?.attempt_id) || result.work.agent_key !== session.agent_key || result.work.room_id !== session.room_id
238
+ || result.work.source_message_id !== capture.source_message_id)
239
+ throw new Error('The server returned a different capture.');
240
+ capture = { ...capture, attempt_id: result.work.attempt_id, next_page: index + 1 };
241
+ persist();
242
+ }
243
+ }
244
+ finally {
245
+ await review?.close();
246
+ }
247
+ if (capture.next_page === total) {
248
+ capture = { ...capture, phase: 'published' };
249
+ persist();
250
+ await rm(directory(capture.capture_id), { recursive: true, force: true });
251
+ }
252
+ return { status: capture.phase === 'published' ? 'published' : 'uploading', capture_id: capture.capture_id,
253
+ source_message_id: capture.source_message_id, attempt_id: capture.attempt_id,
254
+ baseline_available: Boolean(capture.baseline), ...(payload.warning ? { warning: payload.warning } : {}),
255
+ ...(capture.phase === 'published' ? { instruction: 'The summary and captured review are shared in the room. Do not send a duplicate summary.' }
256
+ : { instruction: 'Call publish_workspace_capture again with the same capture_id to finish sharing the captured review. Saved bytes and summary will be reused.' }) };
257
+ }
258
+ catch (error) {
259
+ if (error instanceof ApiError && error.status === 410) {
260
+ capture = { ...capture, phase: 'blocked' };
261
+ persist();
262
+ await rm(directory(capture.capture_id), { recursive: true, force: true });
263
+ }
264
+ throw error;
265
+ }
266
+ });
267
+ }
@@ -6,6 +6,7 @@ import { AGENT_INSTANCE_UUID, RepoRoomAuthRequiredError, apiCall, agentSessionCr
6
6
  import { requireValidWorkerBearerRuntime, workerModeDisabledToolResult, } from "../runtime/worker-bearer.js";
7
7
  import { bindSupervisedWorkerSessionWithContext } from "../runtime/supervisor-bridge.js";
8
8
  import { registerMcpWorker } from "../runtime/worker-handles.js";
9
+ import { WORKSPACE_CAPTURE_INSTRUCTIONS } from './workspace.js';
9
10
  export function registerAgentSessionTools(server) {
10
11
  // -- register_agent_session -------------------------------------------------
11
12
  server.tool("register_agent_session", "Connect a worker to a room. For an independent chat, supply a unique registration_key once, keep the returned worker_id, and use worker_id on room tools. After an MCP restart, reconnect with that worker_id. Separate chats must use separate registration keys. Unregistered traffic remains controller traffic. Legacy agent_session_id registration is retained for existing integrations.", {
@@ -44,6 +45,7 @@ export function registerAgentSessionTools(server) {
44
45
  success: true, worker_id: result.worker.worker_id,
45
46
  agent_session: toPublicAgentSession(result.session),
46
47
  instruction: "Keep worker_id for this chat and pass it to room tools. After an MCP restart, reconnect with register_agent_session(worker_id, room_id). A separate chat needs its own registration_key. Credentials stay private to MCP.",
48
+ workspace_instructions: WORKSPACE_CAPTURE_INSTRUCTIONS,
47
49
  }) }] };
48
50
  }
49
51
  const workerRuntime = requireValidWorkerBearerRuntime();
@@ -277,7 +277,7 @@ export function registerBoardIntentTools(server) {
277
277
  return taskToolError(String(error));
278
278
  }
279
279
  });
280
- server.tool("approve_board_intent", "Approve a pending board intent. For task_create intents this creates the task immediately and returns result.kind=\"task_created\"; do not call add_task afterward. Other intent types return a scoped approval token for the follow-up board action.", {
280
+ server.tool("approve_board_intent", "Approve a pending board intent. For task_create intents this creates the task immediately and returns result.kind=\"task_created\"; do not call add_task afterward. Other intent types notify the proposer to perform the exact follow-up with board_intent_id and their own worker session. The scoped approval token remains available for legacy callers; do not post it in room messages.", {
281
281
  intent_id: z.string().describe("Board intent id to approve."),
282
282
  reason: z.string().optional().describe("Short approval reason."),
283
283
  ...workerTaskIdentitySchema,
@@ -6,8 +6,9 @@ import { resolveTaskToolIdentity, resolveTaskToolTarget, taskActorPayload, } fro
6
6
  import { jsonToolResponse, taskToolError } from "./response.js";
7
7
  import { deprecatedAssigneeSchema, boardIntentApprovalSchema, TASK_STATUSES, workerTaskIdentitySchema, workflowArtifactSchema, } from "./schemas.js";
8
8
  export function registerTaskMutationTools(server) {
9
- server.tool("claim_task", "Claim an accepted task. The task must be in 'accepted' " +
10
- "status. This sets the assignee to you and moves the status to 'assigned'. " +
9
+ server.tool("claim_task", "Claim an accepted task, or retry your own assigned task to recover a missing work lease. " +
10
+ "This sets the assignee to you and moves the status to 'assigned'. A retry with your active lease is idempotent. " +
11
+ "If approval is required, register a task claim intent; after the manager approves it, pass board_intent_id. Managed workers do not need to copy approval tokens. " +
11
12
  "Do NOT claim proposed tasks — they need to be accepted first.", {
12
13
  task_id: z.string().describe("The task ID to claim, e.g. 'task_1'"),
13
14
  assignee: deprecatedAssigneeSchema,
@@ -0,0 +1,11 @@
1
+ import { z } from 'zod';
2
+ import { beginWorkspaceCapture, publishWorkspaceCapture } from '../runtime/workspace-capture.js';
3
+ import { jsonToolResponse } from './messages/response.js';
4
+ export const WORKSPACE_CAPTURE_INSTRUCTIONS = 'For independent coding work you are sharing in a LetAgents room: register this chat as a worker, call begin_workspace_capture before editing, retain capture_id, and call publish_workspace_capture when finished with a short summary. Pass this chat\'s worker_id to both tools. The publish tool posts the summary itself; do not send a duplicate message. If it returns uploading, repeat publish with the same capture_id until published. A missing baseline means exact changes cannot be attributed. Captures include tracked and non-ignored untracked files in the selected repository, and are shared with room participants. These are explicit agent tool calls, not automatic IDE hooks.';
5
+ export function registerWorkspaceTools(server) {
6
+ const identity = { room_id: z.string().min(1).max(512).describe('Canonical hosted room ID.'),
7
+ agent_session_id: z.string().optional().describe('Registered worker session. Prefer the worker_id returned for this chat.') };
8
+ server.tool('begin_workspace_capture', 'Before editing a repository for work shared in this room, record the starting files. Keep capture_id for publish_workspace_capture. Repeating begin preserves this worker\'s outstanding baseline. Files are inspected locally; nothing is posted until publish.', { ...identity, cwd: z.string().optional().describe('Actual project/worktree folder. Defaults to the MCP working directory.') }, async (input) => jsonToolResponse(await beginWorkspaceCapture(input)));
9
+ server.tool('publish_workspace_capture', 'After editing, publish this capture\'s short summary, changed files and saved review to the room as this worker. Posts its own summary message. Retry with the same capture_id after errors or uploading; retries reuse saved bytes. Requires begin_workspace_capture before the work.', { ...identity, capture_id: z.string().uuid().describe('ID returned by begin_workspace_capture for this piece of work.'),
10
+ summary: z.string().trim().min(1).max(400).describe('Plain-English description of what changed. Keep it concise; use Markdown if helpful.') }, async (input) => jsonToolResponse(await publishWorkspaceCapture(input)));
11
+ }
@@ -6,19 +6,20 @@ import { registerTools } from "./server/register-tools.js";
6
6
  import { attachMcpServer, autoJoinFromContext, shutdownRuntime } from "./server/runtime.js";
7
7
  import { requireValidWorkerBearerRuntime } from "./server/runtime/worker-bearer.js";
8
8
  import { executionProfile } from "./server/runtime/execution-profile.js";
9
+ import { WORKSPACE_CAPTURE_INSTRUCTIONS } from "./server/tools/workspace.js";
9
10
  import { LETAGENTS_RUNTIME_CONTRACT_ARG, letAgentsRuntimeContract, } from "./server/runtime-contract.js";
10
11
  async function main() {
11
12
  if (process.argv.slice(2).includes(LETAGENTS_RUNTIME_CONTRACT_ARG)) {
12
13
  process.stdout.write(`${JSON.stringify(letAgentsRuntimeContract())}\n`);
13
14
  return;
14
15
  }
16
+ const profile = executionProfile();
15
17
  const server = new McpServer({
16
18
  name: "letagents",
17
19
  version: "0.2.0",
18
- });
20
+ }, { instructions: profile === "autonomous_mcp_worker" || profile === "interactive_desktop" ? WORKSPACE_CAPTURE_INSTRUCTIONS : undefined });
19
21
  attachMcpServer(server);
20
22
  registerRoomResources(server);
21
- const profile = executionProfile();
22
23
  registerTools(server, profile);
23
24
  requireValidWorkerBearerRuntime();
24
25
  const transport = new StdioServerTransport();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letagents",
3
- "version": "0.12.17",
3
+ "version": "0.12.19",
4
4
  "description": "Let Agents Chat — MCP server for AI agent communication",
5
5
  "type": "module",
6
6
  "main": "dist/mcp/server.js",
@@ -0,0 +1 @@
1
+ export function readableContributionText(value: string | null | undefined, limit?: number): string | null;
@@ -0,0 +1,34 @@
1
+ /** Compact public prose without leaking machine paths or cutting a sentence in half. */
2
+ export function readableContributionText(value, limit = 400) {
3
+ if (!value?.trim()) return null;
4
+ // A legacy 400-character value may already be cut. Never present its trailing fragment.
5
+ const possiblyCut = value.length >= limit;
6
+ const links = [];
7
+ const protect = text => {
8
+ const markdown = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(text);
9
+ const target = markdown?.[2] ?? text;
10
+ const file = /^https?:\/\/(?:github\.com\/[^/]+\/[^/]+\/(?:blob|raw)|raw\.githubusercontent\.com\/[^/]+\/[^/]+)\/[^/]+\/(.+?)(?:[?#].*)?$/.exec(target);
11
+ const name = file?.[1].split('/').at(-1);
12
+ const punctuation = !markdown ? name?.match(/[.,;:!?]+$/)?.[0] ?? '' : '';
13
+ const label = name ? '`' + name.slice(0, name.length - punctuation.length) + '`' + punctuation : markdown?.[1] ?? text;
14
+ return '\u0000LINK' + (links.push(label) - 1) + '\u0000';
15
+ };
16
+ const source = value.replace(/\u0000/g, '').replace(/\[[^\]\n]+\]\(https?:\/\/[^)]+\)|https?:\/\/[^\s<>]+/g, protect);
17
+ let text = source.replace(/\[([^\]\n]+)\]\((<[^>]+>|[^)]+)\)/g, (match, label, target) => /^https?:\/\//.test(target) ? match : '`' + label.split(/[\\/]/).at(-1) + '`')
18
+ .replace(/(?:file:\/\/)?(?:\/[A-Za-z0-9_.~ -]+)+\/([^\s`<>\[\]()]+)|[A-Za-z]:\\(?:[^\s\\]+\\)+([^\s`<>]+)|(?:\.\.?\/)(?:[^\s/]+\/)*([^\s`<>]+)/g,
19
+ (_match, unix, windows, relative) => '`' + (unix || windows || relative) + '`')
20
+ .replace(/(?<!`)``([^`\n]+)``(?!`)/g, '`$1`')
21
+ .replace(/^\s*(?:Done\.?|Path:\s*`[^`]+`)\s*$/gmi, '')
22
+ .trim();
23
+ text = text.replace(/\u0000LINK(\d+)\u0000/g, (_match, index) => links[Number(index)] ?? '');
24
+ if (!text) return null;
25
+ if (text.length > limit || possiblyCut) {
26
+ const prefix = text.slice(0, limit);
27
+ // Keep complete paragraphs or sentences only; a file list below remains the grounded fallback.
28
+ const end = [...prefix.matchAll(/(?<![.!?])[.!?](?=\s|$)/g)].at(-1);
29
+ if (!end) return null;
30
+ text = prefix.slice(0, end.index + 1).trim();
31
+ if ((text.match(/```/g)?.length ?? 0) % 2) text = text.slice(0, text.lastIndexOf('```')).trim();
32
+ }
33
+ return /^Done[.!]?$/i.test(text) ? null : text || null;
34
+ }
@@ -0,0 +1,41 @@
1
+ import { parentPort, workerData } from 'node:worker_threads';
2
+ import { mkdir, writeFile, rename } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { captureWorkspaceTree, captureWorkspacePair } from './workspace-turn-capture.mjs';
5
+ import { encodeWorkspaceReview, REVIEW_PAGE_SIZE } from './workspace-review.mjs';
6
+
7
+ // Git inspection and archive encoding never occupy the MCP transport's thread.
8
+ // A process runs at most one capture worker; only small metadata crosses IPC.
9
+ try {
10
+ const { capture, directory, text } = workerData;
11
+ const identity = `mcp-workspace:${capture.capture_id}`;
12
+ if (workerData.operation === 'begin') {
13
+ parentPort.postMessage({ baseline: await captureWorkspaceTree(capture.workspace, identity) });
14
+ } else {
15
+ const pair = await captureWorkspacePair(capture.workspace, capture.base_revision, capture.baseline, `${identity}:${capture.preparation_id}`);
16
+ pair.contribution.summary = text;
17
+ const { review, ...preview } = pair;
18
+ const summary = { version: 3, recorded_state: 'completed', evidence_incomplete: true, elapsed_ms: null,
19
+ operation_counts: { unresolved: 0, succeeded: 0, failed: 0, denied_before_start: 0,
20
+ cancelled_before_start: 0, interrupted_after_start: 0, lost_after_start: 0 }, ...preview };
21
+ let encoded = null;
22
+ let warning = capture.baseline ? null : 'The starting snapshot is unavailable; no exact change attribution is available.';
23
+ try { encoded = encodeWorkspaceReview(review); }
24
+ catch (error) {
25
+ if (!(error instanceof RangeError)) throw error;
26
+ warning = 'This capture exceeds the full-review size limit. Only its bounded preview is available.';
27
+ }
28
+ await mkdir(directory, { recursive: true, mode: 0o700 });
29
+ if (encoded) {
30
+ await writeFile(join(directory, 'review.tmp'), encoded.data, { mode: 0o600 });
31
+ await rename(join(directory, 'review.tmp'), join(directory, 'review'));
32
+ }
33
+ const prepared = { summary, text, warning,
34
+ archive: encoded ? { digest: encoded.digest, length: encoded.data.length, total: Math.ceil(encoded.data.length / REVIEW_PAGE_SIZE) } : null };
35
+ await writeFile(join(directory, 'prepared.tmp'), JSON.stringify(prepared), { mode: 0o600 });
36
+ await rename(join(directory, 'prepared.tmp'), join(directory, 'prepared.json'));
37
+ parentPort.postMessage({ prepared: true });
38
+ }
39
+ } catch {
40
+ parentPort.postMessage({ error: 'Workspace capture could not finish. Retry with the same capture_id.' });
41
+ }
@@ -1,7 +1,10 @@
1
+ import type { WorkspaceChangeSummary } from "./workspace-change-summary.mjs";
1
2
  export const ROOM_WORK_STATES: readonly ["active", "completed", "completed_no_reply", "failed", "interrupted", "lost", "unknown"];
2
3
  export const ROOM_WORK_OPERATION_OUTCOMES: readonly ["unresolved", "succeeded", "failed", "denied_before_start", "cancelled_before_start", "interrupted_after_start", "lost_after_start"];
3
4
  export type RoomAgentWorkSummary = {
4
- version: 1;
5
+ version: 1 | 2 | 3;
6
+ contribution?: { changes: WorkspaceChangeSummary; summary: string | null };
7
+ workspace?: WorkspaceChangeSummary;
5
8
  recorded_state: typeof ROOM_WORK_STATES[number];
6
9
  evidence_incomplete: boolean;
7
10
  elapsed_ms: number | null;
@@ -1,6 +1,7 @@
1
- // Room-public, host-reported evidence; never execution or approval authority.
2
- // No free-form strings, native handles, paths, command text, or output belong
3
- // in this version. Both the publisher and server use this strict boundary.
1
+ import { parseWorkspaceChangeSummary } from './workspace-change-summary.mjs';
2
+ // v1 is numeric execution evidence. v2 additionally carries a bounded,
3
+ // deliberately room-visible workspace review snapshot. v3 separates changes
4
+ // during one turn from the cumulative workspace, with an optional public summary.
4
5
  export const ROOM_WORK_STATES = [
5
6
  "active", "completed", "completed_no_reply", "failed", "interrupted", "lost", "unknown",
6
7
  ];
@@ -21,8 +22,22 @@ export function isClearedRoomAgentWorkSummary(value) {
21
22
 
22
23
  /** Return a canonical allowlisted copy, or reject without echoing private input. */
23
24
  export function parseRoomAgentWorkSummary(value) {
24
- if (!exactKeys(value, ["version", "recorded_state", "evidence_incomplete", "elapsed_ms", "operation_counts"])
25
- || value.version !== 1 || !ROOM_WORK_STATES.includes(value.recorded_state)
25
+ const workspace = [2, 3].includes(value?.version) ? parseWorkspaceChangeSummary(value.workspace) : null;
26
+ const keys = ["version", "recorded_state", "evidence_incomplete", "elapsed_ms", "operation_counts"];
27
+ if ([2, 3].includes(value?.version)) keys.push("workspace");
28
+ let contribution = null;
29
+ if (value?.version === 3) {
30
+ keys.push("contribution");
31
+ const candidate = value.contribution;
32
+ if (!exactKeys(candidate, ["changes", "summary"]) || !(candidate.summary === null
33
+ || typeof candidate.summary === "string" && candidate.summary.length <= 400)) return null;
34
+ const changes = parseWorkspaceChangeSummary(candidate.changes);
35
+ if (!changes) return null;
36
+ contribution = { changes, summary: candidate.summary };
37
+ if (new TextEncoder().encode(JSON.stringify(value)).length > 500 * 1024) return null;
38
+ }
39
+ if (!exactKeys(value, keys)
40
+ || ![1, 2, 3].includes(value.version) || (value.version >= 2 && !workspace) || !ROOM_WORK_STATES.includes(value.recorded_state)
26
41
  || typeof value.evidence_incomplete !== "boolean"
27
42
  || (value.elapsed_ms !== null && (!Number.isSafeInteger(value.elapsed_ms) || Number(value.elapsed_ms) < 0))
28
43
  || !exactKeys(value.operation_counts, ROOM_WORK_OPERATION_OUTCOMES)) return null;
@@ -37,7 +52,9 @@ export function parseRoomAgentWorkSummary(value) {
37
52
  // A bounded evidence snapshot, not an unbounded lifetime counter.
38
53
  if (total > 10_000) return null;
39
54
  return {
40
- version: 1, recorded_state: value.recorded_state,
55
+ version: value.version, recorded_state: value.recorded_state,
56
+ ...(workspace ? { workspace } : {}),
57
+ ...(contribution ? { contribution } : {}),
41
58
  evidence_incomplete: value.evidence_incomplete, elapsed_ms: value.elapsed_ms,
42
59
  operation_counts: counts,
43
60
  };
@@ -0,0 +1,2 @@
1
+ import type { WorkspaceChangeSummary } from './workspace-change-summary.mjs';
2
+ export function captureWorkspaceChanges(workspace: string, startingRevision: string | null, settledTree?: string, fullReview?: boolean): Promise<WorkspaceChangeSummary>;
@@ -0,0 +1,147 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { constants } from 'node:fs';
3
+ import { open, readlink, lstat } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { WORKSPACE_FILE_LIMIT, WORKSPACE_PATCH_LIMIT, parseWorkspaceChangeSummary } from './workspace-change-summary.mjs';
7
+ const execute = promisify(execFile);
8
+ async function git(cwd, args, limit = 4 * 1024 * 1024) {
9
+ const result = await execute('git', ['--no-optional-locks', ...args], {
10
+ cwd, encoding: 'utf8', timeout: 5_000, maxBuffer: limit,
11
+ env: { ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))), GIT_TERMINAL_PROMPT: '0' },
12
+ });
13
+ return result.stdout;
14
+ }
15
+ /** Reads the actual provider workspace; never stages, commits, or changes its index. */
16
+ export async function captureWorkspaceChanges(workspace, startingRevision, settledTree, fullReview = false) {
17
+ const patchLimit = fullReview ? 128 * 1024 * 1024 : WORKSPACE_PATCH_LIMIT;
18
+ const fileLimit = fullReview ? 10_000 : WORKSPACE_FILE_LIMIT;
19
+ const empty = (state) => ({
20
+ captured_at: new Date().toISOString(), branch: null, base_revision: null, state,
21
+ files: [], additions: 0, deletions: 0, hidden_files: 0, patch: '', patch_truncated: false,
22
+ });
23
+ try {
24
+ if ((await git(workspace, ['rev-parse', '--is-inside-work-tree'])).trim() !== 'true')
25
+ return empty('not_git');
26
+ }
27
+ catch (error) {
28
+ return empty(/not a git repository/i.test(String(error.stderr ?? '')) ? 'not_git' : 'unavailable');
29
+ }
30
+ try {
31
+ if (startingRevision !== null && !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(startingRevision))
32
+ return empty('unavailable');
33
+ const branch = await git(workspace, ['symbolic-ref', '--quiet', '--short', 'HEAD']).then(value => value.trim(), () => null);
34
+ if (settledTree && !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(settledTree))
35
+ return empty('unavailable');
36
+ const base = await git(workspace, ['rev-parse', '--verify', `${startingRevision ?? 'HEAD'}^{tree}`])
37
+ .then(value => value.trim(), error => { if (startingRevision)
38
+ throw error; return null; });
39
+ const diffArgs = ['--no-ext-diff', '--no-textconv', '--no-color', '-M'];
40
+ const [names, stats, untracked] = await Promise.all([
41
+ base ? git(workspace, ['diff', ...diffArgs, '--name-status', '-z', base, ...(settledTree ? [settledTree] : []), '--']) : Promise.resolve(''),
42
+ base ? git(workspace, ['diff', ...diffArgs, '--numstat', '-z', base, ...(settledTree ? [settledTree] : []), '--']) : Promise.resolve(''),
43
+ settledTree ? Promise.resolve('') : git(workspace, ['ls-files', '--others', '--exclude-standard', ...(base ? [] : ['--cached']), '-z']),
44
+ ]);
45
+ const files = new Map();
46
+ const nameParts = names.split('\0');
47
+ for (let i = 0; i < nameParts.length - 1;) {
48
+ const code = nameParts[i++];
49
+ const first = nameParts[i++];
50
+ const renamed = code.startsWith('R') || code.startsWith('C');
51
+ const path = renamed ? nameParts[i++] : first;
52
+ const status = { A: 'added', M: 'modified', D: 'deleted', R: 'renamed', C: 'copied', T: 'typechange' }[code[0]] ?? 'unknown';
53
+ files.set(path, { path, previous_path: renamed ? first : null, status, additions: 0, deletions: 0, binary: false });
54
+ }
55
+ const statParts = stats.split('\0');
56
+ for (let i = 0; i < statParts.length; i++) {
57
+ const match = /^([^\t]+)\t([^\t]+)\t([\s\S]*)$/.exec(statParts[i]);
58
+ if (!match)
59
+ continue;
60
+ let path = match[3];
61
+ if (!path) {
62
+ path = statParts[i + 2];
63
+ i += 2;
64
+ }
65
+ const file = files.get(path);
66
+ if (!file)
67
+ continue;
68
+ file.binary = match[1] === '-' || match[2] === '-';
69
+ file.additions = file.binary ? 0 : Number(match[1]);
70
+ file.deletions = file.binary ? 0 : Number(match[2]);
71
+ }
72
+ let patch = '';
73
+ let truncated = false;
74
+ if (base) {
75
+ try {
76
+ patch = await git(workspace, ['diff', ...diffArgs, '--unified=3', base, ...(settledTree ? [settledTree] : []), '--'], patchLimit);
77
+ }
78
+ catch (error) {
79
+ const failure = error;
80
+ if (failure.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER')
81
+ throw error;
82
+ patch = String(failure.stdout ?? '').slice(0, patchLimit);
83
+ truncated = true;
84
+ }
85
+ }
86
+ const newPaths = [...new Set(untracked.split('\0').filter(Boolean))];
87
+ let inspectedFiles = 0;
88
+ for (const path of newPaths) {
89
+ if (files.has(path))
90
+ continue;
91
+ const file = { path, previous_path: null, status: base ? 'untracked' : 'added', additions: 0, deletions: 0, binary: false };
92
+ files.set(path, file);
93
+ if (++inspectedFiles > fileLimit) {
94
+ truncated = true;
95
+ continue;
96
+ }
97
+ // Do not follow symlinks into files outside the workspace.
98
+ const absolute = join(workspace, path);
99
+ const metadata = await lstat(absolute);
100
+ let bytes;
101
+ if (metadata.isSymbolicLink())
102
+ bytes = Buffer.from(await readlink(absolute));
103
+ else if (metadata.isFile() && metadata.size <= 1024 * 1024) {
104
+ const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
105
+ try {
106
+ const buffer = Buffer.alloc(1024 * 1024 + 1);
107
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
108
+ bytes = buffer.subarray(0, bytesRead);
109
+ if (bytesRead > 1024 * 1024) {
110
+ truncated = true;
111
+ continue;
112
+ }
113
+ }
114
+ finally {
115
+ await handle.close();
116
+ }
117
+ }
118
+ else {
119
+ truncated = true;
120
+ continue;
121
+ }
122
+ file.binary = bytes.includes(0);
123
+ if (file.binary)
124
+ continue;
125
+ const text = bytes.toString('utf8');
126
+ const lines = text ? text.replace(/\n$/, '').split('\n') : [];
127
+ file.additions = lines.length;
128
+ if (patch.length < patchLimit) {
129
+ const label = JSON.stringify(`b/${path}`);
130
+ const addition = `diff --git ${JSON.stringify(`a/${path}`)} ${label}\nnew file mode ${metadata.isSymbolicLink() ? '120000' : '100644'}\n--- /dev/null\n+++ ${label}\n@@ -0,0 +1,${lines.length} @@\n${lines.map(line => `+${line}\n`).join('')}`;
131
+ patch += addition;
132
+ }
133
+ else
134
+ truncated = true;
135
+ }
136
+ const all = [...files.values()];
137
+ const result = parseWorkspaceChangeSummary({ ...empty('ready'), branch, base_revision: startingRevision ?? base,
138
+ files: all.slice(0, fileLimit), additions: all.reduce((n, file) => n + file.additions, 0),
139
+ deletions: all.reduce((n, file) => n + file.deletions, 0), hidden_files: Math.max(0, all.length - fileLimit),
140
+ patch: patch.slice(0, patchLimit), patch_truncated: truncated || patch.length > patchLimit,
141
+ }, fullReview);
142
+ return result ?? empty('unavailable');
143
+ }
144
+ catch {
145
+ return empty('unavailable');
146
+ }
147
+ }
@@ -0,0 +1,24 @@
1
+ export const WORKSPACE_PATCH_LIMIT: number;
2
+ export const WORKSPACE_FILE_LIMIT: number;
3
+ export type WorkspaceChangedFile = {
4
+ path: string;
5
+ previous_path: string | null;
6
+ status: 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechange' | 'untracked' | 'unknown';
7
+ additions: number;
8
+ deletions: number;
9
+ binary: boolean;
10
+ };
11
+ export type WorkspaceChangeSummary = {
12
+ captured_at: string;
13
+ branch: string | null;
14
+ /** Immutable workspace starting revision; includes committed work since creation. */
15
+ base_revision: string | null;
16
+ state: 'ready' | 'unavailable' | 'not_git';
17
+ files: WorkspaceChangedFile[];
18
+ additions: number;
19
+ deletions: number;
20
+ hidden_files: number;
21
+ patch: string;
22
+ patch_truncated: boolean;
23
+ };
24
+ export function parseWorkspaceChangeSummary(value: unknown, fullReview?: boolean): WorkspaceChangeSummary | null;
@@ -0,0 +1,44 @@
1
+ // Bounded room-visible source changes. Absolute host paths and arbitrary metadata
2
+ // are excluded; patch text is intentional review content, never execution input.
3
+ export const WORKSPACE_PATCH_LIMIT = 128 * 1024;
4
+ export const WORKSPACE_FILE_LIMIT = 200;
5
+ const states = ['added', 'modified', 'deleted', 'renamed', 'copied', 'typechange', 'untracked', 'unknown'];
6
+ const exact = (value, keys) => value && typeof value === 'object' && !Array.isArray(value)
7
+ && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
8
+ const count = value => Number.isSafeInteger(value) && value >= 0;
9
+ const path = value => typeof value === 'string' && value.length > 0 && value.length <= 2048
10
+ && !value.startsWith('/') && !/^[A-Za-z]:/.test(value) && !value.split(/[\\/]/).includes('..')
11
+ && !/[\x00-\x1f\x7f]/.test(value);
12
+
13
+ export function parseWorkspaceChangeSummary(value, fullReview = false) {
14
+ if (!exact(value, ['captured_at', 'branch', 'base_revision', 'state', 'files', 'additions', 'deletions', 'hidden_files', 'patch', 'patch_truncated'])
15
+ || typeof value.captured_at !== 'string' || !Number.isFinite(Date.parse(value.captured_at))
16
+ || !(value.branch === null || typeof value.branch === 'string' && value.branch.length <= 256 && !/[\x00-\x1f\x7f]/.test(value.branch))
17
+ || !(value.base_revision === null || typeof value.base_revision === 'string' && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value.base_revision))
18
+ || !['ready', 'unavailable', 'not_git'].includes(value.state)
19
+ || !Array.isArray(value.files) || value.files.length > (fullReview ? 10_000 : WORKSPACE_FILE_LIMIT)
20
+ || !count(value.additions) || !count(value.deletions) || !count(value.hidden_files)
21
+ || typeof value.patch !== 'string' || value.patch.length > (fullReview ? 128 * 1024 * 1024 : WORKSPACE_PATCH_LIMIT)
22
+ || typeof value.patch_truncated !== 'boolean') return null;
23
+ // Full reviews are byte-bounded by their encoded envelope. Re-serializing each
24
+ // patch here creates several full-size copies while decoding a large review.
25
+ if (!fullReview && new TextEncoder().encode(JSON.stringify(value)).length > 480 * 1024) return null;
26
+ const files = [];
27
+ const paths = new Set();
28
+ for (const file of value.files) {
29
+ if (!exact(file, ['path', 'previous_path', 'status', 'additions', 'deletions', 'binary'])
30
+ || !path(file.path) || paths.has(file.path)
31
+ || !(file.previous_path === null || path(file.previous_path)) || !states.includes(file.status)
32
+ || !count(file.additions) || !count(file.deletions) || typeof file.binary !== 'boolean') return null;
33
+ paths.add(file.path);
34
+ files.push({ path: file.path, previous_path: file.previous_path, status: file.status,
35
+ additions: file.additions, deletions: file.deletions, binary: file.binary });
36
+ }
37
+ if (value.state !== 'ready' && (files.length || value.additions || value.deletions || value.hidden_files || value.patch || value.patch_truncated)) return null;
38
+ if (files.reduce((n, file) => n + file.additions, 0) > value.additions
39
+ || files.reduce((n, file) => n + file.deletions, 0) > value.deletions) return null;
40
+ return { captured_at: new Date(value.captured_at).toISOString(), branch: value.branch,
41
+ base_revision: value.base_revision, state: value.state, files, additions: value.additions,
42
+ deletions: value.deletions, hidden_files: value.hidden_files, patch: value.patch,
43
+ patch_truncated: value.patch_truncated };
44
+ }
@@ -0,0 +1,9 @@
1
+ import type { WorkspaceChangedFile } from './workspace-change-summary.mjs';
2
+ export type WorkspaceDiffLine = { text: string; kind: 'context' | 'added' | 'deleted' | 'hunk' | 'metadata'; before: number | null; after: number | null };
3
+ export type WorkspaceDiffPageLine = WorkspaceDiffLine & { textLength: number; textOffset: number; nextTextOffset: number | null };
4
+ export type WorkspaceDiffPage = { lines: WorkspaceDiffPageLine[]; nextOffset: number | null; included: boolean };
5
+ export type WorkspaceDiffPageOptions = { offset?: number; textOffset?: number; singleLine?: boolean };
6
+ export type WorkspaceDiffIndex = { patch: string; files: Map<string, { end: number; checkpoints: { position: number; before: number; after: number }[] }> };
7
+ export function createWorkspaceDiffIndex(patch: string, files: WorkspaceChangedFile[]): WorkspaceDiffIndex;
8
+ export function readWorkspaceDiffPage(index: WorkspaceDiffIndex, path: string, options?: WorkspaceDiffPageOptions): WorkspaceDiffPage;
9
+ export function workspaceFilePatches(patch: string, files: WorkspaceChangedFile[], window?: { offset: number; limit: number }): Map<string, WorkspaceDiffLine[]>;
@@ -0,0 +1,105 @@
1
+ function unquotePath(path) {
2
+ if (!path.startsWith('"') || !path.endsWith('"')) return path;
3
+ const bytes = [];
4
+ const source = path.slice(1, -1);
5
+ const encoder = new TextEncoder();
6
+ for (let i = 0; i < source.length;) {
7
+ const octal = source[i] === '\\' ? /^\\([0-7]{1,3})/.exec(source.slice(i)) : null;
8
+ if (octal) { bytes.push(parseInt(octal[1], 8)); i += octal[0].length; continue; }
9
+ if (source[i] === '\\' && i + 1 < source.length) {
10
+ const char = source[++i];
11
+ bytes.push(...encoder.encode(({ t: '\t', n: '\n', r: '\r', b: '\b', f: '\f', v: '\v' })[char] ?? char));
12
+ i++; continue;
13
+ }
14
+ const char = String.fromCodePoint(source.codePointAt(i));
15
+ bytes.push(...encoder.encode(char)); i += char.length;
16
+ }
17
+ return new TextDecoder().decode(new Uint8Array(bytes));
18
+ }
19
+
20
+ // File boundaries and sparse line checkpoints are retained only by an open review.
21
+ export function createWorkspaceDiffIndex(patch, files) {
22
+ const byPath = new Map(files.map(file => [file.path, file]));
23
+ const byHeader = new Map(files.flatMap(file => [
24
+ [`diff --git a/${file.previous_path ?? file.path} b/${file.path}`, file],
25
+ [`diff --git ${JSON.stringify(`a/${file.previous_path ?? file.path}`)} ${JSON.stringify(`b/${file.path}`)}`, file],
26
+ ]));
27
+ const result = { patch, files: new Map() };
28
+ const boundary = /^diff --git /gm;
29
+ let current = boundary.exec(patch);
30
+ while (current) {
31
+ const next = boundary.exec(patch), end = next?.index ?? patch.length;
32
+ const headers = [];
33
+ let position = current.index;
34
+ while (position < end) {
35
+ const newline = patch.indexOf('\n', position);
36
+ const stop = newline < 0 || newline >= end ? end : newline;
37
+ const line = patch.slice(position, stop);
38
+ if (line.startsWith('@@ ')) break;
39
+ headers.push(line); position = stop + 1;
40
+ }
41
+ const target = headers.find(line => line.startsWith('+++ '));
42
+ const source = headers.find(line => line.startsWith('--- '));
43
+ const path = target && target !== '+++ /dev/null' ? unquotePath(target.slice(4)).replace(/^b\//, '')
44
+ : source ? unquotePath(source.slice(4)).replace(/^a\//, '') : null;
45
+ const renamed = headers.find(line => line.startsWith('rename to '));
46
+ const file = byPath.get(path) ?? (renamed ? byPath.get(unquotePath(renamed.slice(10))) : undefined) ?? byHeader.get(headers[0]);
47
+ if (file) result.files.set(file.path, { end, checkpoints: [{ position, before: 0, after: 0 }] });
48
+ current = next;
49
+ }
50
+ return result;
51
+ }
52
+
53
+ function readLines(index, path, offset, limit, textOffset, characterLimit, budget) {
54
+ const file = index.files.get(path);
55
+ if (!file) return { lines: [], nextOffset: null, included: false };
56
+ const checkpointIndex = Math.min(Math.floor(offset / 500), file.checkpoints.length - 1);
57
+ let { position, before, after } = file.checkpoints[checkpointIndex];
58
+ let row = checkpointIndex * 500;
59
+ const lines = [];
60
+ while (position < file.end) {
61
+ const newline = index.patch.indexOf('\n', position);
62
+ const stop = newline < 0 || newline >= file.end ? file.end : newline;
63
+ const start = position;
64
+ position = stop + 1;
65
+ // Only hunk headers need a regular expression; never slice a giant code line.
66
+ const first = index.patch[start];
67
+ const hunk = first === '@' ? /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(index.patch.slice(start, Math.min(stop, start + 256))) : null;
68
+ let kind, oldLine = null, newLine = null, contentStart = start;
69
+ if (hunk) { before = Number(hunk[1]); after = Number(hunk[2]); kind = 'hunk'; }
70
+ else if (first === '+') { kind = 'added'; contentStart++; newLine = after++; }
71
+ else if (first === '-') { kind = 'deleted'; contentStart++; oldLine = before++; }
72
+ else if (first === ' ') { kind = 'context'; contentStart++; oldLine = before++; newLine = after++; }
73
+ else if (stop > start) kind = 'metadata';
74
+ else continue;
75
+ if (row >= offset) {
76
+ const textLength = stop - contentStart;
77
+ let from = Math.min(textOffset, textLength), to = Math.min(textLength, from + characterLimit, from + budget);
78
+ // Keep UTF-16 surrogate pairs intact at display-chunk boundaries.
79
+ if (from && /[\uDC00-\uDFFF]/.test(index.patch[contentStart + from]) && /[\uD800-\uDBFF]/.test(index.patch[contentStart + from - 1])) from--;
80
+ if (to < textLength && /[\uD800-\uDBFF]/.test(index.patch[contentStart + to - 1])) to--;
81
+ const text = index.patch.slice(contentStart + from, contentStart + to);
82
+ lines.push({ text, kind, before: oldLine, after: newLine, textLength, textOffset: from, nextTextOffset: to < textLength ? to : null });
83
+ budget -= text.length;
84
+ }
85
+ row++;
86
+ if (row % 500 === 0 && !file.checkpoints[row / 500]) file.checkpoints[row / 500] = { position, before, after };
87
+ if (lines.length >= limit || budget <= 0) break;
88
+ }
89
+ return { lines, nextOffset: position < file.end ? row : null, included: true };
90
+ }
91
+
92
+ export function readWorkspaceDiffPage(index, path, options = {}) {
93
+ const { offset = 0, textOffset = 0, singleLine = false } = options;
94
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > index.patch.length
95
+ || !Number.isSafeInteger(textOffset) || textOffset < 0 || textOffset > index.patch.length) throw new Error('Invalid diff page.');
96
+ return readLines(index, path, offset, singleLine ? 1 : 500, textOffset, 4096, 128 * 1024);
97
+ }
98
+
99
+ // Existing callers can still inspect a small preview without a review session.
100
+ export function workspaceFilePatches(patch, files, window) {
101
+ const index = createWorkspaceDiffIndex(patch, files);
102
+ return new Map([...index.files.keys()].map(path => [path,
103
+ readLines(index, path, window?.offset ?? 0, window?.limit ?? Infinity, 0, Infinity, Infinity).lines
104
+ .map(({ text, kind, before, after }) => ({ text, kind, before, after }))]));
105
+ }
@@ -0,0 +1,47 @@
1
+ import { parentPort } from 'node:worker_threads';
2
+ import { DatabaseSync } from 'node:sqlite';
3
+ import { decodeWorkspaceReview, parseWorkspaceReviewPage } from './workspace-review.mjs';
4
+ import { createWorkspaceDiffIndex, readWorkspaceDiffPage } from './workspace-diff.mjs';
5
+
6
+ let review = null;
7
+ let pages = [], digest = '', total = 0;
8
+ const indexes = new Map();
9
+ const describe = () => ({ version: 1, workspace: { ...review.workspace, patch: '' }, contribution: { ...review.contribution, patch: '' } });
10
+ parentPort.on('message', ({ id, method, input }) => {
11
+ try {
12
+ let result;
13
+ if (method === 'local') {
14
+ let db;
15
+ try {
16
+ db = new DatabaseSync(input.databasePath, { readOnly: true });
17
+ const row = db.prepare(`SELECT r.data,r.digest FROM room_workspace_reviews r JOIN room_work_publications p
18
+ USING(agent_id,room_id,source_message_id) WHERE p.room_id=? AND p.agent_key=? AND p.source_message_id=? AND p.api_origin=? AND p.state='open'`)
19
+ .get(input.roomId, input.agentKey, input.sourceMessageId, input.apiOrigin);
20
+ if (row) review = decodeWorkspaceReview(String(row.data), String(row.digest));
21
+ } catch { review = null; } finally { db?.close(); }
22
+ result = review ? describe() : null;
23
+ } else if (method === 'append') {
24
+ if (!Array.isArray(input) || input.length > 4) throw new Error('Invalid review pages.');
25
+ if (!pages.length) { review = null; indexes.clear(); }
26
+ for (const raw of input) {
27
+ const page = parseWorkspaceReviewPage(raw);
28
+ if (!page || page.index !== pages.length || (pages.length && (page.digest !== digest || page.total !== total))) throw new Error('Incomplete workspace review.');
29
+ digest = page.digest; total = page.total; pages.push(page.data);
30
+ }
31
+ result = null;
32
+ } else if (method === 'finish') {
33
+ if (!total || pages.length !== total) throw new Error('Incomplete workspace review.');
34
+ const data = pages.join(''); pages = [];
35
+ review = decodeWorkspaceReview(data, digest);
36
+ result = describe();
37
+ } else if (method === 'page') {
38
+ if (!review || !['workspace', 'contribution'].includes(input.view) || typeof input.path !== 'string') throw new Error('Review is not open.');
39
+ if (!indexes.has(input.view)) {
40
+ const snapshot = review[input.view];
41
+ indexes.set(input.view, createWorkspaceDiffIndex(snapshot.patch, snapshot.files));
42
+ }
43
+ result = readWorkspaceDiffPage(indexes.get(input.view), input.path, input);
44
+ } else throw new Error('Invalid review operation.');
45
+ parentPort.postMessage({ id, result });
46
+ } catch (error) { parentPort.postMessage({ id, error: error.message }); }
47
+ });
@@ -0,0 +1,10 @@
1
+ import type { WorkspaceChangeSummary } from './workspace-change-summary.mjs';
2
+ export const REVIEW_LIMIT: number;
3
+ export const REVIEW_PAGE_SIZE: number;
4
+ export const REVIEW_MAX_PAGES: number;
5
+ export type WorkspaceReview = { version: 1; workspace: WorkspaceChangeSummary; contribution: WorkspaceChangeSummary };
6
+ export type WorkspaceReviewPage = { digest: string; index: number; total: number; data: string };
7
+ export function parseWorkspaceReview(value: unknown): WorkspaceReview | null;
8
+ export function encodeWorkspaceReview(value: WorkspaceReview): { data: string; digest: string };
9
+ export function decodeWorkspaceReview(data: string, digest: string): WorkspaceReview;
10
+ export function parseWorkspaceReviewPage(value: unknown): WorkspaceReviewPage | null;
@@ -0,0 +1,45 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { gzipSync, gunzipSync } from 'node:zlib';
3
+ import { parseWorkspaceChangeSummary } from './workspace-change-summary.mjs';
4
+
5
+ // Full review bytes travel separately from the bounded room timeline.
6
+ export const REVIEW_LIMIT = 128 * 1024 * 1024;
7
+ export const REVIEW_PAGE_SIZE = 64 * 1024;
8
+ export const REVIEW_MAX_PAGES = REVIEW_LIMIT / REVIEW_PAGE_SIZE;
9
+ export function parseWorkspaceReview(value) {
10
+ if (!value || value.version !== 1 || Object.keys(value).sort().join(',') !== 'contribution,version,workspace') return null;
11
+ const workspace = parseWorkspaceChangeSummary(value.workspace, true);
12
+ const contribution = parseWorkspaceChangeSummary(value.contribution, true);
13
+ if (!workspace || !contribution) return null;
14
+ return { version: 1, workspace, contribution };
15
+ }
16
+ export function encodeWorkspaceReview(value) {
17
+ const parsed = parseWorkspaceReview(value);
18
+ if (!parsed) throw new Error('Invalid full workspace review.');
19
+ const json = JSON.stringify(parsed);
20
+ if (Buffer.byteLength(json) > REVIEW_LIMIT) throw new RangeError('Workspace review exceeds capture capacity.');
21
+ const data = gzipSync(json).toString('base64');
22
+ if (data.length > REVIEW_LIMIT) throw new RangeError('Workspace review exceeds transfer capacity.');
23
+ return { data, digest: createHash('sha256').update(data).digest('hex') };
24
+ }
25
+ export function decodeWorkspaceReview(data, digest) {
26
+ if (typeof data !== 'string' || data.length > REVIEW_LIMIT || createHash('sha256').update(data).digest('hex') !== digest) throw new Error('Incomplete workspace review.');
27
+ const compressed = Buffer.from(data, 'base64');
28
+ // Our single-member gzip records its output size in the trailer. Use it only
29
+ // as a bounded allocation hint: zlib still verifies CRC/size and enforces the
30
+ // output limit. One spare byte avoids allocating another full output buffer.
31
+ const chunkSize = compressed.length < 4 ? 16 * 1024
32
+ : Math.max(16 * 1024, Math.min(REVIEW_LIMIT + 1, compressed.readUInt32LE(compressed.length - 4) + 1));
33
+ const value = parseWorkspaceReview(JSON.parse(gunzipSync(compressed, { maxOutputLength: REVIEW_LIMIT, chunkSize }).toString('utf8')));
34
+ if (!value) throw new Error('Invalid workspace review.');
35
+ return value;
36
+ }
37
+ export function parseWorkspaceReviewPage(value) {
38
+ if (!value || Object.keys(value).sort().join(',') !== 'data,digest,index,total'
39
+ || typeof value.digest !== 'string' || !/^[a-f0-9]{64}$/.test(value.digest)
40
+ || !Number.isSafeInteger(value.total) || value.total < 1 || value.total > REVIEW_MAX_PAGES
41
+ || !Number.isSafeInteger(value.index) || value.index < 0 || value.index >= value.total
42
+ || typeof value.data !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value.data)
43
+ || value.data.length > REVIEW_PAGE_SIZE || (value.index < value.total - 1 && value.data.length !== REVIEW_PAGE_SIZE)) return null;
44
+ return { digest: value.digest, index: value.index, total: value.total, data: value.data };
45
+ }
@@ -0,0 +1,10 @@
1
+ import type { WorkspaceChangeSummary } from './workspace-change-summary.mjs';
2
+ import type { WorkspaceReview } from './workspace-review.mjs';
3
+ export function unavailableWorkspace(): WorkspaceChangeSummary;
4
+ export function captureWorkspaceTree(workspace: string, identity: string): Promise<string | null>;
5
+ export function releaseWorkspaceTree(workspace: string, identity: string): Promise<void>;
6
+ export function captureWorkspacePair(workspace: string, startingRevision: string | null, baseline: string | null, identity: string): Promise<{
7
+ workspace: WorkspaceChangeSummary;
8
+ contribution: { changes: WorkspaceChangeSummary; summary: string | null };
9
+ review: WorkspaceReview;
10
+ }>;
@@ -0,0 +1,118 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, mkdtemp, open, readlink, realpath, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join, relative } from 'node:path';
6
+ import { createHash } from 'node:crypto';
7
+ import { captureWorkspaceChanges } from './workspace-change-capture.mjs';
8
+ const environment = () => Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
9
+ function git(cwd, args, signal, index, input) {
10
+ return new Promise((resolve, reject) => {
11
+ const child = execFile('git', ['-c', 'core.hooksPath=/dev/null', '--no-optional-locks', ...args], {
12
+ cwd, signal, timeout: 5_000, maxBuffer: 4 * 8 * 1024 * 1024, encoding: 'utf8',
13
+ env: { ...environment(), GIT_TERMINAL_PROMPT: '0', ...(index ? { GIT_INDEX_FILE: index } : {}) },
14
+ }, (error, stdout) => error ? reject(error) : resolve(stdout));
15
+ child.stdin?.on('error', () => { });
16
+ child.stdin?.end(input);
17
+ });
18
+ }
19
+ export const unavailableWorkspace = () => ({ captured_at: new Date().toISOString(), branch: null,
20
+ base_revision: null, state: 'unavailable', files: [], additions: 0, deletions: 0, hidden_files: 0, patch: '', patch_truncated: false });
21
+ const reviewRef = (identity) => `refs/letagents/workspace-review/${createHash('sha256').update(identity).digest('hex')}`;
22
+ /** Retained Git tree, with no changes to the user's index, branch, or working files.
23
+ * Unsupported/oversized observations fail closed; a partial baseline is never a turn diff. */
24
+ export async function captureWorkspaceTree(workspace, identity) {
25
+ const signal = AbortSignal.timeout(20_000);
26
+ const scratch = await mkdtemp(join(tmpdir(), 'letagents-workspace-'));
27
+ const index = join(scratch, 'index');
28
+ try {
29
+ const root = await realpath(workspace);
30
+ const head = (await git(root, ['rev-parse', '--verify', 'HEAD^{tree}'], signal)).trim();
31
+ await git(root, ['read-tree', head], signal, index);
32
+ const original = new Map((await git(root, ['ls-tree', '-r', '-z', head], signal)).split('\0').filter(Boolean).map(entry => {
33
+ const split = entry.indexOf('\t');
34
+ return [entry.slice(split + 1), entry.slice(0, split).split(' ')];
35
+ }));
36
+ const paths = [...new Set([...original.keys(), ...(await git(root, ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], signal)).split('\0').filter(Boolean)])];
37
+ if (paths.length > 10_000)
38
+ return null;
39
+ const entries = [];
40
+ let bytesRead = 0;
41
+ for (const path of paths) {
42
+ // Parent symlinks must not redirect an observation outside the managed workspace.
43
+ const parent = await realpath(dirname(join(root, path))).catch(() => null);
44
+ if (parent && (relative(root, parent).startsWith('..') || relative(root, parent).startsWith('/')))
45
+ return null;
46
+ if (signal.aborted)
47
+ return null;
48
+ const absolute = join(root, path);
49
+ const stat = await lstat(absolute).catch(error => { if (error.code === 'ENOENT' || error.code === 'ENOTDIR')
50
+ return null; throw error; });
51
+ if (original.get(path)?.[1] === 'commit')
52
+ return null; // Submodules need their own workspace.
53
+ if (!stat) {
54
+ entries.push(`0 ${'0'.repeat(head.length)}\t${path}\0`);
55
+ continue;
56
+ }
57
+ let bytes;
58
+ if (stat.isSymbolicLink())
59
+ bytes = Buffer.from(await readlink(absolute));
60
+ else if (stat.isFile() && stat.size <= 8 * 1024 * 1024) {
61
+ const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
62
+ try {
63
+ const buffer = Buffer.alloc(8 * 1024 * 1024 + 1);
64
+ const read = await handle.read(buffer, 0, buffer.length, 0);
65
+ bytes = buffer.subarray(0, read.bytesRead);
66
+ }
67
+ finally {
68
+ await handle.close();
69
+ }
70
+ }
71
+ else
72
+ return null; // Includes submodule changes: do not attribute a fabricated text diff.
73
+ bytesRead += bytes.length;
74
+ if (bytes.length > 8 * 1024 * 1024 || bytesRead > 64 * 1024 * 1024)
75
+ return null;
76
+ const mode = stat.isSymbolicLink() ? '120000' : stat.mode & 0o111 ? '100755' : '100644';
77
+ const rawOid = createHash(head.length === 64 ? 'sha256' : 'sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
78
+ if (original.get(path)?.[0] === mode && original.get(path)?.[2] === rawOid)
79
+ continue;
80
+ if (entries.length >= 10_000)
81
+ return null;
82
+ const oid = (await git(root, ['hash-object', '-w', '--no-filters', '--stdin'], signal, undefined, bytes)).trim();
83
+ entries.push(`${mode} ${oid}\t${path}\0`);
84
+ }
85
+ if (entries.length)
86
+ await git(root, ['update-index', '-z', '--index-info'], signal, index, entries.join(''));
87
+ const tree = (await git(root, ['write-tree'], signal, index)).trim();
88
+ await git(root, ['update-ref', reviewRef(identity), tree], signal);
89
+ return tree;
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ finally {
95
+ await rm(scratch, { recursive: true, force: true });
96
+ }
97
+ }
98
+ export async function releaseWorkspaceTree(workspace, identity) {
99
+ try {
100
+ await git(workspace, ['update-ref', '-d', reviewRef(identity)], AbortSignal.timeout(5_000));
101
+ }
102
+ catch { /* Retain on failure, never jeopardize provider work. */ }
103
+ }
104
+ export async function captureWorkspacePair(workspace, startingRevision, baseline, identity) {
105
+ const tree = await captureWorkspaceTree(workspace, `${identity}:settled`);
106
+ try {
107
+ const full = tree ? await captureWorkspaceChanges(workspace, startingRevision, tree, true) : unavailableWorkspace();
108
+ const changes = tree && baseline ? await captureWorkspaceChanges(workspace, baseline, tree, true) : unavailableWorkspace();
109
+ const preview = (snapshot) => ({ ...snapshot,
110
+ files: snapshot.files.slice(0, 200), hidden_files: snapshot.hidden_files + Math.max(0, snapshot.files.length - 200),
111
+ patch: snapshot.patch.slice(0, 48 * 1024), patch_truncated: snapshot.patch_truncated || snapshot.patch.length > 48 * 1024 });
112
+ return { workspace: preview(full), contribution: { changes: preview(changes), summary: null },
113
+ review: { version: 1, workspace: full, contribution: changes } };
114
+ }
115
+ finally {
116
+ await releaseWorkspaceTree(workspace, `${identity}:settled`);
117
+ }
118
+ }