@moxt-ai/cli 0.3.3-moxt-run.1 → 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.1"} (${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,2142 +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";
751
1279
 
752
- // src/run/capture.ts
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";
1284
+
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");
795
- }
796
- function withDefaultCaptureOutput(env, home = homedir()) {
797
- const { outputDir, outputRoot } = captureEnvPaths(env);
798
- if (hasValue(outputDir) || hasValue(outputRoot)) {
799
- return env;
800
- }
801
- return {
802
- ...env,
803
- MOXT_RUN_OUTPUT_ROOT: defaultCaptureOutputRoot(home, env)
804
- };
1296
+ function getCheckoutStatePath() {
1297
+ return join(homedir(), ".moxt", "checkout-state.json");
805
1298
  }
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");
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`);
913
1415
  }
914
- await assertRunlogDirectoryMarker(outputDir);
915
- const metadataEntries = RUNLOG_METADATA_FILE_NAMES.filter((entry) => entries.includes(entry));
916
- if (metadataEntries.length === 0) {
917
- return;
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`);
918
1422
  }
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");
1423
+ return path2;
1424
+ }
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;
1429
+ }
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`);
925
1433
  }
1434
+ return value;
926
1435
  }
927
- async function readRunlogMetadataRunId(outputDir, fileName) {
928
- let parsed;
929
- try {
930
- parsed = JSON.parse(await readFile(join(outputDir, fileName), "utf8"));
931
- } catch (error) {
932
- throw new Error(`invalid ${fileName}: ${errorMessage(error)}`);
933
- }
934
- const value = objectValue(parsed);
935
- if (value === null) {
936
- throw new Error(`invalid ${fileName}: expected object`);
937
- }
938
- if (value.schema_version !== RUNLOG_METADATA_SCHEMAS[fileName]) {
939
- throw new Error(`invalid ${fileName}: unexpected schema_version`);
940
- }
941
- if (typeof value.run_id !== "string" || value.run_id.trim() === "") {
942
- throw new Error(`invalid ${fileName}: missing run_id`);
943
- }
944
- return value.run_id;
945
- }
946
- async function assertRunlogDirectoryMarker(outputDir) {
947
- let parsed;
948
- try {
949
- parsed = JSON.parse(await readFile(join(outputDir, RUNLOG_DIRECTORY_MARKER), "utf8"));
950
- } catch (error) {
951
- throw new Error(`invalid ${RUNLOG_DIRECTORY_MARKER}: ${errorMessage(error)}`);
952
- }
953
- const value = objectValue(parsed);
954
- if (value?.schema_version !== RUNLOG_DIRECTORY_MARKER_SCHEMA) {
955
- throw new Error(`invalid ${RUNLOG_DIRECTORY_MARKER}: unexpected schema_version`);
956
- }
1436
+ function isNodeError(error) {
1437
+ return error instanceof Error && "code" in error;
957
1438
  }
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
- );
1439
+ function errorMessage(error) {
1440
+ return error instanceof Error ? error.message : String(error);
992
1441
  }
993
- async function writeCaptureUploadFile(outputDir, runId) {
994
- const path2 = join(outputDir, "upload.json");
995
- try {
996
- await access(path2);
997
- return;
998
- } catch {
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";
999
1449
  }
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
- };
1029
- }
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;
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;
1374
- }
1375
- try {
1376
- const url = new URL(remote);
1377
- url.username = "";
1378
- url.password = "";
1379
- return url.toString();
1380
- } catch {
1381
- 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 };
1382
1604
  }
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;
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);
1470
1657
  }
1471
- if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
1472
- continue;
1473
- }
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;
1495
- }
1496
- function classifyExit(event, observedSignals) {
1497
- if (event.errorCode !== void 0) {
1498
- return {
1499
- code: event.errorCode === "ENOENT" ? 127 : 126,
1500
- status: "unknown"
1501
- };
1502
- }
1503
- if (event.signal !== null) {
1504
- return {
1505
- code: 128 + signalNumber(event.signal),
1506
- status: "cancelled"
1507
- };
1508
- }
1509
- if (event.code !== null) {
1510
- if (event.code === 0) {
1511
- return { code: 0, status: "completed" };
1512
- }
1513
- const signal = event.code - 128;
1514
- return {
1515
- code: event.code,
1516
- status: event.code > 128 && observedSignals.has(signal) ? "cancelled" : "failed"
1517
- };
1518
- }
1519
- return { code: 1, status: "unknown" };
1665
+ function pathIsIncluded(scope, workspacePath) {
1666
+ return workspacePath === scope || workspacePath.startsWith(`${scope}/`);
1520
1667
  }
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;
1574
- }
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);
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");
1580
1673
  }
1581
1674
  }
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;
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");
1589
1679
  }
1590
- if (this.pollInFlight !== null) {
1591
- await this.pollInFlight;
1680
+ if (remoteRepo.prefix !== repo.name) {
1681
+ throw new SandboxPullError(`Remote tree returned unexpected prefix for repo ${repo.repoId}`, "remote");
1592
1682
  }
1593
- await this.poll();
1594
- await this.flushPendingText();
1595
- }
1596
- async finalize(payload, capturedAt) {
1597
- if (!this.enabled) {
1598
- 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");
1599
1685
  }
1600
- if (!isCompleteRecording(payload)) {
1601
- await Promise.all(this.outputs.map((output) => output.invalidate()));
1686
+ return [repo.repoId, { name: repo.name, treeSha: remoteRepo.sha }];
1687
+ }));
1688
+ }
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);
1602
1696
  }
1603
- await finalizeCaptureDirectories(this.directoryPayload(payload), this.env, this.runId, capturedAt, this.files());
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
+ };
1715
+ }
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));
1604
1722
  }
1605
- queuePoll() {
1606
- if (this.pollInFlight !== null) {
1607
- return;
1608
- }
1609
- this.pollInFlight = this.poll().finally(() => {
1610
- this.pollInFlight = null;
1611
- });
1723
+ if (repoPaths.length === 0) {
1724
+ throw new SandboxPullError(`No pull scope resolved for repo: ${repo.name}`, "invalid-path");
1612
1725
  }
1613
- async poll() {
1614
- if (!this.baselineOk || this.captureFailed) {
1615
- return;
1616
- }
1617
- try {
1618
- const snapshot = snapshotJsonl(this.sessions);
1619
- if (this.sessionFile === null) {
1620
- if (this.knownSessionFile !== void 0) {
1621
- const matched2 = matchKnownSessionFile(this.before, snapshot, this.knownSessionFile);
1622
- if (matched2.match === "unique" && matched2.sessionFile !== null) {
1623
- this.sessionMatch = matched2.match;
1624
- this.sessionFile = matched2.sessionFile;
1625
- this.readOffset = matched2.startOffset;
1626
- await this.captureNewText();
1627
- return;
1628
- }
1629
- }
1630
- const opened = this.openSessionFile?.() ?? { match: "none" };
1631
- if (opened.match === "ambiguous") {
1632
- this.sessionMatch = "ambiguous";
1633
- return;
1634
- }
1635
- if (opened.match === "unique") {
1636
- const matched2 = matchKnownSessionFile(this.before, snapshot, opened.sessionFile);
1637
- this.sessionMatch = matched2.match;
1638
- if (matched2.match === "unique" && matched2.sessionFile !== null) {
1639
- this.sessionFile = matched2.sessionFile;
1640
- this.readOffset = matched2.startOffset;
1641
- await this.captureNewText();
1642
- }
1643
- return;
1644
- }
1645
- const matched = matchSession(this.before, snapshot, this.startedAtMs, this.matchOptions());
1646
- this.sessionMatch = matched.match;
1647
- if (matched.match !== "unique" || matched.sessionFile === null) {
1648
- return;
1649
- }
1650
- this.sessionFile = matched.sessionFile;
1651
- this.readOffset = matched.startOffset;
1726
+ return { type: "paths", paths: repoPaths };
1727
+ }
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");
1652
1749
  }
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;
1750
+ urls.set(hash, url);
1675
1751
  }
1676
- const complete = combined.slice(0, lastNewline + 1);
1677
- this.pendingText = combined.slice(lastNewline + 1);
1678
- await this.captureTranscriptText(complete);
1679
1752
  }
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();
1753
+ return urls;
1754
+ }
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");
1716
1759
  }
1717
- matchOptions() {
1718
- return {
1719
- ...this.isSessionCandidate === void 0 ? {} : { isCandidate: this.isSessionCandidate },
1720
- ...this.newSessionFilesOnly ? { newFilesOnly: true } : {}
1721
- };
1760
+ const url = urls.get(hash);
1761
+ if (!url) {
1762
+ throw new SandboxPullError(`No download URL returned for ${entry.prefixedPath}`, "remote");
1722
1763
  }
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");
1764
+ const response = await fetch(url);
1765
+ if (!response.ok) {
1766
+ throw new SandboxPullError(`Download failed [${response.status}]: ${entry.prefixedPath}`, "remote");
1730
1767
  }
1731
- async appendTranscript(text) {
1732
- await this.transcript.appendText(text);
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");
1733
1771
  }
1734
- async appendEvents(text) {
1735
- await this.events.appendText(text);
1772
+ const contentHash = createHash2("sha256").update(content).digest("hex");
1773
+ if (contentHash !== hash) {
1774
+ throw new SandboxPullError(`Downloaded content hash mismatch: ${entry.prefixedPath}`, "remote");
1736
1775
  }
1737
- async invalidate() {
1738
- await Promise.all([this.transcript.invalidate(), this.events.invalidate()]);
1776
+ return content;
1777
+ }
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
+ );
1739
1793
  }
1740
- files() {
1741
- return {
1742
- events: this.events.files(),
1743
- transcript: this.transcript.files()
1744
- };
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
+ );
1745
1802
  }
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
- })
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"
1771
1811
  );
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;
1812
+ }
1813
+ return false;
1814
+ }
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
+ );
1788
1839
  }
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;
1840
+ deletions.push({ localPath });
1808
1841
  }
1809
- };
1810
- function isCompleteRecording(payload) {
1811
- return payload.recording.found_chat && payload.recording.quality === "complete";
1842
+ return deletions;
1843
+ }
1844
+ async function hashLocalFile(localPath) {
1845
+ return createHash2("sha256").update(await readFile2(localPath)).digest("hex");
1812
1846
  }
1813
- function jsonlLines2(text) {
1814
- if (text === "") {
1815
- return [];
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");
1816
1851
  }
1817
- const normalized = text.endsWith("\n") ? text : `${text}
1818
- `;
1819
- return normalized.slice(0, -1).split("\n").map((line) => `${line}
1820
- `);
1852
+ return stats.size;
1821
1853
  }
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 };
1829
- }
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();
1854
+ function requireBlobSha(entry) {
1855
+ if (!entry.sha) {
1856
+ throw new SandboxPullError(`${entry.prefixedPath} does not have downloadable content`, "remote");
1835
1857
  }
1858
+ return entry.sha;
1836
1859
  }
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;
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");
1867
+ }
1868
+ }
1869
+ async function writePulledFile(localPath, remoteContent) {
1870
+ await mkdir2(dirname2(localPath), { recursive: true });
1871
+ await writeFile2(localPath, remoteContent);
1872
+ }
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");
1850
1877
  }
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;
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");
1856
1882
  }
1857
- return headers;
1883
+ return resolved;
1858
1884
  }
1859
- function firstValue(...values) {
1860
- return values.find((value) => value !== void 0 && value !== "");
1885
+ function isNodeError2(error) {
1886
+ return error instanceof Error && "code" in error;
1861
1887
  }
1862
1888
 
1863
- // src/run/moxt-uploader.ts
1864
- var DEFAULT_HOST2 = "api.moxt.ai";
1865
- var DEFAULT_UPLOAD_TIMEOUT_MS = 3e4;
1866
- async function uploadRunArtifacts(options) {
1867
- const outputDirs = captureOutputDirectories(options.env, options.runId);
1868
- const failures = [];
1869
- for (const outputDir of outputDirs) {
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();
1870
1893
  try {
1871
- await uploadDirectory(outputDir, options.runId, options.upload, options.env);
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}`);
1872
1900
  } catch (error) {
1873
- failures.push(`${outputDir}: ${errorMessage2(error)}`);
1901
+ handlePullError(error);
1874
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");
1875
1910
  }
1876
- if (failures.length > 0) {
1877
- throw new Error(failures.join("; "));
1911
+ if (options.scope.length > 0 && (workspacePath || options.all)) {
1912
+ throw new Error("Legacy --scope cannot be combined with a workspace path or --all");
1878
1913
  }
1879
- }
1880
- async function uploadDirectory(outputDir, runId, config, env) {
1881
- const files = await discoverUploadFiles(outputDir);
1882
- const state = await readUploadState(outputDir, runId);
1883
- for (const file of files) {
1884
- const previous = state.files[file.artifactPath];
1885
- if (previous?.status === "uploaded" && previous.sha256 === file.sha256 && previous.bytes === file.bytes) {
1886
- continue;
1887
- }
1888
- const attempts = (previous?.attempts ?? 0) + 1;
1889
- state.files[file.artifactPath] = {
1890
- status: "uploading",
1891
- bytes: file.bytes,
1892
- sha256: file.sha256,
1893
- attempts
1894
- };
1895
- state.status = "uploading";
1896
- state.updated_at = (/* @__PURE__ */ new Date()).toISOString();
1897
- await writeUploadState(outputDir, state);
1898
- try {
1899
- const response = await uploadFile(runId, file, config, env);
1900
- state.files[file.artifactPath] = {
1901
- status: "uploaded",
1902
- bytes: file.bytes,
1903
- sha256: file.sha256,
1904
- attempts,
1905
- uploaded_at: (/* @__PURE__ */ new Date()).toISOString(),
1906
- ...response.etag === void 0 ? {} : { etag: response.etag }
1907
- };
1908
- } catch (error) {
1909
- state.files[file.artifactPath] = {
1910
- status: "failed",
1911
- bytes: file.bytes,
1912
- sha256: file.sha256,
1913
- attempts,
1914
- failed_at: (/* @__PURE__ */ new Date()).toISOString(),
1915
- error: errorMessage2(error)
1916
- };
1917
- }
1918
- state.status = uploadStatus(
1919
- state.files,
1920
- files.map((candidate) => candidate.artifactPath)
1921
- );
1922
- state.updated_at = (/* @__PURE__ */ new Date()).toISOString();
1923
- await writeUploadState(outputDir, state);
1914
+ if (legacyTargetDir && options.dir) {
1915
+ throw new Error("Specify the local checkout directory only once");
1924
1916
  }
1925
- state.status = uploadStatus(
1926
- state.files,
1927
- files.map((file) => file.artifactPath)
1928
- );
1929
- state.updated_at = (/* @__PURE__ */ new Date()).toISOString();
1930
- await writeUploadState(outputDir, state);
1931
- if (state.status === "failed") {
1932
- throw new Error("one or more run files failed to upload");
1933
- }
1934
- }
1935
- async function discoverUploadFiles(outputDir) {
1936
- const paths = await uploadPaths(outputDir);
1937
- const files = await Promise.all(
1938
- paths.map(async (path2) => {
1939
- const absolute = join4(outputDir, path2);
1940
- const content = await readFile2(absolute);
1941
- return {
1942
- artifactPath: path2,
1943
- bytes: content.byteLength,
1944
- sha256: createHash("sha256").update(content).digest("hex"),
1945
- contentText: content.toString("utf8")
1946
- };
1947
- })
1948
- );
1949
- return files.sort((a, b) => a.artifactPath.localeCompare(b.artifactPath));
1950
- }
1951
- async function uploadPaths(outputDir) {
1952
- const paths = [];
1953
- for (const name of ["run.json", "state.json"]) {
1954
- const absolute = join4(outputDir, name);
1955
- try {
1956
- const fileStat = await stat(absolute);
1957
- if (fileStat.isFile()) {
1958
- paths.push(name);
1959
- }
1960
- } catch {
1961
- }
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 };
1962
1922
  }
1963
- for (const dir of ["events", "transcript"]) {
1964
- await collectFiles(outputDir, join4(outputDir, dir), paths);
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.");
1965
1927
  }
1966
- return paths.sort();
1928
+ return {
1929
+ targetDir: state.checkoutRoot,
1930
+ scopes: state.mode === "full" ? void 0 : state.checkedOutScopes
1931
+ };
1967
1932
  }
1968
- async function collectFiles(outputDir, dir, paths) {
1969
- let entries;
1970
- try {
1971
- entries = await readdir2(dir, { withFileTypes: true });
1972
- } catch {
1973
- return;
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}`);
1974
1939
  }
1975
- for (const entry of entries) {
1976
- const absolute = join4(dir, entry.name);
1977
- if (entry.isDirectory()) {
1978
- await collectFiles(outputDir, absolute, paths);
1979
- 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}`);
1980
1946
  }
1981
- if (!entry.isFile()) {
1982
- continue;
1983
- }
1984
- const fileStat = await stat(absolute);
1985
- if (fileStat.size === 0) {
1986
- continue;
1987
- }
1988
- paths.push(relative(outputDir, absolute).replaceAll("\\", "/"));
1989
1947
  }
1990
1948
  }
1991
- async function uploadFile(runId, file, config, env) {
1992
- const apiKey = env.MOXT_API_KEY;
1993
- if (apiKey === void 0 || apiKey === "") {
1994
- throw new Error("MOXT_API_KEY is required for run upload");
1995
- }
1996
- const timeoutMs = config.timeoutMs ?? DEFAULT_UPLOAD_TIMEOUT_MS;
1997
- const controller = new AbortController();
1998
- const timeout = setTimeout(() => {
1999
- controller.abort();
2000
- }, timeoutMs);
1949
+ function collectScope(value, previous) {
1950
+ return [...previous, value];
1951
+ }
1952
+ function resolveSandboxRuntimeContextOrExit2() {
1953
+ let context;
2001
1954
  try {
2002
- const response = await fetch(
2003
- `${apiBaseUrl(env)}/workspaces/${encodeURIComponent(config.workspaceId)}/local-agent-runs/${encodeURIComponent(runId)}/files`,
2004
- {
2005
- method: "PUT",
2006
- headers: {
2007
- "Content-Type": "application/json",
2008
- Authorization: `Bearer ${apiKey}`,
2009
- "User-Agent": userAgent(),
2010
- ...cloudflareAccessHeaders(env)
2011
- },
2012
- signal: controller.signal,
2013
- body: JSON.stringify({
2014
- schema_version: "local_agent_run.upload_file.v1",
2015
- artifact_path: file.artifactPath,
2016
- bytes: file.bytes,
2017
- sha256: file.sha256,
2018
- encoding: "utf8",
2019
- content_text: file.contentText
2020
- })
2021
- }
2022
- );
2023
- if (!response.ok) {
2024
- throw new Error(`HTTP ${response.status}`);
2025
- }
2026
- const headerEtag = response.headers.get("etag") ?? void 0;
2027
- const body = await responseJson(response);
2028
- const { etag: bodyEtagValue } = isObject2(body) ? body : {};
2029
- const bodyEtag = typeof bodyEtagValue === "string" ? bodyEtagValue : void 0;
2030
- const etag = bodyEtag ?? headerEtag;
2031
- return etag === void 0 ? {} : { etag };
1955
+ context = resolveRuntimeContext();
2032
1956
  } catch (error) {
2033
- if (controller.signal.aborted) {
2034
- throw new Error(`upload timed out after ${timeoutMs}ms`);
1957
+ if (error instanceof RuntimeContextError) {
1958
+ printError(error.message);
1959
+ process.exit(1);
2035
1960
  }
2036
1961
  throw error;
2037
- } finally {
2038
- clearTimeout(timeout);
2039
1962
  }
1963
+ if (context.mode !== "sandbox") {
1964
+ printError("moxt pull is only available in sandbox mode");
1965
+ process.exit(1);
1966
+ }
1967
+ return context;
2040
1968
  }
2041
- async function readUploadState(outputDir, runId) {
2042
- try {
2043
- const value = JSON.parse(await readFile2(join4(outputDir, "upload.json"), "utf8"));
2044
- if (isUploadState(value, runId)) {
2045
- return value;
1969
+ function handlePullError(error) {
1970
+ if (error instanceof Error) {
1971
+ printError(error.message);
1972
+ process.exit(1);
1973
+ }
1974
+ throw error;
1975
+ }
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");
1986
+ }
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);
2046
1993
  }
2047
- } catch {
1994
+ }
1995
+ const workerCount = Math.min(concurrency, items.length);
1996
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
1997
+ }
1998
+
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);
2048
2040
  }
2049
2041
  return {
2050
- schema_version: "runlog.upload.v1",
2051
- run_id: runId,
2052
- status: "pending",
2053
- updated_at: (/* @__PURE__ */ new Date()).toISOString(),
2054
- files: {}
2042
+ changes: changes.map((change) => ({ action: change.action, workspacePath: change.workspacePath })),
2043
+ sourceDir: resolvedSourceDir
2055
2044
  };
2056
2045
  }
2057
- async function writeUploadState(outputDir, state) {
2058
- await writeFile3(join4(outputDir, "upload.json"), `${JSON.stringify(state, null, 2)}
2059
- `, "utf8");
2060
- }
2061
- function uploadStatus(files, expectedPaths) {
2062
- if (expectedPaths.length === 0) {
2063
- return "pending";
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");
2064
2050
  }
2065
- const states = expectedPaths.map((path2) => files[path2]?.status ?? "pending");
2066
- if (states.some((status) => status === "failed")) {
2067
- return "failed";
2068
- }
2069
- if (states.every((status) => status === "uploaded")) {
2070
- return "uploaded";
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
+ );
2071
2056
  }
2072
- if (states.some((status) => status === "uploaded")) {
2073
- return "partial";
2057
+ if (state.checkoutRoot !== sourceDir) {
2058
+ throw new SandboxPushError(
2059
+ `Checkout root is ${state.checkoutRoot}, not ${sourceDir}`,
2060
+ "invalid-path"
2061
+ );
2074
2062
  }
2075
- return "uploading";
2076
- }
2077
- function isUploadState(value, runId) {
2078
- if (!isObject2(value)) {
2079
- return false;
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");
2067
+ }
2080
2068
  }
2081
- const { schema_version: schemaVersion, run_id: valueRunId, files } = value;
2082
- return schemaVersion === "runlog.upload.v1" && valueRunId === runId && isObject2(files);
2083
- }
2084
- async function responseJson(response) {
2085
- try {
2086
- return await response.json();
2087
- } catch {
2088
- return null;
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");
2089
2074
  }
2075
+ return state;
2090
2076
  }
2091
- function apiBaseUrl(env) {
2092
- return `https://${env.MOXT_HOST ?? DEFAULT_HOST2}/openapi/v1`;
2093
- }
2094
- function userAgent() {
2095
- return `moxt-cli/${"0.3.3-moxt-run.1"} (${platform2()}/${arch2()}) node/${process.versions.node}`;
2096
- }
2097
- function errorMessage2(error) {
2098
- if (error instanceof Error && error.message !== "") {
2099
- return error.message;
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");
2081
+ }
2082
+ throw error;
2083
+ });
2084
+ if (!stats.isDirectory()) {
2085
+ throw new SandboxPushError(`Local workspace path is not a directory: ${sourceDir}`, "invalid-path");
2100
2086
  }
2101
- return String(error);
2102
- }
2103
- function isObject2(value) {
2104
- return typeof value === "object" && value !== null && !Array.isArray(value);
2105
2087
  }
2106
-
2107
- // src/run/process-session.ts
2108
- import { execFileSync } from "child_process";
2109
- import { isAbsolute, relative as relative2 } from "path";
2110
- function findOpenSessionFile(options) {
2111
- if ((options.platform ?? process.platform) !== "darwin") {
2112
- return { match: "none" };
2113
- }
2114
- try {
2115
- const psOutput = execFileSync("ps", ["-axo", "pid=,ppid="], {
2116
- encoding: "utf8",
2117
- stdio: ["ignore", "pipe", "ignore"]
2118
- });
2119
- const pids = processTreePids(options.rootPid, psOutput);
2120
- const lsofOutput = execFileSync("lsof", ["-F", "pn", "-p", pids.join(",")], {
2121
- encoding: "utf8",
2122
- stdio: ["ignore", "pipe", "ignore"]
2123
- });
2124
- return openSessionFileFromLsof(lsofOutput, {
2125
- sessionRoot: options.sessionRoot,
2126
- ...options.isCandidate === void 0 ? {} : { isCandidate: options.isCandidate }
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 }))
2095
+ }
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;
2127
2121
  });
2128
- } catch {
2129
- return { match: "none" };
2122
+ if (!stats) continue;
2123
+ if (!stats.isDirectory()) {
2124
+ throw new SandboxPushError(`Local repo path is not a directory: ${repo.name}`, "invalid-path");
2125
+ }
2126
+ await collectRepoFiles(repoRoot, repo, "", remoteEntries, state, files);
2130
2127
  }
2128
+ return files;
2131
2129
  }
2132
- function processTreePids(rootPid, psOutput) {
2133
- const childrenByParent = /* @__PURE__ */ new Map();
2134
- for (const line of psOutput.split("\n")) {
2135
- const [pidText, ppidText] = line.trim().split(/\s+/, 2);
2136
- const pid = Number(pidText);
2137
- const ppid = Number(ppidText);
2138
- if (!Number.isInteger(pid) || !Number.isInteger(ppid) || pid <= 0) {
2139
- continue;
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");
2140
2142
  }
2141
- const children = childrenByParent.get(ppid) ?? [];
2142
- children.push(pid);
2143
- childrenByParent.set(ppid, children);
2144
- }
2145
- const pids = [];
2146
- const seen = /* @__PURE__ */ new Set();
2147
- const queue = [rootPid];
2148
- while (queue.length > 0) {
2149
- const pid = queue.shift();
2150
- if (seen.has(pid)) {
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);
2151
2149
  continue;
2152
2150
  }
2153
- seen.add(pid);
2154
- pids.push(pid);
2155
- queue.push(...childrenByParent.get(pid) ?? []);
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
+ );
2165
+ }
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
+ );
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
+ });
2156
2182
  }
2157
- return pids;
2158
2183
  }
2159
- function openSessionFileFromLsof(lsofOutput, options) {
2160
- const files = /* @__PURE__ */ new Set();
2161
- for (const line of lsofOutput.split("\n")) {
2162
- if (!line.startsWith("n")) {
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 });
2163
2199
  continue;
2164
2200
  }
2165
- const path2 = line.slice(1);
2166
- if (!path2.endsWith(".jsonl") || !isInside(options.sessionRoot, path2)) {
2167
- continue;
2201
+ if (existing && !existing.sha) {
2202
+ throw new SandboxPushError(`${file.workspacePath} does not have a base content hash`, "remote");
2168
2203
  }
2169
- if (options.isCandidate !== void 0 && !options.isCandidate(path2)) {
2170
- continue;
2204
+ if (baseline && existing?.sha !== baseline.sha) {
2205
+ throw new SandboxPushError(`Remote file changed since checkout: ${file.workspacePath}`, "conflict");
2171
2206
  }
2172
- files.add(alignPathToRoot(options.sessionRoot, path2));
2173
- }
2174
- if (files.size === 0) {
2175
- return { match: "none" };
2176
- }
2177
- if (files.size === 1) {
2178
- return { match: "unique", sessionFile: [...files].join("") };
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
+ });
2179
2215
  }
2180
- return { match: "ambiguous" };
2181
- }
2182
- function isInside(root, path2) {
2183
- if (isPathInside(root, path2)) {
2184
- return true;
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;
2224
+ }
2225
+ if (existing.type !== "blob" || existing.sha !== baseline.sha) {
2226
+ throw new SandboxPushError(`Remote file changed since checkout: ${workspacePath}`, "conflict");
2227
+ }
2228
+ changes.push({
2229
+ action: "delete",
2230
+ repo,
2231
+ repoPath: baseline.repoPath,
2232
+ workspacePath,
2233
+ fileId: validFileId(existing.fileId) ?? baseline.fileId
2234
+ });
2185
2235
  }
2186
- return isPathInside(normalizeDarwinVarPath(root), normalizeDarwinVarPath(path2));
2187
- }
2188
- function isPathInside(root, path2) {
2189
- const fromRoot = relative2(root, path2);
2190
- return fromRoot === "" || !fromRoot.startsWith("..") && !isAbsolute(fromRoot);
2191
- }
2192
- function normalizeDarwinVarPath(path2) {
2193
- return path2.startsWith("/private/var/") ? path2.slice("/private".length) : path2;
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
+ });
2194
2274
  }
2195
- function alignPathToRoot(root, path2) {
2196
- if (root.startsWith("/var/") && path2.startsWith("/private/var/")) {
2197
- return path2.slice("/private".length);
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
+ );
2198
2322
  }
2199
- if (root.startsWith("/private/var/") && path2.startsWith("/var/")) {
2200
- return `/private${path2}`;
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));
2201
2331
  }
2202
- return path2;
2332
+ await saveCheckoutState({ ...state, files });
2203
2333
  }
2204
-
2205
- // src/run/repo.ts
2206
- import { execFileSync as execFileSync2 } from "child_process";
2207
- function collectRepoFingerprint(cwd) {
2208
- const root = git(["rev-parse", "--show-toplevel"], cwd);
2209
- if (root === null) {
2210
- return null;
2211
- }
2334
+ function isWriteChange(change) {
2335
+ return change.action !== "delete";
2336
+ }
2337
+ function checkoutStateFile(file, fileId, fileType) {
2212
2338
  return {
2213
- root,
2214
- remote: git(["config", "--get", "remote.origin.url"], cwd) ?? "",
2215
- branch: git(["branch", "--show-current"], cwd) ?? "",
2216
- head: git(["rev-parse", "HEAD"], cwd) ?? ""
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
2217
2345
  };
2218
2346
  }
2219
- function git(args, cwd) {
2220
- try {
2221
- return execFileSync2("git", args, {
2222
- cwd,
2223
- encoding: "utf8",
2224
- stdio: ["ignore", "pipe", "ignore"]
2225
- }).trim();
2226
- } catch {
2227
- return null;
2228
- }
2347
+ function validFileId(fileId) {
2348
+ return typeof fileId === "string" && fileId.length > 0 ? fileId : null;
2229
2349
  }
2230
-
2231
- // src/run/session-dir.ts
2232
- import { closeSync, openSync, readSync, realpathSync } from "fs";
2233
- import { join as join5, resolve } from "path";
2234
- var CODEX_METADATA_READ_BYTES = 256 * 1024;
2235
- function claudeProjectDir(cwd, home) {
2236
- return join5(home, ".claude", "projects", encodeClaudeProjectPath(cwd));
2237
- }
2238
- function encodeClaudeProjectPath(cwd) {
2239
- return cwd.replaceAll(/[^A-Za-z0-9]/g, "-");
2240
- }
2241
- function agentSessionDir(options) {
2242
- switch (options.agent) {
2243
- case "claude":
2244
- return claudeProjectDir(options.cwd, options.home);
2245
- case "codex":
2246
- return codexSessionsDir(options.home, options.env);
2247
- }
2248
- }
2249
- function agentSessionCandidateFilter(options) {
2250
- switch (options.agent) {
2251
- case "claude":
2252
- return void 0;
2253
- case "codex": {
2254
- const expectedCwd = expectedCodexCwd(options.cwd, options.agentArgs);
2255
- return (path2) => {
2256
- try {
2257
- const sessionCwd = codexSessionCwd(path2);
2258
- return sessionCwd !== null && sameCodexCwd(sessionCwd, expectedCwd);
2259
- } catch {
2260
- return false;
2261
- }
2262
- };
2263
- }
2264
- }
2350
+ function validFileType(fileType) {
2351
+ return typeof fileType === "number" && Number.isInteger(fileType) && fileType >= 0 ? fileType : null;
2265
2352
  }
2266
- function agentKnownSessionFile(options) {
2267
- switch (options.agent) {
2268
- case "claude": {
2269
- const sessionId = claudeSessionIdArg(options.agentArgs) ?? (claudeForkSessionArg(options.agentArgs) ? void 0 : claudeResumeSessionIdArg(options.agentArgs));
2270
- return sessionId === void 0 ? void 0 : join5(claudeProjectDir(options.cwd, options.home), `${sessionId}.jsonl`);
2271
- }
2272
- case "codex":
2273
- return void 0;
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);
2274
2359
  }
2360
+ return result;
2275
2361
  }
2276
- function codexSessionsDir(home, env = {}) {
2277
- return join5(codexHome(home, env), "sessions");
2362
+ function remoteEntryKey(repoId, repoPath) {
2363
+ return `${repoId}\0${repoPath}`;
2278
2364
  }
2279
- function codexHome(home, env = {}) {
2280
- const { CODEX_HOME: configured } = env;
2281
- return hasValue2(configured) ? configured : join5(home, ".codex");
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
+ }
2282
2369
  }
2283
- function expectedCodexCwd(cwd, agentArgs) {
2284
- const cd = codexCdArg(agentArgs);
2285
- return cd === void 0 ? resolve(cwd) : resolve(cwd, cd);
2370
+ function assertSafeRepoName(sourceDir, repoName) {
2371
+ resolveSafeRepoRoot(sourceDir, repoName);
2286
2372
  }
2287
- function claudeSessionIdArg(agentArgs) {
2288
- const optionArgs = claudeOptionArgs(agentArgs);
2289
- for (let index = 0; index < optionArgs.length; index += 1) {
2290
- const arg = optionArgs[index];
2291
- if (arg === "--session-id") {
2292
- return optionArgs[index + 1];
2293
- }
2294
- if (arg.startsWith("--session-id=")) {
2295
- return arg.slice("--session-id=".length);
2296
- }
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");
2297
2379
  }
2298
- return void 0;
2380
+ return repoRoot;
2299
2381
  }
2300
- function claudeResumeSessionIdArg(agentArgs) {
2301
- const optionArgs = claudeOptionArgs(agentArgs);
2302
- for (let index = 0; index < optionArgs.length; index += 1) {
2303
- const arg = optionArgs[index];
2304
- if (arg === "--resume" || arg === "-r") {
2305
- const value = optionArgs[index + 1];
2306
- return isUuid(value) ? value : void 0;
2307
- }
2308
- if (arg.startsWith("--resume=")) {
2309
- const value = arg.slice("--resume=".length);
2310
- return isUuid(value) ? value : void 0;
2311
- }
2312
- }
2313
- return void 0;
2382
+ function isNodeError3(error) {
2383
+ return error instanceof Error && "code" in error;
2314
2384
  }
2315
- function codexSessionCwd(path2) {
2316
- const text = readFilePrefix(path2, CODEX_METADATA_READ_BYTES);
2317
- for (const line of text.split("\n")) {
2318
- const cwd = codexLineCwd(line);
2319
- if (cwd !== null) {
2320
- return cwd;
2385
+
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();
2390
+ try {
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);
2321
2403
  }
2322
- }
2323
- return null;
2404
+ });
2324
2405
  }
2325
- function codexCdArg(agentArgs) {
2326
- const optionArgs = codexOptionArgs(agentArgs);
2327
- for (let index = 0; index < optionArgs.length; index += 1) {
2328
- const arg = optionArgs[index];
2329
- if (arg === "--cd" || arg === "-C") {
2330
- return optionArgs[index + 1];
2331
- }
2332
- if (arg.startsWith("--cd=")) {
2333
- return arg.slice("--cd=".length);
2406
+ function resolveSandboxRuntimeContextOrExit3() {
2407
+ let context;
2408
+ try {
2409
+ context = resolveRuntimeContext();
2410
+ } catch (error) {
2411
+ if (error instanceof RuntimeContextError) {
2412
+ printError(error.message);
2413
+ process.exit(1);
2334
2414
  }
2415
+ throw error;
2335
2416
  }
2336
- return void 0;
2337
- }
2338
- function codexOptionArgs(agentArgs) {
2339
- const separatorIndex = agentArgs.indexOf("--");
2340
- return separatorIndex === -1 ? agentArgs : agentArgs.slice(0, separatorIndex);
2341
- }
2342
- function sameCodexCwd(left, right) {
2343
- return normalizeCodexCwd(left) === normalizeCodexCwd(right);
2344
- }
2345
- function normalizeCodexCwd(path2) {
2346
- try {
2347
- return realpathSync(path2);
2348
- } catch {
2349
- return resolve(path2);
2417
+ if (context.mode !== "sandbox") {
2418
+ printError("moxt push is only available in sandbox mode");
2419
+ process.exit(1);
2350
2420
  }
2421
+ return context;
2351
2422
  }
2352
- function claudeForkSessionArg(agentArgs) {
2353
- return claudeOptionArgs(agentArgs).includes("--fork-session");
2354
- }
2355
- function claudeOptionArgs(agentArgs) {
2356
- const separatorIndex = agentArgs.indexOf("--");
2357
- return separatorIndex === -1 ? agentArgs : agentArgs.slice(0, separatorIndex);
2358
- }
2359
- function isUuid(value) {
2360
- 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);
2423
+ function handlePushError(error) {
2424
+ if (error instanceof Error) {
2425
+ printError(error.message);
2426
+ process.exit(1);
2427
+ }
2428
+ throw error;
2361
2429
  }
2362
- function codexLineCwd(line) {
2363
- if (line.trim() === "") {
2364
- return null;
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";
2365
2440
  }
2366
- let value;
2367
- try {
2368
- value = JSON.parse(line);
2369
- } catch {
2370
- return null;
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);
2371
2447
  }
2372
- const entry = rawCodexSessionLine(value);
2373
- if (entry === null) {
2374
- return null;
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 });
2375
2453
  }
2376
- const type = entry.type;
2377
- if (type !== "session_meta" && type !== "turn_context") {
2378
- return null;
2454
+ for (const path2 of Object.keys(state.files)) {
2455
+ if (isCheckoutPathIncluded(state, path2) && !localFiles.has(path2)) changes.push({ action: "deleted", path: path2 });
2379
2456
  }
2380
- const payload = rawCodexSessionPayload(entry.payload);
2381
- const cwd = payload?.cwd;
2382
- return typeof cwd === "string" ? cwd : null;
2457
+ return changes.sort((left, right) => left.path.localeCompare(right.path));
2383
2458
  }
2384
- function readFilePrefix(path2, bytes) {
2385
- const fd = openSync(path2, "r");
2459
+ async function requireCheckoutDirectory(checkoutRoot) {
2460
+ let stats;
2386
2461
  try {
2387
- const buffer2 = Buffer.alloc(bytes);
2388
- const read = readSync(fd, buffer2, 0, bytes, 0);
2389
- return buffer2.subarray(0, read).toString("utf8");
2390
- } finally {
2391
- closeSync(fd);
2462
+ stats = await lstat3(checkoutRoot);
2463
+ } catch (error) {
2464
+ throw new CheckoutStatusError(`Checkout root is not available: ${errorMessage2(error)}`);
2392
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}`);
2393
2468
  }
2394
- function objectValue2(value) {
2395
- return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
2396
- }
2397
- function rawCodexSessionLine(value) {
2398
- return objectValue2(value);
2399
- }
2400
- function rawCodexSessionPayload(value) {
2401
- return objectValue2(value);
2402
- }
2403
- function hasValue2(value) {
2404
- return value !== void 0 && value !== "";
2405
- }
2406
-
2407
- // src/run/run-agent.ts
2408
- var MAX_TRANSCRIPT_CAPTURE_BYTES = 1024 * 1024;
2409
- var SESSION_SETTLE_TIMEOUT_MS = 1500;
2410
- var SESSION_SETTLE_INTERVAL_MS = 100;
2411
- var PROCESS_SESSION_POLL_INTERVAL_MS = 100;
2412
- var CODEX_OPTIONS_WITH_VALUE = /* @__PURE__ */ new Set([
2413
- "-a",
2414
- "--add-dir",
2415
- "--ask-for-approval",
2416
- "-c",
2417
- "-C",
2418
- "--cd",
2419
- "--color",
2420
- "--config",
2421
- "-i",
2422
- "--image",
2423
- "--local-provider",
2424
- "-m",
2425
- "--model",
2426
- "-o",
2427
- "--output-last-message",
2428
- "--output-schema",
2429
- "-p",
2430
- "--profile",
2431
- "--remote",
2432
- "--remote-auth-token-env",
2433
- "-s",
2434
- "--sandbox"
2435
- ]);
2436
- async function runLocalAgent(options) {
2437
- const cwd = options.cwd ?? process.cwd();
2438
- const env = options.env ?? process.env;
2439
- const home = options.home ?? env.HOME ?? homedir2();
2440
- const startedAtMs = Date.now();
2441
- const startedAt = new Date(startedAtMs).toISOString();
2442
- const runId = createCaptureRunId(startedAt, options.agent);
2443
- const captureEnv = withDefaultCaptureOutput(env, home);
2469
+ async function scanDirectory(state, workspacePath, localPath, files) {
2470
+ let entries;
2444
2471
  try {
2445
- await prepareCaptureOutputs(captureEnv, runId);
2472
+ entries = await readdir2(localPath, { withFileTypes: true });
2446
2473
  } catch (error) {
2447
- printPrepareError(options.agent, error);
2448
- return 1;
2449
- }
2450
- const repo = collectRepoFingerprint(cwd);
2451
- let baselineOk = false;
2452
- let before = /* @__PURE__ */ new Map();
2453
- let sessions = "";
2454
- let isSessionCandidate;
2455
- let knownSessionFile;
2456
- let childRootPid;
2457
- let openSessionFileMatch = { match: "none" };
2458
- let processSessionTimer;
2459
- const newSessionFilesOnly = shouldUseNewSessionFilesOnly(options.agent, options.agentArgs);
2460
- try {
2461
- const sessionOptions = {
2462
- agent: options.agent,
2463
- cwd,
2464
- home,
2465
- env: captureEnv,
2466
- agentArgs: options.agentArgs
2467
- };
2468
- sessions = agentSessionDir(sessionOptions);
2469
- isSessionCandidate = agentSessionCandidateFilter(sessionOptions);
2470
- knownSessionFile = agentKnownSessionFile(sessionOptions);
2471
- before = snapshotJsonl(sessions);
2472
- baselineOk = true;
2473
- } catch {
2474
- baselineOk = false;
2474
+ if (isNodeError4(error) && error.code === "ENOENT") return;
2475
+ throw new CheckoutStatusError(`Failed to inspect ${workspacePath}: ${errorMessage2(error)}`);
2475
2476
  }
2476
- const rememberOpenSessionFile = () => {
2477
- if (!baselineOk || sessions === "" || childRootPid === void 0 || openSessionFileMatch.match !== "none") {
2478
- return openSessionFileMatch;
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}`);
2479
2485
  }
2480
- const matched = findOpenSessionFile({
2481
- rootPid: childRootPid,
2482
- sessionRoot: sessions,
2483
- ...isSessionCandidate === void 0 ? {} : { isCandidate: isSessionCandidate }
2484
- });
2485
- if (matched.match !== "none") {
2486
- openSessionFileMatch = matched;
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));
2487
2490
  }
2488
- return openSessionFileMatch;
2489
- };
2490
- const stopProcessSessionWatcher = () => {
2491
- if (processSessionTimer !== void 0) {
2492
- clearInterval(processSessionTimer);
2493
- processSessionTimer = void 0;
2491
+ }
2492
+ }
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");
2498
+ }
2499
+ function isNodeError4(error) {
2500
+ return error instanceof Error && "code" in error;
2501
+ }
2502
+ function errorMessage2(error) {
2503
+ return error instanceof Error ? error.message : String(error);
2504
+ }
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);
2494
2513
  }
2495
- };
2496
- const liveCapture = createLiveCapture({
2497
- env: captureEnv,
2498
- runId,
2499
- agent: options.agent,
2500
- startedAt,
2501
- sessions,
2502
- before,
2503
- baselineOk,
2504
- startedAtMs,
2505
- ...knownSessionFile === void 0 ? {} : { knownSessionFile },
2506
- ...isSessionCandidate === void 0 ? {} : { isSessionCandidate },
2507
- ...newSessionFilesOnly ? { newSessionFilesOnly: true } : {},
2508
- openSessionFile: rememberOpenSessionFile
2509
- });
2510
- try {
2511
- await liveCapture.start();
2512
- } catch (error) {
2513
- printError(
2514
- [
2515
- "moxt: cannot prepare run output",
2516
- `reason: ${errorMessage3(error)}`,
2517
- "",
2518
- `${options.agent} was not started because this run cannot be recorded.`
2519
- ].join("\n")
2520
- );
2521
- return 1;
2522
- }
2523
- const observedSignals = /* @__PURE__ */ new Set();
2524
- let child = null;
2525
- let pendingForward = null;
2526
- const observe = (signal) => {
2527
- const number = signalNumber(signal);
2528
- if (number !== 0) observedSignals.add(number);
2529
- };
2530
- const terminalSignalsReachChild = process.stdin.isTTY === true || process.stdout.isTTY === true || process.stderr.isTTY === true;
2531
- const forwardInteractiveSignal = (signal) => {
2532
- observe(signal);
2533
- if (terminalSignalsReachChild) {
2514
+ const status = await buildSandboxStatusOrExit(context);
2515
+ if (options.json) {
2516
+ print(JSON.stringify(status, null, 2));
2534
2517
  return;
2535
2518
  }
2536
- if (child === null) {
2537
- pendingForward = signal;
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`);
2538
2535
  return;
2539
2536
  }
2540
- safeKill(child, signal);
2541
- };
2542
- const onInt = () => forwardInteractiveSignal("SIGINT");
2543
- const onQuit = () => forwardInteractiveSignal("SIGQUIT");
2544
- const onTerm = () => {
2545
- observe("SIGTERM");
2546
- if (child === null) pendingForward = "SIGTERM";
2547
- else safeKill(child, "SIGTERM");
2548
- };
2549
- const onHup = () => {
2550
- observe("SIGHUP");
2551
- if (child === null) pendingForward = "SIGHUP";
2552
- else safeKill(child, "SIGHUP");
2553
- };
2554
- process.on("SIGINT", onInt);
2555
- process.on("SIGQUIT", onQuit);
2556
- process.on("SIGTERM", onTerm);
2557
- process.on("SIGHUP", onHup);
2558
- const spawned = spawn(options.agent, [...options.agentArgs], {
2559
- cwd,
2560
- env: captureEnv,
2561
- stdio: "inherit"
2562
- });
2563
- child = spawned;
2564
- childRootPid = spawned.pid;
2565
- if (childRootPid !== void 0 && baselineOk) {
2566
- rememberOpenSessionFile();
2567
- processSessionTimer = setInterval(() => {
2568
- rememberOpenSessionFile();
2569
- }, PROCESS_SESSION_POLL_INTERVAL_MS);
2570
- }
2571
- if (pendingForward !== null) {
2572
- safeKill(spawned, pendingForward);
2573
- }
2574
- const exit = await waitForExit(spawned, options.agent);
2575
- stopProcessSessionWatcher();
2576
- const onCaptureSignal = () => {
2577
- };
2578
- process.on("SIGINT", onCaptureSignal);
2579
- process.on("SIGQUIT", onCaptureSignal);
2580
- process.on("SIGTERM", onCaptureSignal);
2581
- process.on("SIGHUP", onCaptureSignal);
2582
- process.off("SIGINT", onInt);
2583
- process.off("SIGQUIT", onQuit);
2584
- process.off("SIGTERM", onTerm);
2585
- process.off("SIGHUP", onHup);
2586
- const classified = classifyExit(exit, observedSignals);
2587
- const endedAtMs = exit.waitAt;
2588
- const endedAt = new Date(endedAtMs).toISOString();
2589
- const durationMs = endedAtMs - startedAtMs;
2590
- let after;
2591
- if (baselineOk) {
2592
- try {
2593
- after = await waitForSessionSettle(
2594
- sessions,
2595
- before,
2596
- startedAtMs,
2597
- isSessionCandidate,
2598
- newSessionFilesOnly
2599
- );
2600
- } catch {
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}`);
2601
2542
  }
2602
- }
2603
- try {
2604
- await liveCapture.stop();
2605
- } catch {
2606
- }
2607
- const payload = buildPayload({
2608
- agent: options.agent,
2609
- cwd,
2610
- startedAt,
2611
- endedAt,
2612
- durationMs,
2613
- exitCode: classified.code,
2614
- status: classified.status,
2615
- repo: sanitizeRepo(repo),
2616
- baselineOk,
2617
- sessions,
2618
- before,
2619
- after,
2620
- startedAtMs,
2621
- knownSessionFile,
2622
- isSessionCandidate,
2623
- newSessionFilesOnly,
2624
- openSessionFileMatch
2625
2543
  });
2626
- let finalized = false;
2627
- try {
2628
- if (liveCapture.enabled) {
2629
- await liveCapture.finalize(payload, (/* @__PURE__ */ new Date()).toISOString());
2630
- } else {
2631
- await writeCaptureDirectoryPayload(payload, captureEnv, runId, (/* @__PURE__ */ new Date()).toISOString());
2632
- }
2633
- finalized = true;
2634
- } catch (error) {
2635
- printError(`moxt: cannot finalize run artifact: ${errorMessage3(error)}`);
2636
- } finally {
2637
- process.off("SIGINT", onCaptureSignal);
2638
- process.off("SIGQUIT", onCaptureSignal);
2639
- process.off("SIGTERM", onCaptureSignal);
2640
- process.off("SIGHUP", onCaptureSignal);
2641
- }
2642
- if (finalized && options.upload !== void 0) {
2643
- try {
2644
- await uploadRunArtifacts({
2645
- env: captureEnv,
2646
- runId,
2647
- upload: options.upload
2648
- });
2649
- } catch (error) {
2650
- printError(`moxt: cannot upload run artifact: ${errorMessage3(error)}`);
2651
- }
2652
- }
2653
- return classified.code;
2654
2544
  }
2655
- async function waitForSessionSettle(sessions, before, startedAtMs, isSessionCandidate, newSessionFilesOnly) {
2656
- const deadline = Date.now() + SESSION_SETTLE_TIMEOUT_MS;
2657
- let after = snapshotJsonl(sessions);
2658
- while (!hasSessionChange(before, after, startedAtMs, isSessionCandidate, newSessionFilesOnly) && Date.now() < deadline) {
2659
- await sleep(SESSION_SETTLE_INTERVAL_MS);
2660
- after = snapshotJsonl(sessions);
2661
- }
2662
- return after;
2663
- }
2664
- function hasSessionChange(before, after, startedAtMs, isSessionCandidate, newSessionFilesOnly) {
2665
- const matched = matchSession(before, after, startedAtMs, matchOptions(isSessionCandidate, newSessionFilesOnly));
2666
- if (matched.match !== "unique") {
2667
- return matched.match !== "none";
2668
- }
2669
- if (matched.sessionFile === null) {
2670
- return false;
2671
- }
2672
- const snapshot = after.get(matched.sessionFile);
2673
- return snapshot !== void 0 && snapshot.size > matched.startOffset;
2674
- }
2675
- function sleep(ms) {
2676
- return new Promise((resolve2) => setTimeout(resolve2, ms));
2677
- }
2678
- function safeKill(child, signal) {
2545
+ async function buildSandboxStatusOrExit(context) {
2546
+ const repos = listRepoEntries(context.repoPayload).map((repo) => ({
2547
+ name: repo.name,
2548
+ repoId: repo.repoId
2549
+ }));
2679
2550
  try {
2680
- child.kill(signal);
2681
- } catch {
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));
2568
+ process.exit(1);
2682
2569
  }
2683
2570
  }
2684
- function waitForExit(child, agent) {
2685
- return new Promise((resolve2) => {
2686
- child.once("error", (error) => {
2687
- const errorCode = error.code ?? "UNKNOWN";
2688
- const reason = errorCode === "ENOENT" ? "command not found" : error.message;
2689
- printError(`moxt: cannot start ${agent}: ${reason}`);
2690
- resolve2({
2691
- code: null,
2692
- signal: null,
2693
- waitAt: Date.now(),
2694
- errorCode
2695
- });
2696
- });
2697
- child.once("exit", (code, signal) => {
2698
- resolve2({ code, signal, waitAt: Date.now() });
2699
- });
2700
- });
2701
- }
2702
- function buildPayload(input) {
2703
- let sessionMatch = input.baselineOk ? "none" : "prep_failed";
2704
- let transcript;
2705
- let redactionFailed = false;
2706
- if (input.baselineOk) {
2707
- try {
2708
- const after = input.after ?? snapshotJsonl(input.sessions);
2709
- const knownMatched = input.knownSessionFile === void 0 ? null : matchKnownSessionFile(input.before, after, input.knownSessionFile);
2710
- const matched = knownMatched?.match === "unique" ? knownMatched : input.openSessionFileMatch.match === "unique" ? matchKnownSessionFile(input.before, after, input.openSessionFileMatch.sessionFile) : input.openSessionFileMatch.match === "ambiguous" ? {
2711
- match: "ambiguous",
2712
- sessionFile: null,
2713
- startOffset: 0
2714
- } : matchSession(
2715
- input.before,
2716
- after,
2717
- input.startedAtMs,
2718
- matchOptions(input.isSessionCandidate, input.newSessionFilesOnly)
2719
- );
2720
- sessionMatch = matched.match;
2721
- if (matched.match === "unique" && matched.sessionFile !== null) {
2722
- try {
2723
- transcript = redactText(readFileFromOffset2(matched.sessionFile, matched.startOffset)).text;
2724
- } catch {
2725
- redactionFailed = true;
2726
- }
2727
- }
2728
- } catch {
2729
- sessionMatch = "prep_failed";
2730
- }
2731
- }
2571
+ function buildUninitializedStatus(workspaceId, repos) {
2732
2572
  return {
2733
- ...transcript === void 0 ? {} : { transcript_redacted: transcript },
2734
- ...redactionFailed ? { redaction_failed: true } : {},
2735
- run: {
2736
- agent: input.agent,
2737
- cwd: input.cwd,
2738
- git_root: input.repo?.root ?? null,
2739
- started_at: input.startedAt,
2740
- ended_at: input.endedAt,
2741
- duration_ms: input.durationMs,
2742
- status: input.status,
2743
- exit_code: input.exitCode
2573
+ mode: "sandbox",
2574
+ workspaceId,
2575
+ checkout: {
2576
+ mode: "not_initialized",
2577
+ checkedOutScopes: [],
2578
+ localChangesTracked: false
2744
2579
  },
2745
- recording: recordingFromSessionMatch(sessionMatch),
2746
- repo: input.repo
2580
+ repos
2747
2581
  };
2748
2582
  }
2749
- function matchOptions(isSessionCandidate, newSessionFilesOnly) {
2750
- return {
2751
- ...isSessionCandidate === void 0 ? {} : { isCandidate: isSessionCandidate },
2752
- ...newSessionFilesOnly ? { newFilesOnly: true } : {}
2753
- };
2754
- }
2755
- function shouldUseNewSessionFilesOnly(agent, agentArgs) {
2756
- return agent === "codex" && !isCodexResumeInvocation(agentArgs);
2757
- }
2758
- function isCodexResumeInvocation(agentArgs) {
2759
- const command = nextCodexCommandArg(agentArgs, 0);
2760
- if (command === null) {
2761
- return false;
2762
- }
2763
- if (command.value === "resume") {
2764
- return true;
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}`);
2765
2586
  }
2766
- if (command.value !== "exec" && command.value !== "e") {
2767
- return false;
2768
- }
2769
- return nextCodexCommandArg(agentArgs, command.index + 1)?.value === "resume";
2770
- }
2771
- function nextCodexCommandArg(agentArgs, startIndex) {
2772
- for (let index = startIndex; index < agentArgs.length; index += 1) {
2773
- const arg = agentArgs[index];
2774
- if (arg === void 0) {
2775
- continue;
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}`);
2776
2593
  }
2777
- if (arg === "--") {
2778
- return null;
2779
- }
2780
- if (arg.startsWith("-")) {
2781
- if (!arg.includes("=") && CODEX_OPTIONS_WITH_VALUE.has(arg)) {
2782
- index += 1;
2783
- }
2784
- continue;
2785
- }
2786
- return { value: arg, index };
2787
2594
  }
2788
- return null;
2789
2595
  }
2790
- function readFileFromOffset2(path2, offset) {
2791
- const fd = openSync2(path2, "r");
2596
+ function resolveRuntimeContextOrExit2() {
2792
2597
  try {
2793
- const stat2 = fstatSync(fd);
2794
- const length = Math.max(0, stat2.size - offset);
2795
- if (length > MAX_TRANSCRIPT_CAPTURE_BYTES) {
2796
- throw new Error("transcript capture exceeds maximum size");
2797
- }
2798
- const buffer2 = Buffer.alloc(length);
2799
- if (length === 0) {
2800
- return "";
2801
- }
2802
- readSync2(fd, buffer2, 0, length, offset);
2803
- return buffer2.toString("utf8");
2804
- } finally {
2805
- closeSync2(fd);
2806
- }
2807
- }
2808
- function printPrepareError(agent, error) {
2809
- if (error instanceof CaptureOutputPrepareError) {
2810
- printError(
2811
- [
2812
- "moxt: cannot create run output directory",
2813
- `path: ${error.path}`,
2814
- `reason: ${error.message}`,
2815
- "",
2816
- `${agent} was not started because this run cannot be recorded.`
2817
- ].join("\n")
2818
- );
2819
- return;
2820
- }
2821
- printError(
2822
- [
2823
- "moxt: cannot prepare run output",
2824
- `reason: ${errorMessage3(error)}`,
2825
- "",
2826
- `${agent} was not started because this run cannot be recorded.`
2827
- ].join("\n")
2828
- );
2829
- }
2830
- function errorMessage3(error) {
2831
- if (error instanceof Error && error.message !== "") {
2832
- return error.message;
2833
- }
2834
- return String(error);
2835
- }
2836
-
2837
- // src/commands/run.ts
2838
- function registerRunCommand(program2) {
2839
- 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) => {
2840
- const agent = parseAgent(agentArg);
2841
- const upload = resolveUploadOptions(options);
2842
- const exitCode = await runLocalAgentOrExit(agent, agentArgs ?? [], upload);
2843
- process.exit(exitCode);
2844
- });
2845
- }
2846
- function parseAgent(agent) {
2847
- if (agent === "claude" || agent === "codex") {
2848
- return agent;
2849
- }
2850
- printError(`Error: moxt run supports only "claude" or "codex", got "${agent}".`);
2851
- process.exit(1);
2852
- }
2853
- function resolveUploadOptions(options) {
2854
- const workspaceId = resolveWorkspaceId(options);
2855
- if (workspaceId === void 0) {
2856
- return void 0;
2857
- }
2858
- if (!process.env.MOXT_API_KEY) {
2859
- printError("Error: MOXT_API_KEY is required when using moxt run upload mode with -w/--workspace or MOXT_WORKSPACE_ID.");
2860
- process.exit(1);
2598
+ return resolveRuntimeContext();
2599
+ } catch (error) {
2600
+ if (error instanceof RuntimeContextError) {
2601
+ printError(error.message);
2602
+ process.exit(1);
2603
+ }
2604
+ throw error;
2861
2605
  }
2862
- return {
2863
- workspaceId
2864
- };
2865
2606
  }
2866
- function resolveWorkspaceId(options) {
2867
- return normalizeWorkspaceId(options.workspace) ?? normalizeWorkspaceId(process.env.MOXT_WORKSPACE_ID);
2868
- }
2869
- function normalizeWorkspaceId(value) {
2870
- const trimmed = value?.trim();
2871
- return trimmed === void 0 || trimmed === "" ? void 0 : trimmed;
2872
- }
2873
- async function runLocalAgentOrExit(agent, agentArgs, upload) {
2874
- return await runLocalAgent({
2875
- agent,
2876
- agentArgs,
2877
- ...upload === void 0 ? {} : { upload }
2878
- });
2607
+ function formatCheckoutMode(mode) {
2608
+ return mode === "not_initialized" ? "not initialized" : `${mode} checkout`;
2879
2609
  }
2880
2610
 
2881
2611
  // src/telemetry/config.ts
2882
- import * as fs2 from "fs";
2612
+ import * as fs3 from "fs";
2883
2613
  import * as os from "os";
2884
2614
  import * as path from "path";
2885
2615
  function getConfigPath() {
@@ -2887,7 +2617,7 @@ function getConfigPath() {
2887
2617
  }
2888
2618
  function readTelemetryConfig() {
2889
2619
  try {
2890
- const raw = fs2.readFileSync(getConfigPath(), "utf8");
2620
+ const raw = fs3.readFileSync(getConfigPath(), "utf8");
2891
2621
  const parsed = JSON.parse(raw);
2892
2622
  return parsed.telemetry ?? {};
2893
2623
  } catch {
@@ -2898,14 +2628,14 @@ function writeTelemetryConfig(update) {
2898
2628
  const configPath = getConfigPath();
2899
2629
  let existing = {};
2900
2630
  try {
2901
- existing = JSON.parse(fs2.readFileSync(configPath, "utf8"));
2631
+ existing = JSON.parse(fs3.readFileSync(configPath, "utf8"));
2902
2632
  } catch {
2903
2633
  existing = {};
2904
2634
  }
2905
2635
  const currentTelemetry = existing.telemetry ?? {};
2906
2636
  const next = { ...existing, telemetry: { ...currentTelemetry, ...update } };
2907
- fs2.mkdirSync(path.dirname(configPath), { recursive: true });
2908
- 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));
2909
2639
  }
2910
2640
 
2911
2641
  // src/telemetry/opt-out.ts
@@ -3077,31 +2807,34 @@ function registerWorkspaceCommand(program2) {
3077
2807
 
3078
2808
  // src/program.ts
3079
2809
  function createProgram(version) {
3080
- const program2 = new Command();
2810
+ const program2 = new Command2();
3081
2811
  program2.name("moxt").description("Moxt CLI - AI Workspace").version(version).action(() => {
3082
2812
  program2.help();
3083
2813
  });
2814
+ registerAuthCommand(program2);
3084
2815
  registerWhoamiCommand(program2);
3085
2816
  registerWorkspaceCommand(program2);
3086
2817
  registerFileCommand(program2);
3087
2818
  registerMemoryCommand(program2);
3088
- registerRunCommand(program2);
3089
2819
  registerMiniappCommand(program2);
2820
+ registerPullCommand(program2);
2821
+ registerPushCommand(program2);
2822
+ registerStatusCommand(program2);
3090
2823
  registerTelemetryCommand(program2);
3091
2824
  return program2;
3092
2825
  }
3093
2826
 
3094
2827
  // src/telemetry/client.ts
3095
- import { arch as arch3, platform as platform3 } from "os";
2828
+ import { arch as arch2, platform as platform2 } from "os";
3096
2829
  var buffer = [];
3097
2830
  var FLUSH_TIMEOUT_MS = 3e3;
3098
2831
  var MAX_BATCH_SIZE = 100;
3099
2832
  function commonLabels() {
3100
2833
  return {
3101
- cli_version: "0.3.3-moxt-run.1",
2834
+ cli_version: "0.4.0-beta.0",
3102
2835
  node_version: process.versions.node,
3103
- os: platform3(),
3104
- arch: arch3(),
2836
+ os: platform2(),
2837
+ arch: arch2(),
3105
2838
  is_ci: isCiEnvironment() ? "true" : "false"
3106
2839
  };
3107
2840
  }
@@ -3144,7 +2877,7 @@ async function flush() {
3144
2877
  }
3145
2878
  const batch = buffer.splice(0, MAX_BATCH_SIZE);
3146
2879
  const payload = {
3147
- release_id: "0.3.3-moxt-run.1",
2880
+ release_id: "0.4.0-beta.0",
3148
2881
  client_type: "cli",
3149
2882
  metric_data_list: batch.map((m) => ({
3150
2883
  profile: "cli",
@@ -3285,9 +3018,9 @@ function installExitHandler() {
3285
3018
 
3286
3019
  // src/index.ts
3287
3020
  updateNotifier({
3288
- pkg: { name: "@moxt-ai/cli", version: "0.3.3-moxt-run.1" }
3021
+ pkg: { name: "@moxt-ai/cli", version: "0.4.0-beta.0" }
3289
3022
  }).notify();
3290
- var program = createProgram("0.3.3-moxt-run.1");
3023
+ var program = createProgram("0.4.0-beta.0");
3291
3024
  instrumentProgram(program);
3292
3025
  installExitHandler();
3293
3026
  program.parseAsync(process.argv).then(async () => {