@ricsam/r5d-worker 0.0.10 → 0.0.12

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/mjs/main.mjs CHANGED
@@ -1,9 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  import fs from "node:fs";
3
- import os from "node:os";
4
3
  import path from "node:path";
5
- import { createHash, randomUUID } from "node:crypto";
6
- import { hostname } from "node:os";
4
+ import { createHash } from "node:crypto";
5
+ import os, { hostname } from "node:os";
7
6
  import { spawn as spawnChildProcess } from "node:child_process";
8
7
  import { configureVisibleGitIdentity } from "./git-identity.mjs";
9
8
  const DEFAULT_BASE_URL = "https://r5d.dev";
@@ -11,6 +10,7 @@ const WORKER_PACKAGE_NAME = "@ricsam/r5d-worker";
11
10
  const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/;
12
11
  const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9]$|^[A-Za-z0-9]$/;
13
12
  const DEFAULT_READ_LIMIT = 2e3;
13
+ const DEFAULT_READ_MAX_BYTES = 5e4;
14
14
  const MAX_LINE_LENGTH = 2e3;
15
15
  const MAX_SYNC_DIFF_BYTES = 5 * 1024 * 1024;
16
16
  const R5D_REMOTE_NAME = "r5d";
@@ -30,6 +30,9 @@ function defaultProjectsRoot() {
30
30
  function defaultSyncRoot() {
31
31
  return path.join(os.homedir(), ".r5d", "sync");
32
32
  }
33
+ function defaultArtifactRoot() {
34
+ return path.join(os.homedir(), ".r5d", "artifacts");
35
+ }
33
36
  function printHelp() {
34
37
  process.stdout.write(`Usage:
35
38
  r5d-worker start --label <label> [--base-url <url>] [--token <token>] [--api-key <key>]
@@ -43,6 +46,7 @@ Options:
43
46
  --config <path> Config path (default ~/.config/r5d/r5dctl/config.json)
44
47
  --project-root <dir> Override projects checkout root (default ~/.r5d/projects)
45
48
  --sync-root <dir> Override r5d internal sync git root (default ~/.r5d/sync)
49
+ --artifact-root <dir> Override session artifact root (default from R5D_ARTIFACTS_ROOT or ~/.r5d/artifacts)
46
50
  -v, --version Show r5d-worker version
47
51
  -h, --help Show this help
48
52
  `);
@@ -159,6 +163,11 @@ function parseArgs(argv) {
159
163
  options.syncRoot = syncRoot;
160
164
  continue;
161
165
  }
166
+ const artifactRoot = readOption("--artifact-root");
167
+ if (artifactRoot !== void 0) {
168
+ options.artifactRoot = artifactRoot;
169
+ continue;
170
+ }
162
171
  rest.push(arg);
163
172
  }
164
173
  if (options.version) {
@@ -201,6 +210,170 @@ async function readResponseText(response) {
201
210
  return "";
202
211
  }
203
212
  }
213
+ const R5D_ARTIFACTS_DIR_REF = "$R5D_ARTIFACTS_DIR";
214
+ function validateArtifactSessionId(sessionId) {
215
+ if (!/^(migration-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) {
216
+ throw new Error(`Invalid artifact session id: ${sessionId}`);
217
+ }
218
+ }
219
+ function assertInsideRoot(rootPath, candidatePath, label) {
220
+ const relative = path.relative(rootPath, candidatePath);
221
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
222
+ throw new Error(`${label} escapes the artifact directory`);
223
+ }
224
+ }
225
+ function sessionArtifactDir(artifactRoot, sessionId) {
226
+ validateArtifactSessionId(sessionId);
227
+ return path.join(artifactRoot, sessionId);
228
+ }
229
+ function artifactEnv(artifactRoot, sessionId) {
230
+ if (!sessionId) {
231
+ return {};
232
+ }
233
+ return {
234
+ R5D_ARTIFACTS_DIR: sessionArtifactDir(artifactRoot, sessionId)
235
+ };
236
+ }
237
+ function isArtifactEnvPath(filePath) {
238
+ return filePath === R5D_ARTIFACTS_DIR_REF || filePath.startsWith(`${R5D_ARTIFACTS_DIR_REF}/`);
239
+ }
240
+ function artifactApiBasePath(projectId, branchName, sessionId) {
241
+ return `/preview-api/${encodeURIComponent(projectId)}/${encodeURIComponent(branchName)}/artifacts/${encodeURIComponent(sessionId)}`;
242
+ }
243
+ function artifactManifestUrl(baseUrl, projectId, branchName, sessionId) {
244
+ return new URL(artifactApiBasePath(projectId, branchName, sessionId), baseUrl);
245
+ }
246
+ function artifactDownloadUrl(baseUrl, projectId, branchName, sessionId, filename) {
247
+ return new URL(`${artifactApiBasePath(projectId, branchName, sessionId)}/${encodeURIComponent(filename)}`, baseUrl);
248
+ }
249
+ function sha256Path(filePath) {
250
+ return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
251
+ }
252
+ function parseSessionArtifactManifest(value) {
253
+ if (!value || typeof value !== "object" || !Array.isArray(value.artifacts)) {
254
+ throw new Error("Artifact manifest response is invalid");
255
+ }
256
+ return value.artifacts.map((entry) => {
257
+ if (!entry || typeof entry !== "object") {
258
+ throw new Error("Artifact manifest entry is invalid");
259
+ }
260
+ const artifact = entry;
261
+ if (typeof artifact.filename !== "string" || artifact.filename.includes("/") || artifact.filename.includes("\\")) {
262
+ throw new Error("Artifact manifest entry has an invalid filename");
263
+ }
264
+ if (typeof artifact.size !== "number" || !Number.isFinite(artifact.size) || artifact.size < 0) {
265
+ throw new Error(`Artifact manifest entry has an invalid size: ${artifact.filename}`);
266
+ }
267
+ if (typeof artifact.sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(artifact.sha256)) {
268
+ throw new Error(`Artifact manifest entry has an invalid sha256: ${artifact.filename}`);
269
+ }
270
+ return {
271
+ filename: artifact.filename,
272
+ size: artifact.size,
273
+ sha256: artifact.sha256.toLowerCase()
274
+ };
275
+ });
276
+ }
277
+ async function fetchSessionArtifactManifest(input) {
278
+ const response = await fetch(artifactManifestUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId), {
279
+ headers: {
280
+ Authorization: `Bearer ${input.token}`
281
+ }
282
+ });
283
+ if (!response.ok) {
284
+ const detail = (await readResponseText(response)).trim();
285
+ throw new Error(`Failed to fetch artifact manifest (${response.status})${detail ? `: ${detail}` : ""}`);
286
+ }
287
+ return parseSessionArtifactManifest(await response.json());
288
+ }
289
+ async function downloadSessionArtifact(input) {
290
+ const targetDir = sessionArtifactDir(input.artifactRoot, input.sessionId);
291
+ const targetPath = path.join(targetDir, input.artifact.filename);
292
+ assertInsideRoot(targetDir, targetPath, "Artifact path");
293
+ const response = await fetch(
294
+ artifactDownloadUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId, input.artifact.filename),
295
+ {
296
+ headers: {
297
+ Authorization: `Bearer ${input.token}`
298
+ }
299
+ }
300
+ );
301
+ if (!response.ok) {
302
+ const detail = (await readResponseText(response)).trim();
303
+ throw new Error(`Failed to download artifact ${input.artifact.filename} (${response.status})${detail ? `: ${detail}` : ""}`);
304
+ }
305
+ const buffer = Buffer.from(await response.arrayBuffer());
306
+ const receivedHash = createHash("sha256").update(buffer).digest("hex");
307
+ if (receivedHash !== input.artifact.sha256) {
308
+ throw new Error(`Downloaded artifact ${input.artifact.filename} failed sha256 verification`);
309
+ }
310
+ if (buffer.length !== input.artifact.size) {
311
+ throw new Error(`Downloaded artifact ${input.artifact.filename} has unexpected size`);
312
+ }
313
+ const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
314
+ fs.writeFileSync(tempPath, buffer);
315
+ fs.renameSync(tempPath, targetPath);
316
+ }
317
+ async function syncSessionArtifacts(input) {
318
+ const targetDir = sessionArtifactDir(input.artifactRoot, input.sessionId);
319
+ fs.mkdirSync(targetDir, { recursive: true });
320
+ const artifacts = await fetchSessionArtifactManifest(input);
321
+ const manifestNames = new Set(artifacts.map((artifact) => artifact.filename));
322
+ for (const artifact of artifacts) {
323
+ const localPath = path.join(targetDir, artifact.filename);
324
+ assertInsideRoot(targetDir, localPath, "Artifact path");
325
+ if (fs.existsSync(localPath)) {
326
+ const stats = fs.statSync(localPath);
327
+ if (stats.isFile() && stats.size === artifact.size && sha256Path(localPath) === artifact.sha256) {
328
+ continue;
329
+ }
330
+ fs.rmSync(localPath, { recursive: true, force: true });
331
+ }
332
+ await downloadSessionArtifact({ ...input, artifact });
333
+ }
334
+ for (const localName of fs.readdirSync(targetDir)) {
335
+ if (!manifestNames.has(localName)) {
336
+ const localPath = path.join(targetDir, localName);
337
+ assertInsideRoot(targetDir, localPath, "Artifact path");
338
+ fs.rmSync(localPath, { recursive: true, force: true });
339
+ }
340
+ }
341
+ return targetDir;
342
+ }
343
+ async function resolveArtifactEnvFilePath(input) {
344
+ if (!isArtifactEnvPath(input.filePath)) {
345
+ return null;
346
+ }
347
+ if (!input.sessionId) {
348
+ throw new Error(`${R5D_ARTIFACTS_DIR_REF} paths require an active chat session`);
349
+ }
350
+ if (!input.filePath.startsWith(`${R5D_ARTIFACTS_DIR_REF}/`)) {
351
+ throw new Error(`Artifact path must reference a file under ${R5D_ARTIFACTS_DIR_REF}/`);
352
+ }
353
+ const targetDir = await syncSessionArtifacts({
354
+ baseUrl: input.baseUrl,
355
+ token: input.token,
356
+ projectId: input.projectId,
357
+ branchName: input.branchName,
358
+ sessionId: input.sessionId,
359
+ artifactRoot: input.artifactRoot
360
+ });
361
+ const relativePath = path.posix.normalize(input.filePath.slice(`${R5D_ARTIFACTS_DIR_REF}/`.length));
362
+ if (!relativePath || relativePath === "." || relativePath.startsWith("../") || relativePath.includes("\0")) {
363
+ throw new Error(`Invalid artifact path: ${input.filePath}`);
364
+ }
365
+ const absolutePath = path.resolve(targetDir, ...relativePath.split("/"));
366
+ assertInsideRoot(targetDir, absolutePath, "Artifact path");
367
+ return {
368
+ absolutePath,
369
+ virtualPath: `${R5D_ARTIFACTS_DIR_REF}/${relativePath}`
370
+ };
371
+ }
372
+ function rejectArtifactWritePath(filePath) {
373
+ if (isArtifactEnvPath(filePath)) {
374
+ throw new Error(`${R5D_ARTIFACTS_DIR_REF} attachments are read-only. Copy them into a project path before editing or creating files.`);
375
+ }
376
+ }
204
377
  async function verifyR5dctlAuth(baseUrl, token) {
205
378
  const statusUrl = new URL("/api/r5dctl/auth/status", baseUrl);
206
379
  let response;
@@ -712,16 +885,36 @@ function formatLineNumberedContent(content, offset, limit) {
712
885
  const endLine = Math.min(startLine + readLimit - 1, totalLines);
713
886
  const selectedLines = allLines.slice(startLine - 1, endLine);
714
887
  const padWidth = String(endLine).length;
715
- const formattedLines = selectedLines.map((line, index) => {
888
+ const formattedLines = [];
889
+ let actualEndLine = endLine;
890
+ let budgetTruncated = false;
891
+ for (let index = 0; index < selectedLines.length; index += 1) {
892
+ const line = selectedLines[index] ?? "";
716
893
  const lineNumber = startLine + index;
717
894
  const truncatedLine = line.length > MAX_LINE_LENGTH ? `${line.slice(0, MAX_LINE_LENGTH)}...` : line;
718
- return `${String(lineNumber).padStart(padWidth, " ")}\u2192${truncatedLine}`;
719
- });
895
+ const formattedLine = `${String(lineNumber).padStart(padWidth, " ")}\u2192${truncatedLine}`;
896
+ const candidateLength = formattedLines.length === 0 ? formattedLine.length : formattedLine.length + 1;
897
+ const currentLength = formattedLines.join("\n").length;
898
+ if (currentLength + candidateLength > DEFAULT_READ_MAX_BYTES) {
899
+ budgetTruncated = true;
900
+ break;
901
+ }
902
+ formattedLines.push(formattedLine);
903
+ actualEndLine = lineNumber;
904
+ }
905
+ let formattedContent = formattedLines.join("\n");
906
+ if (budgetTruncated) {
907
+ formattedContent = `${formattedContent}
908
+ ... (truncated by byte budget)`;
909
+ }
910
+ const truncated = budgetTruncated || actualEndLine < totalLines;
720
911
  return {
721
- content: formattedLines.join("\n"),
912
+ content: formattedContent,
722
913
  totalLines,
723
914
  startLine,
724
- endLine
915
+ endLine: actualEndLine,
916
+ truncated,
917
+ continuation: truncated ? `Call read with offset ${actualEndLine + 1} to continue.` : void 0
725
918
  };
726
919
  }
727
920
  function readPngDimensions(bytes) {
@@ -788,7 +981,51 @@ function readImageDimensions(bytes, mediaType) {
788
981
  }
789
982
  return dimensions;
790
983
  }
984
+ const fileMutationQueues = /* @__PURE__ */ new Map();
985
+ async function withFileMutationQueue(key, operation) {
986
+ const previous = fileMutationQueues.get(key) ?? Promise.resolve();
987
+ let release;
988
+ const current = previous.catch(() => void 0).then(() => new Promise((resolve) => {
989
+ release = resolve;
990
+ }));
991
+ fileMutationQueues.set(key, current);
992
+ await previous.catch(() => void 0);
993
+ try {
994
+ return await operation();
995
+ } finally {
996
+ release();
997
+ if (fileMutationQueues.get(key) === current) {
998
+ fileMutationQueues.delete(key);
999
+ }
1000
+ }
1001
+ }
1002
+ function mutationQueueKey(projectId, branchName, filePath) {
1003
+ return `${projectId}:${branchName}:${path.posix.normalize(filePath)}`;
1004
+ }
791
1005
  async function executeReadFileOperation(input) {
1006
+ const artifact = await resolveArtifactEnvFilePath({
1007
+ filePath: input.message.filePath,
1008
+ baseUrl: input.baseUrl,
1009
+ token: input.token,
1010
+ projectId: input.projectId,
1011
+ branchName: input.message.branchName,
1012
+ sessionId: input.message.sessionId,
1013
+ artifactRoot: input.artifactRoot
1014
+ });
1015
+ if (artifact) {
1016
+ if (!fs.existsSync(artifact.absolutePath) || !fs.statSync(artifact.absolutePath).isFile()) {
1017
+ throw new Error(`File not found: ${artifact.virtualPath}`);
1018
+ }
1019
+ const buffer2 = fs.readFileSync(artifact.absolutePath);
1020
+ const formatted2 = formatLineNumberedContent(buffer2.toString("utf8"), input.message.offset, input.message.limit);
1021
+ return {
1022
+ type: "read",
1023
+ kind: "text",
1024
+ file: artifact.virtualPath,
1025
+ gitBlobHash: getGitBlobHashForContent(buffer2),
1026
+ ...formatted2
1027
+ };
1028
+ }
792
1029
  const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
793
1030
  const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
794
1031
  if (!fs.existsSync(resolved.absolutePath) || !fs.statSync(resolved.absolutePath).isFile()) {
@@ -797,222 +1034,242 @@ async function executeReadFileOperation(input) {
797
1034
  const buffer = fs.readFileSync(resolved.absolutePath);
798
1035
  const formatted = formatLineNumberedContent(buffer.toString("utf8"), input.message.offset, input.message.limit);
799
1036
  return {
800
- type: "read_file",
1037
+ type: "read",
1038
+ kind: "text",
801
1039
  file: resolved.virtualPath,
802
1040
  gitBlobHash: getGitBlobHashForContent(buffer),
803
1041
  ...formatted
804
1042
  };
805
1043
  }
806
- async function executeCreateFileOperation(input) {
807
- const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
808
- const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
809
- if (fs.existsSync(resolved.absolutePath)) {
810
- throw new Error(`File "${resolved.virtualPath}" already exists. Use edit_file to modify existing files.`);
811
- }
812
- fs.mkdirSync(path.dirname(resolved.absolutePath), { recursive: true });
813
- fs.writeFileSync(resolved.absolutePath, input.message.content, "utf8");
814
- return {
815
- type: "create_file",
816
- file: resolved.virtualPath,
817
- gitBlobHash: getGitBlobHashForContent(input.message.content)
818
- };
1044
+ async function executeWriteFileOperation(input) {
1045
+ rejectArtifactWritePath(input.message.filePath);
1046
+ return withFileMutationQueue(mutationQueueKey(input.projectId, input.message.branchName, input.message.filePath), async () => {
1047
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1048
+ const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
1049
+ fs.mkdirSync(path.dirname(resolved.absolutePath), { recursive: true });
1050
+ fs.writeFileSync(resolved.absolutePath, input.message.content, "utf8");
1051
+ return {
1052
+ type: "write",
1053
+ file: resolved.virtualPath,
1054
+ gitBlobHash: getGitBlobHashForContent(input.message.content)
1055
+ };
1056
+ });
819
1057
  }
820
1058
  async function executeEditFileOperation(input) {
821
- const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
822
- const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
823
- if (input.message.oldString === input.message.newString) {
824
- throw new Error("old_string and new_string must be different");
825
- }
826
- if (!fs.existsSync(resolved.absolutePath) || !fs.statSync(resolved.absolutePath).isFile()) {
827
- throw new Error(`File not found: ${resolved.virtualPath}`);
828
- }
829
- const content = fs.readFileSync(resolved.absolutePath, "utf8");
830
- if (!content.includes(input.message.oldString)) {
831
- throw new Error(
832
- `Could not find old_string: "${input.message.oldString.slice(0, 100)}${input.message.oldString.length > 100 ? "..." : ""}"`
833
- );
834
- }
835
- if (!input.message.replaceAll) {
836
- const occurrences = content.split(input.message.oldString).length - 1;
837
- if (occurrences > 1) {
838
- throw new Error(
839
- `old_string is not unique in the file (found ${occurrences} occurrences). Provide more context or use replace_all: true.`
840
- );
1059
+ rejectArtifactWritePath(input.message.filePath);
1060
+ return withFileMutationQueue(mutationQueueKey(input.projectId, input.message.branchName, input.message.filePath), async () => {
1061
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1062
+ const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
1063
+ if (!Array.isArray(input.message.edits) || input.message.edits.length === 0) {
1064
+ throw new Error("edit requires at least one replacement");
1065
+ }
1066
+ if (!fs.existsSync(resolved.absolutePath) || !fs.statSync(resolved.absolutePath).isFile()) {
1067
+ throw new Error(`File not found: ${resolved.virtualPath}`);
1068
+ }
1069
+ const originalContent = fs.readFileSync(resolved.absolutePath, "utf8");
1070
+ const ranges = input.message.edits.map((edit, index) => {
1071
+ if (edit.oldText === edit.newText) {
1072
+ throw new Error(`edits[${index}].oldText and edits[${index}].newText must be different`);
1073
+ }
1074
+ if (edit.oldText.length === 0) {
1075
+ throw new Error(`edits[${index}].oldText cannot be empty`);
1076
+ }
1077
+ const occurrences = originalContent.split(edit.oldText).length - 1;
1078
+ if (occurrences === 0) {
1079
+ throw new Error(`Could not find edits[${index}].oldText: "${edit.oldText.slice(0, 100)}${edit.oldText.length > 100 ? "..." : ""}"`);
1080
+ }
1081
+ if (occurrences > 1) {
1082
+ throw new Error(`edits[${index}].oldText is not unique in the file (found ${occurrences} occurrences). Include more surrounding context.`);
1083
+ }
1084
+ const start = originalContent.indexOf(edit.oldText);
1085
+ return { index, start, end: start + edit.oldText.length, edit };
1086
+ });
1087
+ const sortedRanges = [...ranges].sort((a, b) => a.start - b.start);
1088
+ for (let index = 1; index < sortedRanges.length; index += 1) {
1089
+ const previous = sortedRanges[index - 1];
1090
+ const current = sortedRanges[index];
1091
+ if (current.start < previous.end) {
1092
+ throw new Error(`edits[${current.index}] overlaps edits[${previous.index}]. Merge nearby changes into one replacement.`);
1093
+ }
841
1094
  }
842
- }
843
- const nextContent = input.message.replaceAll ? content.split(input.message.oldString).join(input.message.newString) : content.replace(input.message.oldString, input.message.newString);
844
- fs.writeFileSync(resolved.absolutePath, nextContent, "utf8");
845
- return {
846
- type: "edit_file",
847
- file: resolved.virtualPath
848
- };
1095
+ let nextContent = originalContent;
1096
+ for (const range of [...sortedRanges].reverse()) {
1097
+ nextContent = nextContent.slice(0, range.start) + range.edit.newText + nextContent.slice(range.end);
1098
+ }
1099
+ fs.writeFileSync(resolved.absolutePath, nextContent, "utf8");
1100
+ return {
1101
+ type: "edit",
1102
+ file: resolved.virtualPath,
1103
+ edits: input.message.edits.length
1104
+ };
1105
+ });
849
1106
  }
850
- function decodePatchPath(rawPath) {
851
- const trimmed = rawPath.trim();
852
- if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
853
- try {
854
- return JSON.parse(trimmed);
855
- } catch {
856
- return trimmed.slice(1, -1);
1107
+ const IGNORED_ENTRY_NAMES = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", ".next", ".cache", "coverage", "target"]);
1108
+ function normalizeToolLimit(value, fallback, max) {
1109
+ if (!Number.isFinite(value) || value === void 0) return fallback;
1110
+ return Math.max(1, Math.min(max, Math.floor(value)));
1111
+ }
1112
+ function escapeRegex(value) {
1113
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1114
+ }
1115
+ function wildcardToRegex(pattern) {
1116
+ const escaped = escapeRegex(pattern).replace(/\\\*/g, ".*").replace(/\\\?/g, ".");
1117
+ return new RegExp(`^${escaped}$`);
1118
+ }
1119
+ function normalizeFindPattern(pattern) {
1120
+ return /[*?]/.test(pattern) ? pattern : `*${pattern}*`;
1121
+ }
1122
+ function toVirtualPath(branchPath, absolutePath) {
1123
+ const relative = path.relative(branchPath, absolutePath).split(path.sep).join(path.posix.sep);
1124
+ return relative ? `/${relative}` : "/";
1125
+ }
1126
+ function walkProjectEntries(branchPath, startPath) {
1127
+ const entries = [];
1128
+ const visit = (absolutePath) => {
1129
+ const stat = fs.statSync(absolutePath);
1130
+ const virtualPath = toVirtualPath(branchPath, absolutePath);
1131
+ entries.push({ absolutePath, virtualPath, stat });
1132
+ if (!stat.isDirectory()) return;
1133
+ const children = fs.readdirSync(absolutePath, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
1134
+ for (const child of children) {
1135
+ if (IGNORED_ENTRY_NAMES.has(child.name)) continue;
1136
+ visit(path.join(absolutePath, child.name));
857
1137
  }
858
- }
859
- return trimmed;
1138
+ };
1139
+ visit(startPath);
1140
+ return entries;
860
1141
  }
861
- function normalizePatchPath(rawPath) {
862
- const withoutMetadata = rawPath.split(" ")[0] ?? rawPath;
863
- let patchPath = decodePatchPath(withoutMetadata);
864
- if (patchPath === "/dev/null") {
865
- return null;
866
- }
867
- if (patchPath.startsWith("a/") || patchPath.startsWith("b/")) {
868
- patchPath = patchPath.slice(2);
869
- }
870
- if (!patchPath || path.isAbsolute(patchPath)) {
871
- throw new Error(`Invalid patch path: ${rawPath}`);
872
- }
873
- const normalized = path.posix.normalize(patchPath);
874
- if (normalized === "." || normalized.startsWith("../") || normalized === "..") {
875
- throw new Error(`Invalid patch path: ${rawPath}`);
876
- }
877
- return normalized;
1142
+ function isProbablyText(bytes) {
1143
+ return !bytes.subarray(0, Math.min(bytes.length, 4096)).includes(0);
878
1144
  }
879
- function readDiffPathToken(value, startIndex) {
880
- let index = startIndex;
881
- while (index < value.length && /\s/.test(value[index] ?? "")) {
882
- index += 1;
883
- }
884
- if (index >= value.length) {
885
- throw new Error("Missing path in patch header");
886
- }
887
- if (value[index] !== '"') {
888
- const tokenStart2 = index;
889
- while (index < value.length && !/\s/.test(value[index] ?? "")) {
890
- index += 1;
891
- }
892
- return { token: value.slice(tokenStart2, index), nextIndex: index };
893
- }
894
- const tokenStart = index;
895
- index += 1;
896
- let escaped = false;
897
- while (index < value.length) {
898
- const character = value[index];
899
- if (escaped) {
900
- escaped = false;
901
- } else if (character === "\\") {
902
- escaped = true;
903
- } else if (character === '"') {
904
- index += 1;
905
- return { token: value.slice(tokenStart, index), nextIndex: index };
1145
+ async function executeGrepOperation(input) {
1146
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1147
+ const start = resolveProjectFilePath(workspace.branchPath, input.message.path ?? "/");
1148
+ if (!fs.existsSync(start.absolutePath)) {
1149
+ throw new Error(`Path not found: ${start.virtualPath}`);
1150
+ }
1151
+ const limit = normalizeToolLimit(input.message.limit, 100, 1e3);
1152
+ const flags = input.message.caseSensitive === false ? "i" : "";
1153
+ const regex = new RegExp(input.message.pattern, flags);
1154
+ const globRegex = input.message.glob ? wildcardToRegex(input.message.glob) : null;
1155
+ const lines = [];
1156
+ let matchCount = 0;
1157
+ let truncated = false;
1158
+ for (const entry of walkProjectEntries(workspace.branchPath, start.absolutePath)) {
1159
+ if (!entry.stat.isFile()) continue;
1160
+ const repoRelative = entry.virtualPath.slice(1);
1161
+ if (globRegex && !globRegex.test(repoRelative)) continue;
1162
+ const bytes = fs.readFileSync(entry.absolutePath);
1163
+ if (!isProbablyText(bytes)) continue;
1164
+ const fileLines = bytes.toString("utf8").split(/\r?\n/);
1165
+ for (let index = 0; index < fileLines.length; index += 1) {
1166
+ const line = fileLines[index] ?? "";
1167
+ regex.lastIndex = 0;
1168
+ const match = regex.exec(line);
1169
+ if (!match) continue;
1170
+ matchCount += 1;
1171
+ if (lines.length < limit) {
1172
+ const column = Math.max(1, (match.index ?? 0) + 1);
1173
+ lines.push(`${entry.virtualPath}:${index + 1}:${column}: ${line.slice(0, MAX_LINE_LENGTH)}`);
1174
+ } else {
1175
+ truncated = true;
1176
+ }
906
1177
  }
907
- index += 1;
908
- }
909
- throw new Error("Unterminated quoted path in patch header");
910
- }
911
- function parseDiffGitPaths(line) {
912
- if (!line.startsWith("diff --git ")) {
913
- throw new Error(`Invalid patch header: ${line}`);
914
- }
915
- const rest = line.slice("diff --git ".length);
916
- const first = readDiffPathToken(rest, 0);
917
- const second = readDiffPathToken(rest, first.nextIndex);
918
- if (rest.slice(second.nextIndex).trim()) {
919
- throw new Error(`Invalid patch header: ${line}`);
920
1178
  }
1179
+ const hint = truncated ? `
1180
+ ... truncated. Narrow path/glob or increase limit to continue.` : "";
921
1181
  return {
922
- oldPath: normalizePatchPath(first.token),
923
- newPath: normalizePatchPath(second.token)
1182
+ type: "grep",
1183
+ content: lines.length > 0 ? `${lines.join("\n")}${hint}` : "No matches.",
1184
+ matchCount,
1185
+ truncated
924
1186
  };
925
1187
  }
926
- function summarizePatch(patch) {
927
- if (!patch.trim()) {
928
- throw new Error("Patch cannot be empty");
929
- }
930
- const files = [];
931
- let current;
932
- for (const line of patch.split(/\r?\n/)) {
933
- if (line.startsWith("diff --git ")) {
934
- const { oldPath, newPath } = parseDiffGitPaths(line);
935
- if (!oldPath && !newPath) {
936
- throw new Error(`Invalid patch header: ${line}`);
937
- }
938
- current = {
939
- path: newPath ?? oldPath,
940
- ...oldPath && newPath && oldPath !== newPath ? { oldPath } : {},
941
- operation: oldPath && newPath && oldPath !== newPath ? "move" : "update",
942
- additions: 0,
943
- deletions: 0,
944
- hunks: 0
945
- };
946
- files.push(current);
947
- continue;
948
- }
949
- if (!current) {
950
- continue;
951
- }
952
- if (line.startsWith("new file mode ")) {
953
- current.operation = "add";
954
- continue;
955
- }
956
- if (line.startsWith("deleted file mode ")) {
957
- current.operation = "delete";
958
- if (current.oldPath) {
959
- current.path = current.oldPath;
960
- delete current.oldPath;
961
- }
962
- continue;
963
- }
964
- if (line.startsWith("rename from ")) {
965
- current.operation = "move";
966
- current.oldPath = normalizePatchPath(line.slice("rename from ".length)) ?? void 0;
967
- continue;
968
- }
969
- if (line.startsWith("rename to ")) {
970
- current.operation = "move";
971
- current.path = normalizePatchPath(line.slice("rename to ".length)) ?? current.path;
972
- continue;
973
- }
974
- if (line.startsWith("@@")) {
975
- current.hunks += 1;
976
- continue;
977
- }
978
- if (line.startsWith("+") && !line.startsWith("+++")) {
979
- current.additions += 1;
980
- continue;
981
- }
982
- if (line.startsWith("-") && !line.startsWith("---")) {
983
- current.deletions += 1;
1188
+ async function executeFindOperation(input) {
1189
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1190
+ const start = resolveProjectFilePath(workspace.branchPath, input.message.path ?? "/");
1191
+ if (!fs.existsSync(start.absolutePath) || !fs.statSync(start.absolutePath).isDirectory()) {
1192
+ throw new Error(`Directory not found: ${start.virtualPath}`);
1193
+ }
1194
+ const pattern = normalizeFindPattern(input.message.pattern ?? "*");
1195
+ const matcher = wildcardToRegex(pattern);
1196
+ const entryType = input.message.entryType ?? "file";
1197
+ const limit = normalizeToolLimit(input.message.limit, 200, 1e3);
1198
+ const matches = [];
1199
+ let count = 0;
1200
+ let truncated = false;
1201
+ for (const entry of walkProjectEntries(workspace.branchPath, start.absolutePath)) {
1202
+ if (entry.virtualPath === start.virtualPath) continue;
1203
+ const isDirectory = entry.stat.isDirectory();
1204
+ if (entryType === "file" && !entry.stat.isFile()) continue;
1205
+ if (entryType === "directory" && !isDirectory) continue;
1206
+ const label = isDirectory ? `${entry.virtualPath}/` : entry.virtualPath;
1207
+ if (!matcher.test(path.posix.basename(entry.virtualPath)) && !matcher.test(entry.virtualPath.slice(1))) continue;
1208
+ count += 1;
1209
+ if (matches.length < limit) {
1210
+ matches.push(label);
1211
+ } else {
1212
+ truncated = true;
984
1213
  }
985
1214
  }
986
- if (files.length === 0) {
987
- throw new Error("Patch must contain at least one diff --git file header");
988
- }
989
- return files;
1215
+ const hint = truncated ? "\n... truncated. Narrow path/pattern or increase limit to continue." : "";
1216
+ return {
1217
+ type: "find",
1218
+ content: matches.length > 0 ? `${matches.join("\n")}${hint}` : "No paths found.",
1219
+ count,
1220
+ truncated
1221
+ };
990
1222
  }
991
- async function executeApplyPatchOperation(input) {
1223
+ async function executeLsOperation(input) {
992
1224
  const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
993
- const files = summarizePatch(input.message.patch);
994
- const patchPath = path.join(os.tmpdir(), `r5d-apply-patch-${randomUUID()}.patch`);
995
- try {
996
- fs.writeFileSync(patchPath, input.message.patch, "utf8");
997
- runInternalGit(workspace.internalGit, ["apply", "--check", "--whitespace=nowarn", patchPath]);
998
- runInternalGit(workspace.internalGit, ["apply", "--whitespace=nowarn", patchPath]);
999
- } catch (error) {
1000
- throw new Error(`Patch did not apply: ${error instanceof Error ? error.message : String(error)}`);
1001
- } finally {
1002
- fs.rmSync(patchPath, { force: true });
1003
- }
1225
+ const resolved = resolveProjectFilePath(workspace.branchPath, input.message.path ?? "/");
1226
+ if (!fs.existsSync(resolved.absolutePath) || !fs.statSync(resolved.absolutePath).isDirectory()) {
1227
+ throw new Error(`Directory not found: ${resolved.virtualPath}`);
1228
+ }
1229
+ const limit = normalizeToolLimit(input.message.limit, 200, 1e3);
1230
+ const entries = fs.readdirSync(resolved.absolutePath, { withFileTypes: true }).filter((entry) => !IGNORED_ENTRY_NAMES.has(entry.name)).sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
1231
+ const displayed = entries.slice(0, limit).map((entry) => `${entry.name}${entry.isDirectory() ? "/" : ""}`);
1232
+ const truncated = entries.length > displayed.length;
1233
+ const hint = truncated ? "\n... truncated. Increase limit to continue." : "";
1004
1234
  return {
1005
- type: "apply_patch",
1006
- files,
1007
- summary: {
1008
- filesChanged: files.length,
1009
- additions: files.reduce((sum, file) => sum + file.additions, 0),
1010
- deletions: files.reduce((sum, file) => sum + file.deletions, 0),
1011
- hunks: files.reduce((sum, file) => sum + file.hunks, 0)
1012
- }
1235
+ type: "ls",
1236
+ path: resolved.virtualPath,
1237
+ content: displayed.length > 0 ? `${displayed.join("\n")}${hint}` : "(empty directory)",
1238
+ count: entries.length,
1239
+ truncated
1013
1240
  };
1014
1241
  }
1015
1242
  async function executeViewFileBytesOperation(input) {
1243
+ const artifact = await resolveArtifactEnvFilePath({
1244
+ filePath: input.message.filePath,
1245
+ baseUrl: input.baseUrl,
1246
+ token: input.token,
1247
+ projectId: input.projectId,
1248
+ branchName: input.message.branchName,
1249
+ sessionId: input.message.sessionId,
1250
+ artifactRoot: input.artifactRoot
1251
+ });
1252
+ if (artifact) {
1253
+ if (!fs.existsSync(artifact.absolutePath) || !fs.statSync(artifact.absolutePath).isFile()) {
1254
+ throw new Error(`File not found: ${artifact.virtualPath}`);
1255
+ }
1256
+ const extension2 = path.extname(artifact.absolutePath).toLowerCase();
1257
+ const mediaType2 = extension2 === ".jpg" || extension2 === ".jpeg" ? "image/jpeg" : extension2 === ".png" ? "image/png" : null;
1258
+ if (!mediaType2) {
1259
+ throw new Error("read supports PNG and JPEG image inputs only");
1260
+ }
1261
+ const bytes2 = fs.readFileSync(artifact.absolutePath);
1262
+ const dimensions2 = readImageDimensions(bytes2, mediaType2);
1263
+ return {
1264
+ type: "view_file_bytes",
1265
+ filePath: artifact.virtualPath,
1266
+ mediaType: mediaType2,
1267
+ base64: bytes2.toString("base64"),
1268
+ fileSizeBytes: bytes2.length,
1269
+ width: dimensions2.width,
1270
+ height: dimensions2.height
1271
+ };
1272
+ }
1016
1273
  const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1017
1274
  const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
1018
1275
  if (!fs.existsSync(resolved.absolutePath) || !fs.statSync(resolved.absolutePath).isFile()) {
@@ -1021,7 +1278,7 @@ async function executeViewFileBytesOperation(input) {
1021
1278
  const extension = path.extname(resolved.absolutePath).toLowerCase();
1022
1279
  const mediaType = extension === ".jpg" || extension === ".jpeg" ? "image/jpeg" : extension === ".png" ? "image/png" : null;
1023
1280
  if (!mediaType) {
1024
- throw new Error("view_image supports PNG and JPEG files only");
1281
+ throw new Error("read supports PNG and JPEG image inputs only");
1025
1282
  }
1026
1283
  const bytes = fs.readFileSync(resolved.absolutePath);
1027
1284
  const dimensions = readImageDimensions(bytes, mediaType);
@@ -1127,14 +1384,18 @@ function pullBranch(input) {
1127
1384
  }
1128
1385
  async function executeOperation(input) {
1129
1386
  switch (input.message.type) {
1130
- case "read_file":
1387
+ case "read":
1131
1388
  return executeReadFileOperation({ ...input, message: input.message });
1132
- case "create_file":
1133
- return executeCreateFileOperation({ ...input, message: input.message });
1134
- case "edit_file":
1389
+ case "write":
1390
+ return executeWriteFileOperation({ ...input, message: input.message });
1391
+ case "edit":
1135
1392
  return executeEditFileOperation({ ...input, message: input.message });
1136
- case "apply_patch":
1137
- return executeApplyPatchOperation({ ...input, message: input.message });
1393
+ case "grep":
1394
+ return executeGrepOperation({ ...input, message: input.message });
1395
+ case "find":
1396
+ return executeFindOperation({ ...input, message: input.message });
1397
+ case "ls":
1398
+ return executeLsOperation({ ...input, message: input.message });
1138
1399
  case "view_file_bytes":
1139
1400
  return executeViewFileBytesOperation({ ...input, message: input.message });
1140
1401
  case "sync":
@@ -1189,6 +1450,18 @@ async function executeCommand(input) {
1189
1450
  branchName: input.message.branchName,
1190
1451
  manifest: input.manifest
1191
1452
  });
1453
+ let artifactProcessEnv = {};
1454
+ if (input.message.sessionId) {
1455
+ await syncSessionArtifacts({
1456
+ baseUrl: input.baseUrl,
1457
+ token: input.token,
1458
+ projectId: input.projectId,
1459
+ branchName: input.message.branchName,
1460
+ sessionId: input.message.sessionId,
1461
+ artifactRoot: input.artifactRoot
1462
+ });
1463
+ artifactProcessEnv = artifactEnv(input.artifactRoot, input.message.sessionId);
1464
+ }
1192
1465
  const cwd = resolveCommandCwd(workspace.branchPath, input.message.cwd);
1193
1466
  const subprocess = Bun.spawn(input.message.argv, {
1194
1467
  cwd,
@@ -1198,7 +1471,8 @@ async function executeCommand(input) {
1198
1471
  ...process.env,
1199
1472
  ...containerRegistryEnv(),
1200
1473
  ...input.message.env ?? {},
1201
- ...internalGitProcessEnv(workspace, input.message.env)
1474
+ ...internalGitProcessEnv(workspace, input.message.env),
1475
+ ...artifactProcessEnv
1202
1476
  }
1203
1477
  });
1204
1478
  activeProcesses.set(input.message.runId, { process: subprocess, command: input.message.argv });
@@ -1261,6 +1535,15 @@ async function executeStreamingCommand(input) {
1261
1535
  branchName: input.message.branchName,
1262
1536
  manifest: input.manifest
1263
1537
  });
1538
+ await syncSessionArtifacts({
1539
+ baseUrl: input.baseUrl,
1540
+ token: input.token,
1541
+ projectId: input.projectId,
1542
+ branchName: input.message.branchName,
1543
+ sessionId: input.message.sessionId,
1544
+ artifactRoot: input.artifactRoot
1545
+ });
1546
+ const artifactProcessEnv = artifactEnv(input.artifactRoot, input.message.sessionId);
1264
1547
  const cwd = resolveCommandCwd(workspace.branchPath, input.message.cwd);
1265
1548
  const subprocess = Bun.spawn(input.message.argv, {
1266
1549
  cwd,
@@ -1270,7 +1553,8 @@ async function executeStreamingCommand(input) {
1270
1553
  ...process.env,
1271
1554
  ...containerRegistryEnv(),
1272
1555
  ...input.message.env ?? {},
1273
- ...internalGitProcessEnv(workspace, input.message.env)
1556
+ ...internalGitProcessEnv(workspace, input.message.env),
1557
+ ...artifactProcessEnv
1274
1558
  }
1275
1559
  });
1276
1560
  activeProcesses.set(input.message.runId, { process: subprocess, command: input.message.argv });
@@ -1645,11 +1929,14 @@ async function startWorker(options) {
1645
1929
  validateLabel(label);
1646
1930
  const projectsRoot = path.resolve(options.projectRoot ?? defaultProjectsRoot());
1647
1931
  const syncRoot = path.resolve(options.syncRoot ?? process.env.R5D_SYNC_ROOT ?? defaultSyncRoot());
1932
+ const artifactRoot = path.resolve(options.artifactRoot ?? process.env.R5D_ARTIFACTS_ROOT ?? defaultArtifactRoot());
1648
1933
  process.stdout.write(`[r5d-worker] label: ${label}
1649
1934
  `);
1650
1935
  process.stdout.write(`[r5d-worker] projects root: ${projectsRoot}
1651
1936
  `);
1652
1937
  process.stdout.write(`[r5d-worker] sync root: ${syncRoot}
1938
+ `);
1939
+ process.stdout.write(`[r5d-worker] artifact root: ${artifactRoot}
1653
1940
  `);
1654
1941
  process.stdout.write(`[r5d-worker] server: ${baseUrl}
1655
1942
  `);
@@ -1657,6 +1944,7 @@ async function startWorker(options) {
1657
1944
  await verifyR5dctlAuth(baseUrl, token);
1658
1945
  fs.mkdirSync(projectsRoot, { recursive: true });
1659
1946
  fs.mkdirSync(syncRoot, { recursive: true });
1947
+ fs.mkdirSync(artifactRoot, { recursive: true });
1660
1948
  const manifestByProjectId = /* @__PURE__ */ new Map();
1661
1949
  const ws = new WebSocket(websocketUrl(baseUrl, label), {
1662
1950
  headers: {
@@ -1672,7 +1960,8 @@ async function startWorker(options) {
1672
1960
  arch: process.arch,
1673
1961
  pid: process.pid,
1674
1962
  version: getWorkerVersion(),
1675
- projectRoot: projectsRoot
1963
+ projectRoot: projectsRoot,
1964
+ artifactRoot
1676
1965
  }
1677
1966
  };
1678
1967
  ws.send(JSON.stringify(hello));
@@ -1769,7 +2058,7 @@ async function startWorker(options) {
1769
2058
  const result = await withBranchLock(
1770
2059
  message.projectId,
1771
2060
  message.branchName,
1772
- () => executeCommand({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest })
2061
+ () => executeCommand({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
1773
2062
  );
1774
2063
  ws.send(JSON.stringify(result));
1775
2064
  return;
@@ -1788,7 +2077,7 @@ async function startWorker(options) {
1788
2077
  void withBranchLock(
1789
2078
  message.projectId,
1790
2079
  message.branchName,
1791
- () => executeStreamingCommand({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest })
2080
+ () => executeStreamingCommand({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
1792
2081
  ).catch((error) => {
1793
2082
  sendWorkerMessage(ws, {
1794
2083
  type: "exec_start_error",
@@ -1807,14 +2096,14 @@ async function startWorker(options) {
1807
2096
  }
1808
2097
  return;
1809
2098
  }
1810
- if (message.type === "read_file" || message.type === "create_file" || message.type === "edit_file" || message.type === "apply_patch" || message.type === "view_file_bytes" || message.type === "sync" || message.type === "confirm_large_diff" || message.type === "pull_branch") {
2099
+ if (message.type === "read" || message.type === "write" || message.type === "edit" || message.type === "grep" || message.type === "find" || message.type === "ls" || message.type === "view_file_bytes" || message.type === "sync" || message.type === "confirm_large_diff" || message.type === "pull_branch") {
1811
2100
  try {
1812
2101
  const manifest = manifestByProjectId.get(message.projectId);
1813
2102
  const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
1814
2103
  const result = await withBranchLock(
1815
2104
  message.projectId,
1816
2105
  message.branchName,
1817
- () => executeOperation({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest })
2106
+ () => executeOperation({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
1818
2107
  );
1819
2108
  ws.send(
1820
2109
  JSON.stringify({
@@ -1890,5 +2179,7 @@ if (isCliEntrypoint()) {
1890
2179
  });
1891
2180
  }
1892
2181
  export {
1893
- ensureVisibleGitCheckout
2182
+ ensureVisibleGitCheckout,
2183
+ isArtifactEnvPath,
2184
+ syncSessionArtifacts
1894
2185
  };