@lucid-agents/taskmarket 1.17.1 → 1.18.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.
@@ -0,0 +1,831 @@
1
+ import { createRequire as __tmCreateRequire } from 'node:module'; import { fileURLToPath as __tmFileURLToPath } from 'node:url'; import { dirname as __tmDirname } from 'node:path'; const require = __tmCreateRequire(import.meta.url); const __tmFilename = __tmFileURLToPath(import.meta.url); const __tmDirnameValue = __tmDirname(__tmFilename);
2
+ import {
3
+ DEFAULT_SANDBOX_ROOT,
4
+ DEFAULT_SYNC_LIMITS,
5
+ ManifestLimitError,
6
+ SandboxFileError,
7
+ SyncError,
8
+ applyChanges,
9
+ captureProblem,
10
+ claimLost,
11
+ claimProject,
12
+ diffWorkspace,
13
+ killedSession,
14
+ loadBlob,
15
+ loadSyncState,
16
+ planUpload,
17
+ pruneBlobs,
18
+ pushLocalChanges,
19
+ releaseClaim,
20
+ renderChanges,
21
+ saveBlob,
22
+ unresolvedCount,
23
+ updateSyncState,
24
+ uploadWorkspace
25
+ } from "./chunk-64HQ7ZF2.js";
26
+ import {
27
+ AgentLaunchError
28
+ } from "./chunk-YM4S6IE4.js";
29
+
30
+ // src/lib/agent/sync-view.ts
31
+ var SYNC_VIEW_VERSION = 1;
32
+ var MAX_LISTED_CHANGES = 500;
33
+ var MARK = { added: "+", modified: "~", deleted: "-" };
34
+ var WHY = {
35
+ conflict: "changed on this computer too; not applied",
36
+ blocked: "never applied",
37
+ too_large: "over the sync file bound; never applied",
38
+ unreadable: "the sandbox could not read it; never applied",
39
+ unsafe_path: "never applied",
40
+ not_saved: "not brought back; drop it with --keep-local <path> or --discard"
41
+ };
42
+ function changeLine(change) {
43
+ const what = [
44
+ change.kind,
45
+ change.symlink ? "symlink" : change.binary ? "binary" : void 0,
46
+ change.lines ? `+${change.lines.added} -${change.lines.removed}` : void 0
47
+ ].filter(Boolean).join(", ");
48
+ if (change.status === "clean") return ` ${MARK[change.kind]} ${change.path} (${what})`;
49
+ const reason = change.detail ? `${change.detail}; ${WHY[change.status]}` : WHY[change.status];
50
+ return ` ! ${change.path} (${what}; ${change.status.replace("_", " ")}: ${reason})`;
51
+ }
52
+ function countsLine(counts) {
53
+ const parts = [`${counts.clean} to apply`];
54
+ if (counts.conflict) parts.push(`${counts.conflict} in conflict`);
55
+ if (counts.blocked) parts.push(`${counts.blocked} blocked`);
56
+ if (counts.notSaved) parts.push(`${counts.notSaved} not saved`);
57
+ if (counts.tooDeep) parts.push(`${counts.tooDeep} director(ies) nested too deep, not saved`);
58
+ return parts.join(", ");
59
+ }
60
+ function leftOutNote(leftOut) {
61
+ const of = (reason) => leftOut.filter((dir) => dir.reason === reason).map((dir) => dir.path);
62
+ const parts = [];
63
+ const build = of("build_output");
64
+ if (build.length > 0) {
65
+ parts.push(
66
+ `left out as build output: ${build.slice(0, 10).join(", ")}${build.length > 10 ? `, and ${build.length - 10} more` : ""}`
67
+ );
68
+ }
69
+ const deep = of("too_deep");
70
+ if (deep.length > 0) {
71
+ parts.push(
72
+ `not saved, nested too deep: ${deep.slice(0, 10).join(", ")}${deep.length > 10 ? `, and ${deep.length - 10} more` : ""}`
73
+ );
74
+ }
75
+ return `Directories in the sandbox not brought back (${parts.join("; ")}).`;
76
+ }
77
+
78
+ // src/lib/agent/workspace-sync.ts
79
+ import { randomUUID } from "crypto";
80
+ import { promises as fs } from "fs";
81
+ import os from "os";
82
+ import path from "path";
83
+ var MAX_DIFF_CHARS = 8 * 1024 * 1024;
84
+ var MAX_LISTED_PER_REASON = 20;
85
+ var SyncStepError = class extends Error {
86
+ constructor(code, message) {
87
+ super(message);
88
+ this.code = code;
89
+ this.name = "SyncStepError";
90
+ }
91
+ };
92
+ function emptyBase() {
93
+ return { version: 1, entries: [] };
94
+ }
95
+ function parseIncludeSecrets(values = []) {
96
+ const out = /* @__PURE__ */ new Set();
97
+ for (const raw of values) {
98
+ const value = raw.split(path.sep).join("/").replace(/^\.\//, "");
99
+ const segments = value.split("/");
100
+ if (!value || value.startsWith("/") || /^[A-Za-z]:/.test(value) || /[*?[\]{}()!+@\\]/.test(value) || segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
101
+ throw new AgentLaunchError(
102
+ "sync_invalid_path",
103
+ `--include-secret takes a file path inside the project, not a pattern: ${raw}`
104
+ );
105
+ }
106
+ out.add(value);
107
+ }
108
+ return [...out].sort();
109
+ }
110
+ function size(bytes) {
111
+ if (bytes < 1024) return `${bytes} B`;
112
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
113
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
114
+ }
115
+ function listed(paths) {
116
+ const shown = paths.slice(0, MAX_LISTED_PER_REASON).join(", ");
117
+ return paths.length > MAX_LISTED_PER_REASON ? `${shown}, and ${paths.length - MAX_LISTED_PER_REASON} more` : shown;
118
+ }
119
+ function planLines(plan, includeSecrets = []) {
120
+ const tracked = plan.entries.filter((entry) => entry.tracked).length;
121
+ const lines = [
122
+ "",
123
+ `Workspace sync: ${plan.projectDir}`,
124
+ ` Upload: ${plan.entries.length} file(s) (${tracked} tracked, ${plan.entries.length - tracked} untracked), ${size(plan.totalBytes)}${plan.git ? ", respecting .gitignore" : "; not a git work tree, so every file except .git and node_modules"}`
125
+ ];
126
+ const by = (reason) => plan.excluded.filter((entry) => entry.reason === reason).map((entry) => entry.path);
127
+ const secrets = by("secret");
128
+ if (secrets.length > 0) {
129
+ lines.push(
130
+ ` Left out, secret-like (they stay on this computer; --include-secret <path> uploads one): ${listed(secrets)}`
131
+ );
132
+ }
133
+ const named = includeSecrets.filter((rel) => plan.entries.some((entry) => entry.path === rel));
134
+ if (named.length > 0)
135
+ lines.push(` Secret-like, uploaded because you named them: ${listed(named)}`);
136
+ const tooLarge = by("too_large");
137
+ if (tooLarge.length > 0) {
138
+ lines.push(
139
+ ` Left out, over the ${size(DEFAULT_SYNC_LIMITS.maxFileBytes)} file bound: ${listed(tooLarge)}`
140
+ );
141
+ }
142
+ const outside = by("symlink_outside_project");
143
+ if (outside.length > 0) {
144
+ lines.push(` Left out, symlinks leading outside the project: ${listed(outside)}`);
145
+ }
146
+ const other = by("not_a_file");
147
+ if (other.length > 0) lines.push(` Left out, not regular files: ${listed(other)}`);
148
+ const unselected = by("untracked_not_selected");
149
+ if (unselected.length > 0) {
150
+ lines.push(` Left out, untracked and not selected: ${unselected.length}`);
151
+ }
152
+ if (plan.excludedNotListed > 0) {
153
+ lines.push(` Left out and not listed (past the listing bound): ${plan.excludedNotListed}`);
154
+ }
155
+ lines.push(
156
+ ` Bounds: ${DEFAULT_SYNC_LIMITS.maxFiles} files and ${size(DEFAULT_SYNC_LIMITS.maxTotalBytes)} per upload.`,
157
+ " Changes come back only as a diff you review: /pull in the session, or taskmarket agent pull after it."
158
+ );
159
+ return lines;
160
+ }
161
+ function captureProblemMessage(problem) {
162
+ const where = problem.sandboxId ? `sandbox ${problem.sandboxId}` : "its sandbox";
163
+ const what = problem.kind === "interrupted" ? `The last session in this project ended (at ${problem.at}) before the changes left in ${where} were saved.` : `The changes the last session left in ${where} could not be saved when it ended (${problem.reason}).`;
164
+ const recover = problem.sandboxId && problem.base ? " Save them now with taskmarket agent pull --recover, which reconnects to that sandbox while it still exists (an idle sandbox is stopped, and deleted, after about 15 minutes);" : "";
165
+ return `${what}${recover} taskmarket agent pull --discard drops this record and anything still in that sandbox. A new session starts in this project only after one of these.`;
166
+ }
167
+ function pendingMessage(pending) {
168
+ const parts = [];
169
+ if (pending.clean) parts.push(`${pending.clean} to apply`);
170
+ if (pending.conflict) parts.push(`${pending.conflict} in conflict with your local edits`);
171
+ if (pending.notSaved) parts.push(`${pending.notSaved} too large to have been saved`);
172
+ if (pending.tooDeep) {
173
+ parts.push(
174
+ `${pending.tooDeep} director(ies) nested too deep to have been saved (acknowledge each with taskmarket agent pull --keep-local <dir>)`
175
+ );
176
+ }
177
+ return `The last session in this project left ${unresolvedCount(pending)} change(s) you have not pulled (${parts.join(", ")}). Review and apply them with taskmarket agent pull; for a path you want to keep as it is here, taskmarket agent pull --keep-local <path>; or drop them all with taskmarket agent pull --discard. Then start a new session.`;
178
+ }
179
+ function refuseUnfinished(state, owner, notes) {
180
+ const problem = captureProblem(state, owner);
181
+ if (problem) {
182
+ throw new AgentLaunchError(
183
+ problem.kind === "interrupted" ? "sync_capture_interrupted" : "sync_capture_failed",
184
+ captureProblemMessage(problem)
185
+ );
186
+ }
187
+ if (state.capture?.state === "saved" && unresolvedCount(state.capture.pending) > 0) {
188
+ throw new AgentLaunchError("sync_pull_pending", pendingMessage(state.capture.pending));
189
+ }
190
+ const session = state.session;
191
+ if (session && !session.uploaded && killedSession(state, owner)) {
192
+ notes.push(
193
+ "",
194
+ `The last session here (--no-sync) ended without stopping sandbox ${session.sandboxId}. What the agent made there is not brought back unless you run taskmarket agent pull --recover before the sandbox stops on its own once idle (about 15 minutes).`
195
+ );
196
+ state.orphan = {
197
+ sandboxId: session.sandboxId,
198
+ at: session.startedAt,
199
+ base: session.base ?? { version: 1, entries: [] }
200
+ };
201
+ session.endedAt = (/* @__PURE__ */ new Date()).toISOString();
202
+ }
203
+ }
204
+ function syncLaunchError(error) {
205
+ if (error instanceof SyncError) {
206
+ throw new AgentLaunchError(`sync_${error.code}`, error.message);
207
+ }
208
+ throw error;
209
+ }
210
+ async function prepareWorkspaceSync(options) {
211
+ const projectRoot = await fs.realpath(options.cwd ?? process.cwd());
212
+ const includeSecrets = parseIncludeSecrets(options.includeSecrets);
213
+ const owner = options.owner ?? process.pid;
214
+ const notes = [];
215
+ const state = await claimProject(
216
+ projectRoot,
217
+ "session",
218
+ (current) => refuseUnfinished(current, owner, notes),
219
+ owner,
220
+ options.onClaimLost
221
+ );
222
+ if (notes.length > 0) options.say(notes);
223
+ try {
224
+ const prepared = await prepareClaimed(options, projectRoot, includeSecrets, state);
225
+ return { ...prepared, ...state.claim?.nonce ? { claimNonce: state.claim.nonce } : {} };
226
+ } catch (error) {
227
+ await releaseClaim(projectRoot, owner).catch(() => void 0);
228
+ throw error;
229
+ }
230
+ }
231
+ async function prepareClaimed(options, projectRoot, includeSecrets, state) {
232
+ if (options.mode === "none") {
233
+ options.say(["", "Workspace sync: off (--no-sync); the sandbox starts empty."]);
234
+ return { projectRoot, includeSecrets: [] };
235
+ }
236
+ const home = await fs.realpath(os.homedir()).catch(() => os.homedir());
237
+ if (projectRoot === home || path.parse(projectRoot).root === projectRoot) {
238
+ throw new AgentLaunchError(
239
+ "sync_project_unsafe",
240
+ `taskmarket agent uploads the directory it starts in, and ${projectRoot} is your home or a filesystem root. Start it in a project directory, or pass --no-sync.`
241
+ );
242
+ }
243
+ let plan;
244
+ try {
245
+ plan = await planUpload(projectRoot, { includeSecrets });
246
+ } catch (error) {
247
+ syncLaunchError(error);
248
+ }
249
+ const lines = planLines(plan, includeSecrets);
250
+ const approval = state.approval;
251
+ if (approval && includeSecrets.every((rel) => approval.includeSecrets.includes(rel))) {
252
+ options.say([...lines, ` Approved for this project on ${approval.approvedAt}.`]);
253
+ return { projectRoot, plan, includeSecrets };
254
+ }
255
+ if (options.mode === "sync") {
256
+ options.say([...lines, " Approved with --sync, for this session only."]);
257
+ return { projectRoot, plan, includeSecrets };
258
+ }
259
+ if (!options.interactive) {
260
+ throw new AgentLaunchError(
261
+ "sync_approval_required",
262
+ "Uploading this project to the sandbox needs your approval. Run taskmarket agent at a terminal to approve it once for this project, pass --sync to approve this session, or --no-sync to start with an empty sandbox."
263
+ );
264
+ }
265
+ const prompter = options.prompter();
266
+ try {
267
+ prompter.say(lines);
268
+ if (!await prompter.confirm(
269
+ "Upload these files to your sandbox, now and in later sessions in this project?"
270
+ )) {
271
+ throw new AgentLaunchError(
272
+ "sync_declined",
273
+ "Nothing was uploaded and no sandbox was opened. Run taskmarket agent --no-sync to start with an empty sandbox."
274
+ );
275
+ }
276
+ } finally {
277
+ prompter.close();
278
+ }
279
+ const now = (options.now ?? (() => /* @__PURE__ */ new Date()))();
280
+ await updateSyncState(projectRoot, (next) => {
281
+ next.approval = { approvedAt: now.toISOString(), includeSecrets };
282
+ });
283
+ return { projectRoot, plan, includeSecrets };
284
+ }
285
+ async function uploadPlan(backend, plan, signal) {
286
+ try {
287
+ return await uploadWorkspace(backend, plan, { signal });
288
+ } catch (error) {
289
+ if (error instanceof SyncError) syncLaunchError(error);
290
+ throw new AgentLaunchError(
291
+ "sync_upload_failed",
292
+ `The project could not be uploaded to the sandbox: ${error.message}`
293
+ );
294
+ }
295
+ }
296
+ var UNSAVED_DETAIL = {
297
+ budget: `too large to save when the session ended (past the ${size(DEFAULT_SYNC_LIMITS.maxTotalBytes)} save budget)`,
298
+ skipped: "its contents were not saved when the session ended"
299
+ };
300
+ function relabel(change, unsaved) {
301
+ const reason = unsaved.get(change.path);
302
+ const counted = change.status === "unreadable" || change.status === "clean" || change.status === "conflict";
303
+ if (!reason || !counted || change.status === "in_sync") {
304
+ return { status: change.status, detail: change.detail };
305
+ }
306
+ if (reason === "too_large") {
307
+ return { status: "too_large", detail: `over ${DEFAULT_SYNC_LIMITS.maxFileBytes} bytes` };
308
+ }
309
+ return { status: "not_saved", detail: UNSAVED_DETAIL[reason] };
310
+ }
311
+ function summarize(changeSet, unsaved = /* @__PURE__ */ new Map()) {
312
+ const changes = [];
313
+ const counts = { clean: 0, conflict: 0, blocked: 0 };
314
+ for (const change of changeSet.changes) {
315
+ if (change.status === "in_sync") continue;
316
+ const { status, detail } = relabel(change, unsaved);
317
+ if (status === "clean") counts.clean++;
318
+ else if (status === "conflict") counts.conflict++;
319
+ else if (status === "not_saved") counts.notSaved = (counts.notSaved ?? 0) + 1;
320
+ else counts.blocked++;
321
+ const symlink = change.remote?.type === "symlink" || change.local?.type === "symlink";
322
+ const summary = {
323
+ path: change.path,
324
+ kind: change.kind,
325
+ status,
326
+ binary: change.binary,
327
+ symlink,
328
+ ...detail ? { detail } : {}
329
+ };
330
+ if ((status === "clean" || status === "conflict") && !change.binary && !symlink) {
331
+ const patch = renderChanges({ projectDir: changeSet.projectDir, changes: [change] });
332
+ let added = 0;
333
+ let removed = 0;
334
+ for (const line of patch.split("\n")) {
335
+ if (line.startsWith("+") && !line.startsWith("+++")) added++;
336
+ else if (line.startsWith("-") && !line.startsWith("---")) removed++;
337
+ }
338
+ summary.lines = { added, removed };
339
+ }
340
+ changes.push(summary);
341
+ }
342
+ return { changes, counts };
343
+ }
344
+ function remainingAfter(changeSet, applied) {
345
+ const done = new Set(applied);
346
+ const counts = { clean: 0, conflict: 0, blocked: 0 };
347
+ for (const change of changeSet.changes) {
348
+ if (change.status === "in_sync" || done.has(change.path)) continue;
349
+ if (change.status === "clean") counts.clean++;
350
+ else if (change.status === "conflict") counts.conflict++;
351
+ else counts.blocked++;
352
+ }
353
+ return counts;
354
+ }
355
+ function fullDiff(changeSet, maxChars = MAX_DIFF_CHARS) {
356
+ const text = renderChanges(changeSet);
357
+ return text.length > maxChars ? `${text.slice(0, maxChars)}
358
+ ... the diff is cut here; run taskmarket agent pull --diff after the session for all of it
359
+ ` : text;
360
+ }
361
+ function recordingBackend(inner) {
362
+ const files = /* @__PURE__ */ new Map();
363
+ let manifest = [];
364
+ const backend = {
365
+ kind: inner.kind,
366
+ root: inner.root,
367
+ connect: (signal) => inner.connect(signal),
368
+ close: () => inner.close(),
369
+ readFile: async (file, options) => {
370
+ const data = await inner.readFile(file, options);
371
+ files.set(file, data);
372
+ return data;
373
+ },
374
+ openForUpdate: (file, options) => inner.openForUpdate(file, options),
375
+ writeFile: (file, data, options) => inner.writeFile(file, data, options),
376
+ list: (dir, options) => inner.list(dir, options),
377
+ glob: (dir, pattern, options) => inner.glob(dir, pattern, options),
378
+ grep: (file, query, options) => inner.grep(file, query, options),
379
+ manifest: async (dir, options) => {
380
+ manifest = await inner.manifest(dir, options);
381
+ return manifest;
382
+ },
383
+ directories: (dir, parents, options) => inner.directories(dir, parents, options),
384
+ symlink: (file, target, options) => inner.symlink(file, target, options),
385
+ remove: (file, options) => inner.remove(file, options),
386
+ exec: (command, cwd, options) => inner.exec(command, cwd, options)
387
+ };
388
+ return { backend, files, manifest: () => manifest };
389
+ }
390
+ var readOnly = () => Promise.reject(new Error("The saved session changes are read-only"));
391
+ function savedEntry(entry) {
392
+ if (entry.type === "symlink") return { path: entry.path, type: "symlink", target: entry.target };
393
+ return {
394
+ path: entry.path,
395
+ type: "file",
396
+ size: entry.size,
397
+ sha256: entry.unsaved ? "" : entry.sha256
398
+ };
399
+ }
400
+ function savedState(entry) {
401
+ if (!entry) return void 0;
402
+ const listed2 = savedEntry(entry);
403
+ return listed2.type === "symlink" ? { path: listed2.path, type: "symlink", target: listed2.target } : { path: listed2.path, type: "file", sha256: listed2.sha256 };
404
+ }
405
+ function unsavedPaths(manifest) {
406
+ const out = /* @__PURE__ */ new Map();
407
+ for (const entry of manifest) {
408
+ if (entry.type === "file" && entry.unsaved) out.set(entry.path, entry.unsaved);
409
+ }
410
+ return out;
411
+ }
412
+ function savedWorkspace(projectRoot, stored) {
413
+ const root = DEFAULT_SANDBOX_ROOT;
414
+ const manifest = stored.map(savedEntry);
415
+ const byPath = new Map(manifest.map((entry) => [entry.path, entry]));
416
+ const directories = /* @__PURE__ */ new Set();
417
+ for (const entry of manifest) {
418
+ for (let i = entry.path.indexOf("/"); i >= 0; i = entry.path.indexOf("/", i + 1)) {
419
+ directories.add(entry.path.slice(0, i));
420
+ }
421
+ }
422
+ return {
423
+ kind: "saved",
424
+ root,
425
+ connect: async () => ({ label: "saved session changes" }),
426
+ close: async () => void 0,
427
+ readFile: async (file, options) => {
428
+ const rel = file.startsWith(`${root}/`) ? file.slice(root.length + 1) : "";
429
+ const entry = byPath.get(rel);
430
+ if (!entry || entry.type !== "file" || !entry.sha256) {
431
+ throw new SandboxFileError("ENOENT", "open", file);
432
+ }
433
+ const data = await loadBlob(projectRoot, entry.sha256);
434
+ if (!data) throw new SandboxFileError("EIO", "read", file, "the saved copy is missing");
435
+ if (data.length > options.maxBytes) throw new SandboxFileError("EFBIG", "read", file);
436
+ return data;
437
+ },
438
+ openForUpdate: readOnly,
439
+ writeFile: readOnly,
440
+ list: readOnly,
441
+ glob: readOnly,
442
+ grep: readOnly,
443
+ manifest: async (_dir, options) => {
444
+ const within = options.within ? new Set(options.within) : void 0;
445
+ const parent = (rel) => rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
446
+ const entries = manifest.filter(
447
+ (entry) => within ? within.has(parent(entry.path)) : !entry.path.split("/").some((segment) => options.prune.includes(segment))
448
+ );
449
+ if (entries.length > options.limit) throw new ManifestLimitError(options.limit);
450
+ return entries.map((entry) => ({ ...entry }));
451
+ },
452
+ directories: async (_dir, parents) => [...directories].filter(
453
+ (dir) => parents.includes(dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "")
454
+ ),
455
+ symlink: readOnly,
456
+ remove: readOnly,
457
+ exec: readOnly
458
+ };
459
+ }
460
+ var ClaimLostError = class extends Error {
461
+ constructor() {
462
+ super(
463
+ "another taskmarket process took over this project, so this one does not write over its record"
464
+ );
465
+ this.name = "ClaimLostError";
466
+ }
467
+ };
468
+ var SessionSync = class {
469
+ constructor(options) {
470
+ this.options = options;
471
+ this.base = options.base;
472
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
473
+ }
474
+ base;
475
+ review;
476
+ queue = Promise.resolve();
477
+ now;
478
+ serial(work) {
479
+ const next = this.queue.then(work, work);
480
+ this.queue = next.catch(() => void 0);
481
+ return next;
482
+ }
483
+ async record(change) {
484
+ try {
485
+ await updateSyncState(this.options.projectRoot, change);
486
+ } catch (error) {
487
+ this.options.log?.(
488
+ `Workspace sync: the state could not be saved: ${error.message}`
489
+ );
490
+ }
491
+ }
492
+ /**
493
+ * Records the base a `/pull` or `/sync` moved to, before the step reports success: if this
494
+ * process dies before the end-of-session save, `taskmarket agent pull --recover` compares the
495
+ * sandbox with this base, not the one the session started from, so a change already pulled and
496
+ * then edited (or deleted) here is not offered back as the sandbox's.
497
+ */
498
+ /** Refuses to write when another process holds the project under another claim. */
499
+ assertHeld(state) {
500
+ const nonce = this.options.claimNonce;
501
+ if (nonce && state.claim?.nonce !== nonce) throw new ClaimLostError();
502
+ }
503
+ /**
504
+ * Before `/pull` writes local files or `/sync` writes the sandbox: refuses, touching nothing,
505
+ * when another process has taken the project.
506
+ */
507
+ async ensureHeld() {
508
+ const nonce = this.options.claimNonce;
509
+ if (!nonce) return;
510
+ const state = await loadSyncState(this.options.projectRoot);
511
+ if (claimLost(nonce) || state.claim?.nonce !== nonce) {
512
+ const error = new ClaimLostError();
513
+ this.options.log?.(`Workspace sync: sync_claim_lost: ${error.message}`);
514
+ throw new SyncStepError("sync_claim_lost", error.message);
515
+ }
516
+ }
517
+ async keepBase(change) {
518
+ const base = this.base;
519
+ try {
520
+ await updateSyncState(this.options.projectRoot, (state) => {
521
+ this.assertHeld(state);
522
+ change(state);
523
+ if (state.session?.pid === process.pid && !state.session.endedAt) {
524
+ state.session.base = base;
525
+ }
526
+ });
527
+ } catch (error) {
528
+ this.options.log?.(`Workspace sync: sync_state_failed: ${error.message}`);
529
+ if (error instanceof ClaimLostError)
530
+ throw new SyncStepError("sync_claim_lost", error.message);
531
+ throw new SyncStepError(
532
+ "sync_state_failed",
533
+ `The step was done, but its record could not be saved (${error.message}); if this session is cut off, taskmarket agent pull --recover may offer these paths again`
534
+ );
535
+ }
536
+ }
537
+ fail(error) {
538
+ if (error instanceof SyncStepError) throw error;
539
+ const code = error instanceof SyncError ? `sync_${error.code}` : error.name === "SandboxUnavailableError" ? "sandbox_unavailable" : error instanceof ManifestLimitError ? "sync_too_many_files" : "sync_failed";
540
+ this.options.log?.(`Workspace sync: ${code}: ${error.message}`);
541
+ throw new SyncStepError(code, error.message);
542
+ }
543
+ /** `/pull`: what the sandbox changed since the base, compared with this computer. */
544
+ reviewChanges() {
545
+ return this.serial(async () => {
546
+ let changeSet;
547
+ try {
548
+ changeSet = await diffWorkspace(this.options.backend, this.options.projectRoot, this.base);
549
+ } catch (error) {
550
+ this.fail(error);
551
+ }
552
+ const id = randomUUID();
553
+ this.review = { id, changeSet };
554
+ const { changes, counts } = summarize(changeSet);
555
+ await this.record((state) => {
556
+ if (state.session)
557
+ state.session.pending = { ...counts, checkedAt: this.now().toISOString() };
558
+ });
559
+ return {
560
+ version: SYNC_VIEW_VERSION,
561
+ reviewId: id,
562
+ changes: changes.slice(0, MAX_LISTED_CHANGES),
563
+ total: changes.length,
564
+ counts,
565
+ ...changeSet.leftOut ? { leftOut: changeSet.leftOut } : {}
566
+ };
567
+ });
568
+ }
569
+ reviewed(reviewId) {
570
+ if (!this.review || this.review.id !== reviewId) {
571
+ throw new SyncStepError(
572
+ "review_stale",
573
+ "That review is no longer current; run /pull again to see the changes as they are now"
574
+ );
575
+ }
576
+ return this.review.changeSet;
577
+ }
578
+ /** The full diff of the current review. */
579
+ diff(reviewId) {
580
+ return this.serial(async () => fullDiff(this.reviewed(reviewId)));
581
+ }
582
+ /**
583
+ * Applies the reviewed changes the person approved: every `clean` one. Conflicts are never
584
+ * overwritten from inside the session, and each path is checked again before it is written.
585
+ */
586
+ apply(reviewId) {
587
+ return this.serial(async () => {
588
+ const changeSet = this.reviewed(reviewId);
589
+ this.review = void 0;
590
+ await this.ensureHeld();
591
+ let result;
592
+ try {
593
+ result = await applyChanges(changeSet, this.base);
594
+ } catch (error) {
595
+ this.fail(error);
596
+ }
597
+ this.base = result.base;
598
+ const remaining = remainingAfter(changeSet, result.applied);
599
+ await this.keepBase((state) => {
600
+ if (!state.session) return;
601
+ state.session.lastPullAt = this.now().toISOString();
602
+ state.session.pending = { ...remaining, checkedAt: this.now().toISOString() };
603
+ });
604
+ return {
605
+ version: SYNC_VIEW_VERSION,
606
+ applied: result.applied,
607
+ skipped: result.skipped,
608
+ remaining
609
+ };
610
+ });
611
+ }
612
+ /** `/sync`: re-sends local edits; a path the sandbox changed too is left alone on both sides. */
613
+ push() {
614
+ return this.serial(async () => {
615
+ if (!this.options.uploaded) {
616
+ throw new SyncStepError(
617
+ "sync_not_uploaded",
618
+ "This session started with --no-sync, so nothing of this project is sent to the sandbox"
619
+ );
620
+ }
621
+ this.review = void 0;
622
+ await this.ensureHeld();
623
+ let result;
624
+ try {
625
+ result = await pushLocalChanges(this.options.backend, this.options.projectRoot, this.base, {
626
+ includeSecrets: this.options.includeSecrets
627
+ });
628
+ } catch (error) {
629
+ this.fail(error);
630
+ }
631
+ this.base = result.base;
632
+ await this.keepBase((state) => {
633
+ if (state.session) state.session.lastPushAt = this.now().toISOString();
634
+ });
635
+ return { version: SYNC_VIEW_VERSION, pushed: result.pushed, conflicts: result.conflicts };
636
+ });
637
+ }
638
+ /**
639
+ * Pi has exited, so the session is over: records the `saving` marker now, before running
640
+ * purchases drain and `capture` starts. A controller that dies anywhere from here until the
641
+ * save is recorded then leaves an interrupted save (`sync_capture_interrupted`, in `status` with
642
+ * its sandbox), in either mode, rather than a session that looks cut off while Pi ran. `capture`
643
+ * writes the marker again; a failure here is only logged, since `capture` still runs.
644
+ */
645
+ beginSave() {
646
+ return this.serial(async () => {
647
+ const { projectRoot, sandboxId } = this.options;
648
+ const base = this.base;
649
+ try {
650
+ await updateSyncState(projectRoot, (state) => {
651
+ this.assertHeld(state);
652
+ state.saving = savingMarker(this.now().toISOString(), sandboxId, base);
653
+ });
654
+ } catch (error) {
655
+ this.options.log?.(
656
+ `Workspace sync: the start of the save was not recorded: ${error.message}`
657
+ );
658
+ }
659
+ });
660
+ }
661
+ /**
662
+ * At the end of the session, before the sandbox stops: saves what is left to pull, so
663
+ * `taskmarket agent pull` can review it without the sandbox. Never throws: a failure is
664
+ * recorded (and `status`, `pull` and the next launch say so) and returned, and the caller then
665
+ * keeps the sandbox rather than stopping it.
666
+ *
667
+ * Crash-safe in this order: a `saving` marker first (left behind, it tells the next launch the
668
+ * save was interrupted); then the new files, under their hashes beside the old ones; then the
669
+ * new record, replacing the old one and the marker in one atomic write; only then are the files
670
+ * no record names deleted.
671
+ */
672
+ capture(signal, options = {}) {
673
+ const endSession = options.endSession ?? true;
674
+ return this.serial(async () => {
675
+ const { projectRoot, sandboxId } = this.options;
676
+ const startedAt = this.now().toISOString();
677
+ const base = this.base;
678
+ const budget = this.options.saveBudgetBytes ?? DEFAULT_SYNC_LIMITS.maxTotalBytes;
679
+ try {
680
+ await updateSyncState(projectRoot, (state) => {
681
+ this.assertHeld(state);
682
+ state.saving = savingMarker(startedAt, sandboxId, base);
683
+ });
684
+ const recording = recordingBackend(this.options.backend);
685
+ const changeSet = await diffWorkspace(recording.backend, projectRoot, base, { signal });
686
+ const changed = new Set(changeSet.changes.map((change) => change.path));
687
+ const known = new Map(base.entries.map((entry) => [entry.path, entry]));
688
+ const manifest = [];
689
+ const unsaved = /* @__PURE__ */ new Map();
690
+ let saved = 0;
691
+ if (changeSet.changes.some((change) => change.status !== "in_sync")) {
692
+ for (const entry of recording.manifest()) {
693
+ const was = known.get(entry.path);
694
+ const unchanged = entry.type === "symlink" ? was?.type === "symlink" && was.target === entry.target : was?.type === "file" && was.sha256 === entry.sha256;
695
+ if (!unchanged && !changed.has(entry.path)) continue;
696
+ if (entry.type === "symlink" || unchanged) {
697
+ manifest.push({ ...entry });
698
+ continue;
699
+ }
700
+ signal?.throwIfAborted();
701
+ const data = recording.files.get(`${recording.backend.root}/${entry.path}`);
702
+ if (data && saved + data.length <= budget) {
703
+ saved += data.length;
704
+ const sha256 = await saveBlob(projectRoot, data);
705
+ manifest.push({ path: entry.path, type: "file", size: data.length, sha256 });
706
+ continue;
707
+ }
708
+ const reason = data ? "budget" : entry.sha256 === "" ? void 0 : entry.size > DEFAULT_SYNC_LIMITS.maxFileBytes ? "too_large" : "skipped";
709
+ if (reason) unsaved.set(entry.path, reason);
710
+ manifest.push({ ...entry, ...reason ? { unsaved: reason } : {} });
711
+ }
712
+ }
713
+ const { counts } = summarize(changeSet, unsaved);
714
+ const tooDeep = (changeSet.leftOut ?? []).filter((dir) => dir.reason === "too_deep");
715
+ if (tooDeep.length > 0) counts.tooDeep = tooDeep.length;
716
+ const total = counts.clean + counts.conflict + counts.blocked + (counts.notSaved ?? 0) + (counts.tooDeep ?? 0);
717
+ await updateSyncState(projectRoot, (state) => {
718
+ this.assertHeld(state);
719
+ const earlier = state.capture;
720
+ if (earlier?.state === "failed" && earlier.sandboxId !== sandboxId) {
721
+ throw new Error(
722
+ `the changes of an earlier session (sandbox ${earlier.sandboxId ?? "unknown"}) are still unresolved`
723
+ );
724
+ }
725
+ if (total > 0) {
726
+ state.capture = {
727
+ state: "saved",
728
+ capturedAt: startedAt,
729
+ ...sandboxId ? { sandboxId } : {},
730
+ base,
731
+ manifest,
732
+ pending: counts,
733
+ ...changeSet.leftOut ? { leftOut: changeSet.leftOut } : {}
734
+ };
735
+ } else {
736
+ delete state.capture;
737
+ }
738
+ delete state.saving;
739
+ if (state.orphan && state.orphan.sandboxId === sandboxId) delete state.orphan;
740
+ if (ownSession(state, endSession, sandboxId)) {
741
+ state.session.endedAt = startedAt;
742
+ state.session.pending = { ...counts, checkedAt: startedAt };
743
+ }
744
+ });
745
+ await pruneBlobs(projectRoot).catch(
746
+ (error) => this.options.log?.(`Workspace sync: old saved files were not removed: ${error.message}`)
747
+ );
748
+ return {
749
+ saved: true,
750
+ pending: counts,
751
+ ...changeSet.leftOut ? { leftOut: changeSet.leftOut } : {}
752
+ };
753
+ } catch (error) {
754
+ if (error instanceof ClaimLostError) {
755
+ this.options.log?.(`Workspace sync: ${error.message}`);
756
+ return { saved: false, reason: error.message, claimLost: true };
757
+ }
758
+ const reason = error instanceof ManifestLimitError ? `the sandbox holds more than ${error.limit} files outside what the project ignores` : error.message;
759
+ this.options.log?.(`Workspace sync: the session's changes could not be saved: ${reason}`);
760
+ await this.record((state) => {
761
+ const nonce = this.options.claimNonce;
762
+ if (nonce && state.claim?.nonce !== nonce) return;
763
+ const earlier = state.capture;
764
+ if (earlier?.state !== "failed" || earlier.sandboxId === sandboxId) {
765
+ state.capture = {
766
+ state: "failed",
767
+ capturedAt: startedAt,
768
+ ...sandboxId ? { sandboxId } : {},
769
+ base,
770
+ reason
771
+ };
772
+ }
773
+ if (state.saving?.pid === process.pid) delete state.saving;
774
+ if (ownSession(state, endSession, sandboxId)) state.session.endedAt = startedAt;
775
+ });
776
+ return { saved: false, reason };
777
+ }
778
+ });
779
+ }
780
+ };
781
+ function savingMarker(startedAt, sandboxId, base) {
782
+ return { pid: process.pid, startedAt, ...sandboxId ? { sandboxId } : {}, base };
783
+ }
784
+ function ownSession(state, endSession, sandboxId) {
785
+ const session = state.session;
786
+ if (!session || session.endedAt) return false;
787
+ if (endSession && session.pid === process.pid) return true;
788
+ return !!sandboxId && session.sandboxId === sandboxId;
789
+ }
790
+ async function recordSessionStart(input) {
791
+ const now = (input.now ?? /* @__PURE__ */ new Date()).toISOString();
792
+ await updateSyncState(input.projectRoot, (state) => {
793
+ state.session = {
794
+ ...input.sandboxId ? { sandboxId: input.sandboxId } : {},
795
+ pid: process.pid,
796
+ startedAt: now,
797
+ uploaded: input.plan !== void 0,
798
+ base: input.base,
799
+ ...input.plan ? {
800
+ lastUploadAt: now,
801
+ uploadedFiles: input.base.entries.length,
802
+ uploadedBytes: input.plan.totalBytes
803
+ } : {}
804
+ };
805
+ });
806
+ }
807
+ async function recordSessionEnd(projectRoot, now = /* @__PURE__ */ new Date()) {
808
+ await updateSyncState(projectRoot, (state) => {
809
+ if (!state.session || state.session.pid !== process.pid || state.session.endedAt) return;
810
+ state.session.endedAt = now.toISOString();
811
+ });
812
+ }
813
+
814
+ export {
815
+ SYNC_VIEW_VERSION,
816
+ changeLine,
817
+ countsLine,
818
+ leftOutNote,
819
+ emptyBase,
820
+ captureProblemMessage,
821
+ prepareWorkspaceSync,
822
+ uploadPlan,
823
+ summarize,
824
+ fullDiff,
825
+ savedState,
826
+ unsavedPaths,
827
+ savedWorkspace,
828
+ SessionSync,
829
+ recordSessionStart,
830
+ recordSessionEnd
831
+ };