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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4,10 +4,203 @@
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
+ var DEFAULT_USER_HOST = "api.moxt.ai";
28
+ var SANDBOX_ENV_KEYS = [
29
+ "BASE_API_HOST",
30
+ "SANDBOX_TOOL_API_TOKEN",
31
+ "MOXT_REPO_PAYLOAD",
32
+ "MOXT_WORKSPACE_ID"
33
+ ];
34
+ var RuntimeContextError = class extends Error {
35
+ constructor(message) {
36
+ super(message);
37
+ this.name = "RuntimeContextError";
38
+ }
39
+ };
40
+ function resolveRuntimeContext(env = process.env) {
41
+ if (hasAnySandboxEnv(env)) {
42
+ return resolveSandboxRuntimeContext(env);
43
+ }
44
+ const apiKey = readNonEmptyEnv(env, "MOXT_API_KEY");
45
+ if (apiKey) {
46
+ return {
47
+ mode: "user",
48
+ host: readNonEmptyEnv(env, "MOXT_HOST") ?? DEFAULT_USER_HOST,
49
+ apiKey
50
+ };
51
+ }
52
+ throw new RuntimeContextError("Missing auth. Set sandbox environment variables or MOXT_API_KEY.");
53
+ }
54
+ function resolveSandboxRuntimeContext(env = process.env) {
55
+ const missingKeys = SANDBOX_ENV_KEYS.filter((key) => !readNonEmptyEnv(env, key));
56
+ if (missingKeys.length > 0) {
57
+ throw new RuntimeContextError(`Incomplete sandbox environment. Missing: ${missingKeys.join(", ")}.`);
58
+ }
59
+ return {
60
+ mode: "sandbox",
61
+ baseApiHost: normalizeBaseApiHost(readRequiredEnv(env, "BASE_API_HOST")),
62
+ sandboxToolApiToken: readRequiredEnv(env, "SANDBOX_TOOL_API_TOKEN"),
63
+ workspaceId: readRequiredEnv(env, "MOXT_WORKSPACE_ID"),
64
+ repoPayload: parseRepoPayload(readRequiredEnv(env, "MOXT_REPO_PAYLOAD"))
65
+ };
66
+ }
67
+ function parseRepoPayload(raw) {
68
+ let parsed;
69
+ try {
70
+ parsed = JSON.parse(raw);
71
+ } catch (error) {
72
+ throw new RuntimeContextError(`MOXT_REPO_PAYLOAD must be valid JSON: ${error instanceof Error ? error.message : String(error)}`);
73
+ }
74
+ if (!isRecord(parsed)) {
75
+ throw new RuntimeContextError("MOXT_REPO_PAYLOAD must be a JSON object.");
76
+ }
77
+ const personal = parseRepoEntry(parsed.personal, "personal");
78
+ const teamsValue = parsed.teams;
79
+ if (!Array.isArray(teamsValue)) {
80
+ throw new RuntimeContextError("MOXT_REPO_PAYLOAD.teams must be an array.");
81
+ }
82
+ const teams = teamsValue.map((entry, index) => parseRepoEntry(entry, `teams[${index}]`));
83
+ const workspace = parsed.workspace == null ? null : parseRepoEntry(parsed.workspace, "workspace");
84
+ return { personal, teams, workspace };
85
+ }
86
+ function normalizeBaseApiHost(value) {
87
+ const trimmed = value.trim();
88
+ if (!trimmed) {
89
+ throw new RuntimeContextError("BASE_API_HOST must not be empty.");
90
+ }
91
+ return trimmed.replace(/\/+$/, "");
92
+ }
93
+ function hasAnySandboxEnv(env) {
94
+ return SANDBOX_ENV_KEYS.some((key) => readNonEmptyEnv(env, key) !== void 0);
95
+ }
96
+ function readRequiredEnv(env, key) {
97
+ const value = readNonEmptyEnv(env, key);
98
+ if (!value) {
99
+ throw new RuntimeContextError(`${key} is required.`);
100
+ }
101
+ return value;
102
+ }
103
+ function readNonEmptyEnv(env, key) {
104
+ const value = env[key];
105
+ if (typeof value !== "string") return void 0;
106
+ const trimmed = value.trim();
107
+ return trimmed.length > 0 ? trimmed : void 0;
108
+ }
109
+ function parseRepoEntry(value, path2) {
110
+ if (!isRecord(value)) {
111
+ throw new RuntimeContextError(`MOXT_REPO_PAYLOAD.${path2} must be an object.`);
112
+ }
113
+ const repoId = readStringField(value, "repoId", path2);
114
+ const name = readStringField(value, "name", path2);
115
+ return { repoId, name };
116
+ }
117
+ function readStringField(record2, field, path2) {
118
+ const value = record2[field];
119
+ if (typeof value !== "string" || value.trim().length === 0) {
120
+ throw new RuntimeContextError(`MOXT_REPO_PAYLOAD.${path2}.${field} must be a non-empty string.`);
121
+ }
122
+ return value;
123
+ }
124
+ function isRecord(value) {
125
+ return typeof value === "object" && value !== null && !Array.isArray(value);
126
+ }
127
+
128
+ // src/utils/sandbox-path.ts
129
+ var WorkspacePathError = class extends Error {
130
+ constructor(message) {
131
+ super(message);
132
+ this.name = "WorkspacePathError";
133
+ }
134
+ };
135
+ function resolveWorkspacePath(repoPayload, workspacePath) {
136
+ const normalizedPath = normalizeWorkspacePath(workspacePath);
137
+ const [repoName, ...repoPathParts] = normalizedPath.split("/");
138
+ const repoEntries = listRepoEntries(repoPayload);
139
+ const repo = repoEntries.find((entry) => entry.name === repoName);
140
+ if (!repo) {
141
+ throw new WorkspacePathError(`Workspace path must start with one of: ${repoEntries.map((entry) => entry.name).join(", ")}.`);
142
+ }
143
+ return {
144
+ repoId: repo.repoId,
145
+ repoName: repo.name,
146
+ repoPath: repoPathParts.join("/")
147
+ };
148
+ }
149
+ function listRepoEntries(repoPayload) {
150
+ return [
151
+ repoPayload.personal,
152
+ ...repoPayload.teams,
153
+ ...repoPayload.workspace ? [repoPayload.workspace] : []
154
+ ];
155
+ }
156
+ function normalizeWorkspacePath(workspacePath) {
157
+ const trimmed = workspacePath.trim();
158
+ if (!trimmed) {
159
+ throw new WorkspacePathError("Workspace path must not be empty.");
160
+ }
161
+ if (trimmed.startsWith("/")) {
162
+ throw new WorkspacePathError("Workspace path must be relative.");
163
+ }
164
+ const parts = trimmed.split("/").filter((part) => part.length > 0 && part !== ".");
165
+ if (parts.length === 0) {
166
+ throw new WorkspacePathError("Workspace path must include a repo name.");
167
+ }
168
+ if (parts.some((part) => part === "..")) {
169
+ throw new WorkspacePathError('Workspace path must not contain "..".');
170
+ }
171
+ return parts.join("/");
172
+ }
173
+
174
+ // src/commands/auth.ts
175
+ function registerAuthCommand(program2) {
176
+ const auth = program2.command("auth").description("Authentication utilities");
177
+ auth.command("status").description("Show CLI authentication mode").action(() => {
178
+ try {
179
+ const context = resolveRuntimeContext();
180
+ if (context.mode === "sandbox") {
181
+ print(`${colors.bold}Mode${colors.reset}: sandbox`);
182
+ print(`${colors.bold}Workspace${colors.reset}: ${context.workspaceId}`);
183
+ print(`${colors.bold}Base API${colors.reset}: ${context.baseApiHost}`);
184
+ print(`${colors.bold}Repos${colors.reset}:`);
185
+ for (const repo of listRepoEntries(context.repoPayload)) {
186
+ print(` ${repo.name} ${repo.repoId}`);
187
+ }
188
+ return;
189
+ }
190
+ print(`${colors.bold}Mode${colors.reset}: user`);
191
+ print(`${colors.bold}Host${colors.reset}: ${context.host}`);
192
+ } catch (error) {
193
+ if (error instanceof RuntimeContextError) {
194
+ printError(error.message);
195
+ process.exit(1);
196
+ }
197
+ throw error;
198
+ }
199
+ });
200
+ }
8
201
 
9
202
  // src/commands/file.ts
10
- import * as fs from "fs";
203
+ import * as fs2 from "fs";
11
204
 
12
205
  // src/utils/file-selector.ts
13
206
  var SpaceOptionsError = class extends Error {
@@ -149,7 +342,7 @@ function getApiKeyOrUndefined() {
149
342
 
150
343
  // src/utils/http.ts
151
344
  function getUserAgent() {
152
- return `moxt-cli/${"0.3.3-moxt-run.2"} (${platform()}/${arch()}) node/${process.versions.node}`;
345
+ return `moxt-cli/${"0.4.0-beta.0"} (${platform()}/${arch()}) node/${process.versions.node}`;
153
346
  }
154
347
  async function fetchApi(method, path2, body) {
155
348
  const apiKey = getApiKey();
@@ -202,23 +395,6 @@ async function httpRaw(method, path2, body) {
202
395
  };
203
396
  }
204
397
 
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
398
  // src/utils/search-options.ts
223
399
  var SearchOptionsError = class extends Error {
224
400
  constructor(message) {
@@ -272,6 +448,209 @@ function runOrExit(fn) {
272
448
  }
273
449
  }
274
450
 
451
+ // src/utils/sandbox-client.ts
452
+ var DEFAULT_TIMEOUT_MS = 3e4;
453
+ var SandboxApiError = class extends Error {
454
+ status;
455
+ body;
456
+ constructor(status, body) {
457
+ super(`Sandbox API request failed with ${status}: ${body}`);
458
+ this.name = "SandboxApiError";
459
+ this.status = status;
460
+ this.body = body;
461
+ }
462
+ };
463
+ var SandboxMoxtClient = class {
464
+ constructor(context) {
465
+ this.context = context;
466
+ }
467
+ context;
468
+ async request(method, path2, body, options = {}) {
469
+ const signal = AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
470
+ const response = await fetch(`${this.context.baseApiHost}${path2}`, {
471
+ method,
472
+ headers: {
473
+ "Content-Type": "application/json",
474
+ Authorization: `Bearer ${this.context.sandboxToolApiToken}`
475
+ },
476
+ body: body === void 0 ? void 0 : JSON.stringify(body),
477
+ signal
478
+ });
479
+ const contentType = response.headers.get("content-type");
480
+ const data = contentType?.includes("application/json") ? await response.json() : await response.text();
481
+ if (!response.ok) {
482
+ throw new SandboxApiError(response.status, typeof data === "string" ? data : JSON.stringify(data));
483
+ }
484
+ return {
485
+ status: response.status,
486
+ data
487
+ };
488
+ }
489
+ };
490
+
491
+ // src/utils/sandbox-file.ts
492
+ import { createHash } from "crypto";
493
+ var MFS_FILE_TYPE_REGULAR = 1;
494
+ var MFS_FILE_SIZE_LIMIT_BYTES = 10 * 1024 * 1024;
495
+ var MFS_FILE_SIZE_LIMIT_LABEL = `${MFS_FILE_SIZE_LIMIT_BYTES / (1024 * 1024)}MB`;
496
+ var SandboxFileError = class extends Error {
497
+ constructor(message, code) {
498
+ super(message);
499
+ this.code = code;
500
+ this.name = "SandboxFileError";
501
+ }
502
+ code;
503
+ };
504
+ async function readSandboxFile(context, workspacePath) {
505
+ const resolved = resolveSandboxFilePath(context, workspacePath);
506
+ const tree = await fetchRepoTree(context, resolved);
507
+ const entry = findTreeEntry(tree, resolved);
508
+ if (!entry) {
509
+ throw new SandboxFileError(`${workspacePath} does not exist`, "not-found");
510
+ }
511
+ if (entry.type === "tree") {
512
+ throw new SandboxFileError(`${workspacePath} is a directory`, "directory");
513
+ }
514
+ if (!entry.sha) {
515
+ throw new SandboxFileError(`${workspacePath} does not have downloadable content`, "remote");
516
+ }
517
+ if (entry.size !== null && entry.size > MFS_FILE_SIZE_LIMIT_BYTES) {
518
+ throw new SandboxFileError(`File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${workspacePath}`, "too-large");
519
+ }
520
+ const client = new SandboxMoxtClient(context);
521
+ const presign = await client.request(
522
+ "POST",
523
+ `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(resolved.repoId)}/presign-download`,
524
+ { hashes: [entry.sha] }
525
+ );
526
+ const url = presign.data.urls[entry.sha];
527
+ if (!url) {
528
+ throw new SandboxFileError(`No download URL returned for ${workspacePath}`, "remote");
529
+ }
530
+ const response = await fetch(url);
531
+ if (!response.ok) {
532
+ throw new SandboxFileError(`Download failed [${response.status}]`, "remote");
533
+ }
534
+ const content = await response.text();
535
+ const data = Buffer.from(content, "utf8");
536
+ if (data.length > MFS_FILE_SIZE_LIMIT_BYTES) {
537
+ throw new SandboxFileError(`File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${workspacePath}`, "too-large");
538
+ }
539
+ const actualSha = createHash("sha256").update(data).digest("hex");
540
+ if (actualSha !== entry.sha) {
541
+ throw new SandboxFileError(`Downloaded content hash mismatch: ${workspacePath}`, "remote");
542
+ }
543
+ return {
544
+ path: workspacePath,
545
+ content,
546
+ sha: entry.sha,
547
+ size: data.length,
548
+ fileId: entry.fileId,
549
+ fileType: entry.fileType
550
+ };
551
+ }
552
+ async function writeSandboxFile(context, workspacePath, content, policy) {
553
+ const data = Buffer.from(content, "utf8");
554
+ if (data.length > MFS_FILE_SIZE_LIMIT_BYTES) {
555
+ throw new SandboxFileError(`File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${workspacePath}`, "too-large");
556
+ }
557
+ const resolved = resolveSandboxFilePath(context, workspacePath);
558
+ const tree = await fetchRepoTree(context, resolved);
559
+ const existing = findTreeEntry(tree, resolved);
560
+ if (existing?.type === "tree") {
561
+ throw new SandboxFileError(`${workspacePath} is a directory`, "directory");
562
+ }
563
+ if (existing && !existing.sha) {
564
+ throw new SandboxFileError(`${workspacePath} does not have base content hash`, "remote");
565
+ }
566
+ const baseContentHash = resolveBaseContentHash(workspacePath, existing, policy);
567
+ const contentHash = createHash("sha256").update(data).digest("hex");
568
+ const client = new SandboxMoxtClient(context);
569
+ const presign = await client.request(
570
+ "POST",
571
+ `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(resolved.repoId)}/presign-upload`,
572
+ { hashes: [contentHash] }
573
+ );
574
+ const uploadUrl = presign.data.urls[contentHash];
575
+ if (!uploadUrl) {
576
+ throw new SandboxFileError(`No upload URL returned for ${workspacePath}`, "remote");
577
+ }
578
+ const upload = await fetch(uploadUrl, {
579
+ method: "PUT",
580
+ headers: { "Content-Type": "application/octet-stream" },
581
+ body: new Uint8Array(data)
582
+ });
583
+ if (!upload.ok) {
584
+ throw new SandboxFileError(`Upload failed [${upload.status}]`, "remote");
585
+ }
586
+ const action = existing ? "update" : "create";
587
+ await client.request(
588
+ "POST",
589
+ "/agent/api/pipeline-sandbox/mfs-workspace/unified-push",
590
+ {
591
+ message: `moxt file write ${workspacePath}`,
592
+ repoPushes: [
593
+ {
594
+ repoId: resolved.repoId,
595
+ changes: [
596
+ {
597
+ action,
598
+ ...existing?.fileId ? { fileId: existing.fileId } : {},
599
+ path: resolved.repoPath,
600
+ contentHash,
601
+ ...baseContentHash ? { baseContentHash } : {},
602
+ size: data.length,
603
+ fileType: MFS_FILE_TYPE_REGULAR
604
+ }
605
+ ]
606
+ }
607
+ ],
608
+ crossRepoMoves: []
609
+ }
610
+ );
611
+ return { path: workspacePath, created: !existing };
612
+ }
613
+ function resolveBaseContentHash(workspacePath, existing, policy) {
614
+ if (policy.type === "force") return void 0;
615
+ if (policy.type === "use-current-sha") return existing?.sha ?? void 0;
616
+ if (policy.type === "create-only") {
617
+ if (!existing) return void 0;
618
+ throw new SandboxFileError(
619
+ `Existing file requires --base-sha or --force: ${workspacePath} (actual ${existing.sha})`,
620
+ "conflict"
621
+ );
622
+ }
623
+ if (existing?.sha === policy.sha) return policy.sha;
624
+ throw new SandboxFileError(
625
+ `File version conflict: ${workspacePath} (expected ${policy.sha}, actual ${existing?.sha ?? "missing"})`,
626
+ "conflict"
627
+ );
628
+ }
629
+ function resolveSandboxFilePath(context, workspacePath) {
630
+ let resolved;
631
+ try {
632
+ resolved = resolveWorkspacePath(context.repoPayload, workspacePath);
633
+ } catch (error) {
634
+ throw new SandboxFileError(error instanceof Error ? error.message : String(error), "invalid-path");
635
+ }
636
+ if (!resolved.repoPath) {
637
+ throw new SandboxFileError(`${workspacePath} is a directory`, "directory");
638
+ }
639
+ return resolved;
640
+ }
641
+ async function fetchRepoTree(context, resolved) {
642
+ const client = new SandboxMoxtClient(context);
643
+ const params = new URLSearchParams({ entryFilter: "paths", path: resolved.repoPath });
644
+ const response = await client.request(
645
+ "GET",
646
+ `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(resolved.repoId)}/tree?${params.toString()}`
647
+ );
648
+ return response.data;
649
+ }
650
+ function findTreeEntry(tree, resolved) {
651
+ return tree.entries.find((entry) => entry.path === resolved.repoPath);
652
+ }
653
+
275
654
  // src/utils/spinner.ts
276
655
  var frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
277
656
  var intervalId = null;
@@ -294,6 +673,23 @@ function stopSpinner(success, message) {
294
673
  `);
295
674
  }
296
675
 
676
+ // src/utils/write-content.ts
677
+ import * as fs from "fs";
678
+ function readWriteCommandContentOrExit(options, readContentFile, readStdin = () => fs.readFileSync(0, "utf8")) {
679
+ const inputModes = [options.content !== void 0, options.contentFile !== void 0, options.stdin === true].filter(Boolean);
680
+ if (inputModes.length > 1) {
681
+ printError("Only one of --content, --content-file, or --stdin can be used");
682
+ process.exit(1);
683
+ }
684
+ if (options.content !== void 0) {
685
+ return options.content;
686
+ }
687
+ if (options.contentFile !== void 0) {
688
+ return readContentFile(options.contentFile);
689
+ }
690
+ return readStdin();
691
+ }
692
+
297
693
  // src/commands/file.ts
298
694
  function addSpaceOptions(cmd) {
299
695
  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 +788,39 @@ function registerFileCommand(program2) {
392
788
  }
393
789
  }
394
790
  });
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) => {
791
+ 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) => {
792
+ const sandboxRuntime = resolveSandboxRuntimeContextOrExit();
793
+ if (sandboxRuntime) {
794
+ if (options.url) {
795
+ printError("--url cannot be used in sandbox mode");
796
+ process.exit(1);
797
+ }
798
+ const path2 = workspacePath ?? options.path;
799
+ if (!path2) {
800
+ printError("Workspace path is required in sandbox mode");
801
+ process.exit(1);
802
+ }
803
+ if (!options.json) startSpinner("Reading file...");
804
+ try {
805
+ const result = await readSandboxFile(sandboxRuntime, path2);
806
+ if (options.json) {
807
+ print(JSON.stringify(result, null, 2));
808
+ } else {
809
+ stopSpinner(true, "Done");
810
+ print(result.content);
811
+ }
812
+ } catch (error) {
813
+ handleSandboxFileError(error, path2);
814
+ }
815
+ return;
816
+ }
817
+ if (options.json) {
818
+ printError("--json is only available for sandbox workspace paths");
819
+ process.exit(1);
820
+ }
821
+ if (workspacePath && !options.path) {
822
+ options.path = workspacePath;
823
+ }
396
824
  const mode = runOrExit(() => resolveReadMode(options));
397
825
  startSpinner("Reading file...");
398
826
  let response;
@@ -428,29 +856,37 @@ function registerFileCommand(program2) {
428
856
  stopSpinner(true, "Done");
429
857
  print(content ?? "");
430
858
  });
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);
859
+ 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(
860
+ async (workspacePath, options) => {
861
+ const sandboxRuntime = resolveSandboxRuntimeContextOrExit();
862
+ if (sandboxRuntime) {
863
+ if (options.url) {
864
+ printError("--url cannot be used in sandbox mode");
865
+ process.exit(1);
866
+ }
867
+ const path2 = workspacePath ?? options.path;
868
+ if (!path2) {
869
+ printError("Workspace path is required in sandbox mode");
870
+ process.exit(1);
871
+ }
872
+ const { content: content2 } = readLocalTextFileOrExit(options.localPath);
873
+ startSpinner("Uploading...");
874
+ try {
875
+ const response2 = await writeSandboxFile(sandboxRuntime, path2, content2, {
876
+ type: "use-current-sha"
877
+ });
878
+ const action2 = response2.created ? "Created" : "Updated";
879
+ stopSpinner(true, `${action2}: ${response2.path}`);
880
+ } catch (error) {
881
+ handleSandboxFileError(error, path2);
882
+ }
883
+ return;
447
884
  }
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);
885
+ if (workspacePath && !options.path) {
886
+ options.path = workspacePath;
452
887
  }
453
- const content = buffer2.toString("utf8");
888
+ const mode = runOrExit(() => resolveWriteMode(options));
889
+ const { content } = readLocalTextFileOrExit(options.localPath);
454
890
  startSpinner("Uploading...");
455
891
  let response;
456
892
  let displayPath;
@@ -492,6 +928,23 @@ function registerFileCommand(program2) {
492
928
  stopSpinner(true, `${action}: ${response.data.path}`);
493
929
  }
494
930
  );
931
+ 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) => {
932
+ const runtime = resolveSandboxRuntimeContextOrExit();
933
+ if (!runtime) {
934
+ printError("file write is only available in sandbox mode");
935
+ process.exit(1);
936
+ }
937
+ const policy = resolveSandboxFileWritePolicyOrExit(options);
938
+ const content = readWriteCommandContentOrExit(options, (path2) => readLocalTextFileOrExit(path2).content);
939
+ startSpinner("Writing file...");
940
+ try {
941
+ const response = await writeSandboxFile(runtime, workspacePath, content, policy);
942
+ const action = response.created ? "Created" : "Updated";
943
+ stopSpinner(true, `${action}: ${response.path}`);
944
+ } catch (error) {
945
+ handleSandboxFileError(error, workspacePath);
946
+ }
947
+ });
495
948
  addSpaceOptions(
496
949
  file.command("mkdir").description("Create a directory").requiredOption("-p, --path <path>", "Directory path").option("-r, --recursive", "Create parent directories if needed")
497
950
  ).action(
@@ -549,6 +1002,82 @@ function registerFileCommand(program2) {
549
1002
  }
550
1003
  );
551
1004
  }
1005
+ function resolveSandboxFileWritePolicyOrExit(options) {
1006
+ if (options.baseSha && options.force) {
1007
+ printError("Only one of --base-sha or --force can be used");
1008
+ process.exit(1);
1009
+ }
1010
+ if (options.baseSha) {
1011
+ if (!/^[a-f0-9]{64}$/.test(options.baseSha)) {
1012
+ printError("--base-sha must be a lowercase SHA-256 hash");
1013
+ process.exit(1);
1014
+ }
1015
+ return { type: "base-sha", sha: options.baseSha };
1016
+ }
1017
+ return options.force ? { type: "force" } : { type: "create-only" };
1018
+ }
1019
+ function resolveRuntimeContextOrExit() {
1020
+ try {
1021
+ return resolveRuntimeContext();
1022
+ } catch (error) {
1023
+ if (error instanceof RuntimeContextError) {
1024
+ printError(error.message);
1025
+ process.exit(1);
1026
+ }
1027
+ throw error;
1028
+ }
1029
+ }
1030
+ function resolveSandboxRuntimeContextOrExit() {
1031
+ if (!SANDBOX_ENV_KEYS.some((key) => (process.env[key] ?? "").trim().length > 0)) {
1032
+ return void 0;
1033
+ }
1034
+ const context = resolveRuntimeContextOrExit();
1035
+ return context.mode === "sandbox" ? context : void 0;
1036
+ }
1037
+ function readLocalTextFileOrExit(localPath) {
1038
+ if (!fs2.existsSync(localPath)) {
1039
+ print(`${colors.red}\u2620 Local file not found: ${localPath}${colors.reset}`);
1040
+ process.exit(1);
1041
+ }
1042
+ const stats = fs2.statSync(localPath);
1043
+ if (!stats.isFile()) {
1044
+ print(`${colors.red}\u2620 Not a file: ${localPath}${colors.reset}`);
1045
+ process.exit(1);
1046
+ }
1047
+ if (stats.size > MFS_FILE_SIZE_LIMIT_BYTES) {
1048
+ print(`${colors.red}\u2620 File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${localPath}${colors.reset}`);
1049
+ process.exit(1);
1050
+ }
1051
+ const buffer2 = fs2.readFileSync(localPath);
1052
+ if (isBinaryContent(buffer2)) {
1053
+ print(`${colors.red}\u2620 Binary files are not allowed. Only text files can be uploaded.${colors.reset}`);
1054
+ process.exit(1);
1055
+ }
1056
+ return { content: buffer2.toString("utf8") };
1057
+ }
1058
+ function handleSandboxFileError(error, displayPath) {
1059
+ stopSpinner(false, "Failed");
1060
+ if (error instanceof SandboxFileError) {
1061
+ if (error.code === "not-found") {
1062
+ print(`${colors.red}\u2620 ${displayPath} does not exist${colors.reset}`);
1063
+ } else if (error.code === "directory") {
1064
+ print(`${colors.red}\u2620 ${displayPath} is a directory${colors.reset}`);
1065
+ } else {
1066
+ printError(error.message);
1067
+ }
1068
+ process.exit(1);
1069
+ }
1070
+ if (error instanceof SandboxApiError) {
1071
+ printError(`Sandbox API error [${error.status}]: ${error.body}`);
1072
+ process.exit(1);
1073
+ }
1074
+ if (error instanceof Error) {
1075
+ printError(`Sandbox file operation failed: ${error.message}`);
1076
+ process.exit(1);
1077
+ }
1078
+ printError(`Sandbox file operation failed: ${String(error)}`);
1079
+ process.exit(1);
1080
+ }
552
1081
 
553
1082
  // src/commands/memory.ts
554
1083
  function registerMemoryCommand(program2) {
@@ -744,2213 +1273,1343 @@ function registerMiniappCommand(program2) {
744
1273
  });
745
1274
  }
746
1275
 
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";
1276
+ // src/commands/pull.ts
1277
+ import { isAbsolute as isAbsolute3 } from "path";
1278
+ import { Option } from "commander";
1279
+
1280
+ // src/utils/sandbox-pull.ts
1281
+ import { createHash as createHash2 } from "crypto";
1282
+ import { lstat, mkdir as mkdir2, readFile as readFile2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
1283
+ import { dirname as dirname2, isAbsolute as isAbsolute2, relative, resolve } from "path";
751
1284
 
752
- // src/run/capture.ts
1285
+ // src/utils/checkout-state.ts
753
1286
  import { randomUUID } from "crypto";
754
- import { access, mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
755
1287
  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;
1288
+ import { dirname, isAbsolute, join, posix } from "path";
1289
+ import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
1290
+ var CheckoutStateError = class extends Error {
1291
+ constructor(message) {
1292
+ super(message);
1293
+ this.name = "CheckoutStateError";
784
1294
  }
785
1295
  };
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");
1296
+ function getCheckoutStatePath() {
1297
+ return join(homedir(), ".moxt", "checkout-state.json");
795
1298
  }
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
- };
805
- }
806
- async function prepareCaptureOutputs(env, runId) {
807
- const { outputDir, outputRoot } = captureEnvPaths(env);
808
- if (hasValue(outputDir)) {
809
- await prepareOutputPath(outputDir, () => prepareCaptureDirectory(outputDir));
1299
+ async function loadCheckoutState(statePath = getCheckoutStatePath()) {
1300
+ let raw;
1301
+ try {
1302
+ raw = await readFile(statePath, "utf8");
1303
+ } catch (error) {
1304
+ if (isNodeError(error) && error.code === "ENOENT") return void 0;
1305
+ throw new CheckoutStateError(`Failed to read checkout state: ${errorMessage(error)}`);
810
1306
  }
811
- if (hasValue(outputRoot)) {
812
- const runDir = join(outputRoot, runId);
813
- await prepareOutputPath(runDir, () => prepareCaptureDirectory(runDir));
1307
+ let value;
1308
+ try {
1309
+ value = JSON.parse(raw);
1310
+ } catch {
1311
+ throw new CheckoutStateError(`Checkout state is not valid JSON: ${statePath}`);
814
1312
  }
1313
+ return validateCheckoutState(value);
815
1314
  }
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));
1315
+ async function saveCheckoutState(state, statePath = getCheckoutStatePath()) {
1316
+ const validState = validateCheckoutState(state);
1317
+ const stateDir = dirname(statePath);
1318
+ const temporaryPath = `${statePath}.${process.pid}.${randomUUID()}.tmp`;
1319
+ await mkdir(stateDir, { recursive: true });
1320
+ try {
1321
+ await writeFile(temporaryPath, `${JSON.stringify(validState, null, 2)}
1322
+ `, { mode: 384 });
1323
+ await rename(temporaryPath, statePath);
1324
+ } catch (error) {
1325
+ await rm(temporaryPath, { force: true });
1326
+ throw new CheckoutStateError(`Failed to save checkout state: ${errorMessage(error)}`);
824
1327
  }
825
- return [...dirs];
826
1328
  }
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
- })
1329
+ function isCheckoutPathIncluded(state, workspacePath) {
1330
+ if (state.mode === "full") return true;
1331
+ return state.checkedOutScopes.some(
1332
+ (scope) => workspacePath === scope || workspacePath.startsWith(`${scope}/`)
833
1333
  );
834
1334
  }
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
- })
1335
+ function doesCheckoutPathIntersect(state, workspacePath) {
1336
+ if (state.mode === "full") return true;
1337
+ return state.checkedOutScopes.some(
1338
+ (scope) => workspacePath === scope || workspacePath.startsWith(`${scope}/`) || scope.startsWith(`${workspacePath}/`)
842
1339
  );
843
1340
  }
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
- })
1341
+ function validateCheckoutState(value) {
1342
+ const state = requireRecord(value, "Checkout state");
1343
+ if (state.version !== 1) {
1344
+ throw new CheckoutStateError(`Unsupported checkout state version: ${String(state.version)}`);
1345
+ }
1346
+ const workspaceId = requireNonEmptyString(state.workspaceId, "workspaceId");
1347
+ const checkoutRoot = requireNonEmptyString(state.checkoutRoot, "checkoutRoot");
1348
+ if (!isAbsolute(checkoutRoot)) throw new CheckoutStateError("checkoutRoot must be an absolute path");
1349
+ if (state.mode !== "partial" && state.mode !== "full") {
1350
+ throw new CheckoutStateError("mode must be partial or full");
1351
+ }
1352
+ if (!Array.isArray(state.checkedOutScopes)) {
1353
+ throw new CheckoutStateError("checkedOutScopes must be an array");
1354
+ }
1355
+ const checkedOutScopes = state.checkedOutScopes.map(
1356
+ (scope, index) => requireWorkspacePath(scope, `checkedOutScopes[${index}]`)
868
1357
  );
869
- }
870
- function serializeEventsFromTranscript(transcript, startSeq, agent = "claude") {
871
- const events = normalizeEvents(transcript, startSeq, agent);
1358
+ if (new Set(checkedOutScopes).size !== checkedOutScopes.length) {
1359
+ throw new CheckoutStateError("checkedOutScopes must not contain duplicates");
1360
+ }
1361
+ const reposValue = requireRecord(state.repos, "repos");
1362
+ const repos = {};
1363
+ for (const [repoId, rawRepo] of Object.entries(reposValue)) {
1364
+ requireNonEmptyString(repoId, "repo id");
1365
+ const repo = requireRecord(rawRepo, `repos.${repoId}`);
1366
+ const name = requireRepoName(repo.name, `repos.${repoId}.name`);
1367
+ repos[repoId] = {
1368
+ name,
1369
+ treeSha: requireNonEmptyString(repo.treeSha, `repos.${repoId}.treeSha`)
1370
+ };
1371
+ }
1372
+ const filesValue = requireRecord(state.files, "files");
1373
+ const files = {};
1374
+ for (const [workspacePath, rawFile] of Object.entries(filesValue)) {
1375
+ requireWorkspacePath(workspacePath, `files.${workspacePath}`);
1376
+ const file = requireRecord(rawFile, `files.${workspacePath}`);
1377
+ const repoId = requireNonEmptyString(file.repoId, `files.${workspacePath}.repoId`);
1378
+ const repo = repos[repoId];
1379
+ if (!repo) throw new CheckoutStateError(`File references unknown repo: ${workspacePath}`);
1380
+ const repoPath = requireWorkspacePath(file.repoPath, `files.${workspacePath}.repoPath`);
1381
+ if (workspacePath !== `${repo.name}/${repoPath}`) {
1382
+ throw new CheckoutStateError(`File path does not match its repo: ${workspacePath}`);
1383
+ }
1384
+ const fileId = file.fileId === null ? null : requireNonEmptyString(file.fileId, `files.${workspacePath}.fileId`);
1385
+ const sha = requireNonEmptyString(file.sha, `files.${workspacePath}.sha`);
1386
+ if (!/^[a-f0-9]{64}$/.test(sha)) throw new CheckoutStateError(`Invalid file SHA: ${workspacePath}`);
1387
+ files[workspacePath] = {
1388
+ repoId,
1389
+ repoPath,
1390
+ fileId,
1391
+ sha,
1392
+ size: requireNonNegativeInteger(file.size, `files.${workspacePath}.size`),
1393
+ fileType: requireNonNegativeInteger(file.fileType, `files.${workspacePath}.fileType`)
1394
+ };
1395
+ }
872
1396
  return {
873
- text: serializeEvents(events),
874
- nextSeq: startSeq + events.length
1397
+ version: 1,
1398
+ workspaceId,
1399
+ checkoutRoot,
1400
+ mode: state.mode,
1401
+ checkedOutScopes,
1402
+ repos,
1403
+ files
875
1404
  };
876
1405
  }
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;
1406
+ function requireRecord(value, field) {
1407
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1408
+ throw new CheckoutStateError(`${field} must be an object`);
910
1409
  }
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;
1410
+ return value;
1411
+ }
1412
+ function requireNonEmptyString(value, field) {
1413
+ if (typeof value !== "string" || value.length === 0) {
1414
+ throw new CheckoutStateError(`${field} must be a non-empty string`);
918
1415
  }
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");
1416
+ return value;
1417
+ }
1418
+ function requireWorkspacePath(value, field) {
1419
+ const path2 = requireNonEmptyString(value, field);
1420
+ if (path2.includes("\\") || path2.includes("\0") || posix.isAbsolute(path2) || posix.normalize(path2) !== path2) {
1421
+ throw new CheckoutStateError(`${field} must be a normalized relative path`);
925
1422
  }
1423
+ return path2;
926
1424
  }
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;
1425
+ function requireRepoName(value, field) {
1426
+ const name = requireWorkspacePath(value, field);
1427
+ if (name.includes("/")) throw new CheckoutStateError(`${field} must be a single path segment`);
1428
+ return name;
945
1429
  }
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`);
1430
+ function requireNonNegativeInteger(value, field) {
1431
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
1432
+ throw new CheckoutStateError(`${field} must be a non-negative integer`);
956
1433
  }
1434
+ return value;
957
1435
  }
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
- );
1436
+ function isNodeError(error) {
1437
+ return error instanceof Error && "code" in error;
992
1438
  }
993
- async function writeCaptureUploadFile(outputDir, runId) {
994
- const path2 = join(outputDir, "upload.json");
995
- try {
996
- await access(path2);
997
- return;
998
- } catch {
999
- }
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
- return {
1024
- path: chunk.path,
1025
- bytes: chunk.bytes,
1026
- lines: chunk.lines,
1027
- oversized_line: chunk.oversized_line
1028
- };
1439
+ function errorMessage(error) {
1440
+ return error instanceof Error ? error.message : String(error);
1029
1441
  }
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;
1442
+
1443
+ // src/utils/sandbox-pull.ts
1444
+ var SandboxPullError = class extends Error {
1445
+ constructor(message, code) {
1446
+ super(message);
1447
+ this.code = code;
1448
+ this.name = "SandboxPullError";
1449
+ }
1450
+ code;
1451
+ };
1452
+ async function pullSandboxWorkspace(context, targetDir, options = {}) {
1453
+ const resolvedTargetDir = resolve(targetDir);
1454
+ const repos = listRepoEntries(context.repoPayload);
1455
+ const previousState = await loadCheckoutState();
1456
+ const compatibleState = resolveCompatibleState(previousState, context, resolvedTargetDir, repos);
1457
+ const previousFiles = compatibleState?.files ?? {};
1458
+ const selection = resolvePullSelection(context, options.scopes);
1459
+ const repoById = new Map(selection.repos.map((repo) => [repo.repoId, repo]));
1460
+ const tree = await fetchWorkspaceTree(context, selection);
1461
+ const stateRepos = resolveStateRepos(tree, selection.repos);
1462
+ const entries = [...tree.entries].sort((a, b) => a.prefixedPath.localeCompare(b.prefixedPath));
1463
+ const directoryPaths = selection.repos.map((repo) => ({
1464
+ workspacePath: repo.name,
1465
+ localPath: resolveSafeLocalPath(resolvedTargetDir, repo.name, "")
1466
+ }));
1467
+ const blobEntries = [];
1468
+ const remoteBlobPaths = /* @__PURE__ */ new Set();
1469
+ const remotePaths = /* @__PURE__ */ new Set();
1470
+ const matchedScopes = /* @__PURE__ */ new Set();
1471
+ for (const entry of entries) {
1472
+ const repo = repoById.get(entry.repoId);
1473
+ if (!repo) {
1474
+ throw new SandboxPullError(`Remote tree returned unknown repo: ${entry.repoId}`, "remote");
1046
1475
  }
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;
1476
+ const expectedWorkspacePath = entry.path ? `${repo.name}/${entry.path}` : repo.name;
1477
+ if (entry.prefixedPath !== expectedWorkspacePath) {
1478
+ throw new SandboxPullError(`Remote tree returned inconsistent path: ${entry.prefixedPath}`, "remote");
1070
1479
  }
1071
- if (bytes > 0 && bytes + lineBytes > DATA_FILE_TARGET_BYTES) {
1072
- flush2();
1480
+ if (remotePaths.has(expectedWorkspacePath)) {
1481
+ throw new SandboxPullError(`Remote tree returned duplicate path: ${expectedWorkspacePath}`, "remote");
1073
1482
  }
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 [];
1085
- }
1086
- return normalized.slice(0, -1).split("\n").map((line) => `${line}
1087
- `);
1088
- }
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() === "") {
1483
+ remotePaths.add(expectedWorkspacePath);
1484
+ const localPath = resolveSafeLocalPath(resolvedTargetDir, repo.name, entry.path);
1485
+ for (const scope of selection.scopes) {
1486
+ if (pathIsIncluded(scope, expectedWorkspacePath)) matchedScopes.add(scope);
1487
+ }
1488
+ if (!selection.scopes.some((scope) => pathsIntersect(scope, expectedWorkspacePath))) continue;
1489
+ if (entry.type === "tree") {
1490
+ directoryPaths.push({ workspacePath: entry.prefixedPath, localPath });
1100
1491
  continue;
1101
1492
  }
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);
1493
+ if (!entry.sha) {
1494
+ throw new SandboxPullError(`${entry.prefixedPath} does not have downloadable content`, "remote");
1106
1495
  }
1107
- }
1108
- return events;
1109
- }
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;
1496
+ if (entry.fileId !== null && (typeof entry.fileId !== "string" || entry.fileId.length === 0)) {
1497
+ throw new SandboxPullError(`${entry.prefixedPath} has invalid file metadata`, "remote");
1120
1498
  }
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;
1499
+ if (entry.fileType !== null && (!Number.isInteger(entry.fileType) || entry.fileType < 0)) {
1500
+ throw new SandboxPullError(`${entry.prefixedPath} has invalid file metadata`, "remote");
1134
1501
  }
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;
1502
+ if (entry.size !== null && (!Number.isInteger(entry.size) || entry.size < 0)) {
1503
+ throw new SandboxPullError(`${entry.prefixedPath} has invalid file metadata`, "remote");
1175
1504
  }
1176
- return {
1177
- schema_version: "runlog.events.v1",
1178
- seq,
1179
- type: "user_message",
1180
- ...timestamp === void 0 ? {} : { timestamp },
1181
- text
1182
- };
1183
- }
1184
- if (payloadType === "agent_message") {
1185
- const text = stringValue(payload?.message) ?? "";
1186
- if (text === "") {
1187
- return null;
1505
+ if (entry.size !== null && entry.size > MFS_FILE_SIZE_LIMIT_BYTES) {
1506
+ throw new SandboxPullError(`File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${entry.prefixedPath}`, "too-large");
1188
1507
  }
1189
- return {
1190
- schema_version: "runlog.events.v1",
1191
- seq,
1192
- type: "assistant_message",
1193
- ...timestamp === void 0 ? {} : { timestamp },
1194
- text
1195
- };
1508
+ remoteBlobPaths.add(entry.prefixedPath);
1509
+ blobEntries.push({ entry, localPath });
1196
1510
  }
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
- };
1511
+ if (selection.mode === "partial") {
1512
+ for (const scope of selection.scopes) {
1513
+ const repoRoot = selection.repos.some((repo) => repo.name === scope);
1514
+ const previouslyTracked = Object.keys(previousFiles).some((workspacePath) => pathIsIncluded(scope, workspacePath));
1515
+ if (!repoRoot && !matchedScopes.has(scope) && !previouslyTracked) {
1516
+ throw new SandboxPullError(`Workspace path does not exist: ${scope}`, "invalid-path");
1517
+ }
1518
+ }
1206
1519
  }
1207
- return null;
1208
- }
1209
- function parseTranscriptLine(line) {
1210
- try {
1211
- const value = JSON.parse(line);
1212
- return isObject(value) ? value : null;
1213
- } catch {
1214
- return null;
1520
+ const plannedBlobs = [];
1521
+ for (const { entry, localPath } of blobEntries) {
1522
+ plannedBlobs.push({
1523
+ entry,
1524
+ localPath,
1525
+ shouldWrite: await shouldWritePulledFile(
1526
+ localPath,
1527
+ requireBlobSha(entry),
1528
+ entry.prefixedPath,
1529
+ previousFiles[entry.prefixedPath],
1530
+ Boolean(options.force)
1531
+ )
1532
+ });
1215
1533
  }
1216
- }
1217
- function textFromContent(content) {
1218
- if (typeof content === "string") {
1219
- return content;
1534
+ const plannedDeletions = await planRemoteDeletions(
1535
+ compatibleState,
1536
+ selection,
1537
+ resolvedTargetDir,
1538
+ remoteBlobPaths,
1539
+ Boolean(options.force)
1540
+ );
1541
+ for (const directory of directoryPaths) {
1542
+ await assertCanCreateDirectory(directory.localPath, directory.workspacePath);
1220
1543
  }
1221
- if (!Array.isArray(content)) {
1222
- return "";
1544
+ const blobsToDownload = plannedBlobs.filter((planned) => planned.shouldWrite);
1545
+ const downloadUrls = await presignDownloads(context, blobsToDownload.map(({ entry }) => entry));
1546
+ const downloadedEntries = [];
1547
+ for (const planned of blobsToDownload) {
1548
+ const { entry, localPath } = planned;
1549
+ const remoteContent = await downloadBlob(entry, downloadUrls);
1550
+ downloadedEntries.push({ ...planned, content: remoteContent });
1223
1551
  }
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;
1246
- }
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 !== "";
1255
- }
1256
- function errorMessage(error) {
1257
- if (error instanceof Error && error.message !== "") {
1258
- return error.message;
1552
+ for (const deletion of plannedDeletions) {
1553
+ await rm2(deletion.localPath);
1259
1554
  }
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)}>`
1555
+ for (const directory of directoryPaths) {
1556
+ await mkdir2(directory.localPath, { recursive: true });
1338
1557
  }
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
- });
1558
+ for (const downloaded of downloadedEntries) {
1559
+ await writePulledFile(downloaded.localPath, downloaded.content);
1350
1560
  }
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
- });
1561
+ const downloadedContentByPath = new Map(
1562
+ downloadedEntries.map(({ entry, content }) => [entry.prefixedPath, content])
1563
+ );
1564
+ const pulledFiles = Object.fromEntries(await Promise.all(plannedBlobs.map(async ({ entry, localPath }) => {
1565
+ const content = downloadedContentByPath.get(entry.prefixedPath);
1566
+ return [
1567
+ entry.prefixedPath,
1568
+ {
1569
+ repoId: entry.repoId,
1570
+ repoPath: entry.path,
1571
+ fileId: entry.fileId,
1572
+ sha: requireBlobSha(entry),
1573
+ size: content?.length ?? entry.size ?? previousFiles[entry.prefixedPath]?.size ?? await localFileSize(localPath, entry.prefixedPath),
1574
+ fileType: entry.fileType ?? 1
1575
+ }
1576
+ ];
1577
+ })));
1578
+ await saveCheckoutState(buildNextCheckoutState({
1579
+ context,
1580
+ checkoutRoot: resolvedTargetDir,
1581
+ allRepos: repos,
1582
+ selection,
1583
+ previousState: compatibleState,
1584
+ pulledRepos: stateRepos,
1585
+ pulledFiles
1586
+ }));
1358
1587
  return {
1359
- text: redacted,
1360
- hits: [...hitCounts.entries()].map(([rule, count]) => ({ rule, count }))
1588
+ fileCount: blobEntries.length,
1589
+ targetDir: resolvedTargetDir
1361
1590
  };
1362
1591
  }
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);
1592
+ function resolveCompatibleState(state, context, checkoutRoot, repos) {
1593
+ if (!state || state.workspaceId !== context.workspaceId || state.checkoutRoot !== checkoutRoot) return void 0;
1594
+ const currentRepos = new Map(repos.map((repo) => [repo.repoId, repo.name]));
1595
+ const stateMatchesRepos = Object.entries(state.repos).every(
1596
+ ([repoId, repo]) => currentRepos.get(repoId) === repo.name
1597
+ );
1598
+ return stateMatchesRepos ? state : void 0;
1368
1599
  }
1369
-
1370
- // src/run/sanitize.ts
1371
- function stripRemoteCredentials(remote) {
1372
- if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(remote)) {
1373
- return remote;
1600
+ function resolvePullSelection(context, requestedScopes) {
1601
+ const repos = listRepoEntries(context.repoPayload);
1602
+ if (!requestedScopes || requestedScopes.length === 0) {
1603
+ return { mode: "full", scopes: repos.map((repo) => repo.name), repos };
1374
1604
  }
1375
- try {
1376
- const url = new URL(remote);
1377
- url.username = "";
1378
- url.password = "";
1379
- return url.toString();
1380
- } catch {
1381
- return remote;
1382
- }
1383
- }
1384
- function sanitizeRepo(repo) {
1385
- if (repo === null) return null;
1605
+ const resolvedScopes = requestedScopes.map((scope) => {
1606
+ const resolved = resolveWorkspacePath(context.repoPayload, scope);
1607
+ return {
1608
+ path: resolved.repoPath ? `${resolved.repoName}/${resolved.repoPath}` : resolved.repoName,
1609
+ repoId: resolved.repoId
1610
+ };
1611
+ });
1612
+ const scopes = collapseScopes(resolvedScopes.map((scope) => scope.path));
1613
+ const repoIds = new Set(resolvedScopes.map((scope) => scope.repoId));
1386
1614
  return {
1387
- ...repo,
1388
- remote: stripRemoteCredentials(repo.remote)
1615
+ mode: "partial",
1616
+ scopes,
1617
+ repos: repos.filter((repo) => repoIds.has(repo.repoId))
1389
1618
  };
1390
1619
  }
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;
1413
- }
1414
- if (!options.newFilesOnly && current2.size > previous.size) {
1415
- candidates.push({ path: path2, startOffset: previous.size });
1416
- }
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
- };
1433
- }
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 };
1620
+ function buildNextCheckoutState(input) {
1621
+ const { context, checkoutRoot, allRepos, selection, previousState, pulledRepos, pulledFiles } = input;
1622
+ if (selection.mode === "full") {
1623
+ return {
1624
+ version: 1,
1625
+ workspaceId: context.workspaceId,
1626
+ checkoutRoot,
1627
+ mode: "full",
1628
+ checkedOutScopes: selection.scopes,
1629
+ repos: pulledRepos,
1630
+ files: pulledFiles
1631
+ };
1445
1632
  }
1633
+ const previousFiles = previousState?.files ?? {};
1634
+ const preservedFiles = Object.fromEntries(
1635
+ Object.entries(previousFiles).filter(
1636
+ ([workspacePath]) => !selection.scopes.some((scope) => pathIsIncluded(scope, workspacePath))
1637
+ )
1638
+ );
1639
+ const checkedOutScopes = collapseScopes([...previousState?.checkedOutScopes ?? [], ...selection.scopes]);
1640
+ const mode = allRepos.every((repo) => checkedOutScopes.includes(repo.name)) ? "full" : "partial";
1446
1641
  return {
1447
- match: "unique",
1448
- sessionFile,
1449
- startOffset: previous?.size ?? 0
1642
+ version: 1,
1643
+ workspaceId: context.workspaceId,
1644
+ checkoutRoot,
1645
+ mode,
1646
+ checkedOutScopes,
1647
+ repos: { ...previousState?.repos ?? {}, ...pulledRepos },
1648
+ files: { ...preservedFiles, ...pulledFiles }
1450
1649
  };
1451
1650
  }
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;
1470
- }
1471
- if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
1472
- continue;
1651
+ function collapseScopes(scopes) {
1652
+ const result = [];
1653
+ for (const scope of scopes) {
1654
+ if (result.some((existing) => pathIsIncluded(existing, scope))) continue;
1655
+ for (let index = result.length - 1; index >= 0; index--) {
1656
+ if (pathIsIncluded(scope, result[index])) result.splice(index, 1);
1473
1657
  }
1474
- const stat2 = statSync2(path2);
1475
- snapshot.set(path2, {
1476
- mtimeMs: stat2.mtimeMs,
1477
- size: stat2.size
1478
- });
1658
+ result.push(scope);
1479
1659
  }
1660
+ return result;
1480
1661
  }
1481
- function wasChangedAfterStart(current2, startedAtMs) {
1482
- return current2.mtimeMs >= startedAtMs - SESSION_START_TOLERANCE_MS;
1662
+ function pathsIntersect(left, right) {
1663
+ return pathIsIncluded(left, right) || pathIsIncluded(right, left);
1483
1664
  }
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;
1665
+ function pathIsIncluded(scope, workspacePath) {
1666
+ return workspacePath === scope || workspacePath.startsWith(`${scope}/`);
1495
1667
  }
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" };
1668
+ function resolveStateRepos(tree, repos) {
1669
+ const knownRepoIds = new Set(repos.map((repo) => repo.repoId));
1670
+ for (const repoId of Object.keys(tree.repos)) {
1671
+ if (!knownRepoIds.has(repoId)) {
1672
+ throw new SandboxPullError(`Remote tree returned unknown repo: ${repoId}`, "remote");
1512
1673
  }
1513
- const signal = event.code - 128;
1514
- return {
1515
- code: event.code,
1516
- status: event.code > 128 && observedSignals.has(signal) ? "cancelled" : "failed"
1517
- };
1518
1674
  }
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;
1675
+ return Object.fromEntries(repos.map((repo) => {
1676
+ const remoteRepo = tree.repos[repo.repoId];
1677
+ if (!remoteRepo) {
1678
+ throw new SandboxPullError(`Remote tree did not return repo: ${repo.repoId}`, "remote");
1574
1679
  }
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);
1680
+ if (remoteRepo.prefix !== repo.name) {
1681
+ throw new SandboxPullError(`Remote tree returned unexpected prefix for repo ${repo.repoId}`, "remote");
1580
1682
  }
1581
- }
1582
- async stop() {
1583
- if (!this.enabled) {
1584
- return;
1585
- }
1586
- if (this.timer !== void 0) {
1587
- clearInterval(this.timer);
1588
- this.timer = void 0;
1589
- }
1590
- if (this.pollInFlight !== null) {
1591
- await this.pollInFlight;
1592
- }
1593
- await this.poll();
1594
- await this.flushPendingText();
1595
- }
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()));
1602
- }
1603
- await finalizeCaptureDirectories(this.directoryPayload(payload), this.env, this.runId, capturedAt, this.files());
1604
- }
1605
- queuePoll() {
1606
- if (this.pollInFlight !== null) {
1607
- return;
1608
- }
1609
- this.pollInFlight = this.poll().finally(() => {
1610
- this.pollInFlight = null;
1611
- });
1612
- }
1613
- async poll() {
1614
- if (!this.baselineOk || this.captureFailed) {
1615
- return;
1683
+ if (typeof remoteRepo.sha !== "string" || remoteRepo.sha.length === 0) {
1684
+ throw new SandboxPullError(`Remote tree did not return a version for repo ${repo.repoId}`, "remote");
1616
1685
  }
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
- }
1657
- }
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);
1668
- }
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);
1679
- }
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
- );
1699
- }
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
- };
1709
- }
1710
- files() {
1711
- const [output] = this.outputs;
1712
- if (output === void 0) {
1713
- return { events: [], transcript: [] };
1714
- }
1715
- return output.files();
1716
- }
1717
- matchOptions() {
1718
- return {
1719
- ...this.isSessionCandidate === void 0 ? {} : { isCandidate: this.isSessionCandidate },
1720
- ...this.newSessionFilesOnly ? { newFilesOnly: true } : {}
1721
- };
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() {
1741
- return {
1742
- events: this.events.files(),
1743
- transcript: this.transcript.files()
1744
- };
1745
- }
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";
1686
+ return [repo.repoId, { name: repo.name, treeSha: remoteRepo.sha }];
1687
+ }));
1812
1688
  }
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
- `);
1821
- }
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 };
1689
+ async function fetchWorkspaceTree(context, selection) {
1690
+ const client = new SandboxMoxtClient(context);
1691
+ const results = await Promise.all(selection.repos.map(async (repo) => {
1692
+ const filter = resolveRepoTreeFilter(repo, selection);
1693
+ const params = new URLSearchParams({ entryFilter: filter.type });
1694
+ if (filter.type === "paths") {
1695
+ for (const path2 of filter.paths) params.append("path", path2);
1829
1696
  }
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();
1835
- }
1697
+ const response = await client.request(
1698
+ "GET",
1699
+ `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(repo.repoId)}/tree?${params.toString()}`
1700
+ );
1701
+ return { repo, tree: response.data };
1702
+ }));
1703
+ return {
1704
+ repos: Object.fromEntries(
1705
+ results.map(({ repo, tree }) => [repo.repoId, { sha: tree.sha, prefix: repo.name }])
1706
+ ),
1707
+ entries: results.flatMap(
1708
+ ({ repo, tree }) => tree.entries.map((entry) => ({
1709
+ ...entry,
1710
+ repoId: repo.repoId,
1711
+ prefixedPath: entry.path ? `${repo.name}/${entry.path}` : repo.name
1712
+ }))
1713
+ )
1714
+ };
1836
1715
  }
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;
1716
+ function resolveRepoTreeFilter(repo, selection) {
1717
+ if (selection.mode === "full") return { type: "all" };
1718
+ const repoPaths = [];
1719
+ for (const scope of selection.scopes) {
1720
+ if (scope === repo.name) return { type: "all" };
1721
+ if (scope.startsWith(`${repo.name}/`)) repoPaths.push(scope.slice(repo.name.length + 1));
1850
1722
  }
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;
1723
+ if (repoPaths.length === 0) {
1724
+ throw new SandboxPullError(`No pull scope resolved for repo: ${repo.name}`, "invalid-path");
1856
1725
  }
1857
- return headers;
1726
+ return { type: "paths", paths: repoPaths };
1858
1727
  }
1859
- function firstValue(...values) {
1860
- return values.find((value) => value !== void 0 && value !== "");
1861
- }
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;
1890
- }
1891
- this.timer = setInterval(() => {
1892
- this.queueUpload();
1893
- }, this.config.intervalMs);
1894
- this.queueUpload();
1895
- }
1896
- async stop() {
1897
- if (this.timer !== void 0) {
1898
- clearInterval(this.timer);
1899
- this.timer = void 0;
1900
- }
1901
- if (this.uploadInFlight !== null) {
1902
- try {
1903
- await this.uploadInFlight;
1904
- } catch {
1728
+ async function presignDownloads(context, entries) {
1729
+ const client = new SandboxMoxtClient(context);
1730
+ const entriesByRepo = /* @__PURE__ */ new Map();
1731
+ for (const entry of entries) {
1732
+ const existing = entriesByRepo.get(entry.repoId) ?? [];
1733
+ existing.push(entry);
1734
+ entriesByRepo.set(entry.repoId, existing);
1735
+ }
1736
+ const urls = /* @__PURE__ */ new Map();
1737
+ for (const [repoId, repoEntries] of entriesByRepo) {
1738
+ const hashes = [...new Set(repoEntries.map((entry) => entry.sha).filter((sha) => Boolean(sha)))];
1739
+ if (hashes.length === 0) continue;
1740
+ const response = await client.request(
1741
+ "POST",
1742
+ `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(repoId)}/presign-download`,
1743
+ { hashes }
1744
+ );
1745
+ for (const hash of hashes) {
1746
+ const url = response.data.urls[hash];
1747
+ if (!url) {
1748
+ throw new SandboxPullError(`No download URL returned for ${hash}`, "remote");
1905
1749
  }
1750
+ urls.set(hash, url);
1906
1751
  }
1907
1752
  }
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;
1918
- }
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)}`);
1932
- }
1933
- }
1934
- if (failures.length > 0) {
1935
- throw new Error(failures.join("; "));
1936
- }
1753
+ return urls;
1937
1754
  }
1938
- function uploadConfig(upload, env) {
1939
- 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)
1943
- };
1944
- }
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);
1755
+ async function downloadBlob(entry, urls) {
1756
+ const hash = entry.sha;
1757
+ if (!hash) {
1758
+ throw new SandboxPullError(`${entry.prefixedPath} does not have downloadable content`, "remote");
1989
1759
  }
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));
2015
- }
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);
2024
- }
2025
- } catch {
2026
- }
1760
+ const url = urls.get(hash);
1761
+ if (!url) {
1762
+ throw new SandboxPullError(`No download URL returned for ${entry.prefixedPath}`, "remote");
2027
1763
  }
2028
- for (const dir of ["events", "transcript"]) {
2029
- await collectFiles(outputDir, join4(outputDir, dir), paths);
1764
+ const response = await fetch(url);
1765
+ if (!response.ok) {
1766
+ throw new SandboxPullError(`Download failed [${response.status}]: ${entry.prefixedPath}`, "remote");
2030
1767
  }
2031
- return paths.sort();
2032
- }
2033
- async function collectFiles(outputDir, dir, paths) {
2034
- let entries;
2035
- try {
2036
- entries = await readdir2(dir, { withFileTypes: true });
2037
- } catch {
2038
- return;
1768
+ const content = Buffer.from(await response.arrayBuffer());
1769
+ if (content.length > MFS_FILE_SIZE_LIMIT_BYTES) {
1770
+ throw new SandboxPullError(`File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${entry.prefixedPath}`, "too-large");
2039
1771
  }
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("\\", "/"));
1772
+ const contentHash = createHash2("sha256").update(content).digest("hex");
1773
+ if (contentHash !== hash) {
1774
+ throw new SandboxPullError(`Downloaded content hash mismatch: ${entry.prefixedPath}`, "remote");
2050
1775
  }
1776
+ return content;
2051
1777
  }
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");
1778
+ async function shouldWritePulledFile(localPath, remoteContentHash, workspacePath, previousFile, force) {
1779
+ const existing = await lstat(localPath).catch((error) => {
1780
+ if (isNodeError2(error) && error.code === "ENOENT") return void 0;
1781
+ throw error;
1782
+ });
1783
+ if (existing && !existing.isFile()) {
1784
+ throw new SandboxPullError(`Refusing to overwrite local path: ${workspacePath}`, "directory-conflict");
1785
+ }
1786
+ if (!existing) {
1787
+ if (force || !previousFile) return true;
1788
+ if (remoteContentHash === previousFile.sha) return false;
1789
+ throw new SandboxPullError(
1790
+ `Local file was deleted but remote file changed: ${workspacePath}`,
1791
+ "file-conflict"
1792
+ );
2056
1793
  }
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
- }
1794
+ if (force) return true;
1795
+ const localContentHash = await hashLocalFile(localPath);
1796
+ if (localContentHash === remoteContentHash) return false;
1797
+ if (!previousFile) {
1798
+ throw new SandboxPullError(
1799
+ `Refusing to overwrite modified local file: ${workspacePath}. Use --force to overwrite.`,
1800
+ "file-conflict"
1801
+ );
1802
+ }
1803
+ const localChanged = localContentHash !== previousFile.sha;
1804
+ const remoteChanged = remoteContentHash !== previousFile.sha;
1805
+ if (!localChanged && remoteChanged) return true;
1806
+ if (localChanged && !remoteChanged) return false;
1807
+ if (localChanged && remoteChanged) {
1808
+ throw new SandboxPullError(
1809
+ `Local and remote file both changed since checkout: ${workspacePath}`,
1810
+ "file-conflict"
2083
1811
  );
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
1812
  }
1813
+ return false;
2101
1814
  }
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;
1815
+ async function planRemoteDeletions(previousState, selection, checkoutRoot, remoteBlobPaths, force) {
1816
+ if (!previousState) return [];
1817
+ const deletions = [];
1818
+ for (const [workspacePath, previousFile] of Object.entries(previousState.files)) {
1819
+ if (!selection.scopes.some((scope) => pathIsIncluded(scope, workspacePath))) continue;
1820
+ if (remoteBlobPaths.has(workspacePath)) continue;
1821
+ const repo = previousState.repos[previousFile.repoId];
1822
+ if (!repo) {
1823
+ throw new SandboxPullError(`Checkout baseline references unknown repo: ${workspacePath}`, "remote");
1824
+ }
1825
+ const localPath = resolveSafeLocalPath(checkoutRoot, repo.name, previousFile.repoPath);
1826
+ const existing = await lstat(localPath).catch((error) => {
1827
+ if (isNodeError2(error) && error.code === "ENOENT") return void 0;
1828
+ throw error;
1829
+ });
1830
+ if (!existing) continue;
1831
+ if (!existing.isFile()) {
1832
+ throw new SandboxPullError(`Refusing to overwrite local path: ${workspacePath}`, "directory-conflict");
1833
+ }
1834
+ if (!force && await hashLocalFile(localPath) !== previousFile.sha) {
1835
+ throw new SandboxPullError(
1836
+ `Remote file was deleted but local file changed: ${workspacePath}`,
1837
+ "file-conflict"
1838
+ );
2107
1839
  }
2108
- } catch {
1840
+ deletions.push({ localPath });
2109
1841
  }
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
- };
1842
+ return deletions;
2117
1843
  }
2118
- async function writeUploadState(outputDir, state) {
2119
- await writeFile3(join4(outputDir, "upload.json"), `${JSON.stringify(state, null, 2)}
2120
- `, "utf8");
1844
+ async function hashLocalFile(localPath) {
1845
+ return createHash2("sha256").update(await readFile2(localPath)).digest("hex");
2121
1846
  }
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";
1847
+ async function localFileSize(localPath, workspacePath) {
1848
+ const stats = await lstat(localPath);
1849
+ if (!stats.isFile()) {
1850
+ throw new SandboxPullError(`Cannot determine local file size: ${workspacePath}`, "directory-conflict");
2129
1851
  }
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";
1852
+ return stats.size;
2137
1853
  }
2138
- function isUploadState(value, runId) {
2139
- if (!isObject2(value)) {
2140
- return false;
1854
+ function requireBlobSha(entry) {
1855
+ if (!entry.sha) {
1856
+ throw new SandboxPullError(`${entry.prefixedPath} does not have downloadable content`, "remote");
2141
1857
  }
2142
- const { schema_version: schemaVersion, run_id: valueRunId, files } = value;
2143
- return schemaVersion === "runlog.upload.v1" && valueRunId === runId && isObject2(files);
1858
+ return entry.sha;
2144
1859
  }
2145
- async function responseJson(response) {
2146
- try {
2147
- return await response.json();
2148
- } catch {
2149
- return null;
1860
+ async function assertCanCreateDirectory(localPath, workspacePath) {
1861
+ const existing = await lstat(localPath).catch((error) => {
1862
+ if (isNodeError2(error) && error.code === "ENOENT") return void 0;
1863
+ throw error;
1864
+ });
1865
+ if (existing && !existing.isDirectory()) {
1866
+ throw new SandboxPullError(`Refusing to overwrite local path: ${workspacePath}`, "directory-conflict");
2150
1867
  }
2151
1868
  }
2152
- function apiBaseUrl(env) {
2153
- return `https://${env.MOXT_HOST ?? DEFAULT_HOST2}/openapi/v1`;
1869
+ async function writePulledFile(localPath, remoteContent) {
1870
+ await mkdir2(dirname2(localPath), { recursive: true });
1871
+ await writeFile2(localPath, remoteContent);
2154
1872
  }
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;
1873
+ function resolveSafeLocalPath(targetDir, repoName, repoPath) {
1874
+ const pathParts = [repoName, ...repoPath.split("/").filter((part) => part.length > 0)];
1875
+ if (pathParts.some((part) => part === "." || part === ".." || part.includes("/") || part.includes("\\") || part.includes("\0"))) {
1876
+ throw new SandboxPullError(`Remote path is unsafe: ${[repoName, repoPath].filter(Boolean).join("/")}`, "invalid-path");
2161
1877
  }
2162
- return String(error);
2163
- }
2164
- function numberEnv(value) {
2165
- if (value === void 0 || value.trim() === "") {
2166
- return void 0;
1878
+ const resolved = resolve(targetDir, ...pathParts);
1879
+ const relativePath = relative(targetDir, resolved);
1880
+ if (relativePath.startsWith("..") || isAbsolute2(relativePath)) {
1881
+ throw new SandboxPullError(`Remote path escapes target directory: ${repoName}/${repoPath}`, "invalid-path");
2167
1882
  }
2168
- const parsed = Number(value);
2169
- return Number.isFinite(parsed) ? parsed : void 0;
1883
+ return resolved;
2170
1884
  }
2171
- function isObject2(value) {
2172
- return typeof value === "object" && value !== null && !Array.isArray(value);
1885
+ function isNodeError2(error) {
1886
+ return error instanceof Error && "code" in error;
2173
1887
  }
2174
1888
 
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" };
1889
+ // src/commands/pull.ts
1890
+ function registerPullCommand(program2) {
1891
+ 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) => {
1892
+ const context = resolveSandboxRuntimeContextOrExit2();
1893
+ try {
1894
+ const invocation = await resolvePullInvocation(context, workspacePath, options);
1895
+ const result = await pullSandboxWorkspace(context, invocation.targetDir, {
1896
+ force: Boolean(options.force),
1897
+ scopes: invocation.scopes
1898
+ });
1899
+ print(`Pulled ${result.fileCount} ${result.fileCount === 1 ? "file" : "files"} to ${result.targetDir}`);
1900
+ } catch (error) {
1901
+ handlePullError(error);
1902
+ }
1903
+ });
1904
+ }
1905
+ async function resolvePullInvocation(context, argument, options) {
1906
+ const legacyTargetDir = argument && isLegacyTargetDirectory(argument) ? argument : void 0;
1907
+ const workspacePath = legacyTargetDir ? void 0 : argument;
1908
+ if (workspacePath && options.all) {
1909
+ throw new Error("Specify either a workspace path or --all, not both");
2181
1910
  }
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" };
1911
+ if (options.scope.length > 0 && (workspacePath || options.all)) {
1912
+ throw new Error("Legacy --scope cannot be combined with a workspace path or --all");
1913
+ }
1914
+ if (legacyTargetDir && options.dir) {
1915
+ throw new Error("Specify the local checkout directory only once");
1916
+ }
1917
+ const state = await loadCheckoutState();
1918
+ if (state) validateCheckoutStateContext(state, context);
1919
+ const targetDir = options.dir ?? legacyTargetDir ?? state?.checkoutRoot ?? ".";
1920
+ if (options.all || legacyTargetDir && options.scope.length === 0) {
1921
+ return { targetDir };
2198
1922
  }
1923
+ if (workspacePath) return { targetDir, scopes: [workspacePath] };
1924
+ if (options.scope.length > 0) return { targetDir, scopes: options.scope };
1925
+ if (!state || state.checkedOutScopes.length === 0) {
1926
+ throw new Error("Checkout is not initialized. Run moxt pull <workspace-path> or moxt pull --all first.");
1927
+ }
1928
+ return {
1929
+ targetDir: state.checkoutRoot,
1930
+ scopes: state.mode === "full" ? void 0 : state.checkedOutScopes
1931
+ };
2199
1932
  }
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);
1933
+ function isLegacyTargetDirectory(value) {
1934
+ return value === "." || value.startsWith("./") || value.startsWith("../") || isAbsolute3(value);
1935
+ }
1936
+ function validateCheckoutStateContext(state, context) {
1937
+ if (state.workspaceId !== context.workspaceId) {
1938
+ throw new Error(`Checkout state belongs to workspace ${state.workspaceId}, not ${context.workspaceId}`);
2212
1939
  }
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;
1940
+ const currentRepos = new Map(listRepoEntries(context.repoPayload).map((repo) => [repo.repoId, repo.name]));
1941
+ for (const [repoId, repo] of Object.entries(state.repos)) {
1942
+ const currentName = currentRepos.get(repoId);
1943
+ if (!currentName) throw new Error(`Checkout state contains unknown repo: ${repoId}`);
1944
+ if (currentName !== repo.name) {
1945
+ throw new Error(`Checkout state repo name does not match current workspace: ${repo.name}`);
2220
1946
  }
2221
- seen.add(pid);
2222
- pids.push(pid);
2223
- queue.push(...childrenByParent.get(pid) ?? []);
2224
1947
  }
2225
- return pids;
2226
1948
  }
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;
1949
+ function collectScope(value, previous) {
1950
+ return [...previous, value];
1951
+ }
1952
+ function resolveSandboxRuntimeContextOrExit2() {
1953
+ let context;
1954
+ try {
1955
+ context = resolveRuntimeContext();
1956
+ } catch (error) {
1957
+ if (error instanceof RuntimeContextError) {
1958
+ printError(error.message);
1959
+ process.exit(1);
2239
1960
  }
2240
- files.add(alignPathToRoot(options.sessionRoot, path2));
2241
- }
2242
- if (files.size === 0) {
2243
- return { match: "none" };
1961
+ throw error;
2244
1962
  }
2245
- if (files.size === 1) {
2246
- return { match: "unique", sessionFile: [...files].join("") };
1963
+ if (context.mode !== "sandbox") {
1964
+ printError("moxt pull is only available in sandbox mode");
1965
+ process.exit(1);
2247
1966
  }
2248
- return { match: "ambiguous" };
1967
+ return context;
2249
1968
  }
2250
- function isInside(root, path2) {
2251
- if (isPathInside(root, path2)) {
2252
- return true;
1969
+ function handlePullError(error) {
1970
+ if (error instanceof Error) {
1971
+ printError(error.message);
1972
+ process.exit(1);
2253
1973
  }
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);
1974
+ throw error;
2259
1975
  }
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);
1976
+
1977
+ // src/utils/sandbox-push.ts
1978
+ import { createHash as createHash3 } from "crypto";
1979
+ import { lstat as lstat2, readFile as readFile3, readdir } from "fs/promises";
1980
+ import { isAbsolute as isAbsolute4, join as join2, relative as relative2, resolve as resolve2 } from "path";
1981
+
1982
+ // src/utils/concurrency.ts
1983
+ async function runWithConcurrency(items, concurrency, action) {
1984
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
1985
+ throw new RangeError("Concurrency must be a positive integer");
2266
1986
  }
2267
- if (root.startsWith("/private/var/") && path2.startsWith("/var/")) {
2268
- return `/private${path2}`;
1987
+ let nextIndex = 0;
1988
+ async function worker() {
1989
+ while (nextIndex < items.length) {
1990
+ const item = items[nextIndex];
1991
+ nextIndex += 1;
1992
+ await action(item);
1993
+ }
2269
1994
  }
2270
- return path2;
1995
+ const workerCount = Math.min(concurrency, items.length);
1996
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
2271
1997
  }
2272
1998
 
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;
1999
+ // src/utils/sandbox-push.ts
2000
+ var MFS_FILE_TYPE_REGULAR2 = 1;
2001
+ var MAX_CONCURRENT_UPLOADS = 8;
2002
+ var SandboxPushError = class extends Error {
2003
+ constructor(message, code) {
2004
+ super(message);
2005
+ this.code = code;
2006
+ this.name = "SandboxPushError";
2007
+ }
2008
+ code;
2009
+ };
2010
+ async function pushSandboxWorkspace(context, sourceDir, options) {
2011
+ const resolvedSourceDir = resolve2(sourceDir);
2012
+ await assertWorkspaceRoot(resolvedSourceDir);
2013
+ const repos = listRepoEntries(context.repoPayload);
2014
+ for (const repo of repos) {
2015
+ assertSafeRepoName(resolvedSourceDir, repo.name);
2016
+ }
2017
+ const state = await resolveCheckoutState(context, resolvedSourceDir, repos);
2018
+ const checkoutRepos = repos.filter(
2019
+ (repo) => state.repos[repo.repoId] && doesCheckoutPathIntersect(state, repo.name)
2020
+ );
2021
+ if (checkoutRepos.length === 0) {
2022
+ throw new SandboxPushError("Checkout state does not contain a pushable scope", "invalid-path");
2023
+ }
2024
+ const tree = await fetchWorkspaceTree2(context, checkoutRepos);
2025
+ const remoteEntries = indexRemoteEntries(tree, checkoutRepos);
2026
+ const localFiles = await collectLocalFiles(resolvedSourceDir, checkoutRepos, remoteEntries, state);
2027
+ const { changes, reconciledFiles, removedWorkspacePaths } = planChanges(
2028
+ localFiles,
2029
+ remoteEntries,
2030
+ state,
2031
+ checkoutRepos
2032
+ );
2033
+ let pushResponse;
2034
+ if (changes.length > 0) {
2035
+ await uploadChangedFiles(context, changes);
2036
+ pushResponse = await pushChanges(context, checkoutRepos, changes, options.message);
2037
+ }
2038
+ if (changes.length > 0 || reconciledFiles.length > 0 || removedWorkspacePaths.length > 0) {
2039
+ await updateCheckoutState(state, changes, reconciledFiles, removedWorkspacePaths, pushResponse);
2279
2040
  }
2280
2041
  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) ?? ""
2042
+ changes: changes.map((change) => ({ action: change.action, workspacePath: change.workspacePath })),
2043
+ sourceDir: resolvedSourceDir
2285
2044
  };
2286
2045
  }
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;
2046
+ async function resolveCheckoutState(context, sourceDir, repos) {
2047
+ const state = await loadCheckoutState();
2048
+ if (!state) {
2049
+ throw new SandboxPushError("Checkout is not initialized. Run moxt pull first.", "invalid-path");
2296
2050
  }
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
- };
2051
+ if (state.workspaceId !== context.workspaceId) {
2052
+ throw new SandboxPushError(
2053
+ `Checkout state belongs to workspace ${state.workspaceId}, not ${context.workspaceId}`,
2054
+ "invalid-path"
2055
+ );
2056
+ }
2057
+ if (state.checkoutRoot !== sourceDir) {
2058
+ throw new SandboxPushError(
2059
+ `Checkout root is ${state.checkoutRoot}, not ${sourceDir}`,
2060
+ "invalid-path"
2061
+ );
2062
+ }
2063
+ const currentRepos = new Map(repos.map((repo) => [repo.repoId, repo.name]));
2064
+ for (const [repoId, repo] of Object.entries(state.repos)) {
2065
+ if (currentRepos.get(repoId) !== repo.name) {
2066
+ throw new SandboxPushError(`Checkout state contains unknown repo: ${repoId}`, "invalid-path");
2331
2067
  }
2332
2068
  }
2069
+ for (const scope of state.checkedOutScopes) {
2070
+ const hasRepo = Object.values(state.repos).some(
2071
+ (repo) => scope === repo.name || scope.startsWith(`${repo.name}/`)
2072
+ );
2073
+ if (!hasRepo) throw new SandboxPushError(`Checkout state contains invalid scope: ${scope}`, "invalid-path");
2074
+ }
2075
+ return state;
2333
2076
  }
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`);
2077
+ async function assertWorkspaceRoot(sourceDir) {
2078
+ const stats = await lstat2(sourceDir).catch((error) => {
2079
+ if (isNodeError3(error) && error.code === "ENOENT") {
2080
+ throw new SandboxPushError(`Local workspace directory does not exist: ${sourceDir}`, "invalid-path");
2339
2081
  }
2340
- case "codex":
2341
- return void 0;
2082
+ throw error;
2083
+ });
2084
+ if (!stats.isDirectory()) {
2085
+ throw new SandboxPushError(`Local workspace path is not a directory: ${sourceDir}`, "invalid-path");
2342
2086
  }
2343
2087
  }
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];
2088
+ async function fetchWorkspaceTree2(context, repos) {
2089
+ const client = new SandboxMoxtClient(context);
2090
+ const response = await client.request(
2091
+ "POST",
2092
+ "/agent/api/pipeline-sandbox/mfs-workspace/batch-trees",
2093
+ {
2094
+ repos: repos.map((repo) => ({ repoId: repo.repoId, prefix: repo.name }))
2361
2095
  }
2362
- if (arg.startsWith("--session-id=")) {
2363
- return arg.slice("--session-id=".length);
2096
+ );
2097
+ return response.data;
2098
+ }
2099
+ function indexRemoteEntries(tree, repos) {
2100
+ const knownRepoIds = new Set(repos.map((repo) => repo.repoId));
2101
+ const entries = /* @__PURE__ */ new Map();
2102
+ for (const entry of tree.entries) {
2103
+ if (!knownRepoIds.has(entry.repoId)) {
2104
+ throw new SandboxPushError(`Remote tree returned unknown repo: ${entry.repoId}`, "remote");
2105
+ }
2106
+ const key = remoteEntryKey(entry.repoId, entry.path);
2107
+ if (entries.has(key)) {
2108
+ throw new SandboxPushError(`Remote tree returned duplicate path: ${entry.prefixedPath}`, "remote");
2109
+ }
2110
+ entries.set(key, entry);
2111
+ }
2112
+ return entries;
2113
+ }
2114
+ async function collectLocalFiles(sourceDir, repos, remoteEntries, state) {
2115
+ const files = [];
2116
+ for (const repo of repos) {
2117
+ const repoRoot = resolveSafeRepoRoot(sourceDir, repo.name);
2118
+ const stats = await lstat2(repoRoot).catch((error) => {
2119
+ if (isNodeError3(error) && error.code === "ENOENT") return void 0;
2120
+ throw error;
2121
+ });
2122
+ if (!stats) continue;
2123
+ if (!stats.isDirectory()) {
2124
+ throw new SandboxPushError(`Local repo path is not a directory: ${repo.name}`, "invalid-path");
2364
2125
  }
2126
+ await collectRepoFiles(repoRoot, repo, "", remoteEntries, state, files);
2365
2127
  }
2366
- return void 0;
2128
+ return files;
2367
2129
  }
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;
2130
+ async function collectRepoFiles(repoRoot, repo, relativeDir, remoteEntries, state, files) {
2131
+ const localDir = relativeDir ? join2(repoRoot, ...relativeDir.split("/")) : repoRoot;
2132
+ const entries = await readdir(localDir, { withFileTypes: true });
2133
+ entries.sort((a, b) => a.name.localeCompare(b.name));
2134
+ for (const entry of entries) {
2135
+ if (entry.name === ".git") continue;
2136
+ const repoPath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
2137
+ const workspacePath = `${repo.name}/${repoPath}`;
2138
+ assertSafePathPart(entry.name, workspacePath);
2139
+ if (!doesCheckoutPathIntersect(state, workspacePath)) continue;
2140
+ if (entry.isSymbolicLink()) {
2141
+ throw new SandboxPushError(`Refusing to push symbolic link: ${workspacePath}`, "invalid-file");
2142
+ }
2143
+ if (entry.isDirectory()) {
2144
+ const remote = remoteEntries.get(remoteEntryKey(repo.repoId, repoPath));
2145
+ if (remote?.type === "blob") {
2146
+ throw new SandboxPushError(`Local directory conflicts with remote file: ${workspacePath}`, "invalid-file");
2147
+ }
2148
+ await collectRepoFiles(repoRoot, repo, repoPath, remoteEntries, state, files);
2149
+ continue;
2150
+ }
2151
+ if (!entry.isFile()) {
2152
+ throw new SandboxPushError(`Refusing to push unsupported file: ${workspacePath}`, "invalid-file");
2153
+ }
2154
+ const localPath = join2(repoRoot, ...repoPath.split("/"));
2155
+ const stats = await lstat2(localPath);
2156
+ if (!stats.isFile()) {
2157
+ const kind = stats.isSymbolicLink() ? "symbolic link" : "unsupported file";
2158
+ throw new SandboxPushError(`Refusing to push ${kind}: ${workspacePath}`, "invalid-file");
2159
+ }
2160
+ if (stats.size > MFS_FILE_SIZE_LIMIT_BYTES) {
2161
+ throw new SandboxPushError(
2162
+ `File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${workspacePath}`,
2163
+ "too-large"
2164
+ );
2375
2165
  }
2376
- if (arg.startsWith("--resume=")) {
2377
- const value = arg.slice("--resume=".length);
2378
- return isUuid(value) ? value : void 0;
2166
+ const content = await readFile3(localPath);
2167
+ if (content.length > MFS_FILE_SIZE_LIMIT_BYTES) {
2168
+ throw new SandboxPushError(
2169
+ `File too large (max ${MFS_FILE_SIZE_LIMIT_LABEL}): ${workspacePath}`,
2170
+ "too-large"
2171
+ );
2379
2172
  }
2173
+ if (!isCheckoutPathIncluded(state, workspacePath)) continue;
2174
+ files.push({
2175
+ repo,
2176
+ repoPath,
2177
+ workspacePath,
2178
+ localPath,
2179
+ contentHash: createHash3("sha256").update(content).digest("hex"),
2180
+ size: content.length
2181
+ });
2380
2182
  }
2381
- return void 0;
2382
2183
  }
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;
2184
+ function planChanges(localFiles, remoteEntries, state, repos) {
2185
+ const changes = [];
2186
+ const reconciledFiles = [];
2187
+ const removedWorkspacePaths = [];
2188
+ const localWorkspacePaths = new Set(localFiles.map((file) => file.workspacePath));
2189
+ const reposById = new Map(repos.map((repo) => [repo.repoId, repo]));
2190
+ for (const file of localFiles) {
2191
+ const baseline = state.files[file.workspacePath];
2192
+ if (baseline?.sha === file.contentHash) continue;
2193
+ const existing = remoteEntries.get(remoteEntryKey(file.repo.repoId, file.repoPath));
2194
+ if (existing?.type === "tree") {
2195
+ throw new SandboxPushError(`Local file conflicts with remote directory: ${file.workspacePath}`, "invalid-file");
2196
+ }
2197
+ if (existing?.sha === file.contentHash) {
2198
+ reconciledFiles.push({ ...file, remote: existing });
2199
+ continue;
2200
+ }
2201
+ if (existing && !existing.sha) {
2202
+ throw new SandboxPushError(`${file.workspacePath} does not have a base content hash`, "remote");
2389
2203
  }
2204
+ if (baseline && existing?.sha !== baseline.sha) {
2205
+ throw new SandboxPushError(`Remote file changed since checkout: ${file.workspacePath}`, "conflict");
2206
+ }
2207
+ if (!baseline && existing) {
2208
+ throw new SandboxPushError(`Remote file changed since checkout: ${file.workspacePath}`, "conflict");
2209
+ }
2210
+ changes.push({
2211
+ ...file,
2212
+ action: existing ? "update" : "create",
2213
+ ...existing ? { existing } : {}
2214
+ });
2390
2215
  }
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];
2216
+ for (const [workspacePath, baseline] of Object.entries(state.files)) {
2217
+ if (localWorkspacePaths.has(workspacePath) || !isCheckoutPathIncluded(state, workspacePath)) continue;
2218
+ const repo = reposById.get(baseline.repoId);
2219
+ if (!repo) continue;
2220
+ const existing = remoteEntries.get(remoteEntryKey(baseline.repoId, baseline.repoPath));
2221
+ if (!existing) {
2222
+ removedWorkspacePaths.push(workspacePath);
2223
+ continue;
2399
2224
  }
2400
- if (arg.startsWith("--cd=")) {
2401
- return arg.slice("--cd=".length);
2225
+ if (existing.type !== "blob" || existing.sha !== baseline.sha) {
2226
+ throw new SandboxPushError(`Remote file changed since checkout: ${workspacePath}`, "conflict");
2402
2227
  }
2228
+ changes.push({
2229
+ action: "delete",
2230
+ repo,
2231
+ repoPath: baseline.repoPath,
2232
+ workspacePath,
2233
+ fileId: validFileId(existing.fileId) ?? baseline.fileId
2234
+ });
2403
2235
  }
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);
2236
+ return { changes, reconciledFiles, removedWorkspacePaths };
2237
+ }
2238
+ async function uploadChangedFiles(context, changes) {
2239
+ const writeChanges = changes.filter(isWriteChange);
2240
+ if (writeChanges.length === 0) return;
2241
+ const client = new SandboxMoxtClient(context);
2242
+ const changesByRepo = groupChangesByRepo(writeChanges);
2243
+ const uploadUrlsByRepo = await Promise.all(
2244
+ [...changesByRepo].map(async ([repoId, repoChanges]) => {
2245
+ const hashes = [...new Set(repoChanges.map((change) => change.contentHash))];
2246
+ const response = await client.request(
2247
+ "POST",
2248
+ `/agent/api/pipeline-sandbox/mfs/${encodeURIComponent(repoId)}/presign-upload`,
2249
+ { hashes }
2250
+ );
2251
+ return [repoId, response.data.urls];
2252
+ })
2253
+ );
2254
+ const uploadUrls = new Map(uploadUrlsByRepo);
2255
+ await runWithConcurrency(writeChanges, MAX_CONCURRENT_UPLOADS, async (change) => {
2256
+ const uploadUrl = uploadUrls.get(change.repo.repoId)?.[change.contentHash];
2257
+ if (!uploadUrl) {
2258
+ throw new SandboxPushError(`No upload URL returned for ${change.workspacePath}`, "remote");
2259
+ }
2260
+ const content = await readFile3(change.localPath);
2261
+ const currentHash = createHash3("sha256").update(content).digest("hex");
2262
+ if (content.length !== change.size || currentHash !== change.contentHash) {
2263
+ throw new SandboxPushError(`Local file changed while pushing: ${change.workspacePath}`, "invalid-file");
2264
+ }
2265
+ const upload = await fetch(uploadUrl, {
2266
+ method: "PUT",
2267
+ headers: { "Content-Type": "application/octet-stream" },
2268
+ body: new Uint8Array(content)
2269
+ });
2270
+ if (!upload.ok) {
2271
+ throw new SandboxPushError(`Upload failed [${upload.status}]: ${change.workspacePath}`, "remote");
2272
+ }
2273
+ });
2412
2274
  }
2413
- function normalizeCodexCwd(path2) {
2414
- try {
2415
- return realpathSync(path2);
2416
- } catch {
2417
- return resolve(path2);
2275
+ async function pushChanges(context, repos, changes, message) {
2276
+ const client = new SandboxMoxtClient(context);
2277
+ const changesByRepo = groupChangesByRepo(changes);
2278
+ const response = await client.request(
2279
+ "POST",
2280
+ "/agent/api/pipeline-sandbox/mfs-workspace/unified-push",
2281
+ {
2282
+ message,
2283
+ repoPushes: repos.flatMap((repo) => {
2284
+ const repoChanges = changesByRepo.get(repo.repoId);
2285
+ if (!repoChanges) return [];
2286
+ return [{
2287
+ repoId: repo.repoId,
2288
+ changes: repoChanges.map((change) => {
2289
+ if (change.action === "delete") {
2290
+ return {
2291
+ action: change.action,
2292
+ ...change.fileId ? { fileId: change.fileId } : {},
2293
+ path: change.repoPath
2294
+ };
2295
+ }
2296
+ return {
2297
+ action: change.action,
2298
+ ...change.existing?.fileId ? { fileId: change.existing.fileId } : {},
2299
+ path: change.repoPath,
2300
+ contentHash: change.contentHash,
2301
+ ...change.existing?.sha ? { baseContentHash: change.existing.sha } : {},
2302
+ size: change.size,
2303
+ fileType: MFS_FILE_TYPE_REGULAR2
2304
+ };
2305
+ })
2306
+ }];
2307
+ }),
2308
+ crossRepoMoves: []
2309
+ }
2310
+ );
2311
+ return response.data;
2312
+ }
2313
+ async function updateCheckoutState(state, changes, reconciledFiles, removedWorkspacePaths, response) {
2314
+ const files = { ...state.files };
2315
+ for (const workspacePath of removedWorkspacePaths) delete files[workspacePath];
2316
+ for (const file of reconciledFiles) {
2317
+ files[file.workspacePath] = checkoutStateFile(
2318
+ file,
2319
+ validFileId(file.remote.fileId),
2320
+ validFileType(file.remote.fileType)
2321
+ );
2322
+ }
2323
+ for (const change of changes) {
2324
+ if (change.action === "delete") {
2325
+ delete files[change.workspacePath];
2326
+ continue;
2327
+ }
2328
+ const returnedFileId = validFileId(response?.fileMap?.[change.repo.repoId]?.[change.repoPath]);
2329
+ const fileId = returnedFileId ?? change.existing?.fileId ?? state.files[change.workspacePath]?.fileId ?? null;
2330
+ files[change.workspacePath] = checkoutStateFile(change, validFileId(fileId), validFileType(change.existing?.fileType));
2418
2331
  }
2332
+ await saveCheckoutState({ ...state, files });
2419
2333
  }
2420
- function claudeForkSessionArg(agentArgs) {
2421
- return claudeOptionArgs(agentArgs).includes("--fork-session");
2334
+ function isWriteChange(change) {
2335
+ return change.action !== "delete";
2422
2336
  }
2423
- function claudeOptionArgs(agentArgs) {
2424
- const separatorIndex = agentArgs.indexOf("--");
2425
- return separatorIndex === -1 ? agentArgs : agentArgs.slice(0, separatorIndex);
2337
+ function checkoutStateFile(file, fileId, fileType) {
2338
+ return {
2339
+ repoId: file.repo.repoId,
2340
+ repoPath: file.repoPath,
2341
+ fileId,
2342
+ sha: file.contentHash,
2343
+ size: file.size,
2344
+ fileType: fileType ?? MFS_FILE_TYPE_REGULAR2
2345
+ };
2426
2346
  }
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);
2347
+ function validFileId(fileId) {
2348
+ return typeof fileId === "string" && fileId.length > 0 ? fileId : null;
2429
2349
  }
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;
2350
+ function validFileType(fileType) {
2351
+ return typeof fileType === "number" && Number.isInteger(fileType) && fileType >= 0 ? fileType : null;
2451
2352
  }
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);
2353
+ function groupChangesByRepo(changes) {
2354
+ const result = /* @__PURE__ */ new Map();
2355
+ for (const change of changes) {
2356
+ const repoChanges = result.get(change.repo.repoId) ?? [];
2357
+ repoChanges.push(change);
2358
+ result.set(change.repo.repoId, repoChanges);
2460
2359
  }
2360
+ return result;
2461
2361
  }
2462
- function objectValue2(value) {
2463
- return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
2362
+ function remoteEntryKey(repoId, repoPath) {
2363
+ return `${repoId}\0${repoPath}`;
2464
2364
  }
2465
- function rawCodexSessionLine(value) {
2466
- return objectValue2(value);
2365
+ function assertSafePathPart(part, workspacePath) {
2366
+ if (part === "." || part === ".." || part.includes("/") || part.includes("\\") || part.includes("\0")) {
2367
+ throw new SandboxPushError(`Local path is unsafe: ${workspacePath}`, "invalid-path");
2368
+ }
2369
+ }
2370
+ function assertSafeRepoName(sourceDir, repoName) {
2371
+ resolveSafeRepoRoot(sourceDir, repoName);
2467
2372
  }
2468
- function rawCodexSessionPayload(value) {
2469
- return objectValue2(value);
2373
+ function resolveSafeRepoRoot(sourceDir, repoName) {
2374
+ assertSafePathPart(repoName, repoName);
2375
+ const repoRoot = resolve2(sourceDir, repoName);
2376
+ const relativePath = relative2(sourceDir, repoRoot);
2377
+ if (relativePath.startsWith("..") || isAbsolute4(relativePath)) {
2378
+ throw new SandboxPushError(`Repo path escapes local workspace: ${repoName}`, "invalid-path");
2379
+ }
2380
+ return repoRoot;
2470
2381
  }
2471
- function hasValue2(value) {
2472
- return value !== void 0 && value !== "";
2382
+ function isNodeError3(error) {
2383
+ return error instanceof Error && "code" in error;
2473
2384
  }
2474
2385
 
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) {
2386
+ // src/commands/push.ts
2387
+ function registerPushCommand(program2) {
2388
+ 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) => {
2389
+ const context = resolveSandboxRuntimeContextOrExit3();
2666
2390
  try {
2667
- after = await waitForSessionSettle(
2668
- sessions,
2669
- before,
2670
- startedAtMs,
2671
- isSessionCandidate,
2672
- newSessionFilesOnly
2673
- );
2674
- } catch {
2391
+ const result = await pushSandboxWorkspace(context, sourceDir, { message: options.message });
2392
+ for (const change of result.changes) {
2393
+ print(`${change.action} ${change.workspacePath}`);
2394
+ }
2395
+ if (result.changes.length === 0) {
2396
+ print("No changes to push");
2397
+ return;
2398
+ }
2399
+ const fileLabel = result.changes.length === 1 ? "file" : "files";
2400
+ print(`Pushed ${result.changes.length} ${fileLabel} from ${result.sourceDir}`);
2401
+ } catch (error) {
2402
+ handlePushError(error);
2675
2403
  }
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
2404
  });
2701
- let finalized = false;
2405
+ }
2406
+ function resolveSandboxRuntimeContextOrExit3() {
2407
+ let context;
2702
2408
  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;
2409
+ context = resolveRuntimeContext();
2709
2410
  } 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)}`);
2411
+ if (error instanceof RuntimeContextError) {
2412
+ printError(error.message);
2413
+ process.exit(1);
2722
2414
  }
2415
+ throw error;
2723
2416
  }
2724
- return classified.code;
2417
+ if (context.mode !== "sandbox") {
2418
+ printError("moxt push is only available in sandbox mode");
2419
+ process.exit(1);
2420
+ }
2421
+ return context;
2725
2422
  }
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);
2423
+ function handlePushError(error) {
2424
+ if (error instanceof Error) {
2425
+ printError(error.message);
2426
+ process.exit(1);
2732
2427
  }
2733
- return after;
2428
+ throw error;
2734
2429
  }
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";
2430
+
2431
+ // src/utils/checkout-status.ts
2432
+ import { createHash as createHash4 } from "crypto";
2433
+ import { createReadStream } from "fs";
2434
+ import { lstat as lstat3, readdir as readdir2 } from "fs/promises";
2435
+ import { join as join3 } from "path";
2436
+ var CheckoutStatusError = class extends Error {
2437
+ constructor(message) {
2438
+ super(message);
2439
+ this.name = "CheckoutStatusError";
2440
+ }
2441
+ };
2442
+ async function inspectCheckoutStatus(state) {
2443
+ await requireCheckoutDirectory(state.checkoutRoot);
2444
+ const localFiles = /* @__PURE__ */ new Map();
2445
+ for (const repo of Object.values(state.repos)) {
2446
+ await scanDirectory(state, repo.name, join3(state.checkoutRoot, repo.name), localFiles);
2739
2447
  }
2740
- if (matched.sessionFile === null) {
2741
- return false;
2448
+ const changes = [];
2449
+ for (const [path2, sha] of localFiles) {
2450
+ const baseline = state.files[path2];
2451
+ if (!baseline) changes.push({ action: "added", path: path2 });
2452
+ else if (baseline.sha !== sha) changes.push({ action: "modified", path: path2 });
2742
2453
  }
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));
2454
+ for (const path2 of Object.keys(state.files)) {
2455
+ if (isCheckoutPathIncluded(state, path2) && !localFiles.has(path2)) changes.push({ action: "deleted", path: path2 });
2456
+ }
2457
+ return changes.sort((left, right) => left.path.localeCompare(right.path));
2748
2458
  }
2749
- function safeKill(child, signal) {
2459
+ async function requireCheckoutDirectory(checkoutRoot) {
2460
+ let stats;
2750
2461
  try {
2751
- child.kill(signal);
2752
- } catch {
2462
+ stats = await lstat3(checkoutRoot);
2463
+ } catch (error) {
2464
+ throw new CheckoutStatusError(`Checkout root is not available: ${errorMessage2(error)}`);
2753
2465
  }
2466
+ if (stats.isSymbolicLink()) throw new CheckoutStatusError(`Refusing to inspect symbolic link: ${checkoutRoot}`);
2467
+ if (!stats.isDirectory()) throw new CheckoutStatusError(`Checkout root is not a directory: ${checkoutRoot}`);
2754
2468
  }
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";
2469
+ async function scanDirectory(state, workspacePath, localPath, files) {
2470
+ let entries;
2471
+ try {
2472
+ entries = await readdir2(localPath, { withFileTypes: true });
2473
+ } catch (error) {
2474
+ if (isNodeError4(error) && error.code === "ENOENT") return;
2475
+ throw new CheckoutStatusError(`Failed to inspect ${workspacePath}: ${errorMessage2(error)}`);
2476
+ }
2477
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
2478
+ if (entry.name === ".git") continue;
2479
+ const childWorkspacePath = `${workspacePath}/${entry.name}`;
2480
+ if (!doesCheckoutPathIntersect(state, childWorkspacePath)) continue;
2481
+ const childLocalPath = join3(localPath, entry.name);
2482
+ const stats = await lstat3(childLocalPath);
2483
+ if (stats.isSymbolicLink()) {
2484
+ throw new CheckoutStatusError(`Refusing to inspect symbolic link: ${childWorkspacePath}`);
2485
+ }
2486
+ if (stats.isDirectory()) {
2487
+ await scanDirectory(state, childWorkspacePath, childLocalPath, files);
2488
+ } else if (stats.isFile() && isCheckoutPathIncluded(state, childWorkspacePath)) {
2489
+ files.set(childWorkspacePath, await hashFile(childLocalPath));
2801
2490
  }
2802
2491
  }
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
2492
  }
2820
- function matchOptions(isSessionCandidate, newSessionFilesOnly) {
2821
- return {
2822
- ...isSessionCandidate === void 0 ? {} : { isCandidate: isSessionCandidate },
2823
- ...newSessionFilesOnly ? { newFilesOnly: true } : {}
2824
- };
2493
+ async function hashFile(path2) {
2494
+ const hash = createHash4("sha256");
2495
+ const stream = createReadStream(path2);
2496
+ for await (const chunk of stream) hash.update(chunk);
2497
+ return hash.digest("hex");
2825
2498
  }
2826
- function shouldUseNewSessionFilesOnly(agent, agentArgs) {
2827
- return agent === "codex" && !isCodexResumeInvocation(agentArgs);
2499
+ function isNodeError4(error) {
2500
+ return error instanceof Error && "code" in error;
2828
2501
  }
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";
2502
+ function errorMessage2(error) {
2503
+ return error instanceof Error ? error.message : String(error);
2841
2504
  }
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;
2505
+
2506
+ // src/commands/status.ts
2507
+ function registerStatusCommand(program2) {
2508
+ program2.command("status").description("Show Moxt workspace checkout status").option("--json", "Print machine-readable status").action(async (options) => {
2509
+ const context = resolveRuntimeContextOrExit2();
2510
+ if (context.mode !== "sandbox") {
2511
+ printError("moxt status is only available in sandbox mode");
2512
+ process.exit(1);
2847
2513
  }
2848
- if (arg === "--") {
2849
- return null;
2514
+ const status = await buildSandboxStatusOrExit(context);
2515
+ if (options.json) {
2516
+ print(JSON.stringify(status, null, 2));
2517
+ return;
2850
2518
  }
2851
- if (arg.startsWith("-")) {
2852
- if (!arg.includes("=") && CODEX_OPTIONS_WITH_VALUE.has(arg)) {
2853
- index += 1;
2854
- }
2855
- continue;
2519
+ print(`${colors.bold}Workspace${colors.reset}: ${status.workspaceId}`);
2520
+ print(`${colors.bold}Mode${colors.reset}: sandbox`);
2521
+ print(`${colors.bold}Checkout${colors.reset}: ${formatCheckoutMode(status.checkout.mode)}`);
2522
+ if (status.checkout.mode !== "not_initialized") {
2523
+ print(`${colors.bold}Root${colors.reset}: ${status.checkout.root}`);
2524
+ }
2525
+ print("");
2526
+ print(`${colors.bold}Repos${colors.reset}:`);
2527
+ const repoNameWidth = status.repos.reduce((width, repo) => Math.max(width, repo.name.length), 0);
2528
+ for (const repo of status.repos) {
2529
+ print(` ${repo.name.padEnd(repoNameWidth)} ${repo.repoId}`);
2530
+ }
2531
+ print("");
2532
+ if (status.checkout.mode === "not_initialized") {
2533
+ print(`${colors.bold}Checked out scopes${colors.reset}: none`);
2534
+ print(`${colors.bold}Local changes${colors.reset}: not tracked`);
2535
+ return;
2536
+ }
2537
+ print(`${colors.bold}Checked out scopes${colors.reset}:`);
2538
+ for (const scope of status.checkout.checkedOutScopes) print(` ${scope}`);
2539
+ print(`${colors.bold}Local changes${colors.reset}:${status.checkout.changes.length === 0 ? " none" : ""}`);
2540
+ for (const change of status.checkout.changes) {
2541
+ print(` ${change.action.padEnd(8)} ${change.path}`);
2856
2542
  }
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
2543
  });
2916
2544
  }
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.");
2545
+ async function buildSandboxStatusOrExit(context) {
2546
+ const repos = listRepoEntries(context.repoPayload).map((repo) => ({
2547
+ name: repo.name,
2548
+ repoId: repo.repoId
2549
+ }));
2550
+ try {
2551
+ const state = await loadCheckoutState();
2552
+ if (!state) return buildUninitializedStatus(context.workspaceId, repos);
2553
+ validateStateContext(state, context);
2554
+ return {
2555
+ mode: "sandbox",
2556
+ workspaceId: context.workspaceId,
2557
+ checkout: {
2558
+ mode: state.mode,
2559
+ root: state.checkoutRoot,
2560
+ checkedOutScopes: state.checkedOutScopes,
2561
+ localChangesTracked: true,
2562
+ changes: await inspectCheckoutStatus(state)
2563
+ },
2564
+ repos
2565
+ };
2566
+ } catch (error) {
2567
+ printError(error instanceof Error ? error.message : String(error));
2931
2568
  process.exit(1);
2932
2569
  }
2570
+ }
2571
+ function buildUninitializedStatus(workspaceId, repos) {
2933
2572
  return {
2934
- workspaceId
2573
+ mode: "sandbox",
2574
+ workspaceId,
2575
+ checkout: {
2576
+ mode: "not_initialized",
2577
+ checkedOutScopes: [],
2578
+ localChangesTracked: false
2579
+ },
2580
+ repos
2935
2581
  };
2936
2582
  }
2937
- function resolveWorkspaceId(options) {
2938
- return normalizeWorkspaceId(options.workspace) ?? normalizeWorkspaceId(process.env.MOXT_WORKSPACE_ID);
2583
+ function validateStateContext(state, context) {
2584
+ if (state.workspaceId !== context.workspaceId) {
2585
+ throw new Error(`Checkout state belongs to workspace ${state.workspaceId}, not ${context.workspaceId}`);
2586
+ }
2587
+ const currentRepos = new Map(listRepoEntries(context.repoPayload).map((repo) => [repo.repoId, repo.name]));
2588
+ for (const [repoId, repo] of Object.entries(state.repos)) {
2589
+ const currentName = currentRepos.get(repoId);
2590
+ if (!currentName) throw new Error(`Checkout state contains unknown repo: ${repoId}`);
2591
+ if (currentName !== repo.name) {
2592
+ throw new Error(`Checkout state repo name does not match current workspace: ${repo.name}`);
2593
+ }
2594
+ }
2939
2595
  }
2940
- function normalizeWorkspaceId(value) {
2941
- const trimmed = value?.trim();
2942
- return trimmed === void 0 || trimmed === "" ? void 0 : trimmed;
2596
+ function resolveRuntimeContextOrExit2() {
2597
+ try {
2598
+ return resolveRuntimeContext();
2599
+ } catch (error) {
2600
+ if (error instanceof RuntimeContextError) {
2601
+ printError(error.message);
2602
+ process.exit(1);
2603
+ }
2604
+ throw error;
2605
+ }
2943
2606
  }
2944
- async function runLocalAgentOrExit(agent, agentArgs, upload) {
2945
- return await runLocalAgent({
2946
- agent,
2947
- agentArgs,
2948
- ...upload === void 0 ? {} : { upload }
2949
- });
2607
+ function formatCheckoutMode(mode) {
2608
+ return mode === "not_initialized" ? "not initialized" : `${mode} checkout`;
2950
2609
  }
2951
2610
 
2952
2611
  // src/telemetry/config.ts
2953
- import * as fs2 from "fs";
2612
+ import * as fs3 from "fs";
2954
2613
  import * as os from "os";
2955
2614
  import * as path from "path";
2956
2615
  function getConfigPath() {
@@ -2958,7 +2617,7 @@ function getConfigPath() {
2958
2617
  }
2959
2618
  function readTelemetryConfig() {
2960
2619
  try {
2961
- const raw = fs2.readFileSync(getConfigPath(), "utf8");
2620
+ const raw = fs3.readFileSync(getConfigPath(), "utf8");
2962
2621
  const parsed = JSON.parse(raw);
2963
2622
  return parsed.telemetry ?? {};
2964
2623
  } catch {
@@ -2969,14 +2628,14 @@ function writeTelemetryConfig(update) {
2969
2628
  const configPath = getConfigPath();
2970
2629
  let existing = {};
2971
2630
  try {
2972
- existing = JSON.parse(fs2.readFileSync(configPath, "utf8"));
2631
+ existing = JSON.parse(fs3.readFileSync(configPath, "utf8"));
2973
2632
  } catch {
2974
2633
  existing = {};
2975
2634
  }
2976
2635
  const currentTelemetry = existing.telemetry ?? {};
2977
2636
  const next = { ...existing, telemetry: { ...currentTelemetry, ...update } };
2978
- fs2.mkdirSync(path.dirname(configPath), { recursive: true });
2979
- fs2.writeFileSync(configPath, JSON.stringify(next, null, 2));
2637
+ fs3.mkdirSync(path.dirname(configPath), { recursive: true });
2638
+ fs3.writeFileSync(configPath, JSON.stringify(next, null, 2));
2980
2639
  }
2981
2640
 
2982
2641
  // src/telemetry/opt-out.ts
@@ -3148,31 +2807,34 @@ function registerWorkspaceCommand(program2) {
3148
2807
 
3149
2808
  // src/program.ts
3150
2809
  function createProgram(version) {
3151
- const program2 = new Command();
2810
+ const program2 = new Command2();
3152
2811
  program2.name("moxt").description("Moxt CLI - AI Workspace").version(version).action(() => {
3153
2812
  program2.help();
3154
2813
  });
2814
+ registerAuthCommand(program2);
3155
2815
  registerWhoamiCommand(program2);
3156
2816
  registerWorkspaceCommand(program2);
3157
2817
  registerFileCommand(program2);
3158
2818
  registerMemoryCommand(program2);
3159
- registerRunCommand(program2);
3160
2819
  registerMiniappCommand(program2);
2820
+ registerPullCommand(program2);
2821
+ registerPushCommand(program2);
2822
+ registerStatusCommand(program2);
3161
2823
  registerTelemetryCommand(program2);
3162
2824
  return program2;
3163
2825
  }
3164
2826
 
3165
2827
  // src/telemetry/client.ts
3166
- import { arch as arch3, platform as platform3 } from "os";
2828
+ import { arch as arch2, platform as platform2 } from "os";
3167
2829
  var buffer = [];
3168
2830
  var FLUSH_TIMEOUT_MS = 3e3;
3169
2831
  var MAX_BATCH_SIZE = 100;
3170
2832
  function commonLabels() {
3171
2833
  return {
3172
- cli_version: "0.3.3-moxt-run.2",
2834
+ cli_version: "0.4.0-beta.0",
3173
2835
  node_version: process.versions.node,
3174
- os: platform3(),
3175
- arch: arch3(),
2836
+ os: platform2(),
2837
+ arch: arch2(),
3176
2838
  is_ci: isCiEnvironment() ? "true" : "false"
3177
2839
  };
3178
2840
  }
@@ -3215,7 +2877,7 @@ async function flush() {
3215
2877
  }
3216
2878
  const batch = buffer.splice(0, MAX_BATCH_SIZE);
3217
2879
  const payload = {
3218
- release_id: "0.3.3-moxt-run.2",
2880
+ release_id: "0.4.0-beta.0",
3219
2881
  client_type: "cli",
3220
2882
  metric_data_list: batch.map((m) => ({
3221
2883
  profile: "cli",
@@ -3356,9 +3018,9 @@ function installExitHandler() {
3356
3018
 
3357
3019
  // src/index.ts
3358
3020
  updateNotifier({
3359
- pkg: { name: "@moxt-ai/cli", version: "0.3.3-moxt-run.2" }
3021
+ pkg: { name: "@moxt-ai/cli", version: "0.4.0-beta.0" }
3360
3022
  }).notify();
3361
- var program = createProgram("0.3.3-moxt-run.2");
3023
+ var program = createProgram("0.4.0-beta.0");
3362
3024
  instrumentProgram(program);
3363
3025
  installExitHandler();
3364
3026
  program.parseAsync(process.argv).then(async () => {