@moxt-ai/cli 0.3.3-moxt-run.2 → 0.4.0-beta.1

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/dist/index.js CHANGED
@@ -4,10 +4,235 @@
4
4
  import updateNotifier from "update-notifier";
5
5
 
6
6
  // src/program.ts
7
- import { Command } from "commander";
7
+ import { Command as Command2 } from "commander";
8
+
9
+ // src/utils/output.ts
10
+ var colors = {
11
+ bold: "\x1B[1m",
12
+ blue: "\x1B[1;34m",
13
+ green: "\x1B[32m",
14
+ red: "\x1B[31m",
15
+ cyan: "\x1B[36m",
16
+ dim: "\x1B[2m",
17
+ reset: "\x1B[0m"
18
+ };
19
+ function print(message) {
20
+ console.log(message);
21
+ }
22
+ function printError(message) {
23
+ console.error(`${colors.red}${message}${colors.reset}`);
24
+ }
25
+
26
+ // src/utils/runtime-context.ts
27
+ import { readFileSync } from "fs";
28
+ var DEFAULT_USER_HOST = "api.moxt.ai";
29
+ var SANDBOX_ENV_FILE_KEY = "SANDBOX_ENV_FILE";
30
+ var SANDBOX_ENV_KEYS = [
31
+ "BASE_API_HOST",
32
+ "SANDBOX_TOOL_API_TOKEN",
33
+ "MOXT_REPO_PAYLOAD",
34
+ "MOXT_WORKSPACE_ID"
35
+ ];
36
+ var RuntimeContextError = class extends Error {
37
+ constructor(message) {
38
+ super(message);
39
+ this.name = "RuntimeContextError";
40
+ }
41
+ };
42
+ function resolveRuntimeContext(env = process.env) {
43
+ if (hasAnySandboxEnv(env)) {
44
+ return resolveSandboxRuntimeContext(env);
45
+ }
46
+ const apiKey = readNonEmptyEnv(env, "MOXT_API_KEY");
47
+ if (apiKey) {
48
+ return {
49
+ mode: "user",
50
+ host: readNonEmptyEnv(env, "MOXT_HOST") ?? DEFAULT_USER_HOST,
51
+ apiKey
52
+ };
53
+ }
54
+ throw new RuntimeContextError("Missing auth. Set sandbox environment variables or MOXT_API_KEY.");
55
+ }
56
+ function resolveSandboxRuntimeContext(env = process.env) {
57
+ const directRepoPayload = readNonEmptyEnv(env, "MOXT_REPO_PAYLOAD");
58
+ const sandboxEnvFile = readNonEmptyEnv(env, SANDBOX_ENV_FILE_KEY);
59
+ const missingKeys = SANDBOX_ENV_KEYS.filter((key) => {
60
+ if (key === "MOXT_REPO_PAYLOAD") return !directRepoPayload && !sandboxEnvFile;
61
+ return !readNonEmptyEnv(env, key);
62
+ });
63
+ if (missingKeys.length > 0) {
64
+ throw new RuntimeContextError(`Incomplete sandbox environment. Missing: ${missingKeys.join(", ")}.`);
65
+ }
66
+ let repoPayload = directRepoPayload;
67
+ if (!repoPayload) {
68
+ if (!sandboxEnvFile) {
69
+ throw new RuntimeContextError("MOXT_REPO_PAYLOAD is required.");
70
+ }
71
+ repoPayload = readRepoPayloadFromSandboxEnvFile(sandboxEnvFile);
72
+ }
73
+ return {
74
+ mode: "sandbox",
75
+ baseApiHost: normalizeBaseApiHost(readRequiredEnv(env, "BASE_API_HOST")),
76
+ sandboxToolApiToken: readRequiredEnv(env, "SANDBOX_TOOL_API_TOKEN"),
77
+ workspaceId: readRequiredEnv(env, "MOXT_WORKSPACE_ID"),
78
+ repoPayload: parseRepoPayload(repoPayload)
79
+ };
80
+ }
81
+ function readRepoPayloadFromSandboxEnvFile(path2) {
82
+ let parsed;
83
+ try {
84
+ parsed = JSON.parse(readFileSync(path2, "utf8"));
85
+ } catch (error) {
86
+ throw new RuntimeContextError(
87
+ `Failed to read SANDBOX_ENV_FILE: ${error instanceof Error ? error.message : String(error)}`
88
+ );
89
+ }
90
+ if (!isRecord(parsed)) {
91
+ throw new RuntimeContextError("SANDBOX_ENV_FILE must contain a JSON object.");
92
+ }
93
+ const repoPayload = parsed.MOXT_REPO_PAYLOAD;
94
+ if (typeof repoPayload !== "string" || repoPayload.trim().length === 0) {
95
+ throw new RuntimeContextError("MOXT_REPO_PAYLOAD is missing from SANDBOX_ENV_FILE.");
96
+ }
97
+ return repoPayload.trim();
98
+ }
99
+ function parseRepoPayload(raw) {
100
+ let parsed;
101
+ try {
102
+ parsed = JSON.parse(raw);
103
+ } catch (error) {
104
+ throw new RuntimeContextError(`MOXT_REPO_PAYLOAD must be valid JSON: ${error instanceof Error ? error.message : String(error)}`);
105
+ }
106
+ if (!isRecord(parsed)) {
107
+ throw new RuntimeContextError("MOXT_REPO_PAYLOAD must be a JSON object.");
108
+ }
109
+ const personal = parseRepoEntry(parsed.personal, "personal");
110
+ const teamsValue = parsed.teams;
111
+ if (!Array.isArray(teamsValue)) {
112
+ throw new RuntimeContextError("MOXT_REPO_PAYLOAD.teams must be an array.");
113
+ }
114
+ const teams = teamsValue.map((entry, index) => parseRepoEntry(entry, `teams[${index}]`));
115
+ const workspace = parsed.workspace == null ? null : parseRepoEntry(parsed.workspace, "workspace");
116
+ return { personal, teams, workspace };
117
+ }
118
+ function normalizeBaseApiHost(value) {
119
+ const trimmed = value.trim();
120
+ if (!trimmed) {
121
+ throw new RuntimeContextError("BASE_API_HOST must not be empty.");
122
+ }
123
+ return trimmed.replace(/\/+$/, "");
124
+ }
125
+ function hasAnySandboxEnv(env) {
126
+ return SANDBOX_ENV_KEYS.some((key) => readNonEmptyEnv(env, key) !== void 0);
127
+ }
128
+ function readRequiredEnv(env, key) {
129
+ const value = readNonEmptyEnv(env, key);
130
+ if (!value) {
131
+ throw new RuntimeContextError(`${key} is required.`);
132
+ }
133
+ return value;
134
+ }
135
+ function readNonEmptyEnv(env, key) {
136
+ const value = env[key];
137
+ if (typeof value !== "string") return void 0;
138
+ const trimmed = value.trim();
139
+ return trimmed.length > 0 ? trimmed : void 0;
140
+ }
141
+ function parseRepoEntry(value, path2) {
142
+ if (!isRecord(value)) {
143
+ throw new RuntimeContextError(`MOXT_REPO_PAYLOAD.${path2} must be an object.`);
144
+ }
145
+ const repoId = readStringField(value, "repoId", path2);
146
+ const name = readStringField(value, "name", path2);
147
+ return { repoId, name };
148
+ }
149
+ function readStringField(record2, field, path2) {
150
+ const value = record2[field];
151
+ if (typeof value !== "string" || value.trim().length === 0) {
152
+ throw new RuntimeContextError(`MOXT_REPO_PAYLOAD.${path2}.${field} must be a non-empty string.`);
153
+ }
154
+ return value;
155
+ }
156
+ function isRecord(value) {
157
+ return typeof value === "object" && value !== null && !Array.isArray(value);
158
+ }
159
+
160
+ // src/utils/sandbox-path.ts
161
+ var WorkspacePathError = class extends Error {
162
+ constructor(message) {
163
+ super(message);
164
+ this.name = "WorkspacePathError";
165
+ }
166
+ };
167
+ function resolveWorkspacePath(repoPayload, workspacePath) {
168
+ const normalizedPath = normalizeWorkspacePath(workspacePath);
169
+ const [repoName, ...repoPathParts] = normalizedPath.split("/");
170
+ const repoEntries = listRepoEntries(repoPayload);
171
+ const repo = repoEntries.find((entry) => entry.name === repoName);
172
+ if (!repo) {
173
+ throw new WorkspacePathError(`Workspace path must start with one of: ${repoEntries.map((entry) => entry.name).join(", ")}.`);
174
+ }
175
+ return {
176
+ repoId: repo.repoId,
177
+ repoName: repo.name,
178
+ repoPath: repoPathParts.join("/")
179
+ };
180
+ }
181
+ function listRepoEntries(repoPayload) {
182
+ return [
183
+ repoPayload.personal,
184
+ ...repoPayload.teams,
185
+ ...repoPayload.workspace ? [repoPayload.workspace] : []
186
+ ];
187
+ }
188
+ function normalizeWorkspacePath(workspacePath) {
189
+ const trimmed = workspacePath.trim();
190
+ if (!trimmed) {
191
+ throw new WorkspacePathError("Workspace path must not be empty.");
192
+ }
193
+ if (trimmed.startsWith("/")) {
194
+ throw new WorkspacePathError("Workspace path must be relative.");
195
+ }
196
+ const parts = trimmed.split("/").filter((part) => part.length > 0 && part !== ".");
197
+ if (parts.length === 0) {
198
+ throw new WorkspacePathError("Workspace path must include a repo name.");
199
+ }
200
+ if (parts.some((part) => part === "..")) {
201
+ throw new WorkspacePathError('Workspace path must not contain "..".');
202
+ }
203
+ return parts.join("/");
204
+ }
205
+
206
+ // src/commands/auth.ts
207
+ function registerAuthCommand(program2) {
208
+ const auth = program2.command("auth").description("Authentication utilities");
209
+ auth.command("status").description("Show CLI authentication mode").action(() => {
210
+ try {
211
+ const context = resolveRuntimeContext();
212
+ if (context.mode === "sandbox") {
213
+ print(`${colors.bold}Mode${colors.reset}: sandbox`);
214
+ print(`${colors.bold}Workspace${colors.reset}: ${context.workspaceId}`);
215
+ print(`${colors.bold}Base API${colors.reset}: ${context.baseApiHost}`);
216
+ print(`${colors.bold}Repos${colors.reset}:`);
217
+ for (const repo of listRepoEntries(context.repoPayload)) {
218
+ print(` ${repo.name} ${repo.repoId}`);
219
+ }
220
+ return;
221
+ }
222
+ print(`${colors.bold}Mode${colors.reset}: user`);
223
+ print(`${colors.bold}Host${colors.reset}: ${context.host}`);
224
+ } catch (error) {
225
+ if (error instanceof RuntimeContextError) {
226
+ printError(error.message);
227
+ process.exit(1);
228
+ }
229
+ throw error;
230
+ }
231
+ });
232
+ }
8
233
 
9
234
  // src/commands/file.ts
10
- import * as fs from "fs";
235
+ import * as fs2 from "fs";
11
236
 
12
237
  // src/utils/file-selector.ts
13
238
  var SpaceOptionsError = class extends Error {
@@ -149,7 +374,7 @@ function getApiKeyOrUndefined() {
149
374
 
150
375
  // src/utils/http.ts
151
376
  function getUserAgent() {
152
- return `moxt-cli/${"0.3.3-moxt-run.2"} (${platform()}/${arch()}) node/${process.versions.node}`;
377
+ return `moxt-cli/${"0.4.0-beta.1"} (${platform()}/${arch()}) node/${process.versions.node}`;
153
378
  }
154
379
  async function fetchApi(method, path2, body) {
155
380
  const apiKey = getApiKey();
@@ -202,23 +427,6 @@ async function httpRaw(method, path2, body) {
202
427
  };
203
428
  }
204
429
 
205
- // src/utils/output.ts
206
- var colors = {
207
- bold: "\x1B[1m",
208
- blue: "\x1B[1;34m",
209
- green: "\x1B[32m",
210
- red: "\x1B[31m",
211
- cyan: "\x1B[36m",
212
- dim: "\x1B[2m",
213
- reset: "\x1B[0m"
214
- };
215
- function print(message) {
216
- console.log(message);
217
- }
218
- function printError(message) {
219
- console.error(`${colors.red}${message}${colors.reset}`);
220
- }
221
-
222
430
  // src/utils/search-options.ts
223
431
  var SearchOptionsError = class extends Error {
224
432
  constructor(message) {
@@ -272,6 +480,209 @@ function runOrExit(fn) {
272
480
  }
273
481
  }
274
482
 
483
+ // src/utils/sandbox-client.ts
484
+ var DEFAULT_TIMEOUT_MS = 3e4;
485
+ var SandboxApiError = class extends Error {
486
+ status;
487
+ body;
488
+ constructor(status, body) {
489
+ super(`Sandbox API request failed with ${status}: ${body}`);
490
+ this.name = "SandboxApiError";
491
+ this.status = status;
492
+ this.body = body;
493
+ }
494
+ };
495
+ var SandboxMoxtClient = class {
496
+ constructor(context) {
497
+ this.context = context;
498
+ }
499
+ context;
500
+ async request(method, path2, body, options = {}) {
501
+ const signal = AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
502
+ const response = await fetch(`${this.context.baseApiHost}${path2}`, {
503
+ method,
504
+ headers: {
505
+ "Content-Type": "application/json",
506
+ Authorization: `Bearer ${this.context.sandboxToolApiToken}`
507
+ },
508
+ body: body === void 0 ? void 0 : JSON.stringify(body),
509
+ signal
510
+ });
511
+ const contentType = response.headers.get("content-type");
512
+ const data = contentType?.includes("application/json") ? await response.json() : await response.text();
513
+ if (!response.ok) {
514
+ throw new SandboxApiError(response.status, typeof data === "string" ? data : JSON.stringify(data));
515
+ }
516
+ return {
517
+ status: response.status,
518
+ data
519
+ };
520
+ }
521
+ };
522
+
523
+ // src/utils/sandbox-file.ts
524
+ import { createHash } from "crypto";
525
+ var MFS_FILE_TYPE_REGULAR = 1;
526
+ var MFS_FILE_SIZE_LIMIT_BYTES = 10 * 1024 * 1024;
527
+ var MFS_FILE_SIZE_LIMIT_LABEL = `${MFS_FILE_SIZE_LIMIT_BYTES / (1024 * 1024)}MB`;
528
+ var SandboxFileError = class extends Error {
529
+ constructor(message, code) {
530
+ super(message);
531
+ this.code = code;
532
+ this.name = "SandboxFileError";
533
+ }
534
+ code;
535
+ };
536
+ async function readSandboxFile(context, workspacePath) {
537
+ const resolved = resolveSandboxFilePath(context, workspacePath);
538
+ const tree = await fetchRepoTree(context, resolved);
539
+ const entry = findTreeEntry(tree, resolved);
540
+ if (!entry) {
541
+ throw new SandboxFileError(`${workspacePath} does not exist`, "not-found");
542
+ }
543
+ if (entry.type === "tree") {
544
+ throw new SandboxFileError(`${workspacePath} is a directory`, "directory");
545
+ }
546
+ if (!entry.sha) {
547
+ throw new SandboxFileError(`${workspacePath} does not have downloadable content`, "remote");
548
+ }
549
+ if (entry.size !== null && entry.size > MFS_FILE_SIZE_LIMIT_BYTES) {
550
+ throw new SandboxFileError(`File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${workspacePath}`, "too-large");
551
+ }
552
+ const client = new SandboxMoxtClient(context);
553
+ const presign = await client.request(
554
+ "POST",
555
+ `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(resolved.repoId)}/presign-download`,
556
+ { hashes: [entry.sha] }
557
+ );
558
+ const url = presign.data.urls[entry.sha];
559
+ if (!url) {
560
+ throw new SandboxFileError(`No download URL returned for ${workspacePath}`, "remote");
561
+ }
562
+ const response = await fetch(url);
563
+ if (!response.ok) {
564
+ throw new SandboxFileError(`Download failed [${response.status}]`, "remote");
565
+ }
566
+ const content = await response.text();
567
+ const data = Buffer.from(content, "utf8");
568
+ if (data.length > MFS_FILE_SIZE_LIMIT_BYTES) {
569
+ throw new SandboxFileError(`File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${workspacePath}`, "too-large");
570
+ }
571
+ const actualSha = createHash("sha256").update(data).digest("hex");
572
+ if (actualSha !== entry.sha) {
573
+ throw new SandboxFileError(`Downloaded content hash mismatch: ${workspacePath}`, "remote");
574
+ }
575
+ return {
576
+ path: workspacePath,
577
+ content,
578
+ sha: entry.sha,
579
+ size: data.length,
580
+ fileId: entry.fileId,
581
+ fileType: entry.fileType
582
+ };
583
+ }
584
+ async function writeSandboxFile(context, workspacePath, content, policy) {
585
+ const data = Buffer.from(content, "utf8");
586
+ if (data.length > MFS_FILE_SIZE_LIMIT_BYTES) {
587
+ throw new SandboxFileError(`File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${workspacePath}`, "too-large");
588
+ }
589
+ const resolved = resolveSandboxFilePath(context, workspacePath);
590
+ const tree = await fetchRepoTree(context, resolved);
591
+ const existing = findTreeEntry(tree, resolved);
592
+ if (existing?.type === "tree") {
593
+ throw new SandboxFileError(`${workspacePath} is a directory`, "directory");
594
+ }
595
+ if (existing && !existing.sha) {
596
+ throw new SandboxFileError(`${workspacePath} does not have base content hash`, "remote");
597
+ }
598
+ const baseContentHash = resolveBaseContentHash(workspacePath, existing, policy);
599
+ const contentHash = createHash("sha256").update(data).digest("hex");
600
+ const client = new SandboxMoxtClient(context);
601
+ const presign = await client.request(
602
+ "POST",
603
+ `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(resolved.repoId)}/presign-upload`,
604
+ { hashes: [contentHash] }
605
+ );
606
+ const uploadUrl = presign.data.urls[contentHash];
607
+ if (!uploadUrl) {
608
+ throw new SandboxFileError(`No upload URL returned for ${workspacePath}`, "remote");
609
+ }
610
+ const upload = await fetch(uploadUrl, {
611
+ method: "PUT",
612
+ headers: { "Content-Type": "application/octet-stream" },
613
+ body: new Uint8Array(data)
614
+ });
615
+ if (!upload.ok) {
616
+ throw new SandboxFileError(`Upload failed [${upload.status}]`, "remote");
617
+ }
618
+ const action = existing ? "update" : "create";
619
+ await client.request(
620
+ "POST",
621
+ "/agent/api/pipeline-sandbox/mfs-workspace/unified-push",
622
+ {
623
+ message: `moxt file write ${workspacePath}`,
624
+ repoPushes: [
625
+ {
626
+ repoId: resolved.repoId,
627
+ changes: [
628
+ {
629
+ action,
630
+ ...existing?.fileId ? { fileId: existing.fileId } : {},
631
+ path: resolved.repoPath,
632
+ contentHash,
633
+ ...baseContentHash ? { baseContentHash } : {},
634
+ size: data.length,
635
+ fileType: MFS_FILE_TYPE_REGULAR
636
+ }
637
+ ]
638
+ }
639
+ ],
640
+ crossRepoMoves: []
641
+ }
642
+ );
643
+ return { path: workspacePath, created: !existing };
644
+ }
645
+ function resolveBaseContentHash(workspacePath, existing, policy) {
646
+ if (policy.type === "force") return void 0;
647
+ if (policy.type === "use-current-sha") return existing?.sha ?? void 0;
648
+ if (policy.type === "create-only") {
649
+ if (!existing) return void 0;
650
+ throw new SandboxFileError(
651
+ `Existing file requires --base-sha or --force: ${workspacePath} (actual ${existing.sha})`,
652
+ "conflict"
653
+ );
654
+ }
655
+ if (existing?.sha === policy.sha) return policy.sha;
656
+ throw new SandboxFileError(
657
+ `File version conflict: ${workspacePath} (expected ${policy.sha}, actual ${existing?.sha ?? "missing"})`,
658
+ "conflict"
659
+ );
660
+ }
661
+ function resolveSandboxFilePath(context, workspacePath) {
662
+ let resolved;
663
+ try {
664
+ resolved = resolveWorkspacePath(context.repoPayload, workspacePath);
665
+ } catch (error) {
666
+ throw new SandboxFileError(error instanceof Error ? error.message : String(error), "invalid-path");
667
+ }
668
+ if (!resolved.repoPath) {
669
+ throw new SandboxFileError(`${workspacePath} is a directory`, "directory");
670
+ }
671
+ return resolved;
672
+ }
673
+ async function fetchRepoTree(context, resolved) {
674
+ const client = new SandboxMoxtClient(context);
675
+ const params = new URLSearchParams({ entryFilter: "paths", path: resolved.repoPath });
676
+ const response = await client.request(
677
+ "GET",
678
+ `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(resolved.repoId)}/tree?${params.toString()}`
679
+ );
680
+ return response.data;
681
+ }
682
+ function findTreeEntry(tree, resolved) {
683
+ return tree.entries.find((entry) => entry.path === resolved.repoPath);
684
+ }
685
+
275
686
  // src/utils/spinner.ts
276
687
  var frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
277
688
  var intervalId = null;
@@ -294,6 +705,23 @@ function stopSpinner(success, message) {
294
705
  `);
295
706
  }
296
707
 
708
+ // src/utils/write-content.ts
709
+ import * as fs from "fs";
710
+ function readWriteCommandContentOrExit(options, readContentFile, readStdin = () => fs.readFileSync(0, "utf8")) {
711
+ const inputModes = [options.content !== void 0, options.contentFile !== void 0, options.stdin === true].filter(Boolean);
712
+ if (inputModes.length > 1) {
713
+ printError("Only one of --content, --content-file, or --stdin can be used");
714
+ process.exit(1);
715
+ }
716
+ if (options.content !== void 0) {
717
+ return options.content;
718
+ }
719
+ if (options.contentFile !== void 0) {
720
+ return readContentFile(options.contentFile);
721
+ }
722
+ return readStdin();
723
+ }
724
+
297
725
  // src/commands/file.ts
298
726
  function addSpaceOptions(cmd) {
299
727
  return cmd.requiredOption("-w, --workspace <workspaceId>", "Workspace ID").option("-s, --space <name>", "Space name (personal or teamspace name)").option("--personal", "Use personal space (default)").option("--team-space-id <teamSpaceId>", "Team space ID").option("--teammate-id <teammateId>", "Teammate ID (teammate space)");
@@ -392,7 +820,39 @@ function registerFileCommand(program2) {
392
820
  }
393
821
  }
394
822
  });
395
- file.command("read").description("Read file content").option("-w, --workspace <workspaceId>", "Workspace ID").option("-s, --space <name>", "Space name (personal or teamspace name)").option("--personal", "Use personal space (default)").option("--team-space-id <teamSpaceId>", "Team space ID").option("--teammate-id <teammateId>", "Teammate ID (teammate space)").option("-p, --path <path>", "File path").option("-u, --url <url>", "Moxt file URL (e.g., https://moxt.ai/w/{workspaceId}/{fileId})").action(async (options) => {
823
+ file.command("read").description("Read file content").argument("[workspacePath]", "Workspace path in sandbox mode, e.g. personal/docs/readme.md").option("-w, --workspace <workspaceId>", "Workspace ID").option("-s, --space <name>", "Space name (personal or teamspace name)").option("--personal", "Use personal space (default)").option("--team-space-id <teamSpaceId>", "Team space ID").option("--teammate-id <teammateId>", "Teammate ID (teammate space)").option("-p, --path <path>", "File path").option("-u, --url <url>", "Moxt file URL (e.g., https://moxt.ai/w/{workspaceId}/{fileId})").option("--json", "Print sandbox file content and metadata as JSON").action(async (workspacePath, options) => {
824
+ const sandboxRuntime = resolveSandboxRuntimeContextOrExit();
825
+ if (sandboxRuntime) {
826
+ if (options.url) {
827
+ printError("--url cannot be used in sandbox mode");
828
+ process.exit(1);
829
+ }
830
+ const path2 = workspacePath ?? options.path;
831
+ if (!path2) {
832
+ printError("Workspace path is required in sandbox mode");
833
+ process.exit(1);
834
+ }
835
+ if (!options.json) startSpinner("Reading file...");
836
+ try {
837
+ const result = await readSandboxFile(sandboxRuntime, path2);
838
+ if (options.json) {
839
+ print(JSON.stringify(result, null, 2));
840
+ } else {
841
+ stopSpinner(true, "Done");
842
+ print(result.content);
843
+ }
844
+ } catch (error) {
845
+ handleSandboxFileError(error, path2);
846
+ }
847
+ return;
848
+ }
849
+ if (options.json) {
850
+ printError("--json is only available for sandbox workspace paths");
851
+ process.exit(1);
852
+ }
853
+ if (workspacePath && !options.path) {
854
+ options.path = workspacePath;
855
+ }
396
856
  const mode = runOrExit(() => resolveReadMode(options));
397
857
  startSpinner("Reading file...");
398
858
  let response;
@@ -428,29 +888,37 @@ function registerFileCommand(program2) {
428
888
  stopSpinner(true, "Done");
429
889
  print(content ?? "");
430
890
  });
431
- file.command("put").description("Upload a file").option("-w, --workspace <workspaceId>", "Workspace ID").option("-s, --space <name>", "Space name (personal or teamspace name)").option("--personal", "Use personal space (default)").option("--team-space-id <teamSpaceId>", "Team space ID").option("--teammate-id <teammateId>", "Teammate ID (teammate space)").option("-p, --path <path>", "Remote file path").option("-u, --url <url>", "Moxt file URL (e.g., https://moxt.ai/w/{workspaceId}/{fileId})").requiredOption("-l, --local-path <localPath>", "Local file path").option("-r, --recursive", "Create parent directories if needed").action(
432
- async (options) => {
433
- const mode = runOrExit(() => resolveWriteMode(options));
434
- if (!fs.existsSync(options.localPath)) {
435
- print(`${colors.red}\u2620 Local file not found: ${options.localPath}${colors.reset}`);
436
- process.exit(1);
437
- }
438
- const stats = fs.statSync(options.localPath);
439
- if (!stats.isFile()) {
440
- print(`${colors.red}\u2620 Not a file: ${options.localPath}${colors.reset}`);
441
- process.exit(1);
442
- }
443
- const maxSize = 10 * 1024 * 1024;
444
- if (stats.size > maxSize) {
445
- print(`${colors.red}\u2620 File too large (max 10MB): ${options.localPath}${colors.reset}`);
446
- process.exit(1);
891
+ file.command("put").description("Upload a file").argument("[workspacePath]", "Workspace path in sandbox mode, e.g. personal/docs/readme.md").option("-w, --workspace <workspaceId>", "Workspace ID").option("-s, --space <name>", "Space name (personal or teamspace name)").option("--personal", "Use personal space (default)").option("--team-space-id <teamSpaceId>", "Team space ID").option("--teammate-id <teammateId>", "Teammate ID (teammate space)").option("-p, --path <path>", "Remote file path").option("-u, --url <url>", "Moxt file URL (e.g., https://moxt.ai/w/{workspaceId}/{fileId})").requiredOption("-l, --local-path <localPath>", "Local file path").option("-r, --recursive", "Create parent directories if needed").action(
892
+ async (workspacePath, options) => {
893
+ const sandboxRuntime = resolveSandboxRuntimeContextOrExit();
894
+ if (sandboxRuntime) {
895
+ if (options.url) {
896
+ printError("--url cannot be used in sandbox mode");
897
+ process.exit(1);
898
+ }
899
+ const path2 = workspacePath ?? options.path;
900
+ if (!path2) {
901
+ printError("Workspace path is required in sandbox mode");
902
+ process.exit(1);
903
+ }
904
+ const { content: content2 } = readLocalTextFileOrExit(options.localPath);
905
+ startSpinner("Uploading...");
906
+ try {
907
+ const response2 = await writeSandboxFile(sandboxRuntime, path2, content2, {
908
+ type: "use-current-sha"
909
+ });
910
+ const action2 = response2.created ? "Created" : "Updated";
911
+ stopSpinner(true, `${action2}: ${response2.path}`);
912
+ } catch (error) {
913
+ handleSandboxFileError(error, path2);
914
+ }
915
+ return;
447
916
  }
448
- const buffer2 = fs.readFileSync(options.localPath);
449
- if (isBinaryContent(buffer2)) {
450
- print(`${colors.red}\u2620 Binary files are not allowed. Only text files can be uploaded.${colors.reset}`);
451
- process.exit(1);
917
+ if (workspacePath && !options.path) {
918
+ options.path = workspacePath;
452
919
  }
453
- const content = buffer2.toString("utf8");
920
+ const mode = runOrExit(() => resolveWriteMode(options));
921
+ const { content } = readLocalTextFileOrExit(options.localPath);
454
922
  startSpinner("Uploading...");
455
923
  let response;
456
924
  let displayPath;
@@ -492,6 +960,23 @@ function registerFileCommand(program2) {
492
960
  stopSpinner(true, `${action}: ${response.data.path}`);
493
961
  }
494
962
  );
963
+ file.command("write").description("Write file content in sandbox mode").argument("<workspacePath>", "Workspace path, e.g. personal/docs/readme.md").option("--content <content>", "Text content to write").option("--content-file <contentFile>", "Local text file to write").option("--stdin", "Read text content from stdin (default)").option("--base-sha <sha>", "Write only if the remote file still has this SHA").option("--force", "Overwrite an existing remote file without a version condition").action(async (workspacePath, options) => {
964
+ const runtime = resolveSandboxRuntimeContextOrExit();
965
+ if (!runtime) {
966
+ printError("file write is only available in sandbox mode");
967
+ process.exit(1);
968
+ }
969
+ const policy = resolveSandboxFileWritePolicyOrExit(options);
970
+ const content = readWriteCommandContentOrExit(options, (path2) => readLocalTextFileOrExit(path2).content);
971
+ startSpinner("Writing file...");
972
+ try {
973
+ const response = await writeSandboxFile(runtime, workspacePath, content, policy);
974
+ const action = response.created ? "Created" : "Updated";
975
+ stopSpinner(true, `${action}: ${response.path}`);
976
+ } catch (error) {
977
+ handleSandboxFileError(error, workspacePath);
978
+ }
979
+ });
495
980
  addSpaceOptions(
496
981
  file.command("mkdir").description("Create a directory").requiredOption("-p, --path <path>", "Directory path").option("-r, --recursive", "Create parent directories if needed")
497
982
  ).action(
@@ -549,6 +1034,82 @@ function registerFileCommand(program2) {
549
1034
  }
550
1035
  );
551
1036
  }
1037
+ function resolveSandboxFileWritePolicyOrExit(options) {
1038
+ if (options.baseSha && options.force) {
1039
+ printError("Only one of --base-sha or --force can be used");
1040
+ process.exit(1);
1041
+ }
1042
+ if (options.baseSha) {
1043
+ if (!/^[a-f0-9]{64}$/.test(options.baseSha)) {
1044
+ printError("--base-sha must be a lowercase SHA-256 hash");
1045
+ process.exit(1);
1046
+ }
1047
+ return { type: "base-sha", sha: options.baseSha };
1048
+ }
1049
+ return options.force ? { type: "force" } : { type: "create-only" };
1050
+ }
1051
+ function resolveRuntimeContextOrExit() {
1052
+ try {
1053
+ return resolveRuntimeContext();
1054
+ } catch (error) {
1055
+ if (error instanceof RuntimeContextError) {
1056
+ printError(error.message);
1057
+ process.exit(1);
1058
+ }
1059
+ throw error;
1060
+ }
1061
+ }
1062
+ function resolveSandboxRuntimeContextOrExit() {
1063
+ if (!SANDBOX_ENV_KEYS.some((key) => (process.env[key] ?? "").trim().length > 0)) {
1064
+ return void 0;
1065
+ }
1066
+ const context = resolveRuntimeContextOrExit();
1067
+ return context.mode === "sandbox" ? context : void 0;
1068
+ }
1069
+ function readLocalTextFileOrExit(localPath) {
1070
+ if (!fs2.existsSync(localPath)) {
1071
+ print(`${colors.red}\u2620 Local file not found: ${localPath}${colors.reset}`);
1072
+ process.exit(1);
1073
+ }
1074
+ const stats = fs2.statSync(localPath);
1075
+ if (!stats.isFile()) {
1076
+ print(`${colors.red}\u2620 Not a file: ${localPath}${colors.reset}`);
1077
+ process.exit(1);
1078
+ }
1079
+ if (stats.size > MFS_FILE_SIZE_LIMIT_BYTES) {
1080
+ print(`${colors.red}\u2620 File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${localPath}${colors.reset}`);
1081
+ process.exit(1);
1082
+ }
1083
+ const buffer2 = fs2.readFileSync(localPath);
1084
+ if (isBinaryContent(buffer2)) {
1085
+ print(`${colors.red}\u2620 Binary files are not allowed. Only text files can be uploaded.${colors.reset}`);
1086
+ process.exit(1);
1087
+ }
1088
+ return { content: buffer2.toString("utf8") };
1089
+ }
1090
+ function handleSandboxFileError(error, displayPath) {
1091
+ stopSpinner(false, "Failed");
1092
+ if (error instanceof SandboxFileError) {
1093
+ if (error.code === "not-found") {
1094
+ print(`${colors.red}\u2620 ${displayPath} does not exist${colors.reset}`);
1095
+ } else if (error.code === "directory") {
1096
+ print(`${colors.red}\u2620 ${displayPath} is a directory${colors.reset}`);
1097
+ } else {
1098
+ printError(error.message);
1099
+ }
1100
+ process.exit(1);
1101
+ }
1102
+ if (error instanceof SandboxApiError) {
1103
+ printError(`Sandbox API error [${error.status}]: ${error.body}`);
1104
+ process.exit(1);
1105
+ }
1106
+ if (error instanceof Error) {
1107
+ printError(`Sandbox file operation failed: ${error.message}`);
1108
+ process.exit(1);
1109
+ }
1110
+ printError(`Sandbox file operation failed: ${String(error)}`);
1111
+ process.exit(1);
1112
+ }
552
1113
 
553
1114
  // src/commands/memory.ts
554
1115
  function registerMemoryCommand(program2) {
@@ -744,2213 +1305,1343 @@ function registerMiniappCommand(program2) {
744
1305
  });
745
1306
  }
746
1307
 
747
- // src/run/run-agent.ts
748
- import { spawn } from "child_process";
749
- import { closeSync as closeSync2, fstatSync, openSync as openSync2, readSync as readSync2 } from "fs";
750
- import { homedir as homedir2 } from "os";
1308
+ // src/commands/pull.ts
1309
+ import { isAbsolute as isAbsolute3 } from "path";
1310
+ import { Option } from "commander";
751
1311
 
752
- // src/run/capture.ts
1312
+ // src/utils/sandbox-pull.ts
1313
+ import { createHash as createHash2 } from "crypto";
1314
+ import { lstat, mkdir as mkdir2, readFile as readFile2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
1315
+ import { dirname as dirname2, isAbsolute as isAbsolute2, relative, resolve } from "path";
1316
+
1317
+ // src/utils/checkout-state.ts
753
1318
  import { randomUUID } from "crypto";
754
- import { access, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
755
1319
  import { homedir } from "os";
756
- import { join, posix, win32 } from "path";
757
- var DATA_FILE_TARGET_BYTES = 1024 * 1024;
758
- var RUNLOG_DIRECTORY_MARKER = ".runlog";
759
- var RUNLOG_DIRECTORY_MARKER_SCHEMA = "runlog.directory.v1";
760
- var RUNLOG_MANAGED_OUTPUT_NAMES = /* @__PURE__ */ new Set([
761
- ".DS_Store",
762
- RUNLOG_DIRECTORY_MARKER,
763
- "events",
764
- "events.jsonl",
765
- "run.json",
766
- "state.json",
767
- "transcript",
768
- "transcript.jsonl",
769
- "upload.json"
770
- ]);
771
- var RUNLOG_METADATA_SCHEMAS = {
772
- "run.json": "runlog.capture.v1",
773
- "state.json": "runlog.state.v1",
774
- "upload.json": "runlog.upload.v1"
775
- };
776
- var RUNLOG_METADATA_FILE_NAMES = Object.keys(RUNLOG_METADATA_SCHEMAS);
777
- var CaptureOutputPrepareError = class extends Error {
778
- path;
779
- constructor(path2, cause) {
780
- super(errorMessage(cause));
781
- this.name = "CaptureOutputPrepareError";
782
- this.path = path2;
783
- this.cause = cause;
1320
+ import { dirname, isAbsolute, join, posix } from "path";
1321
+ import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
1322
+ var CheckoutStateError = class extends Error {
1323
+ constructor(message) {
1324
+ super(message);
1325
+ this.name = "CheckoutStateError";
784
1326
  }
785
1327
  };
786
- function defaultCaptureOutputRoot(home = homedir(), env = process.env, nodePlatform = process.platform) {
787
- const pathJoin = nodePlatform === "win32" ? win32.join : posix.join;
788
- if (nodePlatform === "darwin") {
789
- return pathJoin(home, "Library", "Application Support", "moxt", "runs");
790
- }
791
- if (nodePlatform === "win32") {
792
- return pathJoin(env.LOCALAPPDATA ?? pathJoin(home, "AppData", "Local"), "Moxt", "runs");
793
- }
794
- return pathJoin(env.XDG_DATA_HOME ?? pathJoin(home, ".local", "share"), "moxt", "runs");
795
- }
796
- function withDefaultCaptureOutput(env, home = homedir()) {
797
- const { outputDir, outputRoot } = captureEnvPaths(env);
798
- if (hasValue(outputDir) || hasValue(outputRoot)) {
799
- return env;
800
- }
801
- return {
802
- ...env,
803
- MOXT_RUN_OUTPUT_ROOT: defaultCaptureOutputRoot(home, env)
804
- };
1328
+ function getCheckoutStatePath() {
1329
+ return join(homedir(), ".moxt", "checkout-state.json");
805
1330
  }
806
- async function prepareCaptureOutputs(env, runId) {
807
- const { outputDir, outputRoot } = captureEnvPaths(env);
808
- if (hasValue(outputDir)) {
809
- await prepareOutputPath(outputDir, () => prepareCaptureDirectory(outputDir));
1331
+ async function loadCheckoutState(statePath = getCheckoutStatePath()) {
1332
+ let raw;
1333
+ try {
1334
+ raw = await readFile(statePath, "utf8");
1335
+ } catch (error) {
1336
+ if (isNodeError(error) && error.code === "ENOENT") return void 0;
1337
+ throw new CheckoutStateError(`Failed to read checkout state: ${errorMessage(error)}`);
810
1338
  }
811
- if (hasValue(outputRoot)) {
812
- const runDir = join(outputRoot, runId);
813
- await prepareOutputPath(runDir, () => prepareCaptureDirectory(runDir));
1339
+ let value;
1340
+ try {
1341
+ value = JSON.parse(raw);
1342
+ } catch {
1343
+ throw new CheckoutStateError(`Checkout state is not valid JSON: ${statePath}`);
814
1344
  }
1345
+ return validateCheckoutState(value);
815
1346
  }
816
- function captureOutputDirectories(env, runId) {
817
- const { outputDir, outputRoot } = captureEnvPaths(env);
818
- const dirs = /* @__PURE__ */ new Set();
819
- if (hasValue(outputDir)) {
820
- dirs.add(outputDir);
821
- }
822
- if (hasValue(outputRoot)) {
823
- dirs.add(join(outputRoot, runId));
1347
+ async function saveCheckoutState(state, statePath = getCheckoutStatePath()) {
1348
+ const validState = validateCheckoutState(state);
1349
+ const stateDir = dirname(statePath);
1350
+ const temporaryPath = `${statePath}.${process.pid}.${randomUUID()}.tmp`;
1351
+ await mkdir(stateDir, { recursive: true });
1352
+ try {
1353
+ await writeFile(temporaryPath, `${JSON.stringify(validState, null, 2)}
1354
+ `, { mode: 384 });
1355
+ await rename(temporaryPath, statePath);
1356
+ } catch (error) {
1357
+ await rm(temporaryPath, { force: true });
1358
+ throw new CheckoutStateError(`Failed to save checkout state: ${errorMessage(error)}`);
824
1359
  }
825
- return [...dirs];
826
1360
  }
827
- async function emitOpenCaptureDirectories(env, runId, capturedAt) {
828
- await Promise.all(
829
- captureOutputDirectories(env, runId).map(async (outputDir) => {
830
- await writeCaptureUploadFile(outputDir, runId);
831
- await writeCaptureStateFile(outputDir, runId, "open", capturedAt);
832
- })
1361
+ function isCheckoutPathIncluded(state, workspacePath) {
1362
+ if (state.mode === "full") return true;
1363
+ return state.checkedOutScopes.some(
1364
+ (scope) => workspacePath === scope || workspacePath.startsWith(`${scope}/`)
833
1365
  );
834
1366
  }
835
- async function finalizeCaptureDirectories(payload, env, runId, capturedAt, files) {
836
- await Promise.all(
837
- captureOutputDirectories(env, runId).map(async (outputDir) => {
838
- await writeCaptureRunFile(outputDir, payload, runId, files);
839
- await writeCaptureUploadFile(outputDir, runId);
840
- await writeCaptureStateFile(outputDir, runId, "closed", capturedAt);
841
- })
1367
+ function doesCheckoutPathIntersect(state, workspacePath) {
1368
+ if (state.mode === "full") return true;
1369
+ return state.checkedOutScopes.some(
1370
+ (scope) => workspacePath === scope || workspacePath.startsWith(`${scope}/`) || scope.startsWith(`${workspacePath}/`)
842
1371
  );
843
1372
  }
844
- function createCaptureRunId(startedAt, agent) {
845
- const timestamp = startedAt.replaceAll(/[^A-Za-z0-9]/g, "-");
846
- const suffix = randomUUID().replaceAll("-", "").slice(0, 8);
847
- return agent === void 0 ? `${timestamp}-${suffix}` : `${timestamp}-${agent}-${suffix}`;
848
- }
849
- async function writeCaptureDirectoryPayload(payload, env, runId, capturedAt) {
850
- const hasTranscript = payload.transcript_redacted !== void 0;
851
- const transcript = hasTranscript ? normalizeJsonl(payload.transcript_redacted ?? "") : "";
852
- const transcriptChunks = hasTranscript ? chunkJsonl(transcript, "transcript", "transcript") : [];
853
- const eventChunks = hasTranscript ? chunkJsonl(serializeEventsFromTranscript(transcript, 1, payload.run.agent).text, "events", "events") : [];
854
- await Promise.all(
855
- captureOutputDirectories(env, runId).map(async (outputDir) => {
856
- await prepareCaptureDirectory(outputDir);
857
- if (hasTranscript) {
858
- await writeChunks(outputDir, eventChunks);
859
- await writeChunks(outputDir, transcriptChunks);
860
- }
861
- await writeCaptureRunFile(outputDir, payload, runId, {
862
- events: eventChunks.map(withoutContent),
863
- transcript: transcriptChunks.map(withoutContent)
864
- });
865
- await writeCaptureUploadFile(outputDir, runId);
866
- await writeCaptureStateFile(outputDir, runId, "closed", capturedAt);
867
- })
1373
+ function validateCheckoutState(value) {
1374
+ const state = requireRecord(value, "Checkout state");
1375
+ if (state.version !== 1) {
1376
+ throw new CheckoutStateError(`Unsupported checkout state version: ${String(state.version)}`);
1377
+ }
1378
+ const workspaceId = requireNonEmptyString(state.workspaceId, "workspaceId");
1379
+ const checkoutRoot = requireNonEmptyString(state.checkoutRoot, "checkoutRoot");
1380
+ if (!isAbsolute(checkoutRoot)) throw new CheckoutStateError("checkoutRoot must be an absolute path");
1381
+ if (state.mode !== "partial" && state.mode !== "full") {
1382
+ throw new CheckoutStateError("mode must be partial or full");
1383
+ }
1384
+ if (!Array.isArray(state.checkedOutScopes)) {
1385
+ throw new CheckoutStateError("checkedOutScopes must be an array");
1386
+ }
1387
+ const checkedOutScopes = state.checkedOutScopes.map(
1388
+ (scope, index) => requireWorkspacePath(scope, `checkedOutScopes[${index}]`)
868
1389
  );
869
- }
870
- function serializeEventsFromTranscript(transcript, startSeq, agent = "claude") {
871
- const events = normalizeEvents(transcript, startSeq, agent);
872
- return {
873
- text: serializeEvents(events),
874
- nextSeq: startSeq + events.length
875
- };
876
- }
877
- function normalizeJsonl(text) {
878
- if (text === "") {
879
- return "";
880
- }
881
- return text.endsWith("\n") ? text : `${text}
882
- `;
883
- }
884
- async function prepareCaptureDirectory(outputDir) {
885
- await mkdir(outputDir, { recursive: true });
886
- await assertMoxtManagedOutputDir(outputDir);
887
- await Promise.all([
888
- rm(join(outputDir, RUNLOG_DIRECTORY_MARKER), { force: true }),
889
- rm(join(outputDir, "run.json"), { force: true }),
890
- rm(join(outputDir, "state.json"), { force: true }),
891
- rm(join(outputDir, "upload.json"), { force: true }),
892
- rm(join(outputDir, "events.jsonl"), { force: true }),
893
- rm(join(outputDir, "transcript.jsonl"), { force: true }),
894
- rm(join(outputDir, "events"), { recursive: true, force: true }),
895
- rm(join(outputDir, "transcript"), { recursive: true, force: true })
896
- ]);
897
- await writeRunlogDirectoryMarker(outputDir);
898
- await mkdir(join(outputDir, "events"), { recursive: true });
899
- await mkdir(join(outputDir, "transcript"), { recursive: true });
900
- }
901
- async function assertMoxtManagedOutputDir(outputDir) {
902
- const entries = await readdir(outputDir);
903
- const unmanaged = entries.filter((entry) => !RUNLOG_MANAGED_OUTPUT_NAMES.has(entry));
904
- if (unmanaged.length > 0) {
905
- throw new Error(`refusing to use run output directory with unmanaged files: ${unmanaged.slice(0, 5).join(", ")}`);
906
- }
907
- const meaningfulEntries = entries.filter((entry) => entry !== ".DS_Store");
908
- if (meaningfulEntries.length === 0) {
909
- return;
910
- }
911
- if (!entries.includes(RUNLOG_DIRECTORY_MARKER)) {
912
- throw new Error("refusing to use run output directory without ownership marker");
913
- }
914
- await assertRunlogDirectoryMarker(outputDir);
915
- const metadataEntries = RUNLOG_METADATA_FILE_NAMES.filter((entry) => entries.includes(entry));
916
- if (metadataEntries.length === 0) {
917
- return;
918
- }
919
- const runIds = await Promise.all(
920
- metadataEntries.map((entry) => readRunlogMetadataRunId(outputDir, entry))
921
- );
922
- const uniqueRunIds = new Set(runIds);
923
- if (uniqueRunIds.size > 1) {
924
- throw new Error("refusing to use run output directory with mismatched run metadata");
925
- }
926
- }
927
- async function readRunlogMetadataRunId(outputDir, fileName) {
928
- let parsed;
929
- try {
930
- parsed = JSON.parse(await readFile(join(outputDir, fileName), "utf8"));
931
- } catch (error) {
932
- throw new Error(`invalid ${fileName}: ${errorMessage(error)}`);
933
- }
934
- const value = objectValue(parsed);
935
- if (value === null) {
936
- throw new Error(`invalid ${fileName}: expected object`);
937
- }
938
- if (value.schema_version !== RUNLOG_METADATA_SCHEMAS[fileName]) {
939
- throw new Error(`invalid ${fileName}: unexpected schema_version`);
940
- }
941
- if (typeof value.run_id !== "string" || value.run_id.trim() === "") {
942
- throw new Error(`invalid ${fileName}: missing run_id`);
943
- }
944
- return value.run_id;
945
- }
946
- async function assertRunlogDirectoryMarker(outputDir) {
947
- let parsed;
948
- try {
949
- parsed = JSON.parse(await readFile(join(outputDir, RUNLOG_DIRECTORY_MARKER), "utf8"));
950
- } catch (error) {
951
- throw new Error(`invalid ${RUNLOG_DIRECTORY_MARKER}: ${errorMessage(error)}`);
952
- }
953
- const value = objectValue(parsed);
954
- if (value?.schema_version !== RUNLOG_DIRECTORY_MARKER_SCHEMA) {
955
- throw new Error(`invalid ${RUNLOG_DIRECTORY_MARKER}: unexpected schema_version`);
1390
+ if (new Set(checkedOutScopes).size !== checkedOutScopes.length) {
1391
+ throw new CheckoutStateError("checkedOutScopes must not contain duplicates");
1392
+ }
1393
+ const reposValue = requireRecord(state.repos, "repos");
1394
+ const repos = {};
1395
+ for (const [repoId, rawRepo] of Object.entries(reposValue)) {
1396
+ requireNonEmptyString(repoId, "repo id");
1397
+ const repo = requireRecord(rawRepo, `repos.${repoId}`);
1398
+ const name = requireRepoName(repo.name, `repos.${repoId}.name`);
1399
+ repos[repoId] = {
1400
+ name,
1401
+ treeSha: requireNonEmptyString(repo.treeSha, `repos.${repoId}.treeSha`)
1402
+ };
956
1403
  }
957
- }
958
- async function prepareOutputPath(path2, prepare = async () => {
959
- await mkdir(path2, { recursive: true });
960
- }) {
961
- try {
962
- await prepare();
963
- } catch (error) {
964
- throw new CaptureOutputPrepareError(path2, error);
965
- }
966
- }
967
- async function writeCaptureRunFile(outputDir, payload, runId, files) {
968
- const runFile = {
969
- schema_version: "runlog.capture.v1",
970
- run_id: runId,
971
- run: payload.run,
972
- recording: payload.recording,
973
- repo: payload.repo,
974
- ...payload.redaction_failed ? { redaction_failed: true } : {},
975
- files: {
976
- state: "state.json",
977
- upload: "upload.json",
978
- events: files.events,
979
- transcript: files.transcript
980
- }
981
- };
982
- await writeFile(join(outputDir, "run.json"), `${JSON.stringify(runFile, null, 2)}
983
- `, "utf8");
984
- }
985
- async function writeRunlogDirectoryMarker(outputDir) {
986
- await writeFile(
987
- join(outputDir, RUNLOG_DIRECTORY_MARKER),
988
- `${JSON.stringify({ schema_version: RUNLOG_DIRECTORY_MARKER_SCHEMA }, null, 2)}
989
- `,
990
- "utf8"
991
- );
992
- }
993
- async function writeCaptureUploadFile(outputDir, runId) {
994
- const path2 = join(outputDir, "upload.json");
995
- try {
996
- await access(path2);
997
- return;
998
- } catch {
1404
+ const filesValue = requireRecord(state.files, "files");
1405
+ const files = {};
1406
+ for (const [workspacePath, rawFile] of Object.entries(filesValue)) {
1407
+ requireWorkspacePath(workspacePath, `files.${workspacePath}`);
1408
+ const file = requireRecord(rawFile, `files.${workspacePath}`);
1409
+ const repoId = requireNonEmptyString(file.repoId, `files.${workspacePath}.repoId`);
1410
+ const repo = repos[repoId];
1411
+ if (!repo) throw new CheckoutStateError(`File references unknown repo: ${workspacePath}`);
1412
+ const repoPath = requireWorkspacePath(file.repoPath, `files.${workspacePath}.repoPath`);
1413
+ if (workspacePath !== `${repo.name}/${repoPath}`) {
1414
+ throw new CheckoutStateError(`File path does not match its repo: ${workspacePath}`);
1415
+ }
1416
+ const fileId = file.fileId === null ? null : requireNonEmptyString(file.fileId, `files.${workspacePath}.fileId`);
1417
+ const sha = requireNonEmptyString(file.sha, `files.${workspacePath}.sha`);
1418
+ if (!/^[a-f0-9]{64}$/.test(sha)) throw new CheckoutStateError(`Invalid file SHA: ${workspacePath}`);
1419
+ files[workspacePath] = {
1420
+ repoId,
1421
+ repoPath,
1422
+ fileId,
1423
+ sha,
1424
+ size: requireNonNegativeInteger(file.size, `files.${workspacePath}.size`),
1425
+ fileType: requireNonNegativeInteger(file.fileType, `files.${workspacePath}.fileType`)
1426
+ };
999
1427
  }
1000
- const uploadFile2 = {
1001
- schema_version: "runlog.upload.v1",
1002
- run_id: runId,
1003
- status: "pending",
1004
- files: {}
1005
- };
1006
- await writeFile(path2, `${JSON.stringify(uploadFile2, null, 2)}
1007
- `, "utf8");
1008
- }
1009
- async function writeCaptureStateFile(outputDir, runId, status, capturedAt) {
1010
- const stateFile = {
1011
- schema_version: "runlog.state.v1",
1012
- run_id: runId,
1013
- status,
1014
- updated_at: capturedAt
1015
- };
1016
- await writeFile(join(outputDir, "state.json"), `${JSON.stringify(stateFile, null, 2)}
1017
- `, "utf8");
1018
- }
1019
- async function writeChunks(outputDir, chunks) {
1020
- await Promise.all(chunks.map((chunk) => writeFile(join(outputDir, chunk.path), chunk.content, "utf8")));
1021
- }
1022
- function withoutContent(chunk) {
1023
1428
  return {
1024
- path: chunk.path,
1025
- bytes: chunk.bytes,
1026
- lines: chunk.lines,
1027
- oversized_line: chunk.oversized_line
1429
+ version: 1,
1430
+ workspaceId,
1431
+ checkoutRoot,
1432
+ mode: state.mode,
1433
+ checkedOutScopes,
1434
+ repos,
1435
+ files
1028
1436
  };
1029
1437
  }
1030
- function captureEnvPaths(env) {
1031
- const { MOXT_RUN_OUTPUT_DIR: outputDir, MOXT_RUN_OUTPUT_ROOT: outputRoot } = env;
1032
- return { outputDir, outputRoot };
1033
- }
1034
- function chunkJsonl(text, dir, prefix) {
1035
- if (text === "") {
1036
- return [];
1037
- }
1038
- const chunks = [];
1039
- let content = "";
1040
- let bytes = 0;
1041
- let lines = 0;
1042
- let oversizedLine = false;
1043
- const flush2 = () => {
1044
- if (content === "") {
1045
- return;
1046
- }
1047
- const index = String(chunks.length + 1).padStart(6, "0");
1048
- chunks.push({
1049
- path: `${dir}/${prefix}-${index}.jsonl`,
1050
- content,
1051
- bytes,
1052
- lines,
1053
- oversized_line: oversizedLine
1054
- });
1055
- content = "";
1056
- bytes = 0;
1057
- lines = 0;
1058
- oversizedLine = false;
1059
- };
1060
- for (const line of jsonlLines(text)) {
1061
- const lineBytes = Buffer.byteLength(line);
1062
- if (lineBytes > DATA_FILE_TARGET_BYTES) {
1063
- flush2();
1064
- content = line;
1065
- bytes = lineBytes;
1066
- lines = 1;
1067
- oversizedLine = true;
1068
- flush2();
1069
- continue;
1070
- }
1071
- if (bytes > 0 && bytes + lineBytes > DATA_FILE_TARGET_BYTES) {
1072
- flush2();
1073
- }
1074
- content += line;
1075
- bytes += lineBytes;
1076
- lines += 1;
1077
- }
1078
- flush2();
1079
- return chunks;
1080
- }
1081
- function jsonlLines(text) {
1082
- const normalized = normalizeJsonl(text);
1083
- if (normalized === "") {
1084
- return [];
1438
+ function requireRecord(value, field) {
1439
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1440
+ throw new CheckoutStateError(`${field} must be an object`);
1085
1441
  }
1086
- return normalized.slice(0, -1).split("\n").map((line) => `${line}
1087
- `);
1442
+ return value;
1088
1443
  }
1089
- function serializeEvents(events) {
1090
- if (events.length === 0) {
1091
- return "";
1092
- }
1093
- return `${events.map((event) => JSON.stringify(event)).join("\n")}
1094
- `;
1095
- }
1096
- function normalizeEvents(transcript, startSeq, agent) {
1097
- const events = [];
1098
- for (const line of transcript.split("\n")) {
1099
- if (line.trim() === "") {
1100
- continue;
1101
- }
1102
- const raw = parseTranscriptLine(line);
1103
- const event = agent === "codex" ? normalizeCodexEvent(raw, startSeq + events.length) : normalizeClaudeEvent(raw, startSeq + events.length);
1104
- if (event !== null) {
1105
- events.push(event);
1106
- }
1444
+ function requireNonEmptyString(value, field) {
1445
+ if (typeof value !== "string" || value.length === 0) {
1446
+ throw new CheckoutStateError(`${field} must be a non-empty string`);
1107
1447
  }
1108
- return events;
1448
+ return value;
1109
1449
  }
1110
- function normalizeClaudeEvent(raw, seq) {
1111
- if (raw === null) {
1112
- return null;
1113
- }
1114
- const timestamp = stringValue(raw.timestamp);
1115
- if (raw.type === "user") {
1116
- const message = messageValue(raw.message);
1117
- const text = textFromContent(message?.content);
1118
- if (text === "") {
1119
- return null;
1120
- }
1121
- return {
1122
- schema_version: "runlog.events.v1",
1123
- seq,
1124
- type: "user_message",
1125
- ...timestamp === void 0 ? {} : { timestamp },
1126
- text
1127
- };
1128
- }
1129
- if (raw.type === "assistant") {
1130
- const message = messageValue(raw.message);
1131
- const text = textFromContent(message?.content);
1132
- if (text === "") {
1133
- return null;
1134
- }
1135
- const usage = usageValue(message?.usage);
1136
- const model = stringValue(message?.model);
1137
- const inputTokens = numberValue(usage?.input_tokens);
1138
- const outputTokens = numberValue(usage?.output_tokens);
1139
- return {
1140
- schema_version: "runlog.events.v1",
1141
- seq,
1142
- type: "assistant_message",
1143
- ...timestamp === void 0 ? {} : { timestamp },
1144
- ...model === void 0 ? {} : { model },
1145
- text,
1146
- ...inputTokens === void 0 ? {} : { input_tokens: inputTokens },
1147
- ...outputTokens === void 0 ? {} : { output_tokens: outputTokens }
1148
- };
1149
- }
1150
- if (raw.type === "system" && raw.subtype === "turn_duration") {
1151
- const durationMs = numberValue(raw.durationMs);
1152
- const messageCount = numberValue(raw.messageCount);
1153
- return {
1154
- schema_version: "runlog.events.v1",
1155
- seq,
1156
- type: "turn_summary",
1157
- ...timestamp === void 0 ? {} : { timestamp },
1158
- ...durationMs === void 0 ? {} : { duration_ms: durationMs },
1159
- ...messageCount === void 0 ? {} : { message_count: messageCount }
1160
- };
1161
- }
1162
- return null;
1163
- }
1164
- function normalizeCodexEvent(raw, seq) {
1165
- if (raw === null || raw.type !== "event_msg") {
1166
- return null;
1167
- }
1168
- const timestamp = stringValue(raw.timestamp);
1169
- const payload = codexPayloadValue(raw.payload);
1170
- const payloadType = stringValue(payload?.type);
1171
- if (payloadType === "user_message") {
1172
- const text = stringValue(payload?.message) ?? "";
1173
- if (text === "") {
1174
- return null;
1175
- }
1176
- return {
1177
- schema_version: "runlog.events.v1",
1178
- seq,
1179
- type: "user_message",
1180
- ...timestamp === void 0 ? {} : { timestamp },
1181
- text
1182
- };
1450
+ function requireWorkspacePath(value, field) {
1451
+ const path2 = requireNonEmptyString(value, field);
1452
+ if (path2.includes("\\") || path2.includes("\0") || posix.isAbsolute(path2) || posix.normalize(path2) !== path2) {
1453
+ throw new CheckoutStateError(`${field} must be a normalized relative path`);
1183
1454
  }
1184
- if (payloadType === "agent_message") {
1185
- const text = stringValue(payload?.message) ?? "";
1186
- if (text === "") {
1187
- return null;
1188
- }
1189
- return {
1190
- schema_version: "runlog.events.v1",
1191
- seq,
1192
- type: "assistant_message",
1193
- ...timestamp === void 0 ? {} : { timestamp },
1194
- text
1195
- };
1196
- }
1197
- if (payloadType === "task_complete") {
1198
- const durationMs = numberValue(payload?.duration_ms);
1199
- return {
1200
- schema_version: "runlog.events.v1",
1201
- seq,
1202
- type: "turn_summary",
1203
- ...timestamp === void 0 ? {} : { timestamp },
1204
- ...durationMs === void 0 ? {} : { duration_ms: durationMs }
1205
- };
1206
- }
1207
- return null;
1455
+ return path2;
1208
1456
  }
1209
- function parseTranscriptLine(line) {
1210
- try {
1211
- const value = JSON.parse(line);
1212
- return isObject(value) ? value : null;
1213
- } catch {
1214
- return null;
1215
- }
1457
+ function requireRepoName(value, field) {
1458
+ const name = requireWorkspacePath(value, field);
1459
+ if (name.includes("/")) throw new CheckoutStateError(`${field} must be a single path segment`);
1460
+ return name;
1216
1461
  }
1217
- function textFromContent(content) {
1218
- if (typeof content === "string") {
1219
- return content;
1220
- }
1221
- if (!Array.isArray(content)) {
1222
- return "";
1462
+ function requireNonNegativeInteger(value, field) {
1463
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
1464
+ throw new CheckoutStateError(`${field} must be a non-negative integer`);
1223
1465
  }
1224
- return content.map((part) => {
1225
- const contentPart = contentPartValue(part);
1226
- if (contentPart?.type !== "text") {
1227
- return "";
1228
- }
1229
- return stringValue(contentPart.text) ?? "";
1230
- }).filter((text) => text !== "").join("\n");
1231
- }
1232
- function messageValue(value) {
1233
- return isObject(value) ? value : void 0;
1234
- }
1235
- function codexPayloadValue(value) {
1236
- return isObject(value) ? value : void 0;
1237
- }
1238
- function usageValue(value) {
1239
- return isObject(value) ? value : void 0;
1240
- }
1241
- function contentPartValue(value) {
1242
- return isObject(value) ? value : void 0;
1243
- }
1244
- function stringValue(value) {
1245
- return typeof value === "string" ? value : void 0;
1466
+ return value;
1246
1467
  }
1247
- function numberValue(value) {
1248
- return typeof value === "number" ? value : void 0;
1249
- }
1250
- function isObject(value) {
1251
- return typeof value === "object" && value !== null && !Array.isArray(value);
1252
- }
1253
- function hasValue(value) {
1254
- return value !== void 0 && value !== "";
1468
+ function isNodeError(error) {
1469
+ return error instanceof Error && "code" in error;
1255
1470
  }
1256
1471
  function errorMessage(error) {
1257
- if (error instanceof Error && error.message !== "") {
1258
- return error.message;
1259
- }
1260
- return String(error);
1261
- }
1262
- function objectValue(value) {
1263
- return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
1264
- }
1265
-
1266
- // src/run/redaction.ts
1267
- import { createHmac } from "crypto";
1268
- var SECRET_RULES = [
1269
- {
1270
- name: "private_key",
1271
- pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
1272
- replacement: (value, salt) => `<REDACTED:private_key:${tag(value, salt)}>`
1273
- },
1274
- {
1275
- name: "bearer",
1276
- pattern: /Bearer\s+[A-Za-z0-9._~+/=-]+/gi,
1277
- replacement: (value, salt) => `<REDACTED:bearer:${tag(value, salt)}>`
1278
- },
1279
- {
1280
- name: "basic",
1281
- pattern: /Basic\s+[A-Za-z0-9+/=-]+/gi,
1282
- replacement: (value, salt) => `<REDACTED:basic:${tag(value, salt)}>`
1283
- },
1284
- {
1285
- name: "jwt",
1286
- pattern: /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
1287
- replacement: (value, salt) => `<REDACTED:jwt:${tag(value, salt)}>`
1288
- },
1289
- {
1290
- name: "github",
1291
- pattern: /github_pat_[A-Za-z0-9_]{20,}/g,
1292
- replacement: (value, salt) => `<REDACTED:github:${tag(value, salt)}>`
1293
- },
1294
- {
1295
- name: "github",
1296
- pattern: /github_pat_[A-Za-z0-9_]{4,}(?:\u2026|\.{3})/g,
1297
- replacement: (value, salt) => `<REDACTED:github:${tag(value, salt)}>`
1298
- },
1299
- {
1300
- name: "github",
1301
- pattern: /gh[opsu]_[A-Za-z0-9_]{8,}/g,
1302
- replacement: (value, salt) => `<REDACTED:github:${tag(value, salt)}>`
1303
- },
1304
- {
1305
- name: "github",
1306
- pattern: /gh[opsu]_[A-Za-z0-9_]{4,}(?:\u2026|\.{3})/g,
1307
- replacement: (value, salt) => `<REDACTED:github:${tag(value, salt)}>`
1308
- },
1309
- {
1310
- name: "openai",
1311
- pattern: /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g,
1312
- replacement: (value, salt) => `<REDACTED:openai:${tag(value, salt)}>`
1313
- },
1314
- {
1315
- name: "openai",
1316
- pattern: /sk-(?:proj-)?[A-Za-z0-9_-]{4,}(?:\u2026|\.{3})/g,
1317
- replacement: (value, salt) => `<REDACTED:openai:${tag(value, salt)}>`
1318
- },
1319
- {
1320
- name: "slack",
1321
- pattern: /xox[baprs]-[A-Za-z0-9-]{10,}/g,
1322
- replacement: (value, salt) => `<REDACTED:slack:${tag(value, salt)}>`
1323
- },
1324
- {
1325
- name: "stripe",
1326
- pattern: /(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{10,}/g,
1327
- replacement: (value, salt) => `<REDACTED:stripe:${tag(value, salt)}>`
1328
- },
1329
- {
1330
- name: "aws_key",
1331
- pattern: /(?:AKIA|ASIA)[0-9A-Z]{16}/g,
1332
- replacement: (value, salt) => `<REDACTED:aws_key:${tag(value, salt)}>`
1333
- },
1334
- {
1335
- name: "aws_key",
1336
- pattern: /(?:AKIA|ASIA)[0-9A-Z]{4,}(?:\u2026|\.{3})/g,
1337
- replacement: (value, salt) => `<REDACTED:aws_key:${tag(value, salt)}>`
1338
- }
1339
- ];
1340
- var FIELD_QUOTED_PATTERN = /(["']?(?:password|passwd|pwd|secret|client_secret|token|access_token|refresh_token|authorization|cookie|set-cookie|api_key|apikey|private_key)["']?\s*[:=]\s*)(["'])(?:\\.|(?!\2)[\s\S])*?\2/gi;
1341
- var FIELD_UNQUOTED_PATTERN = /(["']?(?:password|passwd|pwd|secret|client_secret|token|access_token|refresh_token|cookie|set-cookie|api_key|apikey|private_key)["']?\s*[:=]\s*)[^"',\n}\s]+/gi;
1342
- function redactText(text, salt = "moxt-run-local-salt") {
1343
- const hitCounts = /* @__PURE__ */ new Map();
1344
- let redacted = text;
1345
- for (const rule of SECRET_RULES) {
1346
- redacted = redacted.replace(rule.pattern, (value) => {
1347
- addHit(hitCounts, rule.name);
1348
- return rule.replacement(value, salt);
1349
- });
1350
- }
1351
- redacted = redacted.replace(FIELD_QUOTED_PATTERN, (_match, prefix, quote) => {
1352
- addHit(hitCounts, "field");
1353
- return `${prefix}${quote}<REDACTED:field>${quote}`;
1354
- }).replace(FIELD_UNQUOTED_PATTERN, (_match, prefix) => {
1355
- addHit(hitCounts, "field");
1356
- return `${prefix}<REDACTED:field>`;
1357
- });
1358
- return {
1359
- text: redacted,
1360
- hits: [...hitCounts.entries()].map(([rule, count]) => ({ rule, count }))
1361
- };
1362
- }
1363
- function tag(value, salt) {
1364
- return createHmac("sha256", salt).update(value).digest("hex").slice(0, 8);
1365
- }
1366
- function addHit(hitCounts, rule) {
1367
- hitCounts.set(rule, (hitCounts.get(rule) ?? 0) + 1);
1472
+ return error instanceof Error ? error.message : String(error);
1368
1473
  }
1369
1474
 
1370
- // src/run/sanitize.ts
1371
- function stripRemoteCredentials(remote) {
1372
- if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(remote)) {
1373
- return remote;
1374
- }
1375
- try {
1376
- const url = new URL(remote);
1377
- url.username = "";
1378
- url.password = "";
1379
- return url.toString();
1380
- } catch {
1381
- return remote;
1475
+ // src/utils/sandbox-pull.ts
1476
+ var SandboxPullError = class extends Error {
1477
+ constructor(message, code) {
1478
+ super(message);
1479
+ this.code = code;
1480
+ this.name = "SandboxPullError";
1382
1481
  }
1383
- }
1384
- function sanitizeRepo(repo) {
1385
- if (repo === null) return null;
1386
- return {
1387
- ...repo,
1388
- remote: stripRemoteCredentials(repo.remote)
1389
- };
1390
- }
1391
-
1392
- // src/run/session.ts
1393
- import { existsSync as existsSync2, readdirSync, statSync as statSync2 } from "fs";
1394
- import { join as join2 } from "path";
1395
- var SESSION_START_TOLERANCE_MS = 1e3;
1396
- function snapshotJsonl(root) {
1397
- const snapshot = /* @__PURE__ */ new Map();
1398
- if (!existsSync2(root)) {
1399
- return snapshot;
1400
- }
1401
- collectJsonl(root, snapshot);
1402
- return snapshot;
1403
- }
1404
- function matchSession(before, after, startedAtMs, options = {}) {
1405
- const candidates = [];
1406
- for (const [path2, current2] of after.entries()) {
1407
- const previous = before.get(path2);
1408
- if (previous === void 0) {
1409
- if (wasChangedAfterStart(current2, startedAtMs)) {
1410
- candidates.push({ path: path2, startOffset: 0 });
1411
- }
1412
- continue;
1482
+ code;
1483
+ };
1484
+ async function pullSandboxWorkspace(context, targetDir, options = {}) {
1485
+ const resolvedTargetDir = resolve(targetDir);
1486
+ const repos = listRepoEntries(context.repoPayload);
1487
+ const previousState = await loadCheckoutState();
1488
+ const compatibleState = resolveCompatibleState(previousState, context, resolvedTargetDir, repos);
1489
+ const previousFiles = compatibleState?.files ?? {};
1490
+ const selection = resolvePullSelection(context, options.scopes);
1491
+ const repoById = new Map(selection.repos.map((repo) => [repo.repoId, repo]));
1492
+ const tree = await fetchWorkspaceTree(context, selection);
1493
+ const stateRepos = resolveStateRepos(tree, selection.repos);
1494
+ const entries = [...tree.entries].sort((a, b) => a.prefixedPath.localeCompare(b.prefixedPath));
1495
+ const directoryPaths = selection.repos.map((repo) => ({
1496
+ workspacePath: repo.name,
1497
+ localPath: resolveSafeLocalPath(resolvedTargetDir, repo.name, "")
1498
+ }));
1499
+ const blobEntries = [];
1500
+ const remoteBlobPaths = /* @__PURE__ */ new Set();
1501
+ const remotePaths = /* @__PURE__ */ new Set();
1502
+ const matchedScopes = /* @__PURE__ */ new Set();
1503
+ for (const entry of entries) {
1504
+ const repo = repoById.get(entry.repoId);
1505
+ if (!repo) {
1506
+ throw new SandboxPullError(`Remote tree returned unknown repo: ${entry.repoId}`, "remote");
1413
1507
  }
1414
- if (!options.newFilesOnly && current2.size > previous.size) {
1415
- candidates.push({ path: path2, startOffset: previous.size });
1508
+ const expectedWorkspacePath = entry.path ? `${repo.name}/${entry.path}` : repo.name;
1509
+ if (entry.prefixedPath !== expectedWorkspacePath) {
1510
+ throw new SandboxPullError(`Remote tree returned inconsistent path: ${entry.prefixedPath}`, "remote");
1416
1511
  }
1417
- }
1418
- if (candidates.length === 0) {
1419
- return { match: "none", sessionFile: null, startOffset: 0 };
1420
- }
1421
- const trustedCandidates = options.isCandidate === void 0 ? candidates : candidates.filter((candidate) => options.isCandidate?.(candidate.path));
1422
- if (trustedCandidates.length === 0) {
1423
- return { match: "none", sessionFile: null, startOffset: 0 };
1424
- }
1425
- if (trustedCandidates.length === 1) {
1426
- const [candidate] = trustedCandidates;
1427
- if (candidate !== void 0) {
1428
- return {
1429
- match: "unique",
1430
- sessionFile: candidate.path,
1431
- startOffset: candidate.startOffset
1432
- };
1512
+ if (remotePaths.has(expectedWorkspacePath)) {
1513
+ throw new SandboxPullError(`Remote tree returned duplicate path: ${expectedWorkspacePath}`, "remote");
1433
1514
  }
1434
- }
1435
- return { match: "ambiguous", sessionFile: null, startOffset: 0 };
1436
- }
1437
- function matchKnownSessionFile(before, after, sessionFile) {
1438
- const current2 = after.get(sessionFile);
1439
- if (current2 === void 0) {
1440
- return { match: "none", sessionFile: null, startOffset: 0 };
1441
- }
1442
- const previous = before.get(sessionFile);
1443
- if (previous !== void 0 && current2.size <= previous.size) {
1444
- return { match: "none", sessionFile: null, startOffset: 0 };
1445
- }
1446
- return {
1447
- match: "unique",
1448
- sessionFile,
1449
- startOffset: previous?.size ?? 0
1450
- };
1451
- }
1452
- function recordingFromSessionMatch(match) {
1453
- switch (match) {
1454
- case "unique":
1455
- return { found_chat: true, quality: "complete" };
1456
- case "none":
1457
- return { found_chat: false, quality: "not_found" };
1458
- case "ambiguous":
1459
- return { found_chat: false, quality: "uncertain" };
1460
- case "prep_failed":
1461
- return { found_chat: false, quality: "unavailable" };
1462
- }
1463
- }
1464
- function collectJsonl(dir, snapshot) {
1465
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
1466
- const path2 = join2(dir, entry.name);
1467
- if (entry.isDirectory()) {
1468
- collectJsonl(path2, snapshot);
1469
- continue;
1515
+ remotePaths.add(expectedWorkspacePath);
1516
+ const localPath = resolveSafeLocalPath(resolvedTargetDir, repo.name, entry.path);
1517
+ for (const scope of selection.scopes) {
1518
+ if (pathIsIncluded(scope, expectedWorkspacePath)) matchedScopes.add(scope);
1470
1519
  }
1471
- if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
1520
+ if (!selection.scopes.some((scope) => pathsIntersect(scope, expectedWorkspacePath))) continue;
1521
+ if (entry.type === "tree") {
1522
+ directoryPaths.push({ workspacePath: entry.prefixedPath, localPath });
1472
1523
  continue;
1473
1524
  }
1474
- const stat2 = statSync2(path2);
1475
- snapshot.set(path2, {
1476
- mtimeMs: stat2.mtimeMs,
1477
- size: stat2.size
1478
- });
1479
- }
1480
- }
1481
- function wasChangedAfterStart(current2, startedAtMs) {
1482
- return current2.mtimeMs >= startedAtMs - SESSION_START_TOLERANCE_MS;
1483
- }
1484
-
1485
- // src/run/status.ts
1486
- var SIGNAL_NUMBERS = /* @__PURE__ */ new Map([
1487
- ["SIGHUP", 1],
1488
- ["SIGINT", 2],
1489
- ["SIGQUIT", 3],
1490
- ["SIGKILL", 9],
1491
- ["SIGTERM", 15]
1492
- ]);
1493
- function signalNumber(signal) {
1494
- return SIGNAL_NUMBERS.get(signal) ?? 0;
1495
- }
1496
- function classifyExit(event, observedSignals) {
1497
- if (event.errorCode !== void 0) {
1498
- return {
1499
- code: event.errorCode === "ENOENT" ? 127 : 126,
1500
- status: "unknown"
1501
- };
1502
- }
1503
- if (event.signal !== null) {
1504
- return {
1505
- code: 128 + signalNumber(event.signal),
1506
- status: "cancelled"
1507
- };
1508
- }
1509
- if (event.code !== null) {
1510
- if (event.code === 0) {
1511
- return { code: 0, status: "completed" };
1512
- }
1513
- const signal = event.code - 128;
1514
- return {
1515
- code: event.code,
1516
- status: event.code > 128 && observedSignals.has(signal) ? "cancelled" : "failed"
1517
- };
1518
- }
1519
- return { code: 1, status: "unknown" };
1520
- }
1521
-
1522
- // src/run/live-capture.ts
1523
- import { appendFile, open, writeFile as writeFile2 } from "fs/promises";
1524
- import { join as join3 } from "path";
1525
- var DATA_FILE_TARGET_BYTES2 = 1024 * 1024;
1526
- var LIVE_CAPTURE_INTERVAL_MS = 250;
1527
- function createLiveCapture(options) {
1528
- return new LiveCaptureRecorder(options);
1529
- }
1530
- var LiveCaptureRecorder = class {
1531
- enabled;
1532
- outputs;
1533
- env;
1534
- runId;
1535
- agent;
1536
- startedAt;
1537
- sessions;
1538
- before;
1539
- baselineOk;
1540
- startedAtMs;
1541
- knownSessionFile;
1542
- isSessionCandidate;
1543
- newSessionFilesOnly;
1544
- openSessionFile;
1545
- timer;
1546
- pollInFlight = null;
1547
- sessionFile = null;
1548
- readOffset = 0;
1549
- pendingText = "";
1550
- nextSeq = 1;
1551
- sessionMatch;
1552
- captureFailed = false;
1553
- constructor(options) {
1554
- const outputDirs = captureOutputDirectories(options.env, options.runId);
1555
- this.enabled = outputDirs.length > 0;
1556
- this.outputs = outputDirs.map((outputDir) => new CaptureDirectoryOutput(outputDir));
1557
- this.env = options.env;
1558
- this.runId = options.runId;
1559
- this.agent = options.agent;
1560
- this.startedAt = options.startedAt;
1561
- this.sessions = options.sessions;
1562
- this.before = options.before;
1563
- this.baselineOk = options.baselineOk;
1564
- this.startedAtMs = options.startedAtMs;
1565
- this.knownSessionFile = options.knownSessionFile;
1566
- this.isSessionCandidate = options.isSessionCandidate;
1567
- this.newSessionFilesOnly = options.newSessionFilesOnly ?? false;
1568
- this.openSessionFile = options.openSessionFile;
1569
- this.sessionMatch = options.baselineOk ? "none" : "prep_failed";
1570
- }
1571
- async start() {
1572
- if (!this.enabled) {
1573
- return;
1525
+ if (!entry.sha) {
1526
+ throw new SandboxPullError(`${entry.prefixedPath} does not have downloadable content`, "remote");
1574
1527
  }
1575
- await emitOpenCaptureDirectories(this.env, this.runId, this.startedAt);
1576
- if (this.baselineOk) {
1577
- this.timer = setInterval(() => {
1578
- this.queuePoll();
1579
- }, LIVE_CAPTURE_INTERVAL_MS);
1528
+ if (entry.fileId !== null && (typeof entry.fileId !== "string" || entry.fileId.length === 0)) {
1529
+ throw new SandboxPullError(`${entry.prefixedPath} has invalid file metadata`, "remote");
1580
1530
  }
1581
- }
1582
- async stop() {
1583
- if (!this.enabled) {
1584
- return;
1531
+ if (entry.fileType !== null && (!Number.isInteger(entry.fileType) || entry.fileType < 0)) {
1532
+ throw new SandboxPullError(`${entry.prefixedPath} has invalid file metadata`, "remote");
1585
1533
  }
1586
- if (this.timer !== void 0) {
1587
- clearInterval(this.timer);
1588
- this.timer = void 0;
1534
+ if (entry.size !== null && (!Number.isInteger(entry.size) || entry.size < 0)) {
1535
+ throw new SandboxPullError(`${entry.prefixedPath} has invalid file metadata`, "remote");
1589
1536
  }
1590
- if (this.pollInFlight !== null) {
1591
- await this.pollInFlight;
1537
+ if (entry.size !== null && entry.size > MFS_FILE_SIZE_LIMIT_BYTES) {
1538
+ throw new SandboxPullError(`File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${entry.prefixedPath}`, "too-large");
1592
1539
  }
1593
- await this.poll();
1594
- await this.flushPendingText();
1540
+ remoteBlobPaths.add(entry.prefixedPath);
1541
+ blobEntries.push({ entry, localPath });
1595
1542
  }
1596
- async finalize(payload, capturedAt) {
1597
- if (!this.enabled) {
1598
- return;
1599
- }
1600
- if (!isCompleteRecording(payload)) {
1601
- await Promise.all(this.outputs.map((output) => output.invalidate()));
1543
+ if (selection.mode === "partial") {
1544
+ for (const scope of selection.scopes) {
1545
+ const repoRoot = selection.repos.some((repo) => repo.name === scope);
1546
+ const previouslyTracked = Object.keys(previousFiles).some((workspacePath) => pathIsIncluded(scope, workspacePath));
1547
+ if (!repoRoot && !matchedScopes.has(scope) && !previouslyTracked) {
1548
+ throw new SandboxPullError(`Workspace path does not exist: ${scope}`, "invalid-path");
1549
+ }
1602
1550
  }
1603
- await finalizeCaptureDirectories(this.directoryPayload(payload), this.env, this.runId, capturedAt, this.files());
1604
1551
  }
1605
- queuePoll() {
1606
- if (this.pollInFlight !== null) {
1607
- return;
1608
- }
1609
- this.pollInFlight = this.poll().finally(() => {
1610
- this.pollInFlight = null;
1552
+ const plannedBlobs = [];
1553
+ for (const { entry, localPath } of blobEntries) {
1554
+ plannedBlobs.push({
1555
+ entry,
1556
+ localPath,
1557
+ shouldWrite: await shouldWritePulledFile(
1558
+ localPath,
1559
+ requireBlobSha(entry),
1560
+ entry.prefixedPath,
1561
+ previousFiles[entry.prefixedPath],
1562
+ Boolean(options.force)
1563
+ )
1611
1564
  });
1612
1565
  }
1613
- async poll() {
1614
- if (!this.baselineOk || this.captureFailed) {
1615
- return;
1616
- }
1617
- try {
1618
- const snapshot = snapshotJsonl(this.sessions);
1619
- if (this.sessionFile === null) {
1620
- if (this.knownSessionFile !== void 0) {
1621
- const matched2 = matchKnownSessionFile(this.before, snapshot, this.knownSessionFile);
1622
- if (matched2.match === "unique" && matched2.sessionFile !== null) {
1623
- this.sessionMatch = matched2.match;
1624
- this.sessionFile = matched2.sessionFile;
1625
- this.readOffset = matched2.startOffset;
1626
- await this.captureNewText();
1627
- return;
1628
- }
1629
- }
1630
- const opened = this.openSessionFile?.() ?? { match: "none" };
1631
- if (opened.match === "ambiguous") {
1632
- this.sessionMatch = "ambiguous";
1633
- return;
1634
- }
1635
- if (opened.match === "unique") {
1636
- const matched2 = matchKnownSessionFile(this.before, snapshot, opened.sessionFile);
1637
- this.sessionMatch = matched2.match;
1638
- if (matched2.match === "unique" && matched2.sessionFile !== null) {
1639
- this.sessionFile = matched2.sessionFile;
1640
- this.readOffset = matched2.startOffset;
1641
- await this.captureNewText();
1642
- }
1643
- return;
1644
- }
1645
- const matched = matchSession(this.before, snapshot, this.startedAtMs, this.matchOptions());
1646
- this.sessionMatch = matched.match;
1647
- if (matched.match !== "unique" || matched.sessionFile === null) {
1648
- return;
1649
- }
1650
- this.sessionFile = matched.sessionFile;
1651
- this.readOffset = matched.startOffset;
1652
- }
1653
- await this.captureNewText();
1654
- } catch {
1655
- this.captureFailed = true;
1656
- }
1566
+ const plannedDeletions = await planRemoteDeletions(
1567
+ compatibleState,
1568
+ selection,
1569
+ resolvedTargetDir,
1570
+ remoteBlobPaths,
1571
+ Boolean(options.force)
1572
+ );
1573
+ for (const directory of directoryPaths) {
1574
+ await assertCanCreateDirectory(directory.localPath, directory.workspacePath);
1657
1575
  }
1658
- async captureNewText() {
1659
- if (this.sessionFile === null) {
1660
- return;
1661
- }
1662
- const next = await readFileFromOffset(this.sessionFile, this.readOffset);
1663
- if (next.bytes === 0) {
1664
- return;
1665
- }
1666
- this.readOffset += next.bytes;
1667
- await this.captureCompleteLines(next.text);
1576
+ const blobsToDownload = plannedBlobs.filter((planned) => planned.shouldWrite);
1577
+ const downloadUrls = await presignDownloads(context, blobsToDownload.map(({ entry }) => entry));
1578
+ const downloadedEntries = [];
1579
+ for (const planned of blobsToDownload) {
1580
+ const { entry, localPath } = planned;
1581
+ const remoteContent = await downloadBlob(entry, downloadUrls);
1582
+ downloadedEntries.push({ ...planned, content: remoteContent });
1668
1583
  }
1669
- async captureCompleteLines(text) {
1670
- const combined = `${this.pendingText}${text}`;
1671
- const lastNewline = combined.lastIndexOf("\n");
1672
- if (lastNewline === -1) {
1673
- this.pendingText = combined;
1674
- return;
1675
- }
1676
- const complete = combined.slice(0, lastNewline + 1);
1677
- this.pendingText = combined.slice(lastNewline + 1);
1678
- await this.captureTranscriptText(complete);
1584
+ for (const deletion of plannedDeletions) {
1585
+ await rm2(deletion.localPath);
1679
1586
  }
1680
- async flushPendingText() {
1681
- if (this.pendingText === "") {
1682
- return;
1683
- }
1684
- const text = `${this.pendingText}
1685
- `;
1686
- this.pendingText = "";
1687
- await this.captureTranscriptText(text);
1688
- }
1689
- async captureTranscriptText(text) {
1690
- const redacted = redactText(text).text;
1691
- const events = serializeEventsFromTranscript(redacted, this.nextSeq, this.agent);
1692
- this.nextSeq = events.nextSeq;
1693
- await Promise.all(
1694
- this.outputs.map(async (output) => {
1695
- await output.appendTranscript(redacted);
1696
- await output.appendEvents(events.text);
1697
- })
1698
- );
1587
+ for (const directory of directoryPaths) {
1588
+ await mkdir2(directory.localPath, { recursive: true });
1699
1589
  }
1700
- directoryPayload(payload) {
1701
- const recording = isCompleteRecording(payload) ? this.sessionFile === null ? payload.recording : recordingFromSessionMatch(this.sessionMatch) : payload.recording;
1702
- const keepRedactionFailure = this.captureFailed || this.files().transcript.length === 0 && payload.redaction_failed;
1703
- const { redaction_failed: _redactionFailed, ...rest } = payload;
1704
- return {
1705
- ...rest,
1706
- ...keepRedactionFailure ? { redaction_failed: true } : {},
1707
- recording
1708
- };
1590
+ for (const downloaded of downloadedEntries) {
1591
+ await writePulledFile(downloaded.localPath, downloaded.content);
1709
1592
  }
1710
- files() {
1711
- const [output] = this.outputs;
1712
- if (output === void 0) {
1713
- return { events: [], transcript: [] };
1714
- }
1715
- return output.files();
1593
+ const downloadedContentByPath = new Map(
1594
+ downloadedEntries.map(({ entry, content }) => [entry.prefixedPath, content])
1595
+ );
1596
+ const pulledFiles = Object.fromEntries(await Promise.all(plannedBlobs.map(async ({ entry, localPath }) => {
1597
+ const content = downloadedContentByPath.get(entry.prefixedPath);
1598
+ return [
1599
+ entry.prefixedPath,
1600
+ {
1601
+ repoId: entry.repoId,
1602
+ repoPath: entry.path,
1603
+ fileId: entry.fileId,
1604
+ sha: requireBlobSha(entry),
1605
+ size: content?.length ?? entry.size ?? previousFiles[entry.prefixedPath]?.size ?? await localFileSize(localPath, entry.prefixedPath),
1606
+ fileType: entry.fileType ?? 1
1607
+ }
1608
+ ];
1609
+ })));
1610
+ await saveCheckoutState(buildNextCheckoutState({
1611
+ context,
1612
+ checkoutRoot: resolvedTargetDir,
1613
+ allRepos: repos,
1614
+ selection,
1615
+ previousState: compatibleState,
1616
+ pulledRepos: stateRepos,
1617
+ pulledFiles
1618
+ }));
1619
+ return {
1620
+ fileCount: blobEntries.length,
1621
+ targetDir: resolvedTargetDir
1622
+ };
1623
+ }
1624
+ function resolveCompatibleState(state, context, checkoutRoot, repos) {
1625
+ if (!state || state.workspaceId !== context.workspaceId || state.checkoutRoot !== checkoutRoot) return void 0;
1626
+ const currentRepos = new Map(repos.map((repo) => [repo.repoId, repo.name]));
1627
+ const stateMatchesRepos = Object.entries(state.repos).every(
1628
+ ([repoId, repo]) => currentRepos.get(repoId) === repo.name
1629
+ );
1630
+ return stateMatchesRepos ? state : void 0;
1631
+ }
1632
+ function resolvePullSelection(context, requestedScopes) {
1633
+ const repos = listRepoEntries(context.repoPayload);
1634
+ if (!requestedScopes || requestedScopes.length === 0) {
1635
+ return { mode: "full", scopes: repos.map((repo) => repo.name), repos };
1716
1636
  }
1717
- matchOptions() {
1637
+ const resolvedScopes = requestedScopes.map((scope) => {
1638
+ const resolved = resolveWorkspacePath(context.repoPayload, scope);
1718
1639
  return {
1719
- ...this.isSessionCandidate === void 0 ? {} : { isCandidate: this.isSessionCandidate },
1720
- ...this.newSessionFilesOnly ? { newFilesOnly: true } : {}
1640
+ path: resolved.repoPath ? `${resolved.repoName}/${resolved.repoPath}` : resolved.repoName,
1641
+ repoId: resolved.repoId
1721
1642
  };
1722
- }
1723
- };
1724
- var CaptureDirectoryOutput = class {
1725
- transcript;
1726
- events;
1727
- constructor(outputDir) {
1728
- this.transcript = new JsonlStreamWriter(outputDir, "transcript", "transcript");
1729
- this.events = new JsonlStreamWriter(outputDir, "events", "events");
1730
- }
1731
- async appendTranscript(text) {
1732
- await this.transcript.appendText(text);
1733
- }
1734
- async appendEvents(text) {
1735
- await this.events.appendText(text);
1736
- }
1737
- async invalidate() {
1738
- await Promise.all([this.transcript.invalidate(), this.events.invalidate()]);
1739
- }
1740
- files() {
1643
+ });
1644
+ const scopes = collapseScopes(resolvedScopes.map((scope) => scope.path));
1645
+ const repoIds = new Set(resolvedScopes.map((scope) => scope.repoId));
1646
+ return {
1647
+ mode: "partial",
1648
+ scopes,
1649
+ repos: repos.filter((repo) => repoIds.has(repo.repoId))
1650
+ };
1651
+ }
1652
+ function buildNextCheckoutState(input) {
1653
+ const { context, checkoutRoot, allRepos, selection, previousState, pulledRepos, pulledFiles } = input;
1654
+ if (selection.mode === "full") {
1741
1655
  return {
1742
- events: this.events.files(),
1743
- transcript: this.transcript.files()
1656
+ version: 1,
1657
+ workspaceId: context.workspaceId,
1658
+ checkoutRoot,
1659
+ mode: "full",
1660
+ checkedOutScopes: selection.scopes,
1661
+ repos: pulledRepos,
1662
+ files: pulledFiles
1744
1663
  };
1745
1664
  }
1746
- };
1747
- var JsonlStreamWriter = class {
1748
- outputDir;
1749
- dir;
1750
- prefix;
1751
- writtenFiles = [];
1752
- current = null;
1753
- constructor(outputDir, dir, prefix) {
1754
- this.outputDir = outputDir;
1755
- this.dir = dir;
1756
- this.prefix = prefix;
1757
- }
1758
- async appendText(text) {
1759
- for (const line of jsonlLines2(text)) {
1760
- await this.appendLine(line);
1761
- }
1762
- }
1763
- async invalidate() {
1764
- await Promise.all(
1765
- this.writtenFiles.map(async (file) => {
1766
- file.bytes = 0;
1767
- file.lines = 0;
1768
- file.oversized_line = false;
1769
- await writeFile2(join3(this.outputDir, file.path), "", "utf8");
1770
- })
1771
- );
1772
- this.current = null;
1773
- }
1774
- files() {
1775
- return this.writtenFiles.map((file) => ({ ...file }));
1776
- }
1777
- async appendLine(line) {
1778
- const lineBytes = Buffer.byteLength(line);
1779
- if (lineBytes > DATA_FILE_TARGET_BYTES2) {
1780
- this.current = null;
1781
- const file2 = this.createFile();
1782
- file2.bytes += lineBytes;
1783
- file2.lines += 1;
1784
- file2.oversized_line = true;
1785
- await appendFile(join3(this.outputDir, file2.path), line, "utf8");
1786
- this.current = null;
1787
- return;
1788
- }
1789
- if (this.current !== null && this.current.bytes + lineBytes > DATA_FILE_TARGET_BYTES2) {
1790
- this.current = null;
1791
- }
1792
- const file = this.current ?? this.createFile();
1793
- file.bytes += lineBytes;
1794
- file.lines += 1;
1795
- await appendFile(join3(this.outputDir, file.path), line, "utf8");
1796
- }
1797
- createFile() {
1798
- const index = String(this.writtenFiles.length + 1).padStart(6, "0");
1799
- const file = {
1800
- path: `${this.dir}/${this.prefix}-${index}.jsonl`,
1801
- bytes: 0,
1802
- lines: 0,
1803
- oversized_line: false
1804
- };
1805
- this.writtenFiles.push(file);
1806
- this.current = file;
1807
- return file;
1808
- }
1809
- };
1810
- function isCompleteRecording(payload) {
1811
- return payload.recording.found_chat && payload.recording.quality === "complete";
1812
- }
1813
- function jsonlLines2(text) {
1814
- if (text === "") {
1815
- return [];
1816
- }
1817
- const normalized = text.endsWith("\n") ? text : `${text}
1818
- `;
1819
- return normalized.slice(0, -1).split("\n").map((line) => `${line}
1820
- `);
1665
+ const previousFiles = previousState?.files ?? {};
1666
+ const preservedFiles = Object.fromEntries(
1667
+ Object.entries(previousFiles).filter(
1668
+ ([workspacePath]) => !selection.scopes.some((scope) => pathIsIncluded(scope, workspacePath))
1669
+ )
1670
+ );
1671
+ const checkedOutScopes = collapseScopes([...previousState?.checkedOutScopes ?? [], ...selection.scopes]);
1672
+ const mode = allRepos.every((repo) => checkedOutScopes.includes(repo.name)) ? "full" : "partial";
1673
+ return {
1674
+ version: 1,
1675
+ workspaceId: context.workspaceId,
1676
+ checkoutRoot,
1677
+ mode,
1678
+ checkedOutScopes,
1679
+ repos: { ...previousState?.repos ?? {}, ...pulledRepos },
1680
+ files: { ...preservedFiles, ...pulledFiles }
1681
+ };
1821
1682
  }
1822
- async function readFileFromOffset(path2, offset) {
1823
- const file = await open(path2, "r");
1824
- try {
1825
- const stat2 = await file.stat();
1826
- const length = Math.max(0, stat2.size - offset);
1827
- if (length === 0) {
1828
- return { text: "", bytes: 0 };
1683
+ function collapseScopes(scopes) {
1684
+ const result = [];
1685
+ for (const scope of scopes) {
1686
+ if (result.some((existing) => pathIsIncluded(existing, scope))) continue;
1687
+ for (let index = result.length - 1; index >= 0; index--) {
1688
+ if (pathIsIncluded(scope, result[index])) result.splice(index, 1);
1829
1689
  }
1830
- const buffer2 = Buffer.alloc(length);
1831
- const result = await file.read(buffer2, 0, length, offset);
1832
- return { text: buffer2.subarray(0, result.bytesRead).toString("utf8"), bytes: result.bytesRead };
1833
- } finally {
1834
- await file.close();
1690
+ result.push(scope);
1835
1691
  }
1692
+ return result;
1836
1693
  }
1837
-
1838
- // src/run/moxt-uploader.ts
1839
- import { createHash } from "crypto";
1840
- import { readdir as readdir2, readFile as readFile2, stat, writeFile as writeFile3 } from "fs/promises";
1841
- import { arch as arch2, platform as platform2 } from "os";
1842
- import { join as join4, relative } from "path";
1843
-
1844
- // src/utils/cf-access.ts
1845
- function cloudflareAccessHeaders(env = process.env) {
1846
- const headers = {};
1847
- const token = firstValue(env.MOXT_CF_ACCESS_TOKEN, env.CF_ACCESS_TOKEN);
1848
- if (token !== void 0) {
1849
- headers["CF-Access-Token"] = token;
1850
- }
1851
- const clientId = firstValue(env.MOXT_CF_ACCESS_CLIENT_ID, env.CF_ACCESS_CLIENT_ID);
1852
- const clientSecret = firstValue(env.MOXT_CF_ACCESS_CLIENT_SECRET, env.CF_ACCESS_CLIENT_SECRET);
1853
- if (clientId !== void 0 && clientSecret !== void 0) {
1854
- headers["CF-Access-Client-Id"] = clientId;
1855
- headers["CF-Access-Client-Secret"] = clientSecret;
1856
- }
1857
- return headers;
1694
+ function pathsIntersect(left, right) {
1695
+ return pathIsIncluded(left, right) || pathIsIncluded(right, left);
1858
1696
  }
1859
- function firstValue(...values) {
1860
- return values.find((value) => value !== void 0 && value !== "");
1697
+ function pathIsIncluded(scope, workspacePath) {
1698
+ return workspacePath === scope || workspacePath.startsWith(`${scope}/`);
1861
1699
  }
1862
-
1863
- // src/run/moxt-uploader.ts
1864
- var DEFAULT_HOST2 = "api.moxt.ai";
1865
- var DEFAULT_UPLOAD_INTERVAL_MS = 5e3;
1866
- var DEFAULT_UPLOAD_TIMEOUT_MS = 3e4;
1867
- var MIN_UPLOAD_INTERVAL_MS = 250;
1868
- var MIN_UPLOAD_TIMEOUT_MS = 100;
1869
- function createRunArtifactUploader(options) {
1870
- return new RunArtifactUploader(options);
1871
- }
1872
- var RunArtifactUploader = class {
1873
- enabled;
1874
- env;
1875
- runId;
1876
- config;
1877
- outputDirs;
1878
- timer;
1879
- uploadInFlight = null;
1880
- constructor(options) {
1881
- this.env = options.env;
1882
- this.runId = options.runId;
1883
- this.config = uploadConfig(options.upload, options.env);
1884
- this.outputDirs = captureOutputDirectories(options.env, options.runId);
1885
- this.enabled = this.outputDirs.length > 0;
1886
- }
1887
- start() {
1888
- if (!this.enabled) {
1889
- return;
1700
+ function resolveStateRepos(tree, repos) {
1701
+ const knownRepoIds = new Set(repos.map((repo) => repo.repoId));
1702
+ for (const repoId of Object.keys(tree.repos)) {
1703
+ if (!knownRepoIds.has(repoId)) {
1704
+ throw new SandboxPullError(`Remote tree returned unknown repo: ${repoId}`, "remote");
1890
1705
  }
1891
- this.timer = setInterval(() => {
1892
- this.queueUpload();
1893
- }, this.config.intervalMs);
1894
- this.queueUpload();
1895
1706
  }
1896
- async stop() {
1897
- if (this.timer !== void 0) {
1898
- clearInterval(this.timer);
1899
- this.timer = void 0;
1707
+ return Object.fromEntries(repos.map((repo) => {
1708
+ const remoteRepo = tree.repos[repo.repoId];
1709
+ if (!remoteRepo) {
1710
+ throw new SandboxPullError(`Remote tree did not return repo: ${repo.repoId}`, "remote");
1900
1711
  }
1901
- if (this.uploadInFlight !== null) {
1902
- try {
1903
- await this.uploadInFlight;
1904
- } catch {
1905
- }
1906
- }
1907
- }
1908
- async flush() {
1909
- if (!this.enabled) {
1910
- return;
1911
- }
1912
- await this.stop();
1913
- await uploadOutputDirs(this.outputDirs, this.runId, this.config, this.env);
1914
- }
1915
- queueUpload() {
1916
- if (this.uploadInFlight !== null) {
1917
- return;
1712
+ if (remoteRepo.prefix !== repo.name) {
1713
+ throw new SandboxPullError(`Remote tree returned unexpected prefix for repo ${repo.repoId}`, "remote");
1918
1714
  }
1919
- this.uploadInFlight = uploadOutputDirs(this.outputDirs, this.runId, this.config, this.env).catch(() => {
1920
- }).finally(() => {
1921
- this.uploadInFlight = null;
1922
- });
1923
- }
1924
- };
1925
- async function uploadOutputDirs(outputDirs, runId, config, env) {
1926
- const failures = [];
1927
- for (const outputDir of outputDirs) {
1928
- try {
1929
- await uploadDirectory(outputDir, runId, config, env);
1930
- } catch (error) {
1931
- failures.push(`${outputDir}: ${errorMessage2(error)}`);
1715
+ if (typeof remoteRepo.sha !== "string" || remoteRepo.sha.length === 0) {
1716
+ throw new SandboxPullError(`Remote tree did not return a version for repo ${repo.repoId}`, "remote");
1932
1717
  }
1933
- }
1934
- if (failures.length > 0) {
1935
- throw new Error(failures.join("; "));
1936
- }
1718
+ return [repo.repoId, { name: repo.name, treeSha: remoteRepo.sha }];
1719
+ }));
1937
1720
  }
1938
- function uploadConfig(upload, env) {
1721
+ async function fetchWorkspaceTree(context, selection) {
1722
+ const client = new SandboxMoxtClient(context);
1723
+ const results = await Promise.all(selection.repos.map(async (repo) => {
1724
+ const filter = resolveRepoTreeFilter(repo, selection);
1725
+ const params = new URLSearchParams({ entryFilter: filter.type });
1726
+ if (filter.type === "paths") {
1727
+ for (const path2 of filter.paths) params.append("path", path2);
1728
+ }
1729
+ const response = await client.request(
1730
+ "GET",
1731
+ `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(repo.repoId)}/tree?${params.toString()}`
1732
+ );
1733
+ return { repo, tree: response.data };
1734
+ }));
1939
1735
  return {
1940
- ...upload,
1941
- intervalMs: Math.max(MIN_UPLOAD_INTERVAL_MS, numberEnv(env.MOXT_RUN_UPLOAD_INTERVAL_MS) ?? DEFAULT_UPLOAD_INTERVAL_MS),
1942
- timeoutMs: Math.max(MIN_UPLOAD_TIMEOUT_MS, numberEnv(env.MOXT_RUN_UPLOAD_TIMEOUT_MS) ?? DEFAULT_UPLOAD_TIMEOUT_MS)
1736
+ repos: Object.fromEntries(
1737
+ results.map(({ repo, tree }) => [repo.repoId, { sha: tree.sha, prefix: repo.name }])
1738
+ ),
1739
+ entries: results.flatMap(
1740
+ ({ repo, tree }) => tree.entries.map((entry) => ({
1741
+ ...entry,
1742
+ repoId: repo.repoId,
1743
+ prefixedPath: entry.path ? `${repo.name}/${entry.path}` : repo.name
1744
+ }))
1745
+ )
1943
1746
  };
1944
1747
  }
1945
- async function uploadDirectory(outputDir, runId, config, env) {
1946
- const files = await discoverUploadFiles(outputDir);
1947
- const state = await readUploadState(outputDir, runId);
1948
- for (const file of files) {
1949
- const previous = state.files[file.artifactPath];
1950
- if (previous?.status === "uploaded" && previous.sha256 === file.sha256 && previous.bytes === file.bytes) {
1951
- continue;
1952
- }
1953
- const attempts = (previous?.attempts ?? 0) + 1;
1954
- state.files[file.artifactPath] = {
1955
- status: "uploading",
1956
- bytes: file.bytes,
1957
- sha256: file.sha256,
1958
- attempts
1959
- };
1960
- state.status = "uploading";
1961
- state.updated_at = (/* @__PURE__ */ new Date()).toISOString();
1962
- await writeUploadState(outputDir, state);
1963
- try {
1964
- const response = await uploadFile(runId, file, config, env);
1965
- state.files[file.artifactPath] = {
1966
- status: "uploaded",
1967
- bytes: file.bytes,
1968
- sha256: file.sha256,
1969
- attempts,
1970
- uploaded_at: (/* @__PURE__ */ new Date()).toISOString(),
1971
- ...response.etag === void 0 ? {} : { etag: response.etag }
1972
- };
1973
- } catch (error) {
1974
- state.files[file.artifactPath] = {
1975
- status: "failed",
1976
- bytes: file.bytes,
1977
- sha256: file.sha256,
1978
- attempts,
1979
- failed_at: (/* @__PURE__ */ new Date()).toISOString(),
1980
- error: errorMessage2(error)
1981
- };
1982
- }
1983
- state.status = uploadStatus(
1984
- state.files,
1985
- files.map((candidate) => candidate.artifactPath)
1986
- );
1987
- state.updated_at = (/* @__PURE__ */ new Date()).toISOString();
1988
- await writeUploadState(outputDir, state);
1748
+ function resolveRepoTreeFilter(repo, selection) {
1749
+ if (selection.mode === "full") return { type: "all" };
1750
+ const repoPaths = [];
1751
+ for (const scope of selection.scopes) {
1752
+ if (scope === repo.name) return { type: "all" };
1753
+ if (scope.startsWith(`${repo.name}/`)) repoPaths.push(scope.slice(repo.name.length + 1));
1989
1754
  }
1990
- state.status = uploadStatus(
1991
- state.files,
1992
- files.map((file) => file.artifactPath)
1993
- );
1994
- state.updated_at = (/* @__PURE__ */ new Date()).toISOString();
1995
- await writeUploadState(outputDir, state);
1996
- if (state.status === "failed") {
1997
- throw new Error("one or more run files failed to upload");
1998
- }
1999
- }
2000
- async function discoverUploadFiles(outputDir) {
2001
- const paths = await uploadPaths(outputDir);
2002
- const files = await Promise.all(
2003
- paths.map(async (path2) => {
2004
- const absolute = join4(outputDir, path2);
2005
- const content = await readFile2(absolute);
2006
- return {
2007
- artifactPath: path2,
2008
- bytes: content.byteLength,
2009
- sha256: createHash("sha256").update(content).digest("hex"),
2010
- contentText: content.toString("utf8")
2011
- };
2012
- })
2013
- );
2014
- return files.sort((a, b) => a.artifactPath.localeCompare(b.artifactPath));
1755
+ if (repoPaths.length === 0) {
1756
+ throw new SandboxPullError(`No pull scope resolved for repo: ${repo.name}`, "invalid-path");
1757
+ }
1758
+ return { type: "paths", paths: repoPaths };
2015
1759
  }
2016
- async function uploadPaths(outputDir) {
2017
- const paths = [];
2018
- for (const name of ["run.json", "state.json"]) {
2019
- const absolute = join4(outputDir, name);
2020
- try {
2021
- const fileStat = await stat(absolute);
2022
- if (fileStat.isFile()) {
2023
- paths.push(name);
1760
+ async function presignDownloads(context, entries) {
1761
+ const client = new SandboxMoxtClient(context);
1762
+ const entriesByRepo = /* @__PURE__ */ new Map();
1763
+ for (const entry of entries) {
1764
+ const existing = entriesByRepo.get(entry.repoId) ?? [];
1765
+ existing.push(entry);
1766
+ entriesByRepo.set(entry.repoId, existing);
1767
+ }
1768
+ const urls = /* @__PURE__ */ new Map();
1769
+ for (const [repoId, repoEntries] of entriesByRepo) {
1770
+ const hashes = [...new Set(repoEntries.map((entry) => entry.sha).filter((sha) => Boolean(sha)))];
1771
+ if (hashes.length === 0) continue;
1772
+ const response = await client.request(
1773
+ "POST",
1774
+ `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(repoId)}/presign-download`,
1775
+ { hashes }
1776
+ );
1777
+ for (const hash of hashes) {
1778
+ const url = response.data.urls[hash];
1779
+ if (!url) {
1780
+ throw new SandboxPullError(`No download URL returned for ${hash}`, "remote");
2024
1781
  }
2025
- } catch {
1782
+ urls.set(hash, url);
2026
1783
  }
2027
1784
  }
2028
- for (const dir of ["events", "transcript"]) {
2029
- await collectFiles(outputDir, join4(outputDir, dir), paths);
2030
- }
2031
- return paths.sort();
1785
+ return urls;
2032
1786
  }
2033
- async function collectFiles(outputDir, dir, paths) {
2034
- let entries;
2035
- try {
2036
- entries = await readdir2(dir, { withFileTypes: true });
2037
- } catch {
2038
- return;
1787
+ async function downloadBlob(entry, urls) {
1788
+ const hash = entry.sha;
1789
+ if (!hash) {
1790
+ throw new SandboxPullError(`${entry.prefixedPath} does not have downloadable content`, "remote");
2039
1791
  }
2040
- for (const entry of entries) {
2041
- const absolute = join4(dir, entry.name);
2042
- if (entry.isDirectory()) {
2043
- await collectFiles(outputDir, absolute, paths);
2044
- continue;
2045
- }
2046
- if (!entry.isFile()) {
2047
- continue;
2048
- }
2049
- paths.push(relative(outputDir, absolute).replaceAll("\\", "/"));
1792
+ const url = urls.get(hash);
1793
+ if (!url) {
1794
+ throw new SandboxPullError(`No download URL returned for ${entry.prefixedPath}`, "remote");
1795
+ }
1796
+ const response = await fetch(url);
1797
+ if (!response.ok) {
1798
+ throw new SandboxPullError(`Download failed [${response.status}]: ${entry.prefixedPath}`, "remote");
2050
1799
  }
1800
+ const content = Buffer.from(await response.arrayBuffer());
1801
+ if (content.length > MFS_FILE_SIZE_LIMIT_BYTES) {
1802
+ throw new SandboxPullError(`File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${entry.prefixedPath}`, "too-large");
1803
+ }
1804
+ const contentHash = createHash2("sha256").update(content).digest("hex");
1805
+ if (contentHash !== hash) {
1806
+ throw new SandboxPullError(`Downloaded content hash mismatch: ${entry.prefixedPath}`, "remote");
1807
+ }
1808
+ return content;
2051
1809
  }
2052
- async function uploadFile(runId, file, config, env) {
2053
- const apiKey = env.MOXT_API_KEY;
2054
- if (apiKey === void 0 || apiKey === "") {
2055
- throw new Error("MOXT_API_KEY is required for run upload");
1810
+ async function shouldWritePulledFile(localPath, remoteContentHash, workspacePath, previousFile, force) {
1811
+ const existing = await lstat(localPath).catch((error) => {
1812
+ if (isNodeError2(error) && error.code === "ENOENT") return void 0;
1813
+ throw error;
1814
+ });
1815
+ if (existing && !existing.isFile()) {
1816
+ throw new SandboxPullError(`Refusing to overwrite local path: ${workspacePath}`, "directory-conflict");
1817
+ }
1818
+ if (!existing) {
1819
+ if (force || !previousFile) return true;
1820
+ if (remoteContentHash === previousFile.sha) return false;
1821
+ throw new SandboxPullError(
1822
+ `Local file was deleted but remote file changed: ${workspacePath}`,
1823
+ "file-conflict"
1824
+ );
2056
1825
  }
2057
- const timeoutMs = config.timeoutMs ?? DEFAULT_UPLOAD_TIMEOUT_MS;
2058
- const controller = new AbortController();
2059
- const timeout = setTimeout(() => {
2060
- controller.abort();
2061
- }, timeoutMs);
2062
- try {
2063
- const response = await fetch(
2064
- `${apiBaseUrl(env)}/workspaces/${encodeURIComponent(config.workspaceId)}/local-agent-runs/${encodeURIComponent(runId)}/files`,
2065
- {
2066
- method: "PUT",
2067
- headers: {
2068
- "Content-Type": "application/json",
2069
- Authorization: `Bearer ${apiKey}`,
2070
- "User-Agent": userAgent(),
2071
- ...cloudflareAccessHeaders(env)
2072
- },
2073
- signal: controller.signal,
2074
- body: JSON.stringify({
2075
- schema_version: "local_agent_run.upload_file.v1",
2076
- artifact_path: file.artifactPath,
2077
- bytes: file.bytes,
2078
- sha256: file.sha256,
2079
- encoding: "utf8",
2080
- content_text: file.contentText
2081
- })
2082
- }
1826
+ if (force) return true;
1827
+ const localContentHash = await hashLocalFile(localPath);
1828
+ if (localContentHash === remoteContentHash) return false;
1829
+ if (!previousFile) {
1830
+ throw new SandboxPullError(
1831
+ `Refusing to overwrite modified local file: ${workspacePath}. Use --force to overwrite.`,
1832
+ "file-conflict"
1833
+ );
1834
+ }
1835
+ const localChanged = localContentHash !== previousFile.sha;
1836
+ const remoteChanged = remoteContentHash !== previousFile.sha;
1837
+ if (!localChanged && remoteChanged) return true;
1838
+ if (localChanged && !remoteChanged) return false;
1839
+ if (localChanged && remoteChanged) {
1840
+ throw new SandboxPullError(
1841
+ `Local and remote file both changed since checkout: ${workspacePath}`,
1842
+ "file-conflict"
2083
1843
  );
2084
- if (!response.ok) {
2085
- throw new Error(`HTTP ${response.status}`);
2086
- }
2087
- const headerEtag = response.headers.get("etag") ?? void 0;
2088
- const body = await responseJson(response);
2089
- const { etag: bodyEtagValue } = isObject2(body) ? body : {};
2090
- const bodyEtag = typeof bodyEtagValue === "string" ? bodyEtagValue : void 0;
2091
- const etag = bodyEtag ?? headerEtag;
2092
- return etag === void 0 ? {} : { etag };
2093
- } catch (error) {
2094
- if (controller.signal.aborted) {
2095
- throw new Error(`upload timed out after ${timeoutMs}ms`);
2096
- }
2097
- throw error;
2098
- } finally {
2099
- clearTimeout(timeout);
2100
1844
  }
1845
+ return false;
2101
1846
  }
2102
- async function readUploadState(outputDir, runId) {
2103
- try {
2104
- const value = JSON.parse(await readFile2(join4(outputDir, "upload.json"), "utf8"));
2105
- if (isUploadState(value, runId)) {
2106
- return value;
1847
+ async function planRemoteDeletions(previousState, selection, checkoutRoot, remoteBlobPaths, force) {
1848
+ if (!previousState) return [];
1849
+ const deletions = [];
1850
+ for (const [workspacePath, previousFile] of Object.entries(previousState.files)) {
1851
+ if (!selection.scopes.some((scope) => pathIsIncluded(scope, workspacePath))) continue;
1852
+ if (remoteBlobPaths.has(workspacePath)) continue;
1853
+ const repo = previousState.repos[previousFile.repoId];
1854
+ if (!repo) {
1855
+ throw new SandboxPullError(`Checkout baseline references unknown repo: ${workspacePath}`, "remote");
1856
+ }
1857
+ const localPath = resolveSafeLocalPath(checkoutRoot, repo.name, previousFile.repoPath);
1858
+ const existing = await lstat(localPath).catch((error) => {
1859
+ if (isNodeError2(error) && error.code === "ENOENT") return void 0;
1860
+ throw error;
1861
+ });
1862
+ if (!existing) continue;
1863
+ if (!existing.isFile()) {
1864
+ throw new SandboxPullError(`Refusing to overwrite local path: ${workspacePath}`, "directory-conflict");
1865
+ }
1866
+ if (!force && await hashLocalFile(localPath) !== previousFile.sha) {
1867
+ throw new SandboxPullError(
1868
+ `Remote file was deleted but local file changed: ${workspacePath}`,
1869
+ "file-conflict"
1870
+ );
2107
1871
  }
2108
- } catch {
1872
+ deletions.push({ localPath });
2109
1873
  }
2110
- return {
2111
- schema_version: "runlog.upload.v1",
2112
- run_id: runId,
2113
- status: "pending",
2114
- updated_at: (/* @__PURE__ */ new Date()).toISOString(),
2115
- files: {}
2116
- };
1874
+ return deletions;
2117
1875
  }
2118
- async function writeUploadState(outputDir, state) {
2119
- await writeFile3(join4(outputDir, "upload.json"), `${JSON.stringify(state, null, 2)}
2120
- `, "utf8");
1876
+ async function hashLocalFile(localPath) {
1877
+ return createHash2("sha256").update(await readFile2(localPath)).digest("hex");
2121
1878
  }
2122
- function uploadStatus(files, expectedPaths) {
2123
- if (expectedPaths.length === 0) {
2124
- return "pending";
2125
- }
2126
- const states = expectedPaths.map((path2) => files[path2]?.status ?? "pending");
2127
- if (states.some((status) => status === "failed")) {
2128
- return "failed";
1879
+ async function localFileSize(localPath, workspacePath) {
1880
+ const stats = await lstat(localPath);
1881
+ if (!stats.isFile()) {
1882
+ throw new SandboxPullError(`Cannot determine local file size: ${workspacePath}`, "directory-conflict");
2129
1883
  }
2130
- if (states.every((status) => status === "uploaded")) {
2131
- return "uploaded";
2132
- }
2133
- if (states.some((status) => status === "uploaded")) {
2134
- return "partial";
2135
- }
2136
- return "uploading";
1884
+ return stats.size;
2137
1885
  }
2138
- function isUploadState(value, runId) {
2139
- if (!isObject2(value)) {
2140
- return false;
1886
+ function requireBlobSha(entry) {
1887
+ if (!entry.sha) {
1888
+ throw new SandboxPullError(`${entry.prefixedPath} does not have downloadable content`, "remote");
2141
1889
  }
2142
- const { schema_version: schemaVersion, run_id: valueRunId, files } = value;
2143
- return schemaVersion === "runlog.upload.v1" && valueRunId === runId && isObject2(files);
1890
+ return entry.sha;
2144
1891
  }
2145
- async function responseJson(response) {
2146
- try {
2147
- return await response.json();
2148
- } catch {
2149
- return null;
1892
+ async function assertCanCreateDirectory(localPath, workspacePath) {
1893
+ const existing = await lstat(localPath).catch((error) => {
1894
+ if (isNodeError2(error) && error.code === "ENOENT") return void 0;
1895
+ throw error;
1896
+ });
1897
+ if (existing && !existing.isDirectory()) {
1898
+ throw new SandboxPullError(`Refusing to overwrite local path: ${workspacePath}`, "directory-conflict");
2150
1899
  }
2151
1900
  }
2152
- function apiBaseUrl(env) {
2153
- return `https://${env.MOXT_HOST ?? DEFAULT_HOST2}/openapi/v1`;
1901
+ async function writePulledFile(localPath, remoteContent) {
1902
+ await mkdir2(dirname2(localPath), { recursive: true });
1903
+ await writeFile2(localPath, remoteContent);
2154
1904
  }
2155
- function userAgent() {
2156
- return `moxt-cli/${"0.3.3-moxt-run.2"} (${platform2()}/${arch2()}) node/${process.versions.node}`;
2157
- }
2158
- function errorMessage2(error) {
2159
- if (error instanceof Error && error.message !== "") {
2160
- return error.message;
1905
+ function resolveSafeLocalPath(targetDir, repoName, repoPath) {
1906
+ const pathParts = [repoName, ...repoPath.split("/").filter((part) => part.length > 0)];
1907
+ if (pathParts.some((part) => part === "." || part === ".." || part.includes("/") || part.includes("\\") || part.includes("\0"))) {
1908
+ throw new SandboxPullError(`Remote path is unsafe: ${[repoName, repoPath].filter(Boolean).join("/")}`, "invalid-path");
2161
1909
  }
2162
- return String(error);
2163
- }
2164
- function numberEnv(value) {
2165
- if (value === void 0 || value.trim() === "") {
2166
- return void 0;
1910
+ const resolved = resolve(targetDir, ...pathParts);
1911
+ const relativePath = relative(targetDir, resolved);
1912
+ if (relativePath.startsWith("..") || isAbsolute2(relativePath)) {
1913
+ throw new SandboxPullError(`Remote path escapes target directory: ${repoName}/${repoPath}`, "invalid-path");
2167
1914
  }
2168
- const parsed = Number(value);
2169
- return Number.isFinite(parsed) ? parsed : void 0;
1915
+ return resolved;
2170
1916
  }
2171
- function isObject2(value) {
2172
- return typeof value === "object" && value !== null && !Array.isArray(value);
1917
+ function isNodeError2(error) {
1918
+ return error instanceof Error && "code" in error;
2173
1919
  }
2174
1920
 
2175
- // src/run/process-session.ts
2176
- import { execFileSync } from "child_process";
2177
- import { isAbsolute, relative as relative2 } from "path";
2178
- function findOpenSessionFile(options) {
2179
- if ((options.platform ?? process.platform) !== "darwin") {
2180
- return { match: "none" };
1921
+ // src/commands/pull.ts
1922
+ function registerPullCommand(program2) {
1923
+ program2.command("pull").description("Pull workspace files into the local checkout").argument("[workspacePath]", "Workspace file or directory to pull").option("--all", "Pull the entire workspace").option("--force", "Overwrite changed local files").addOption(new Option("--dir <dir>").hideHelp()).addOption(new Option("--scope <workspacePath>").argParser(collectScope).default([]).hideHelp()).action(async (workspacePath, options) => {
1924
+ const context = resolveSandboxRuntimeContextOrExit2();
1925
+ try {
1926
+ const invocation = await resolvePullInvocation(context, workspacePath, options);
1927
+ const result = await pullSandboxWorkspace(context, invocation.targetDir, {
1928
+ force: Boolean(options.force),
1929
+ scopes: invocation.scopes
1930
+ });
1931
+ print(`Pulled ${result.fileCount} ${result.fileCount === 1 ? "file" : "files"} to ${result.targetDir}`);
1932
+ } catch (error) {
1933
+ handlePullError(error);
1934
+ }
1935
+ });
1936
+ }
1937
+ async function resolvePullInvocation(context, argument, options) {
1938
+ const legacyTargetDir = argument && isLegacyTargetDirectory(argument) ? argument : void 0;
1939
+ const workspacePath = legacyTargetDir ? void 0 : argument;
1940
+ if (workspacePath && options.all) {
1941
+ throw new Error("Specify either a workspace path or --all, not both");
2181
1942
  }
2182
- try {
2183
- const psOutput = execFileSync("ps", ["-axo", "pid=,ppid="], {
2184
- encoding: "utf8",
2185
- stdio: ["ignore", "pipe", "ignore"]
2186
- });
2187
- const pids = processTreePids(options.rootPid, psOutput);
2188
- const lsofOutput = execFileSync("lsof", ["-F", "pn", "-p", pids.join(",")], {
2189
- encoding: "utf8",
2190
- stdio: ["ignore", "pipe", "ignore"]
2191
- });
2192
- return openSessionFileFromLsof(lsofOutput, {
2193
- sessionRoot: options.sessionRoot,
2194
- ...options.isCandidate === void 0 ? {} : { isCandidate: options.isCandidate }
2195
- });
2196
- } catch {
2197
- return { match: "none" };
1943
+ if (options.scope.length > 0 && (workspacePath || options.all)) {
1944
+ throw new Error("Legacy --scope cannot be combined with a workspace path or --all");
1945
+ }
1946
+ if (legacyTargetDir && options.dir) {
1947
+ throw new Error("Specify the local checkout directory only once");
1948
+ }
1949
+ const state = await loadCheckoutState();
1950
+ if (state) validateCheckoutStateContext(state, context);
1951
+ const targetDir = options.dir ?? legacyTargetDir ?? state?.checkoutRoot ?? ".";
1952
+ if (options.all || legacyTargetDir && options.scope.length === 0) {
1953
+ return { targetDir };
2198
1954
  }
1955
+ if (workspacePath) return { targetDir, scopes: [workspacePath] };
1956
+ if (options.scope.length > 0) return { targetDir, scopes: options.scope };
1957
+ if (!state || state.checkedOutScopes.length === 0) {
1958
+ throw new Error("Checkout is not initialized. Run moxt pull <workspace-path> or moxt pull --all first.");
1959
+ }
1960
+ return {
1961
+ targetDir: state.checkoutRoot,
1962
+ scopes: state.mode === "full" ? void 0 : state.checkedOutScopes
1963
+ };
2199
1964
  }
2200
- function processTreePids(rootPid, psOutput) {
2201
- const childrenByParent = /* @__PURE__ */ new Map();
2202
- for (const line of psOutput.split("\n")) {
2203
- const [pidText, ppidText] = line.trim().split(/\s+/, 2);
2204
- const pid = Number(pidText);
2205
- const ppid = Number(ppidText);
2206
- if (!Number.isInteger(pid) || !Number.isInteger(ppid) || pid <= 0) {
2207
- continue;
2208
- }
2209
- const children = childrenByParent.get(ppid) ?? [];
2210
- children.push(pid);
2211
- childrenByParent.set(ppid, children);
1965
+ function isLegacyTargetDirectory(value) {
1966
+ return value === "." || value.startsWith("./") || value.startsWith("../") || isAbsolute3(value);
1967
+ }
1968
+ function validateCheckoutStateContext(state, context) {
1969
+ if (state.workspaceId !== context.workspaceId) {
1970
+ throw new Error(`Checkout state belongs to workspace ${state.workspaceId}, not ${context.workspaceId}`);
2212
1971
  }
2213
- const pids = [];
2214
- const seen = /* @__PURE__ */ new Set();
2215
- const queue = [rootPid];
2216
- while (queue.length > 0) {
2217
- const pid = queue.shift();
2218
- if (seen.has(pid)) {
2219
- continue;
1972
+ const currentRepos = new Map(listRepoEntries(context.repoPayload).map((repo) => [repo.repoId, repo.name]));
1973
+ for (const [repoId, repo] of Object.entries(state.repos)) {
1974
+ const currentName = currentRepos.get(repoId);
1975
+ if (!currentName) throw new Error(`Checkout state contains unknown repo: ${repoId}`);
1976
+ if (currentName !== repo.name) {
1977
+ throw new Error(`Checkout state repo name does not match current workspace: ${repo.name}`);
2220
1978
  }
2221
- seen.add(pid);
2222
- pids.push(pid);
2223
- queue.push(...childrenByParent.get(pid) ?? []);
2224
1979
  }
2225
- return pids;
2226
1980
  }
2227
- function openSessionFileFromLsof(lsofOutput, options) {
2228
- const files = /* @__PURE__ */ new Set();
2229
- for (const line of lsofOutput.split("\n")) {
2230
- if (!line.startsWith("n")) {
2231
- continue;
2232
- }
2233
- const path2 = line.slice(1);
2234
- if (!path2.endsWith(".jsonl") || !isInside(options.sessionRoot, path2)) {
2235
- continue;
2236
- }
2237
- if (options.isCandidate !== void 0 && !options.isCandidate(path2)) {
2238
- continue;
1981
+ function collectScope(value, previous) {
1982
+ return [...previous, value];
1983
+ }
1984
+ function resolveSandboxRuntimeContextOrExit2() {
1985
+ let context;
1986
+ try {
1987
+ context = resolveRuntimeContext();
1988
+ } catch (error) {
1989
+ if (error instanceof RuntimeContextError) {
1990
+ printError(error.message);
1991
+ process.exit(1);
2239
1992
  }
2240
- files.add(alignPathToRoot(options.sessionRoot, path2));
2241
- }
2242
- if (files.size === 0) {
2243
- return { match: "none" };
1993
+ throw error;
2244
1994
  }
2245
- if (files.size === 1) {
2246
- return { match: "unique", sessionFile: [...files].join("") };
1995
+ if (context.mode !== "sandbox") {
1996
+ printError("moxt pull is only available in sandbox mode");
1997
+ process.exit(1);
2247
1998
  }
2248
- return { match: "ambiguous" };
1999
+ return context;
2249
2000
  }
2250
- function isInside(root, path2) {
2251
- if (isPathInside(root, path2)) {
2252
- return true;
2001
+ function handlePullError(error) {
2002
+ if (error instanceof Error) {
2003
+ printError(error.message);
2004
+ process.exit(1);
2253
2005
  }
2254
- return isPathInside(normalizeDarwinVarPath(root), normalizeDarwinVarPath(path2));
2255
- }
2256
- function isPathInside(root, path2) {
2257
- const fromRoot = relative2(root, path2);
2258
- return fromRoot === "" || !fromRoot.startsWith("..") && !isAbsolute(fromRoot);
2006
+ throw error;
2259
2007
  }
2260
- function normalizeDarwinVarPath(path2) {
2261
- return path2.startsWith("/private/var/") ? path2.slice("/private".length) : path2;
2262
- }
2263
- function alignPathToRoot(root, path2) {
2264
- if (root.startsWith("/var/") && path2.startsWith("/private/var/")) {
2265
- return path2.slice("/private".length);
2008
+
2009
+ // src/utils/sandbox-push.ts
2010
+ import { createHash as createHash3 } from "crypto";
2011
+ import { lstat as lstat2, readFile as readFile3, readdir } from "fs/promises";
2012
+ import { isAbsolute as isAbsolute4, join as join2, relative as relative2, resolve as resolve2 } from "path";
2013
+
2014
+ // src/utils/concurrency.ts
2015
+ async function runWithConcurrency(items, concurrency, action) {
2016
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
2017
+ throw new RangeError("Concurrency must be a positive integer");
2266
2018
  }
2267
- if (root.startsWith("/private/var/") && path2.startsWith("/var/")) {
2268
- return `/private${path2}`;
2019
+ let nextIndex = 0;
2020
+ async function worker() {
2021
+ while (nextIndex < items.length) {
2022
+ const item = items[nextIndex];
2023
+ nextIndex += 1;
2024
+ await action(item);
2025
+ }
2269
2026
  }
2270
- return path2;
2027
+ const workerCount = Math.min(concurrency, items.length);
2028
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
2271
2029
  }
2272
2030
 
2273
- // src/run/repo.ts
2274
- import { execFileSync as execFileSync2 } from "child_process";
2275
- function collectRepoFingerprint(cwd) {
2276
- const root = git(["rev-parse", "--show-toplevel"], cwd);
2277
- if (root === null) {
2278
- return null;
2031
+ // src/utils/sandbox-push.ts
2032
+ var MFS_FILE_TYPE_REGULAR2 = 1;
2033
+ var MAX_CONCURRENT_UPLOADS = 8;
2034
+ var SandboxPushError = class extends Error {
2035
+ constructor(message, code) {
2036
+ super(message);
2037
+ this.code = code;
2038
+ this.name = "SandboxPushError";
2039
+ }
2040
+ code;
2041
+ };
2042
+ async function pushSandboxWorkspace(context, sourceDir, options) {
2043
+ const resolvedSourceDir = resolve2(sourceDir);
2044
+ await assertWorkspaceRoot(resolvedSourceDir);
2045
+ const repos = listRepoEntries(context.repoPayload);
2046
+ for (const repo of repos) {
2047
+ assertSafeRepoName(resolvedSourceDir, repo.name);
2048
+ }
2049
+ const state = await resolveCheckoutState(context, resolvedSourceDir, repos);
2050
+ const checkoutRepos = repos.filter(
2051
+ (repo) => state.repos[repo.repoId] && doesCheckoutPathIntersect(state, repo.name)
2052
+ );
2053
+ if (checkoutRepos.length === 0) {
2054
+ throw new SandboxPushError("Checkout state does not contain a pushable scope", "invalid-path");
2055
+ }
2056
+ const tree = await fetchWorkspaceTree2(context, checkoutRepos);
2057
+ const remoteEntries = indexRemoteEntries(tree, checkoutRepos);
2058
+ const localFiles = await collectLocalFiles(resolvedSourceDir, checkoutRepos, remoteEntries, state);
2059
+ const { changes, reconciledFiles, removedWorkspacePaths } = planChanges(
2060
+ localFiles,
2061
+ remoteEntries,
2062
+ state,
2063
+ checkoutRepos
2064
+ );
2065
+ let pushResponse;
2066
+ if (changes.length > 0) {
2067
+ await uploadChangedFiles(context, changes);
2068
+ pushResponse = await pushChanges(context, checkoutRepos, changes, options.message);
2069
+ }
2070
+ if (changes.length > 0 || reconciledFiles.length > 0 || removedWorkspacePaths.length > 0) {
2071
+ await updateCheckoutState(state, changes, reconciledFiles, removedWorkspacePaths, pushResponse);
2279
2072
  }
2280
2073
  return {
2281
- root,
2282
- remote: git(["config", "--get", "remote.origin.url"], cwd) ?? "",
2283
- branch: git(["branch", "--show-current"], cwd) ?? "",
2284
- head: git(["rev-parse", "HEAD"], cwd) ?? ""
2074
+ changes: changes.map((change) => ({ action: change.action, workspacePath: change.workspacePath })),
2075
+ sourceDir: resolvedSourceDir
2285
2076
  };
2286
2077
  }
2287
- function git(args, cwd) {
2288
- try {
2289
- return execFileSync2("git", args, {
2290
- cwd,
2291
- encoding: "utf8",
2292
- stdio: ["ignore", "pipe", "ignore"]
2293
- }).trim();
2294
- } catch {
2295
- return null;
2078
+ async function resolveCheckoutState(context, sourceDir, repos) {
2079
+ const state = await loadCheckoutState();
2080
+ if (!state) {
2081
+ throw new SandboxPushError("Checkout is not initialized. Run moxt pull first.", "invalid-path");
2296
2082
  }
2297
- }
2298
-
2299
- // src/run/session-dir.ts
2300
- import { closeSync, openSync, readSync, realpathSync } from "fs";
2301
- import { join as join5, resolve } from "path";
2302
- var CODEX_METADATA_READ_BYTES = 256 * 1024;
2303
- function claudeProjectDir(cwd, home) {
2304
- return join5(home, ".claude", "projects", encodeClaudeProjectPath(cwd));
2305
- }
2306
- function encodeClaudeProjectPath(cwd) {
2307
- return cwd.replaceAll(/[^A-Za-z0-9]/g, "-");
2308
- }
2309
- function agentSessionDir(options) {
2310
- switch (options.agent) {
2311
- case "claude":
2312
- return claudeProjectDir(options.cwd, options.home);
2313
- case "codex":
2314
- return codexSessionsDir(options.home, options.env);
2315
- }
2316
- }
2317
- function agentSessionCandidateFilter(options) {
2318
- switch (options.agent) {
2319
- case "claude":
2320
- return void 0;
2321
- case "codex": {
2322
- const expectedCwd = expectedCodexCwd(options.cwd, options.agentArgs);
2323
- return (path2) => {
2324
- try {
2325
- const sessionCwd = codexSessionCwd(path2);
2326
- return sessionCwd !== null && sameCodexCwd(sessionCwd, expectedCwd);
2327
- } catch {
2328
- return false;
2329
- }
2330
- };
2083
+ if (state.workspaceId !== context.workspaceId) {
2084
+ throw new SandboxPushError(
2085
+ `Checkout state belongs to workspace ${state.workspaceId}, not ${context.workspaceId}`,
2086
+ "invalid-path"
2087
+ );
2088
+ }
2089
+ if (state.checkoutRoot !== sourceDir) {
2090
+ throw new SandboxPushError(
2091
+ `Checkout root is ${state.checkoutRoot}, not ${sourceDir}`,
2092
+ "invalid-path"
2093
+ );
2094
+ }
2095
+ const currentRepos = new Map(repos.map((repo) => [repo.repoId, repo.name]));
2096
+ for (const [repoId, repo] of Object.entries(state.repos)) {
2097
+ if (currentRepos.get(repoId) !== repo.name) {
2098
+ throw new SandboxPushError(`Checkout state contains unknown repo: ${repoId}`, "invalid-path");
2331
2099
  }
2332
2100
  }
2101
+ for (const scope of state.checkedOutScopes) {
2102
+ const hasRepo = Object.values(state.repos).some(
2103
+ (repo) => scope === repo.name || scope.startsWith(`${repo.name}/`)
2104
+ );
2105
+ if (!hasRepo) throw new SandboxPushError(`Checkout state contains invalid scope: ${scope}`, "invalid-path");
2106
+ }
2107
+ return state;
2333
2108
  }
2334
- function agentKnownSessionFile(options) {
2335
- switch (options.agent) {
2336
- case "claude": {
2337
- const sessionId = claudeSessionIdArg(options.agentArgs) ?? (claudeForkSessionArg(options.agentArgs) ? void 0 : claudeResumeSessionIdArg(options.agentArgs));
2338
- return sessionId === void 0 ? void 0 : join5(claudeProjectDir(options.cwd, options.home), `${sessionId}.jsonl`);
2109
+ async function assertWorkspaceRoot(sourceDir) {
2110
+ const stats = await lstat2(sourceDir).catch((error) => {
2111
+ if (isNodeError3(error) && error.code === "ENOENT") {
2112
+ throw new SandboxPushError(`Local workspace directory does not exist: ${sourceDir}`, "invalid-path");
2339
2113
  }
2340
- case "codex":
2341
- return void 0;
2114
+ throw error;
2115
+ });
2116
+ if (!stats.isDirectory()) {
2117
+ throw new SandboxPushError(`Local workspace path is not a directory: ${sourceDir}`, "invalid-path");
2342
2118
  }
2343
2119
  }
2344
- function codexSessionsDir(home, env = {}) {
2345
- return join5(codexHome(home, env), "sessions");
2346
- }
2347
- function codexHome(home, env = {}) {
2348
- const { CODEX_HOME: configured } = env;
2349
- return hasValue2(configured) ? configured : join5(home, ".codex");
2350
- }
2351
- function expectedCodexCwd(cwd, agentArgs) {
2352
- const cd = codexCdArg(agentArgs);
2353
- return cd === void 0 ? resolve(cwd) : resolve(cwd, cd);
2354
- }
2355
- function claudeSessionIdArg(agentArgs) {
2356
- const optionArgs = claudeOptionArgs(agentArgs);
2357
- for (let index = 0; index < optionArgs.length; index += 1) {
2358
- const arg = optionArgs[index];
2359
- if (arg === "--session-id") {
2360
- return optionArgs[index + 1];
2120
+ async function fetchWorkspaceTree2(context, repos) {
2121
+ const client = new SandboxMoxtClient(context);
2122
+ const response = await client.request(
2123
+ "POST",
2124
+ "/agent/api/pipeline-sandbox/mfs-workspace/batch-trees",
2125
+ {
2126
+ repos: repos.map((repo) => ({ repoId: repo.repoId, prefix: repo.name }))
2361
2127
  }
2362
- if (arg.startsWith("--session-id=")) {
2363
- return arg.slice("--session-id=".length);
2128
+ );
2129
+ return response.data;
2130
+ }
2131
+ function indexRemoteEntries(tree, repos) {
2132
+ const knownRepoIds = new Set(repos.map((repo) => repo.repoId));
2133
+ const entries = /* @__PURE__ */ new Map();
2134
+ for (const entry of tree.entries) {
2135
+ if (!knownRepoIds.has(entry.repoId)) {
2136
+ throw new SandboxPushError(`Remote tree returned unknown repo: ${entry.repoId}`, "remote");
2137
+ }
2138
+ const key = remoteEntryKey(entry.repoId, entry.path);
2139
+ if (entries.has(key)) {
2140
+ throw new SandboxPushError(`Remote tree returned duplicate path: ${entry.prefixedPath}`, "remote");
2141
+ }
2142
+ entries.set(key, entry);
2143
+ }
2144
+ return entries;
2145
+ }
2146
+ async function collectLocalFiles(sourceDir, repos, remoteEntries, state) {
2147
+ const files = [];
2148
+ for (const repo of repos) {
2149
+ const repoRoot = resolveSafeRepoRoot(sourceDir, repo.name);
2150
+ const stats = await lstat2(repoRoot).catch((error) => {
2151
+ if (isNodeError3(error) && error.code === "ENOENT") return void 0;
2152
+ throw error;
2153
+ });
2154
+ if (!stats) continue;
2155
+ if (!stats.isDirectory()) {
2156
+ throw new SandboxPushError(`Local repo path is not a directory: ${repo.name}`, "invalid-path");
2364
2157
  }
2158
+ await collectRepoFiles(repoRoot, repo, "", remoteEntries, state, files);
2365
2159
  }
2366
- return void 0;
2160
+ return files;
2367
2161
  }
2368
- function claudeResumeSessionIdArg(agentArgs) {
2369
- const optionArgs = claudeOptionArgs(agentArgs);
2370
- for (let index = 0; index < optionArgs.length; index += 1) {
2371
- const arg = optionArgs[index];
2372
- if (arg === "--resume" || arg === "-r") {
2373
- const value = optionArgs[index + 1];
2374
- return isUuid(value) ? value : void 0;
2162
+ async function collectRepoFiles(repoRoot, repo, relativeDir, remoteEntries, state, files) {
2163
+ const localDir = relativeDir ? join2(repoRoot, ...relativeDir.split("/")) : repoRoot;
2164
+ const entries = await readdir(localDir, { withFileTypes: true });
2165
+ entries.sort((a, b) => a.name.localeCompare(b.name));
2166
+ for (const entry of entries) {
2167
+ if (entry.name === ".git") continue;
2168
+ const repoPath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
2169
+ const workspacePath = `${repo.name}/${repoPath}`;
2170
+ assertSafePathPart(entry.name, workspacePath);
2171
+ if (!doesCheckoutPathIntersect(state, workspacePath)) continue;
2172
+ if (entry.isSymbolicLink()) {
2173
+ throw new SandboxPushError(`Refusing to push symbolic link: ${workspacePath}`, "invalid-file");
2174
+ }
2175
+ if (entry.isDirectory()) {
2176
+ const remote = remoteEntries.get(remoteEntryKey(repo.repoId, repoPath));
2177
+ if (remote?.type === "blob") {
2178
+ throw new SandboxPushError(`Local directory conflicts with remote file: ${workspacePath}`, "invalid-file");
2179
+ }
2180
+ await collectRepoFiles(repoRoot, repo, repoPath, remoteEntries, state, files);
2181
+ continue;
2182
+ }
2183
+ if (!entry.isFile()) {
2184
+ throw new SandboxPushError(`Refusing to push unsupported file: ${workspacePath}`, "invalid-file");
2185
+ }
2186
+ const localPath = join2(repoRoot, ...repoPath.split("/"));
2187
+ const stats = await lstat2(localPath);
2188
+ if (!stats.isFile()) {
2189
+ const kind = stats.isSymbolicLink() ? "symbolic link" : "unsupported file";
2190
+ throw new SandboxPushError(`Refusing to push ${kind}: ${workspacePath}`, "invalid-file");
2191
+ }
2192
+ if (stats.size > MFS_FILE_SIZE_LIMIT_BYTES) {
2193
+ throw new SandboxPushError(
2194
+ `File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${workspacePath}`,
2195
+ "too-large"
2196
+ );
2375
2197
  }
2376
- if (arg.startsWith("--resume=")) {
2377
- const value = arg.slice("--resume=".length);
2378
- return isUuid(value) ? value : void 0;
2198
+ const content = await readFile3(localPath);
2199
+ if (content.length > MFS_FILE_SIZE_LIMIT_BYTES) {
2200
+ throw new SandboxPushError(
2201
+ `File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${workspacePath}`,
2202
+ "too-large"
2203
+ );
2379
2204
  }
2205
+ if (!isCheckoutPathIncluded(state, workspacePath)) continue;
2206
+ files.push({
2207
+ repo,
2208
+ repoPath,
2209
+ workspacePath,
2210
+ localPath,
2211
+ contentHash: createHash3("sha256").update(content).digest("hex"),
2212
+ size: content.length
2213
+ });
2380
2214
  }
2381
- return void 0;
2382
2215
  }
2383
- function codexSessionCwd(path2) {
2384
- const text = readFilePrefix(path2, CODEX_METADATA_READ_BYTES);
2385
- for (const line of text.split("\n")) {
2386
- const cwd = codexLineCwd(line);
2387
- if (cwd !== null) {
2388
- return cwd;
2216
+ function planChanges(localFiles, remoteEntries, state, repos) {
2217
+ const changes = [];
2218
+ const reconciledFiles = [];
2219
+ const removedWorkspacePaths = [];
2220
+ const localWorkspacePaths = new Set(localFiles.map((file) => file.workspacePath));
2221
+ const reposById = new Map(repos.map((repo) => [repo.repoId, repo]));
2222
+ for (const file of localFiles) {
2223
+ const baseline = state.files[file.workspacePath];
2224
+ if (baseline?.sha === file.contentHash) continue;
2225
+ const existing = remoteEntries.get(remoteEntryKey(file.repo.repoId, file.repoPath));
2226
+ if (existing?.type === "tree") {
2227
+ throw new SandboxPushError(`Local file conflicts with remote directory: ${file.workspacePath}`, "invalid-file");
2228
+ }
2229
+ if (existing?.sha === file.contentHash) {
2230
+ reconciledFiles.push({ ...file, remote: existing });
2231
+ continue;
2232
+ }
2233
+ if (existing && !existing.sha) {
2234
+ throw new SandboxPushError(`${file.workspacePath} does not have a base content hash`, "remote");
2389
2235
  }
2236
+ if (baseline && existing?.sha !== baseline.sha) {
2237
+ throw new SandboxPushError(`Remote file changed since checkout: ${file.workspacePath}`, "conflict");
2238
+ }
2239
+ if (!baseline && existing) {
2240
+ throw new SandboxPushError(`Remote file changed since checkout: ${file.workspacePath}`, "conflict");
2241
+ }
2242
+ changes.push({
2243
+ ...file,
2244
+ action: existing ? "update" : "create",
2245
+ ...existing ? { existing } : {}
2246
+ });
2390
2247
  }
2391
- return null;
2392
- }
2393
- function codexCdArg(agentArgs) {
2394
- const optionArgs = codexOptionArgs(agentArgs);
2395
- for (let index = 0; index < optionArgs.length; index += 1) {
2396
- const arg = optionArgs[index];
2397
- if (arg === "--cd" || arg === "-C") {
2398
- return optionArgs[index + 1];
2248
+ for (const [workspacePath, baseline] of Object.entries(state.files)) {
2249
+ if (localWorkspacePaths.has(workspacePath) || !isCheckoutPathIncluded(state, workspacePath)) continue;
2250
+ const repo = reposById.get(baseline.repoId);
2251
+ if (!repo) continue;
2252
+ const existing = remoteEntries.get(remoteEntryKey(baseline.repoId, baseline.repoPath));
2253
+ if (!existing) {
2254
+ removedWorkspacePaths.push(workspacePath);
2255
+ continue;
2399
2256
  }
2400
- if (arg.startsWith("--cd=")) {
2401
- return arg.slice("--cd=".length);
2257
+ if (existing.type !== "blob" || existing.sha !== baseline.sha) {
2258
+ throw new SandboxPushError(`Remote file changed since checkout: ${workspacePath}`, "conflict");
2402
2259
  }
2260
+ changes.push({
2261
+ action: "delete",
2262
+ repo,
2263
+ repoPath: baseline.repoPath,
2264
+ workspacePath,
2265
+ fileId: validFileId(existing.fileId) ?? baseline.fileId
2266
+ });
2403
2267
  }
2404
- return void 0;
2405
- }
2406
- function codexOptionArgs(agentArgs) {
2407
- const separatorIndex = agentArgs.indexOf("--");
2408
- return separatorIndex === -1 ? agentArgs : agentArgs.slice(0, separatorIndex);
2409
- }
2410
- function sameCodexCwd(left, right) {
2411
- return normalizeCodexCwd(left) === normalizeCodexCwd(right);
2268
+ return { changes, reconciledFiles, removedWorkspacePaths };
2269
+ }
2270
+ async function uploadChangedFiles(context, changes) {
2271
+ const writeChanges = changes.filter(isWriteChange);
2272
+ if (writeChanges.length === 0) return;
2273
+ const client = new SandboxMoxtClient(context);
2274
+ const changesByRepo = groupChangesByRepo(writeChanges);
2275
+ const uploadUrlsByRepo = await Promise.all(
2276
+ [...changesByRepo].map(async ([repoId, repoChanges]) => {
2277
+ const hashes = [...new Set(repoChanges.map((change) => change.contentHash))];
2278
+ const response = await client.request(
2279
+ "POST",
2280
+ `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(repoId)}/presign-upload`,
2281
+ { hashes }
2282
+ );
2283
+ return [repoId, response.data.urls];
2284
+ })
2285
+ );
2286
+ const uploadUrls = new Map(uploadUrlsByRepo);
2287
+ await runWithConcurrency(writeChanges, MAX_CONCURRENT_UPLOADS, async (change) => {
2288
+ const uploadUrl = uploadUrls.get(change.repo.repoId)?.[change.contentHash];
2289
+ if (!uploadUrl) {
2290
+ throw new SandboxPushError(`No upload URL returned for ${change.workspacePath}`, "remote");
2291
+ }
2292
+ const content = await readFile3(change.localPath);
2293
+ const currentHash = createHash3("sha256").update(content).digest("hex");
2294
+ if (content.length !== change.size || currentHash !== change.contentHash) {
2295
+ throw new SandboxPushError(`Local file changed while pushing: ${change.workspacePath}`, "invalid-file");
2296
+ }
2297
+ const upload = await fetch(uploadUrl, {
2298
+ method: "PUT",
2299
+ headers: { "Content-Type": "application/octet-stream" },
2300
+ body: new Uint8Array(content)
2301
+ });
2302
+ if (!upload.ok) {
2303
+ throw new SandboxPushError(`Upload failed [${upload.status}]: ${change.workspacePath}`, "remote");
2304
+ }
2305
+ });
2412
2306
  }
2413
- function normalizeCodexCwd(path2) {
2414
- try {
2415
- return realpathSync(path2);
2416
- } catch {
2417
- return resolve(path2);
2307
+ async function pushChanges(context, repos, changes, message) {
2308
+ const client = new SandboxMoxtClient(context);
2309
+ const changesByRepo = groupChangesByRepo(changes);
2310
+ const response = await client.request(
2311
+ "POST",
2312
+ "/agent/api/pipeline-sandbox/mfs-workspace/unified-push",
2313
+ {
2314
+ message,
2315
+ repoPushes: repos.flatMap((repo) => {
2316
+ const repoChanges = changesByRepo.get(repo.repoId);
2317
+ if (!repoChanges) return [];
2318
+ return [{
2319
+ repoId: repo.repoId,
2320
+ changes: repoChanges.map((change) => {
2321
+ if (change.action === "delete") {
2322
+ return {
2323
+ action: change.action,
2324
+ ...change.fileId ? { fileId: change.fileId } : {},
2325
+ path: change.repoPath
2326
+ };
2327
+ }
2328
+ return {
2329
+ action: change.action,
2330
+ ...change.existing?.fileId ? { fileId: change.existing.fileId } : {},
2331
+ path: change.repoPath,
2332
+ contentHash: change.contentHash,
2333
+ ...change.existing?.sha ? { baseContentHash: change.existing.sha } : {},
2334
+ size: change.size,
2335
+ fileType: MFS_FILE_TYPE_REGULAR2
2336
+ };
2337
+ })
2338
+ }];
2339
+ }),
2340
+ crossRepoMoves: []
2341
+ }
2342
+ );
2343
+ return response.data;
2344
+ }
2345
+ async function updateCheckoutState(state, changes, reconciledFiles, removedWorkspacePaths, response) {
2346
+ const files = { ...state.files };
2347
+ for (const workspacePath of removedWorkspacePaths) delete files[workspacePath];
2348
+ for (const file of reconciledFiles) {
2349
+ files[file.workspacePath] = checkoutStateFile(
2350
+ file,
2351
+ validFileId(file.remote.fileId),
2352
+ validFileType(file.remote.fileType)
2353
+ );
2354
+ }
2355
+ for (const change of changes) {
2356
+ if (change.action === "delete") {
2357
+ delete files[change.workspacePath];
2358
+ continue;
2359
+ }
2360
+ const returnedFileId = validFileId(response?.fileMap?.[change.repo.repoId]?.[change.repoPath]);
2361
+ const fileId = returnedFileId ?? change.existing?.fileId ?? state.files[change.workspacePath]?.fileId ?? null;
2362
+ files[change.workspacePath] = checkoutStateFile(change, validFileId(fileId), validFileType(change.existing?.fileType));
2418
2363
  }
2364
+ await saveCheckoutState({ ...state, files });
2419
2365
  }
2420
- function claudeForkSessionArg(agentArgs) {
2421
- return claudeOptionArgs(agentArgs).includes("--fork-session");
2366
+ function isWriteChange(change) {
2367
+ return change.action !== "delete";
2422
2368
  }
2423
- function claudeOptionArgs(agentArgs) {
2424
- const separatorIndex = agentArgs.indexOf("--");
2425
- return separatorIndex === -1 ? agentArgs : agentArgs.slice(0, separatorIndex);
2369
+ function checkoutStateFile(file, fileId, fileType) {
2370
+ return {
2371
+ repoId: file.repo.repoId,
2372
+ repoPath: file.repoPath,
2373
+ fileId,
2374
+ sha: file.contentHash,
2375
+ size: file.size,
2376
+ fileType: fileType ?? MFS_FILE_TYPE_REGULAR2
2377
+ };
2426
2378
  }
2427
- function isUuid(value) {
2428
- return value !== void 0 && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{12}$/i.test(value);
2379
+ function validFileId(fileId) {
2380
+ return typeof fileId === "string" && fileId.length > 0 ? fileId : null;
2429
2381
  }
2430
- function codexLineCwd(line) {
2431
- if (line.trim() === "") {
2432
- return null;
2433
- }
2434
- let value;
2435
- try {
2436
- value = JSON.parse(line);
2437
- } catch {
2438
- return null;
2439
- }
2440
- const entry = rawCodexSessionLine(value);
2441
- if (entry === null) {
2442
- return null;
2443
- }
2444
- const type = entry.type;
2445
- if (type !== "session_meta" && type !== "turn_context") {
2446
- return null;
2447
- }
2448
- const payload = rawCodexSessionPayload(entry.payload);
2449
- const cwd = payload?.cwd;
2450
- return typeof cwd === "string" ? cwd : null;
2382
+ function validFileType(fileType) {
2383
+ return typeof fileType === "number" && Number.isInteger(fileType) && fileType >= 0 ? fileType : null;
2451
2384
  }
2452
- function readFilePrefix(path2, bytes) {
2453
- const fd = openSync(path2, "r");
2454
- try {
2455
- const buffer2 = Buffer.alloc(bytes);
2456
- const read = readSync(fd, buffer2, 0, bytes, 0);
2457
- return buffer2.subarray(0, read).toString("utf8");
2458
- } finally {
2459
- closeSync(fd);
2385
+ function groupChangesByRepo(changes) {
2386
+ const result = /* @__PURE__ */ new Map();
2387
+ for (const change of changes) {
2388
+ const repoChanges = result.get(change.repo.repoId) ?? [];
2389
+ repoChanges.push(change);
2390
+ result.set(change.repo.repoId, repoChanges);
2460
2391
  }
2392
+ return result;
2461
2393
  }
2462
- function objectValue2(value) {
2463
- return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
2394
+ function remoteEntryKey(repoId, repoPath) {
2395
+ return `${repoId}\0${repoPath}`;
2464
2396
  }
2465
- function rawCodexSessionLine(value) {
2466
- return objectValue2(value);
2397
+ function assertSafePathPart(part, workspacePath) {
2398
+ if (part === "." || part === ".." || part.includes("/") || part.includes("\\") || part.includes("\0")) {
2399
+ throw new SandboxPushError(`Local path is unsafe: ${workspacePath}`, "invalid-path");
2400
+ }
2401
+ }
2402
+ function assertSafeRepoName(sourceDir, repoName) {
2403
+ resolveSafeRepoRoot(sourceDir, repoName);
2467
2404
  }
2468
- function rawCodexSessionPayload(value) {
2469
- return objectValue2(value);
2405
+ function resolveSafeRepoRoot(sourceDir, repoName) {
2406
+ assertSafePathPart(repoName, repoName);
2407
+ const repoRoot = resolve2(sourceDir, repoName);
2408
+ const relativePath = relative2(sourceDir, repoRoot);
2409
+ if (relativePath.startsWith("..") || isAbsolute4(relativePath)) {
2410
+ throw new SandboxPushError(`Repo path escapes local workspace: ${repoName}`, "invalid-path");
2411
+ }
2412
+ return repoRoot;
2470
2413
  }
2471
- function hasValue2(value) {
2472
- return value !== void 0 && value !== "";
2414
+ function isNodeError3(error) {
2415
+ return error instanceof Error && "code" in error;
2473
2416
  }
2474
2417
 
2475
- // src/run/run-agent.ts
2476
- var MAX_TRANSCRIPT_CAPTURE_BYTES = 1024 * 1024;
2477
- var SESSION_SETTLE_TIMEOUT_MS = 1500;
2478
- var SESSION_SETTLE_INTERVAL_MS = 100;
2479
- var PROCESS_SESSION_POLL_INTERVAL_MS = 100;
2480
- var CODEX_OPTIONS_WITH_VALUE = /* @__PURE__ */ new Set([
2481
- "-a",
2482
- "--add-dir",
2483
- "--ask-for-approval",
2484
- "-c",
2485
- "-C",
2486
- "--cd",
2487
- "--color",
2488
- "--config",
2489
- "-i",
2490
- "--image",
2491
- "--local-provider",
2492
- "-m",
2493
- "--model",
2494
- "-o",
2495
- "--output-last-message",
2496
- "--output-schema",
2497
- "-p",
2498
- "--profile",
2499
- "--remote",
2500
- "--remote-auth-token-env",
2501
- "-s",
2502
- "--sandbox"
2503
- ]);
2504
- async function runLocalAgent(options) {
2505
- const cwd = options.cwd ?? process.cwd();
2506
- const env = options.env ?? process.env;
2507
- const home = options.home ?? env.HOME ?? homedir2();
2508
- const startedAtMs = Date.now();
2509
- const startedAt = new Date(startedAtMs).toISOString();
2510
- const runId = createCaptureRunId(startedAt, options.agent);
2511
- const captureEnv = withDefaultCaptureOutput(env, home);
2512
- try {
2513
- await prepareCaptureOutputs(captureEnv, runId);
2514
- } catch (error) {
2515
- printPrepareError(options.agent, error);
2516
- return 1;
2517
- }
2518
- const repo = collectRepoFingerprint(cwd);
2519
- let baselineOk = false;
2520
- let before = /* @__PURE__ */ new Map();
2521
- let sessions = "";
2522
- let isSessionCandidate;
2523
- let knownSessionFile;
2524
- let childRootPid;
2525
- let openSessionFileMatch = { match: "none" };
2526
- let processSessionTimer;
2527
- const newSessionFilesOnly = shouldUseNewSessionFilesOnly(options.agent, options.agentArgs);
2528
- try {
2529
- const sessionOptions = {
2530
- agent: options.agent,
2531
- cwd,
2532
- home,
2533
- env: captureEnv,
2534
- agentArgs: options.agentArgs
2535
- };
2536
- sessions = agentSessionDir(sessionOptions);
2537
- isSessionCandidate = agentSessionCandidateFilter(sessionOptions);
2538
- knownSessionFile = agentKnownSessionFile(sessionOptions);
2539
- before = snapshotJsonl(sessions);
2540
- baselineOk = true;
2541
- } catch {
2542
- baselineOk = false;
2543
- }
2544
- const rememberOpenSessionFile = () => {
2545
- if (!baselineOk || sessions === "" || childRootPid === void 0 || openSessionFileMatch.match !== "none") {
2546
- return openSessionFileMatch;
2547
- }
2548
- const matched = findOpenSessionFile({
2549
- rootPid: childRootPid,
2550
- sessionRoot: sessions,
2551
- ...isSessionCandidate === void 0 ? {} : { isCandidate: isSessionCandidate }
2552
- });
2553
- if (matched.match !== "none") {
2554
- openSessionFileMatch = matched;
2555
- }
2556
- return openSessionFileMatch;
2557
- };
2558
- const stopProcessSessionWatcher = () => {
2559
- if (processSessionTimer !== void 0) {
2560
- clearInterval(processSessionTimer);
2561
- processSessionTimer = void 0;
2562
- }
2563
- };
2564
- const liveCapture = createLiveCapture({
2565
- env: captureEnv,
2566
- runId,
2567
- agent: options.agent,
2568
- startedAt,
2569
- sessions,
2570
- before,
2571
- baselineOk,
2572
- startedAtMs,
2573
- ...knownSessionFile === void 0 ? {} : { knownSessionFile },
2574
- ...isSessionCandidate === void 0 ? {} : { isSessionCandidate },
2575
- ...newSessionFilesOnly ? { newSessionFilesOnly: true } : {},
2576
- openSessionFile: rememberOpenSessionFile
2577
- });
2578
- try {
2579
- await liveCapture.start();
2580
- } catch (error) {
2581
- printError(
2582
- [
2583
- "moxt: cannot prepare run output",
2584
- `reason: ${errorMessage3(error)}`,
2585
- "",
2586
- `${options.agent} was not started because this run cannot be recorded.`
2587
- ].join("\n")
2588
- );
2589
- return 1;
2590
- }
2591
- const uploader = options.upload === void 0 ? void 0 : createRunArtifactUploader({
2592
- env: captureEnv,
2593
- runId,
2594
- upload: options.upload
2595
- });
2596
- uploader?.start();
2597
- const observedSignals = /* @__PURE__ */ new Set();
2598
- let child = null;
2599
- let pendingForward = null;
2600
- const observe = (signal) => {
2601
- const number = signalNumber(signal);
2602
- if (number !== 0) observedSignals.add(number);
2603
- };
2604
- const terminalSignalsReachChild = process.stdin.isTTY === true || process.stdout.isTTY === true || process.stderr.isTTY === true;
2605
- const forwardInteractiveSignal = (signal) => {
2606
- observe(signal);
2607
- if (terminalSignalsReachChild) {
2608
- return;
2609
- }
2610
- if (child === null) {
2611
- pendingForward = signal;
2612
- return;
2613
- }
2614
- safeKill(child, signal);
2615
- };
2616
- const onInt = () => forwardInteractiveSignal("SIGINT");
2617
- const onQuit = () => forwardInteractiveSignal("SIGQUIT");
2618
- const onTerm = () => {
2619
- observe("SIGTERM");
2620
- if (child === null) pendingForward = "SIGTERM";
2621
- else safeKill(child, "SIGTERM");
2622
- };
2623
- const onHup = () => {
2624
- observe("SIGHUP");
2625
- if (child === null) pendingForward = "SIGHUP";
2626
- else safeKill(child, "SIGHUP");
2627
- };
2628
- process.on("SIGINT", onInt);
2629
- process.on("SIGQUIT", onQuit);
2630
- process.on("SIGTERM", onTerm);
2631
- process.on("SIGHUP", onHup);
2632
- const spawned = spawn(options.agent, [...options.agentArgs], {
2633
- cwd,
2634
- env: captureEnv,
2635
- stdio: "inherit"
2636
- });
2637
- child = spawned;
2638
- childRootPid = spawned.pid;
2639
- if (childRootPid !== void 0 && baselineOk) {
2640
- rememberOpenSessionFile();
2641
- processSessionTimer = setInterval(() => {
2642
- rememberOpenSessionFile();
2643
- }, PROCESS_SESSION_POLL_INTERVAL_MS);
2644
- }
2645
- if (pendingForward !== null) {
2646
- safeKill(spawned, pendingForward);
2647
- }
2648
- const exit = await waitForExit(spawned, options.agent);
2649
- stopProcessSessionWatcher();
2650
- const onCaptureSignal = () => {
2651
- };
2652
- process.on("SIGINT", onCaptureSignal);
2653
- process.on("SIGQUIT", onCaptureSignal);
2654
- process.on("SIGTERM", onCaptureSignal);
2655
- process.on("SIGHUP", onCaptureSignal);
2656
- process.off("SIGINT", onInt);
2657
- process.off("SIGQUIT", onQuit);
2658
- process.off("SIGTERM", onTerm);
2659
- process.off("SIGHUP", onHup);
2660
- const classified = classifyExit(exit, observedSignals);
2661
- const endedAtMs = exit.waitAt;
2662
- const endedAt = new Date(endedAtMs).toISOString();
2663
- const durationMs = endedAtMs - startedAtMs;
2664
- let after;
2665
- if (baselineOk) {
2418
+ // src/commands/push.ts
2419
+ function registerPushCommand(program2) {
2420
+ program2.command("push").description("Push local workspace files to the sandbox workspace").argument("[dir]", "Local workspace directory", ".").option("-m, --message <message>", "Push message", "moxt push").action(async (sourceDir, options) => {
2421
+ const context = resolveSandboxRuntimeContextOrExit3();
2666
2422
  try {
2667
- after = await waitForSessionSettle(
2668
- sessions,
2669
- before,
2670
- startedAtMs,
2671
- isSessionCandidate,
2672
- newSessionFilesOnly
2673
- );
2674
- } catch {
2423
+ const result = await pushSandboxWorkspace(context, sourceDir, { message: options.message });
2424
+ for (const change of result.changes) {
2425
+ print(`${change.action} ${change.workspacePath}`);
2426
+ }
2427
+ if (result.changes.length === 0) {
2428
+ print("No changes to push");
2429
+ return;
2430
+ }
2431
+ const fileLabel = result.changes.length === 1 ? "file" : "files";
2432
+ print(`Pushed ${result.changes.length} ${fileLabel} from ${result.sourceDir}`);
2433
+ } catch (error) {
2434
+ handlePushError(error);
2675
2435
  }
2676
- }
2677
- try {
2678
- await liveCapture.stop();
2679
- } catch {
2680
- }
2681
- await uploader?.stop();
2682
- const payload = buildPayload({
2683
- agent: options.agent,
2684
- cwd,
2685
- startedAt,
2686
- endedAt,
2687
- durationMs,
2688
- exitCode: classified.code,
2689
- status: classified.status,
2690
- repo: sanitizeRepo(repo),
2691
- baselineOk,
2692
- sessions,
2693
- before,
2694
- after,
2695
- startedAtMs,
2696
- knownSessionFile,
2697
- isSessionCandidate,
2698
- newSessionFilesOnly,
2699
- openSessionFileMatch
2700
2436
  });
2701
- let finalized = false;
2437
+ }
2438
+ function resolveSandboxRuntimeContextOrExit3() {
2439
+ let context;
2702
2440
  try {
2703
- if (liveCapture.enabled) {
2704
- await liveCapture.finalize(payload, (/* @__PURE__ */ new Date()).toISOString());
2705
- } else {
2706
- await writeCaptureDirectoryPayload(payload, captureEnv, runId, (/* @__PURE__ */ new Date()).toISOString());
2707
- }
2708
- finalized = true;
2441
+ context = resolveRuntimeContext();
2709
2442
  } catch (error) {
2710
- printError(`moxt: cannot finalize run artifact: ${errorMessage3(error)}`);
2711
- } finally {
2712
- process.off("SIGINT", onCaptureSignal);
2713
- process.off("SIGQUIT", onCaptureSignal);
2714
- process.off("SIGTERM", onCaptureSignal);
2715
- process.off("SIGHUP", onCaptureSignal);
2716
- }
2717
- if (finalized && options.upload !== void 0) {
2718
- try {
2719
- await uploader?.flush();
2720
- } catch (error) {
2721
- printError(`moxt: cannot upload run artifact: ${errorMessage3(error)}`);
2443
+ if (error instanceof RuntimeContextError) {
2444
+ printError(error.message);
2445
+ process.exit(1);
2722
2446
  }
2447
+ throw error;
2723
2448
  }
2724
- return classified.code;
2449
+ if (context.mode !== "sandbox") {
2450
+ printError("moxt push is only available in sandbox mode");
2451
+ process.exit(1);
2452
+ }
2453
+ return context;
2725
2454
  }
2726
- async function waitForSessionSettle(sessions, before, startedAtMs, isSessionCandidate, newSessionFilesOnly) {
2727
- const deadline = Date.now() + SESSION_SETTLE_TIMEOUT_MS;
2728
- let after = snapshotJsonl(sessions);
2729
- while (!hasSessionChange(before, after, startedAtMs, isSessionCandidate, newSessionFilesOnly) && Date.now() < deadline) {
2730
- await sleep(SESSION_SETTLE_INTERVAL_MS);
2731
- after = snapshotJsonl(sessions);
2455
+ function handlePushError(error) {
2456
+ if (error instanceof Error) {
2457
+ printError(error.message);
2458
+ process.exit(1);
2732
2459
  }
2733
- return after;
2460
+ throw error;
2734
2461
  }
2735
- function hasSessionChange(before, after, startedAtMs, isSessionCandidate, newSessionFilesOnly) {
2736
- const matched = matchSession(before, after, startedAtMs, matchOptions(isSessionCandidate, newSessionFilesOnly));
2737
- if (matched.match !== "unique") {
2738
- return matched.match !== "none";
2462
+
2463
+ // src/utils/checkout-status.ts
2464
+ import { createHash as createHash4 } from "crypto";
2465
+ import { createReadStream } from "fs";
2466
+ import { lstat as lstat3, readdir as readdir2 } from "fs/promises";
2467
+ import { join as join3 } from "path";
2468
+ var CheckoutStatusError = class extends Error {
2469
+ constructor(message) {
2470
+ super(message);
2471
+ this.name = "CheckoutStatusError";
2472
+ }
2473
+ };
2474
+ async function inspectCheckoutStatus(state) {
2475
+ await requireCheckoutDirectory(state.checkoutRoot);
2476
+ const localFiles = /* @__PURE__ */ new Map();
2477
+ for (const repo of Object.values(state.repos)) {
2478
+ await scanDirectory(state, repo.name, join3(state.checkoutRoot, repo.name), localFiles);
2739
2479
  }
2740
- if (matched.sessionFile === null) {
2741
- return false;
2480
+ const changes = [];
2481
+ for (const [path2, sha] of localFiles) {
2482
+ const baseline = state.files[path2];
2483
+ if (!baseline) changes.push({ action: "added", path: path2 });
2484
+ else if (baseline.sha !== sha) changes.push({ action: "modified", path: path2 });
2742
2485
  }
2743
- const snapshot = after.get(matched.sessionFile);
2744
- return snapshot !== void 0 && snapshot.size > matched.startOffset;
2745
- }
2746
- function sleep(ms) {
2747
- return new Promise((resolve2) => setTimeout(resolve2, ms));
2486
+ for (const path2 of Object.keys(state.files)) {
2487
+ if (isCheckoutPathIncluded(state, path2) && !localFiles.has(path2)) changes.push({ action: "deleted", path: path2 });
2488
+ }
2489
+ return changes.sort((left, right) => left.path.localeCompare(right.path));
2748
2490
  }
2749
- function safeKill(child, signal) {
2491
+ async function requireCheckoutDirectory(checkoutRoot) {
2492
+ let stats;
2750
2493
  try {
2751
- child.kill(signal);
2752
- } catch {
2494
+ stats = await lstat3(checkoutRoot);
2495
+ } catch (error) {
2496
+ throw new CheckoutStatusError(`Checkout root is not available: ${errorMessage2(error)}`);
2753
2497
  }
2498
+ if (stats.isSymbolicLink()) throw new CheckoutStatusError(`Refusing to inspect symbolic link: ${checkoutRoot}`);
2499
+ if (!stats.isDirectory()) throw new CheckoutStatusError(`Checkout root is not a directory: ${checkoutRoot}`);
2754
2500
  }
2755
- function waitForExit(child, agent) {
2756
- return new Promise((resolve2) => {
2757
- child.once("error", (error) => {
2758
- const errorCode = error.code ?? "UNKNOWN";
2759
- const reason = errorCode === "ENOENT" ? "command not found" : error.message;
2760
- printError(`moxt: cannot start ${agent}: ${reason}`);
2761
- resolve2({
2762
- code: null,
2763
- signal: null,
2764
- waitAt: Date.now(),
2765
- errorCode
2766
- });
2767
- });
2768
- child.once("exit", (code, signal) => {
2769
- resolve2({ code, signal, waitAt: Date.now() });
2770
- });
2771
- });
2772
- }
2773
- function buildPayload(input) {
2774
- let sessionMatch = input.baselineOk ? "none" : "prep_failed";
2775
- let transcript;
2776
- let redactionFailed = false;
2777
- if (input.baselineOk) {
2778
- try {
2779
- const after = input.after ?? snapshotJsonl(input.sessions);
2780
- const knownMatched = input.knownSessionFile === void 0 ? null : matchKnownSessionFile(input.before, after, input.knownSessionFile);
2781
- const matched = knownMatched?.match === "unique" ? knownMatched : input.openSessionFileMatch.match === "unique" ? matchKnownSessionFile(input.before, after, input.openSessionFileMatch.sessionFile) : input.openSessionFileMatch.match === "ambiguous" ? {
2782
- match: "ambiguous",
2783
- sessionFile: null,
2784
- startOffset: 0
2785
- } : matchSession(
2786
- input.before,
2787
- after,
2788
- input.startedAtMs,
2789
- matchOptions(input.isSessionCandidate, input.newSessionFilesOnly)
2790
- );
2791
- sessionMatch = matched.match;
2792
- if (matched.match === "unique" && matched.sessionFile !== null) {
2793
- try {
2794
- transcript = redactText(readFileFromOffset2(matched.sessionFile, matched.startOffset)).text;
2795
- } catch {
2796
- redactionFailed = true;
2797
- }
2798
- }
2799
- } catch {
2800
- sessionMatch = "prep_failed";
2501
+ async function scanDirectory(state, workspacePath, localPath, files) {
2502
+ let entries;
2503
+ try {
2504
+ entries = await readdir2(localPath, { withFileTypes: true });
2505
+ } catch (error) {
2506
+ if (isNodeError4(error) && error.code === "ENOENT") return;
2507
+ throw new CheckoutStatusError(`Failed to inspect ${workspacePath}: ${errorMessage2(error)}`);
2508
+ }
2509
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
2510
+ if (entry.name === ".git") continue;
2511
+ const childWorkspacePath = `${workspacePath}/${entry.name}`;
2512
+ if (!doesCheckoutPathIntersect(state, childWorkspacePath)) continue;
2513
+ const childLocalPath = join3(localPath, entry.name);
2514
+ const stats = await lstat3(childLocalPath);
2515
+ if (stats.isSymbolicLink()) {
2516
+ throw new CheckoutStatusError(`Refusing to inspect symbolic link: ${childWorkspacePath}`);
2517
+ }
2518
+ if (stats.isDirectory()) {
2519
+ await scanDirectory(state, childWorkspacePath, childLocalPath, files);
2520
+ } else if (stats.isFile() && isCheckoutPathIncluded(state, childWorkspacePath)) {
2521
+ files.set(childWorkspacePath, await hashFile(childLocalPath));
2801
2522
  }
2802
2523
  }
2803
- return {
2804
- ...transcript === void 0 ? {} : { transcript_redacted: transcript },
2805
- ...redactionFailed ? { redaction_failed: true } : {},
2806
- run: {
2807
- agent: input.agent,
2808
- cwd: input.cwd,
2809
- git_root: input.repo?.root ?? null,
2810
- started_at: input.startedAt,
2811
- ended_at: input.endedAt,
2812
- duration_ms: input.durationMs,
2813
- status: input.status,
2814
- exit_code: input.exitCode
2815
- },
2816
- recording: recordingFromSessionMatch(sessionMatch),
2817
- repo: input.repo
2818
- };
2819
2524
  }
2820
- function matchOptions(isSessionCandidate, newSessionFilesOnly) {
2821
- return {
2822
- ...isSessionCandidate === void 0 ? {} : { isCandidate: isSessionCandidate },
2823
- ...newSessionFilesOnly ? { newFilesOnly: true } : {}
2824
- };
2525
+ async function hashFile(path2) {
2526
+ const hash = createHash4("sha256");
2527
+ const stream = createReadStream(path2);
2528
+ for await (const chunk of stream) hash.update(chunk);
2529
+ return hash.digest("hex");
2825
2530
  }
2826
- function shouldUseNewSessionFilesOnly(agent, agentArgs) {
2827
- return agent === "codex" && !isCodexResumeInvocation(agentArgs);
2531
+ function isNodeError4(error) {
2532
+ return error instanceof Error && "code" in error;
2828
2533
  }
2829
- function isCodexResumeInvocation(agentArgs) {
2830
- const command = nextCodexCommandArg(agentArgs, 0);
2831
- if (command === null) {
2832
- return false;
2833
- }
2834
- if (command.value === "resume") {
2835
- return true;
2836
- }
2837
- if (command.value !== "exec" && command.value !== "e") {
2838
- return false;
2839
- }
2840
- return nextCodexCommandArg(agentArgs, command.index + 1)?.value === "resume";
2534
+ function errorMessage2(error) {
2535
+ return error instanceof Error ? error.message : String(error);
2841
2536
  }
2842
- function nextCodexCommandArg(agentArgs, startIndex) {
2843
- for (let index = startIndex; index < agentArgs.length; index += 1) {
2844
- const arg = agentArgs[index];
2845
- if (arg === void 0) {
2846
- continue;
2537
+
2538
+ // src/commands/status.ts
2539
+ function registerStatusCommand(program2) {
2540
+ program2.command("status").description("Show Moxt workspace checkout status").option("--json", "Print machine-readable status").action(async (options) => {
2541
+ const context = resolveRuntimeContextOrExit2();
2542
+ if (context.mode !== "sandbox") {
2543
+ printError("moxt status is only available in sandbox mode");
2544
+ process.exit(1);
2847
2545
  }
2848
- if (arg === "--") {
2849
- return null;
2546
+ const status = await buildSandboxStatusOrExit(context);
2547
+ if (options.json) {
2548
+ print(JSON.stringify(status, null, 2));
2549
+ return;
2850
2550
  }
2851
- if (arg.startsWith("-")) {
2852
- if (!arg.includes("=") && CODEX_OPTIONS_WITH_VALUE.has(arg)) {
2853
- index += 1;
2854
- }
2855
- continue;
2551
+ print(`${colors.bold}Workspace${colors.reset}: ${status.workspaceId}`);
2552
+ print(`${colors.bold}Mode${colors.reset}: sandbox`);
2553
+ print(`${colors.bold}Checkout${colors.reset}: ${formatCheckoutMode(status.checkout.mode)}`);
2554
+ if (status.checkout.mode !== "not_initialized") {
2555
+ print(`${colors.bold}Root${colors.reset}: ${status.checkout.root}`);
2556
+ }
2557
+ print("");
2558
+ print(`${colors.bold}Repos${colors.reset}:`);
2559
+ const repoNameWidth = status.repos.reduce((width, repo) => Math.max(width, repo.name.length), 0);
2560
+ for (const repo of status.repos) {
2561
+ print(` ${repo.name.padEnd(repoNameWidth)} ${repo.repoId}`);
2562
+ }
2563
+ print("");
2564
+ if (status.checkout.mode === "not_initialized") {
2565
+ print(`${colors.bold}Checked out scopes${colors.reset}: none`);
2566
+ print(`${colors.bold}Local changes${colors.reset}: not tracked`);
2567
+ return;
2568
+ }
2569
+ print(`${colors.bold}Checked out scopes${colors.reset}:`);
2570
+ for (const scope of status.checkout.checkedOutScopes) print(` ${scope}`);
2571
+ print(`${colors.bold}Local changes${colors.reset}:${status.checkout.changes.length === 0 ? " none" : ""}`);
2572
+ for (const change of status.checkout.changes) {
2573
+ print(` ${change.action.padEnd(8)} ${change.path}`);
2856
2574
  }
2857
- return { value: arg, index };
2858
- }
2859
- return null;
2860
- }
2861
- function readFileFromOffset2(path2, offset) {
2862
- const fd = openSync2(path2, "r");
2863
- try {
2864
- const stat2 = fstatSync(fd);
2865
- const length = Math.max(0, stat2.size - offset);
2866
- if (length > MAX_TRANSCRIPT_CAPTURE_BYTES) {
2867
- throw new Error("transcript capture exceeds maximum size");
2868
- }
2869
- const buffer2 = Buffer.alloc(length);
2870
- if (length === 0) {
2871
- return "";
2872
- }
2873
- readSync2(fd, buffer2, 0, length, offset);
2874
- return buffer2.toString("utf8");
2875
- } finally {
2876
- closeSync2(fd);
2877
- }
2878
- }
2879
- function printPrepareError(agent, error) {
2880
- if (error instanceof CaptureOutputPrepareError) {
2881
- printError(
2882
- [
2883
- "moxt: cannot create run output directory",
2884
- `path: ${error.path}`,
2885
- `reason: ${error.message}`,
2886
- "",
2887
- `${agent} was not started because this run cannot be recorded.`
2888
- ].join("\n")
2889
- );
2890
- return;
2891
- }
2892
- printError(
2893
- [
2894
- "moxt: cannot prepare run output",
2895
- `reason: ${errorMessage3(error)}`,
2896
- "",
2897
- `${agent} was not started because this run cannot be recorded.`
2898
- ].join("\n")
2899
- );
2900
- }
2901
- function errorMessage3(error) {
2902
- if (error instanceof Error && error.message !== "") {
2903
- return error.message;
2904
- }
2905
- return String(error);
2906
- }
2907
-
2908
- // src/commands/run.ts
2909
- function registerRunCommand(program2) {
2910
- program2.command("run").description("Run a local agent and capture its work as Moxt context").option("-w, --workspace <workspaceId>", "Workspace ID for upload mode").argument("<agent>", "Local agent to run: claude or codex").argument("[agentArgs...]", "Arguments passed to the local agent after --").allowUnknownOption(true).allowExcessArguments(true).action(async (agentArg, agentArgs, options) => {
2911
- const agent = parseAgent(agentArg);
2912
- const upload = resolveUploadOptions(options);
2913
- const exitCode = await runLocalAgentOrExit(agent, agentArgs ?? [], upload);
2914
- process.exit(exitCode);
2915
2575
  });
2916
2576
  }
2917
- function parseAgent(agent) {
2918
- if (agent === "claude" || agent === "codex") {
2919
- return agent;
2920
- }
2921
- printError(`Error: moxt run supports only "claude" or "codex", got "${agent}".`);
2922
- process.exit(1);
2923
- }
2924
- function resolveUploadOptions(options) {
2925
- const workspaceId = resolveWorkspaceId(options);
2926
- if (workspaceId === void 0) {
2927
- return void 0;
2928
- }
2929
- if (!process.env.MOXT_API_KEY) {
2930
- printError("Error: MOXT_API_KEY is required when using moxt run upload mode with -w/--workspace or MOXT_WORKSPACE_ID.");
2577
+ async function buildSandboxStatusOrExit(context) {
2578
+ const repos = listRepoEntries(context.repoPayload).map((repo) => ({
2579
+ name: repo.name,
2580
+ repoId: repo.repoId
2581
+ }));
2582
+ try {
2583
+ const state = await loadCheckoutState();
2584
+ if (!state) return buildUninitializedStatus(context.workspaceId, repos);
2585
+ validateStateContext(state, context);
2586
+ return {
2587
+ mode: "sandbox",
2588
+ workspaceId: context.workspaceId,
2589
+ checkout: {
2590
+ mode: state.mode,
2591
+ root: state.checkoutRoot,
2592
+ checkedOutScopes: state.checkedOutScopes,
2593
+ localChangesTracked: true,
2594
+ changes: await inspectCheckoutStatus(state)
2595
+ },
2596
+ repos
2597
+ };
2598
+ } catch (error) {
2599
+ printError(error instanceof Error ? error.message : String(error));
2931
2600
  process.exit(1);
2932
2601
  }
2602
+ }
2603
+ function buildUninitializedStatus(workspaceId, repos) {
2933
2604
  return {
2934
- workspaceId
2605
+ mode: "sandbox",
2606
+ workspaceId,
2607
+ checkout: {
2608
+ mode: "not_initialized",
2609
+ checkedOutScopes: [],
2610
+ localChangesTracked: false
2611
+ },
2612
+ repos
2935
2613
  };
2936
2614
  }
2937
- function resolveWorkspaceId(options) {
2938
- return normalizeWorkspaceId(options.workspace) ?? normalizeWorkspaceId(process.env.MOXT_WORKSPACE_ID);
2615
+ function validateStateContext(state, context) {
2616
+ if (state.workspaceId !== context.workspaceId) {
2617
+ throw new Error(`Checkout state belongs to workspace ${state.workspaceId}, not ${context.workspaceId}`);
2618
+ }
2619
+ const currentRepos = new Map(listRepoEntries(context.repoPayload).map((repo) => [repo.repoId, repo.name]));
2620
+ for (const [repoId, repo] of Object.entries(state.repos)) {
2621
+ const currentName = currentRepos.get(repoId);
2622
+ if (!currentName) throw new Error(`Checkout state contains unknown repo: ${repoId}`);
2623
+ if (currentName !== repo.name) {
2624
+ throw new Error(`Checkout state repo name does not match current workspace: ${repo.name}`);
2625
+ }
2626
+ }
2939
2627
  }
2940
- function normalizeWorkspaceId(value) {
2941
- const trimmed = value?.trim();
2942
- return trimmed === void 0 || trimmed === "" ? void 0 : trimmed;
2628
+ function resolveRuntimeContextOrExit2() {
2629
+ try {
2630
+ return resolveRuntimeContext();
2631
+ } catch (error) {
2632
+ if (error instanceof RuntimeContextError) {
2633
+ printError(error.message);
2634
+ process.exit(1);
2635
+ }
2636
+ throw error;
2637
+ }
2943
2638
  }
2944
- async function runLocalAgentOrExit(agent, agentArgs, upload) {
2945
- return await runLocalAgent({
2946
- agent,
2947
- agentArgs,
2948
- ...upload === void 0 ? {} : { upload }
2949
- });
2639
+ function formatCheckoutMode(mode) {
2640
+ return mode === "not_initialized" ? "not initialized" : `${mode} checkout`;
2950
2641
  }
2951
2642
 
2952
2643
  // src/telemetry/config.ts
2953
- import * as fs2 from "fs";
2644
+ import * as fs3 from "fs";
2954
2645
  import * as os from "os";
2955
2646
  import * as path from "path";
2956
2647
  function getConfigPath() {
@@ -2958,7 +2649,7 @@ function getConfigPath() {
2958
2649
  }
2959
2650
  function readTelemetryConfig() {
2960
2651
  try {
2961
- const raw = fs2.readFileSync(getConfigPath(), "utf8");
2652
+ const raw = fs3.readFileSync(getConfigPath(), "utf8");
2962
2653
  const parsed = JSON.parse(raw);
2963
2654
  return parsed.telemetry ?? {};
2964
2655
  } catch {
@@ -2969,14 +2660,14 @@ function writeTelemetryConfig(update) {
2969
2660
  const configPath = getConfigPath();
2970
2661
  let existing = {};
2971
2662
  try {
2972
- existing = JSON.parse(fs2.readFileSync(configPath, "utf8"));
2663
+ existing = JSON.parse(fs3.readFileSync(configPath, "utf8"));
2973
2664
  } catch {
2974
2665
  existing = {};
2975
2666
  }
2976
2667
  const currentTelemetry = existing.telemetry ?? {};
2977
2668
  const next = { ...existing, telemetry: { ...currentTelemetry, ...update } };
2978
- fs2.mkdirSync(path.dirname(configPath), { recursive: true });
2979
- fs2.writeFileSync(configPath, JSON.stringify(next, null, 2));
2669
+ fs3.mkdirSync(path.dirname(configPath), { recursive: true });
2670
+ fs3.writeFileSync(configPath, JSON.stringify(next, null, 2));
2980
2671
  }
2981
2672
 
2982
2673
  // src/telemetry/opt-out.ts
@@ -3148,31 +2839,34 @@ function registerWorkspaceCommand(program2) {
3148
2839
 
3149
2840
  // src/program.ts
3150
2841
  function createProgram(version) {
3151
- const program2 = new Command();
2842
+ const program2 = new Command2();
3152
2843
  program2.name("moxt").description("Moxt CLI - AI Workspace").version(version).action(() => {
3153
2844
  program2.help();
3154
2845
  });
2846
+ registerAuthCommand(program2);
3155
2847
  registerWhoamiCommand(program2);
3156
2848
  registerWorkspaceCommand(program2);
3157
2849
  registerFileCommand(program2);
3158
2850
  registerMemoryCommand(program2);
3159
- registerRunCommand(program2);
3160
2851
  registerMiniappCommand(program2);
2852
+ registerPullCommand(program2);
2853
+ registerPushCommand(program2);
2854
+ registerStatusCommand(program2);
3161
2855
  registerTelemetryCommand(program2);
3162
2856
  return program2;
3163
2857
  }
3164
2858
 
3165
2859
  // src/telemetry/client.ts
3166
- import { arch as arch3, platform as platform3 } from "os";
2860
+ import { arch as arch2, platform as platform2 } from "os";
3167
2861
  var buffer = [];
3168
2862
  var FLUSH_TIMEOUT_MS = 3e3;
3169
2863
  var MAX_BATCH_SIZE = 100;
3170
2864
  function commonLabels() {
3171
2865
  return {
3172
- cli_version: "0.3.3-moxt-run.2",
2866
+ cli_version: "0.4.0-beta.1",
3173
2867
  node_version: process.versions.node,
3174
- os: platform3(),
3175
- arch: arch3(),
2868
+ os: platform2(),
2869
+ arch: arch2(),
3176
2870
  is_ci: isCiEnvironment() ? "true" : "false"
3177
2871
  };
3178
2872
  }
@@ -3215,7 +2909,7 @@ async function flush() {
3215
2909
  }
3216
2910
  const batch = buffer.splice(0, MAX_BATCH_SIZE);
3217
2911
  const payload = {
3218
- release_id: "0.3.3-moxt-run.2",
2912
+ release_id: "0.4.0-beta.1",
3219
2913
  client_type: "cli",
3220
2914
  metric_data_list: batch.map((m) => ({
3221
2915
  profile: "cli",
@@ -3356,9 +3050,9 @@ function installExitHandler() {
3356
3050
 
3357
3051
  // src/index.ts
3358
3052
  updateNotifier({
3359
- pkg: { name: "@moxt-ai/cli", version: "0.3.3-moxt-run.2" }
3053
+ pkg: { name: "@moxt-ai/cli", version: "0.4.0-beta.1" }
3360
3054
  }).notify();
3361
- var program = createProgram("0.3.3-moxt-run.2");
3055
+ var program = createProgram("0.4.0-beta.1");
3362
3056
  instrumentProgram(program);
3363
3057
  installExitHandler();
3364
3058
  program.parseAsync(process.argv).then(async () => {