@ricsam/r5d-worker 0.0.29 → 0.0.31

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
@@ -7,6 +7,7 @@ import { spawn as spawnChildProcess } from "node:child_process";
7
7
  import { installCliUpdate, readInstalledCliVersion } from "./cli-update.mjs";
8
8
  import { configureVisibleGitIdentity } from "./git-identity.mjs";
9
9
  import { hasWorkerHeartbeatTimedOut, WORKER_HEARTBEAT_INTERVAL_MS } from "./heartbeat.mjs";
10
+ import { terminateProcessTree } from "./process-tree.mjs";
10
11
  import { superviseWorkerRuntime, WORKER_RELOAD_EXIT_CODE, WORKER_RUNTIME_ENV } from "./supervisor.mjs";
11
12
  const DEFAULT_BASE_URL = "https://r5d.dev";
12
13
  const WORKER_PACKAGE_NAME = "@ricsam/r5d-worker";
@@ -662,36 +663,69 @@ function isInsideBranchPath(branchPath, filePath) {
662
663
  const relative = path.relative(path.resolve(branchPath), path.resolve(filePath));
663
664
  return relative === "" || relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
664
665
  }
665
- function assertInsideBranch(branchPath, filePath) {
666
- if (!isInsideBranchPath(branchPath, filePath)) {
667
- throw new Error("File path escapes the project branch");
666
+ function toProjectDisplayPath(branchPath, absolutePath) {
667
+ const relative = path.relative(path.resolve(branchPath), path.resolve(absolutePath)).split(path.sep).join(path.posix.sep);
668
+ return relative || ".";
669
+ }
670
+ function assertAllowedProjectPath(repoRelativePath, inputPath) {
671
+ if (repoRelativePath === ".git" || repoRelativePath.startsWith(".git/")) {
672
+ throw new Error("Refusing to access .git through worker file tools");
673
+ }
674
+ if (repoRelativePath.split("/").includes("..")) {
675
+ throw new Error(`Invalid project file path: ${inputPath}`);
668
676
  }
669
677
  }
670
- function resolveProjectFilePath(branchPath, virtualPath) {
671
- if (typeof virtualPath !== "string" || !virtualPath.startsWith("/") || virtualPath.includes("\0")) {
672
- throw new Error(`File path must be an absolute project path starting with /. Got: ${JSON.stringify(virtualPath)}`);
678
+ function resolveWorkerFilePath(branchPath, inputPath) {
679
+ if (typeof inputPath !== "string" || inputPath.length === 0 || inputPath.includes("\0")) {
680
+ throw new Error(`File path must be a non-empty string. Got: ${JSON.stringify(inputPath)}`);
673
681
  }
674
- const checkoutAbsolutePath = path.resolve(virtualPath);
675
- if (isInsideBranchPath(branchPath, checkoutAbsolutePath)) {
676
- const checkoutVirtualPath = toVirtualPath(path.resolve(branchPath), checkoutAbsolutePath);
677
- if (checkoutVirtualPath !== virtualPath) {
678
- return resolveProjectFilePath(branchPath, checkoutVirtualPath);
682
+ const resolvedBranchPath = path.resolve(branchPath);
683
+ if (path.isAbsolute(inputPath)) {
684
+ const absolutePath2 = path.resolve(inputPath);
685
+ if (!isInsideBranchPath(resolvedBranchPath, absolutePath2)) {
686
+ return {
687
+ absolutePath: absolutePath2,
688
+ displayPath: absolutePath2,
689
+ repoRelativePath: null,
690
+ scope: "host"
691
+ };
679
692
  }
693
+ const displayPath = toProjectDisplayPath(resolvedBranchPath, absolutePath2);
694
+ const repoRelativePath2 = displayPath === "." ? "" : displayPath;
695
+ assertAllowedProjectPath(repoRelativePath2, inputPath);
696
+ return {
697
+ absolutePath: absolutePath2,
698
+ displayPath,
699
+ repoRelativePath: repoRelativePath2,
700
+ scope: "project"
701
+ };
680
702
  }
681
- if (virtualPath.split("/").includes("..")) {
682
- throw new Error(`Invalid project file path: ${virtualPath}`);
703
+ const normalizedInputPath = inputPath.replace(/\\/g, "/");
704
+ if (normalizedInputPath.split("/").includes("..")) {
705
+ throw new Error(`Invalid project file path: ${inputPath}`);
683
706
  }
684
- const normalized = path.posix.normalize(virtualPath);
685
- if (normalized.startsWith("/../")) {
686
- throw new Error(`Invalid project file path: ${virtualPath}`);
707
+ const normalized = path.posix.normalize(normalizedInputPath);
708
+ const repoRelativePath = normalized === "." ? "" : normalized.replace(/^\.\/+/, "");
709
+ assertAllowedProjectPath(repoRelativePath, inputPath);
710
+ const absolutePath = repoRelativePath ? path.resolve(resolvedBranchPath, ...repoRelativePath.split("/")) : resolvedBranchPath;
711
+ if (!isInsideBranchPath(resolvedBranchPath, absolutePath)) {
712
+ throw new Error(`Invalid project file path: ${inputPath}`);
687
713
  }
688
- const repoRelativePath = normalized === "/" ? "" : normalized.slice(1);
689
- if (repoRelativePath === ".git" || repoRelativePath.startsWith(".git/")) {
690
- throw new Error("Refusing to access .git through worker file tools");
714
+ return {
715
+ absolutePath,
716
+ displayPath: repoRelativePath || ".",
717
+ repoRelativePath,
718
+ scope: "project"
719
+ };
720
+ }
721
+ function resolveProjectFilePath(branchPath, inputPath) {
722
+ const resolved = resolveWorkerFilePath(branchPath, inputPath);
723
+ if (resolved.scope === "host") {
724
+ throw new Error(
725
+ `Project file writes must use a checkout-relative path. Absolute host paths are read-only in file tools: ${inputPath}`
726
+ );
691
727
  }
692
- const absolutePath = repoRelativePath ? path.resolve(branchPath, ...repoRelativePath.split("/")) : path.resolve(branchPath);
693
- assertInsideBranch(branchPath, absolutePath);
694
- return { absolutePath, virtualPath: repoRelativePath ? `/${repoRelativePath}` : "/", repoRelativePath };
728
+ return resolved;
695
729
  }
696
730
  function resolveRemoteUrl(baseUrl, remoteUrl) {
697
731
  if (/^https?:\/\//i.test(remoteUrl)) {
@@ -1305,6 +1339,21 @@ async function withFileMutationQueue(key, operation) {
1305
1339
  function mutationQueueKey(projectId, branchName, filePath) {
1306
1340
  return `${projectId}:${branchName}:${path.posix.normalize(filePath)}`;
1307
1341
  }
1342
+ function readWorkerTextFile(branchPath, filePath, offset, limit) {
1343
+ const resolved = resolveWorkerFilePath(branchPath, filePath);
1344
+ if (!fs.existsSync(resolved.absolutePath) || !fs.statSync(resolved.absolutePath).isFile()) {
1345
+ throw new Error(`File not found: ${resolved.displayPath}`);
1346
+ }
1347
+ const buffer = fs.readFileSync(resolved.absolutePath);
1348
+ const formatted = formatLineNumberedContent(buffer.toString("utf8"), offset, limit);
1349
+ return {
1350
+ type: "read",
1351
+ kind: "text",
1352
+ file: resolved.displayPath,
1353
+ gitBlobHash: getGitBlobHashForContent(buffer),
1354
+ ...formatted
1355
+ };
1356
+ }
1308
1357
  async function executeReadFileOperation(input) {
1309
1358
  const artifact = await resolveArtifactEnvFilePath({
1310
1359
  filePath: input.message.filePath,
@@ -1319,55 +1368,43 @@ async function executeReadFileOperation(input) {
1319
1368
  if (!fs.existsSync(artifact.absolutePath) || !fs.statSync(artifact.absolutePath).isFile()) {
1320
1369
  throw new Error(`File not found: ${artifact.virtualPath}`);
1321
1370
  }
1322
- const buffer2 = fs.readFileSync(artifact.absolutePath);
1323
- const formatted2 = formatLineNumberedContent(buffer2.toString("utf8"), input.message.offset, input.message.limit);
1371
+ const buffer = fs.readFileSync(artifact.absolutePath);
1372
+ const formatted = formatLineNumberedContent(buffer.toString("utf8"), input.message.offset, input.message.limit);
1324
1373
  return {
1325
1374
  type: "read",
1326
1375
  kind: "text",
1327
1376
  file: artifact.virtualPath,
1328
- gitBlobHash: getGitBlobHashForContent(buffer2),
1329
- ...formatted2
1377
+ gitBlobHash: getGitBlobHashForContent(buffer),
1378
+ ...formatted
1330
1379
  };
1331
1380
  }
1332
1381
  const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1333
- const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
1334
- if (!fs.existsSync(resolved.absolutePath) || !fs.statSync(resolved.absolutePath).isFile()) {
1335
- throw new Error(`File not found: ${resolved.virtualPath}`);
1336
- }
1337
- const buffer = fs.readFileSync(resolved.absolutePath);
1338
- const formatted = formatLineNumberedContent(buffer.toString("utf8"), input.message.offset, input.message.limit);
1339
- return {
1340
- type: "read",
1341
- kind: "text",
1342
- file: resolved.virtualPath,
1343
- gitBlobHash: getGitBlobHashForContent(buffer),
1344
- ...formatted
1345
- };
1382
+ return readWorkerTextFile(workspace.branchPath, input.message.filePath, input.message.offset, input.message.limit);
1346
1383
  }
1347
1384
  async function executeWriteFileOperation(input) {
1348
1385
  rejectArtifactWritePath(input.message.filePath);
1349
- return withFileMutationQueue(mutationQueueKey(input.projectId, input.message.branchName, input.message.filePath), async () => {
1350
- const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1351
- const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
1386
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1387
+ const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
1388
+ return withFileMutationQueue(mutationQueueKey(input.projectId, input.message.branchName, resolved.repoRelativePath), async () => {
1352
1389
  fs.mkdirSync(path.dirname(resolved.absolutePath), { recursive: true });
1353
1390
  fs.writeFileSync(resolved.absolutePath, input.message.content, "utf8");
1354
1391
  return {
1355
1392
  type: "write",
1356
- file: resolved.virtualPath,
1393
+ file: resolved.displayPath,
1357
1394
  gitBlobHash: getGitBlobHashForContent(input.message.content)
1358
1395
  };
1359
1396
  });
1360
1397
  }
1361
1398
  async function executeEditFileOperation(input) {
1362
1399
  rejectArtifactWritePath(input.message.filePath);
1363
- return withFileMutationQueue(mutationQueueKey(input.projectId, input.message.branchName, input.message.filePath), async () => {
1364
- const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1365
- const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
1400
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1401
+ const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
1402
+ return withFileMutationQueue(mutationQueueKey(input.projectId, input.message.branchName, resolved.repoRelativePath), async () => {
1366
1403
  if (!Array.isArray(input.message.edits) || input.message.edits.length === 0) {
1367
1404
  throw new Error("edit requires at least one replacement");
1368
1405
  }
1369
1406
  if (!fs.existsSync(resolved.absolutePath) || !fs.statSync(resolved.absolutePath).isFile()) {
1370
- throw new Error(`File not found: ${resolved.virtualPath}`);
1407
+ throw new Error(`File not found: ${resolved.displayPath}`);
1371
1408
  }
1372
1409
  const originalContent = fs.readFileSync(resolved.absolutePath, "utf8");
1373
1410
  const ranges = input.message.edits.map((edit, index) => {
@@ -1402,7 +1439,7 @@ async function executeEditFileOperation(input) {
1402
1439
  fs.writeFileSync(resolved.absolutePath, nextContent, "utf8");
1403
1440
  return {
1404
1441
  type: "edit",
1405
- file: resolved.virtualPath,
1442
+ file: resolved.displayPath,
1406
1443
  edits: input.message.edits.length
1407
1444
  };
1408
1445
  });
@@ -1422,47 +1459,70 @@ function wildcardToRegex(pattern) {
1422
1459
  function normalizeFindPattern(pattern) {
1423
1460
  return /[*?]/.test(pattern) ? pattern : `*${pattern}*`;
1424
1461
  }
1425
- function toVirtualPath(branchPath, absolutePath) {
1426
- const relative = path.relative(branchPath, absolutePath).split(path.sep).join(path.posix.sep);
1427
- return relative ? `/${relative}` : "/";
1428
- }
1429
- function walkProjectEntries(branchPath, startPath) {
1462
+ function walkWorkerEntries(branchPath, start) {
1430
1463
  const entries = [];
1431
- const visit = (absolutePath) => {
1432
- const stat = fs.statSync(absolutePath);
1433
- const virtualPath = toVirtualPath(branchPath, absolutePath);
1434
- entries.push({ absolutePath, virtualPath, stat });
1464
+ const visitedDirectories = /* @__PURE__ */ new Set();
1465
+ const visit = (absolutePath, isRoot) => {
1466
+ let stat;
1467
+ try {
1468
+ stat = fs.statSync(absolutePath);
1469
+ } catch (error) {
1470
+ if (isRoot) throw error;
1471
+ return;
1472
+ }
1473
+ const displayPath = start.scope === "project" ? toProjectDisplayPath(branchPath, absolutePath) : path.resolve(absolutePath);
1474
+ const hostRelativePath = path.relative(start.absolutePath, absolutePath).split(path.sep).join(path.posix.sep);
1475
+ const matchPath = start.scope === "project" ? displayPath : hostRelativePath || path.basename(start.absolutePath);
1476
+ entries.push({ absolutePath, displayPath, matchPath, stat });
1435
1477
  if (!stat.isDirectory()) return;
1436
- const children = fs.readdirSync(absolutePath, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
1478
+ let realDirectoryPath;
1479
+ try {
1480
+ realDirectoryPath = fs.realpathSync(absolutePath);
1481
+ } catch (error) {
1482
+ if (isRoot) throw error;
1483
+ return;
1484
+ }
1485
+ if (visitedDirectories.has(realDirectoryPath)) return;
1486
+ visitedDirectories.add(realDirectoryPath);
1487
+ let children;
1488
+ try {
1489
+ children = fs.readdirSync(absolutePath, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
1490
+ } catch (error) {
1491
+ if (isRoot) throw error;
1492
+ return;
1493
+ }
1437
1494
  for (const child of children) {
1438
1495
  if (IGNORED_ENTRY_NAMES.has(child.name)) continue;
1439
- visit(path.join(absolutePath, child.name));
1496
+ visit(path.join(absolutePath, child.name), false);
1440
1497
  }
1441
1498
  };
1442
- visit(startPath);
1499
+ visit(start.absolutePath, true);
1443
1500
  return entries;
1444
1501
  }
1445
1502
  function isProbablyText(bytes) {
1446
1503
  return !bytes.subarray(0, Math.min(bytes.length, 4096)).includes(0);
1447
1504
  }
1448
- async function executeGrepOperation(input) {
1449
- const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1450
- const start = resolveProjectFilePath(workspace.branchPath, input.message.path ?? "/");
1505
+ function grepWorkerFiles(branchPath, input) {
1506
+ const start = resolveWorkerFilePath(branchPath, input.path ?? ".");
1451
1507
  if (!fs.existsSync(start.absolutePath)) {
1452
- throw new Error(`Path not found: ${start.virtualPath}`);
1508
+ throw new Error(`Path not found: ${start.displayPath}`);
1453
1509
  }
1454
- const limit = normalizeToolLimit(input.message.limit, 100, 1e3);
1455
- const flags = input.message.caseSensitive === false ? "i" : "";
1456
- const regex = new RegExp(input.message.pattern, flags);
1457
- const globRegex = input.message.glob ? wildcardToRegex(input.message.glob) : null;
1510
+ const limit = normalizeToolLimit(input.limit, 100, 1e3);
1511
+ const flags = input.caseSensitive === false ? "i" : "";
1512
+ const regex = new RegExp(input.pattern, flags);
1513
+ const globRegex = input.glob ? wildcardToRegex(input.glob) : null;
1458
1514
  const lines = [];
1459
1515
  let matchCount = 0;
1460
1516
  let truncated = false;
1461
- for (const entry of walkProjectEntries(workspace.branchPath, start.absolutePath)) {
1517
+ for (const entry of walkWorkerEntries(branchPath, start)) {
1462
1518
  if (!entry.stat.isFile()) continue;
1463
- const repoRelative = entry.virtualPath.slice(1);
1464
- if (globRegex && !globRegex.test(repoRelative)) continue;
1465
- const bytes = fs.readFileSync(entry.absolutePath);
1519
+ if (globRegex && !globRegex.test(entry.matchPath)) continue;
1520
+ let bytes;
1521
+ try {
1522
+ bytes = fs.readFileSync(entry.absolutePath);
1523
+ } catch {
1524
+ continue;
1525
+ }
1466
1526
  if (!isProbablyText(bytes)) continue;
1467
1527
  const fileLines = bytes.toString("utf8").split(/\r?\n/);
1468
1528
  for (let index = 0; index < fileLines.length; index += 1) {
@@ -1473,7 +1533,7 @@ async function executeGrepOperation(input) {
1473
1533
  matchCount += 1;
1474
1534
  if (lines.length < limit) {
1475
1535
  const column = Math.max(1, (match.index ?? 0) + 1);
1476
- lines.push(`${entry.virtualPath}:${index + 1}:${column}: ${line.slice(0, MAX_LINE_LENGTH)}`);
1536
+ lines.push(`${entry.displayPath}:${index + 1}:${column}: ${line.slice(0, MAX_LINE_LENGTH)}`);
1477
1537
  } else {
1478
1538
  truncated = true;
1479
1539
  }
@@ -1488,26 +1548,35 @@ async function executeGrepOperation(input) {
1488
1548
  truncated
1489
1549
  };
1490
1550
  }
1491
- async function executeFindOperation(input) {
1551
+ async function executeGrepOperation(input) {
1492
1552
  const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1493
- const start = resolveProjectFilePath(workspace.branchPath, input.message.path ?? "/");
1553
+ return grepWorkerFiles(workspace.branchPath, {
1554
+ pattern: input.message.pattern,
1555
+ path: input.message.path,
1556
+ glob: input.message.glob,
1557
+ caseSensitive: input.message.caseSensitive,
1558
+ limit: input.message.limit
1559
+ });
1560
+ }
1561
+ function findWorkerFiles(branchPath, input) {
1562
+ const start = resolveWorkerFilePath(branchPath, input.path ?? ".");
1494
1563
  if (!fs.existsSync(start.absolutePath) || !fs.statSync(start.absolutePath).isDirectory()) {
1495
- throw new Error(`Directory not found: ${start.virtualPath}`);
1564
+ throw new Error(`Directory not found: ${start.displayPath}`);
1496
1565
  }
1497
- const pattern = normalizeFindPattern(input.message.pattern ?? "*");
1566
+ const pattern = normalizeFindPattern(input.pattern ?? "*");
1498
1567
  const matcher = wildcardToRegex(pattern);
1499
- const entryType = input.message.entryType ?? "file";
1500
- const limit = normalizeToolLimit(input.message.limit, 200, 1e3);
1568
+ const entryType = input.entryType ?? "file";
1569
+ const limit = normalizeToolLimit(input.limit, 200, 1e3);
1501
1570
  const matches = [];
1502
1571
  let count = 0;
1503
1572
  let truncated = false;
1504
- for (const entry of walkProjectEntries(workspace.branchPath, start.absolutePath)) {
1505
- if (entry.virtualPath === start.virtualPath) continue;
1573
+ for (const entry of walkWorkerEntries(branchPath, start)) {
1574
+ if (entry.absolutePath === start.absolutePath) continue;
1506
1575
  const isDirectory = entry.stat.isDirectory();
1507
1576
  if (entryType === "file" && !entry.stat.isFile()) continue;
1508
1577
  if (entryType === "directory" && !isDirectory) continue;
1509
- const label = isDirectory ? `${entry.virtualPath}/` : entry.virtualPath;
1510
- if (!matcher.test(path.posix.basename(entry.virtualPath)) && !matcher.test(entry.virtualPath.slice(1))) continue;
1578
+ const label = isDirectory ? `${entry.displayPath}${start.scope === "host" ? path.sep : "/"}` : entry.displayPath;
1579
+ if (!matcher.test(path.basename(entry.absolutePath)) && !matcher.test(entry.matchPath)) continue;
1511
1580
  count += 1;
1512
1581
  if (matches.length < limit) {
1513
1582
  matches.push(label);
@@ -1523,25 +1592,59 @@ async function executeFindOperation(input) {
1523
1592
  truncated
1524
1593
  };
1525
1594
  }
1526
- async function executeLsOperation(input) {
1595
+ async function executeFindOperation(input) {
1527
1596
  const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1528
- const resolved = resolveProjectFilePath(workspace.branchPath, input.message.path ?? "/");
1597
+ return findWorkerFiles(workspace.branchPath, {
1598
+ pattern: input.message.pattern,
1599
+ path: input.message.path,
1600
+ entryType: input.message.entryType,
1601
+ limit: input.message.limit
1602
+ });
1603
+ }
1604
+ function listWorkerDirectory(branchPath, inputPath = ".", inputLimit) {
1605
+ const resolved = resolveWorkerFilePath(branchPath, inputPath);
1529
1606
  if (!fs.existsSync(resolved.absolutePath) || !fs.statSync(resolved.absolutePath).isDirectory()) {
1530
- throw new Error(`Directory not found: ${resolved.virtualPath}`);
1607
+ throw new Error(`Directory not found: ${resolved.displayPath}`);
1531
1608
  }
1532
- const limit = normalizeToolLimit(input.message.limit, 200, 1e3);
1609
+ const limit = normalizeToolLimit(inputLimit, 200, 1e3);
1533
1610
  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));
1534
1611
  const displayed = entries.slice(0, limit).map((entry) => `${entry.name}${entry.isDirectory() ? "/" : ""}`);
1535
1612
  const truncated = entries.length > displayed.length;
1536
1613
  const hint = truncated ? "\n... truncated. Increase limit to continue." : "";
1537
1614
  return {
1538
1615
  type: "ls",
1539
- path: resolved.virtualPath,
1616
+ path: resolved.displayPath,
1540
1617
  content: displayed.length > 0 ? `${displayed.join("\n")}${hint}` : "(empty directory)",
1541
1618
  count: entries.length,
1542
1619
  truncated
1543
1620
  };
1544
1621
  }
1622
+ async function executeLsOperation(input) {
1623
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1624
+ return listWorkerDirectory(workspace.branchPath, input.message.path, input.message.limit);
1625
+ }
1626
+ function readWorkerImageFile(branchPath, filePath) {
1627
+ const resolved = resolveWorkerFilePath(branchPath, filePath);
1628
+ if (!fs.existsSync(resolved.absolutePath) || !fs.statSync(resolved.absolutePath).isFile()) {
1629
+ throw new Error(`File not found: ${resolved.displayPath}`);
1630
+ }
1631
+ const extension = path.extname(resolved.absolutePath).toLowerCase();
1632
+ const mediaType = extension === ".jpg" || extension === ".jpeg" ? "image/jpeg" : extension === ".png" ? "image/png" : null;
1633
+ if (!mediaType) {
1634
+ throw new Error("read supports PNG and JPEG image inputs only");
1635
+ }
1636
+ const bytes = fs.readFileSync(resolved.absolutePath);
1637
+ const dimensions = readImageDimensions(bytes, mediaType);
1638
+ return {
1639
+ type: "view_file_bytes",
1640
+ filePath: resolved.displayPath,
1641
+ mediaType,
1642
+ base64: bytes.toString("base64"),
1643
+ fileSizeBytes: bytes.length,
1644
+ width: dimensions.width,
1645
+ height: dimensions.height
1646
+ };
1647
+ }
1545
1648
  async function executeViewFileBytesOperation(input) {
1546
1649
  const artifact = await resolveArtifactEnvFilePath({
1547
1650
  filePath: input.message.filePath,
@@ -1556,44 +1659,25 @@ async function executeViewFileBytesOperation(input) {
1556
1659
  if (!fs.existsSync(artifact.absolutePath) || !fs.statSync(artifact.absolutePath).isFile()) {
1557
1660
  throw new Error(`File not found: ${artifact.virtualPath}`);
1558
1661
  }
1559
- const extension2 = path.extname(artifact.absolutePath).toLowerCase();
1560
- const mediaType2 = extension2 === ".jpg" || extension2 === ".jpeg" ? "image/jpeg" : extension2 === ".png" ? "image/png" : null;
1561
- if (!mediaType2) {
1662
+ const extension = path.extname(artifact.absolutePath).toLowerCase();
1663
+ const mediaType = extension === ".jpg" || extension === ".jpeg" ? "image/jpeg" : extension === ".png" ? "image/png" : null;
1664
+ if (!mediaType) {
1562
1665
  throw new Error("read supports PNG and JPEG image inputs only");
1563
1666
  }
1564
- const bytes2 = fs.readFileSync(artifact.absolutePath);
1565
- const dimensions2 = readImageDimensions(bytes2, mediaType2);
1667
+ const bytes = fs.readFileSync(artifact.absolutePath);
1668
+ const dimensions = readImageDimensions(bytes, mediaType);
1566
1669
  return {
1567
1670
  type: "view_file_bytes",
1568
1671
  filePath: artifact.virtualPath,
1569
- mediaType: mediaType2,
1570
- base64: bytes2.toString("base64"),
1571
- fileSizeBytes: bytes2.length,
1572
- width: dimensions2.width,
1573
- height: dimensions2.height
1672
+ mediaType,
1673
+ base64: bytes.toString("base64"),
1674
+ fileSizeBytes: bytes.length,
1675
+ width: dimensions.width,
1676
+ height: dimensions.height
1574
1677
  };
1575
1678
  }
1576
1679
  const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1577
- const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
1578
- if (!fs.existsSync(resolved.absolutePath) || !fs.statSync(resolved.absolutePath).isFile()) {
1579
- throw new Error(`File not found: ${resolved.virtualPath}`);
1580
- }
1581
- const extension = path.extname(resolved.absolutePath).toLowerCase();
1582
- const mediaType = extension === ".jpg" || extension === ".jpeg" ? "image/jpeg" : extension === ".png" ? "image/png" : null;
1583
- if (!mediaType) {
1584
- throw new Error("read supports PNG and JPEG image inputs only");
1585
- }
1586
- const bytes = fs.readFileSync(resolved.absolutePath);
1587
- const dimensions = readImageDimensions(bytes, mediaType);
1588
- return {
1589
- type: "view_file_bytes",
1590
- filePath: resolved.virtualPath,
1591
- mediaType,
1592
- base64: bytes.toString("base64"),
1593
- fileSizeBytes: bytes.length,
1594
- width: dimensions.width,
1595
- height: dimensions.height
1596
- };
1680
+ return readWorkerImageFile(workspace.branchPath, input.message.filePath);
1597
1681
  }
1598
1682
  function stagedBlobSizeBytes(context) {
1599
1683
  const names = runInternalGit(context, ["diff", "--cached", "--name-only", "-z", "--", ".", ":(exclude).git"]).split("\0").filter(Boolean);
@@ -1774,6 +1858,7 @@ async function executeCommand(input) {
1774
1858
  cwd,
1775
1859
  stdout: "pipe",
1776
1860
  stderr: "pipe",
1861
+ detached: true,
1777
1862
  env: {
1778
1863
  ...process.env,
1779
1864
  ...containerRegistryEnv(),
@@ -1795,7 +1880,12 @@ async function executeCommand(input) {
1795
1880
  });
1796
1881
  if (input.message.timeoutMs) {
1797
1882
  timeout = setTimeout(() => {
1798
- subprocess.kill("SIGTERM");
1883
+ void terminateProcessTree(subprocess).catch((error) => {
1884
+ process.stderr.write(
1885
+ `[r5d-worker] failed to terminate timed-out command ${input.message.runId}: ${error instanceof Error ? error.message : String(error)}
1886
+ `
1887
+ );
1888
+ });
1799
1889
  }, input.message.timeoutMs);
1800
1890
  }
1801
1891
  const [stdout, stderr, exitCode] = await Promise.all([
@@ -1831,6 +1921,8 @@ async function executeCommand(input) {
1831
1921
  async function executeStreamingCommand(input) {
1832
1922
  const startedAt = Date.now();
1833
1923
  let started = false;
1924
+ let timedOut = false;
1925
+ let timeout;
1834
1926
  try {
1835
1927
  if (cancelledProcessRuns.delete(input.message.runId)) {
1836
1928
  sendWorkerMessage(input.ws, {
@@ -1871,6 +1963,7 @@ async function executeStreamingCommand(input) {
1871
1963
  cwd,
1872
1964
  stdout: "pipe",
1873
1965
  stderr: "pipe",
1966
+ detached: true,
1874
1967
  env: {
1875
1968
  ...process.env,
1876
1969
  ...containerRegistryEnv(),
@@ -1897,6 +1990,17 @@ async function executeStreamingCommand(input) {
1897
1990
  requestId: input.message.requestId,
1898
1991
  runId: input.message.runId
1899
1992
  });
1993
+ if (input.message.timeoutMs) {
1994
+ timeout = setTimeout(() => {
1995
+ timedOut = true;
1996
+ void terminateProcessTree(subprocess).catch((error) => {
1997
+ process.stderr.write(
1998
+ `[r5d-worker] failed to terminate timed-out process ${input.message.runId}: ${error instanceof Error ? error.message : String(error)}
1999
+ `
2000
+ );
2001
+ });
2002
+ }, input.message.timeoutMs);
2003
+ }
1900
2004
  const [exitCode] = await Promise.all([
1901
2005
  subprocess.exited,
1902
2006
  streamCommandOutput(subprocess.stdout, (data) => {
@@ -1920,7 +2024,8 @@ async function executeStreamingCommand(input) {
1920
2024
  type: "exec_exit",
1921
2025
  runId: input.message.runId,
1922
2026
  exitCode,
1923
- durationMs: Date.now() - startedAt
2027
+ durationMs: Date.now() - startedAt,
2028
+ ...timedOut ? { timedOut: true } : {}
1924
2029
  });
1925
2030
  } catch (error) {
1926
2031
  const message = error instanceof Error ? error.message : String(error);
@@ -1940,6 +2045,9 @@ async function executeStreamingCommand(input) {
1940
2045
  });
1941
2046
  }
1942
2047
  } finally {
2048
+ if (timeout) {
2049
+ clearTimeout(timeout);
2050
+ }
1943
2051
  activeProcesses.delete(input.message.runId);
1944
2052
  cancelledProcessRuns.delete(input.message.runId);
1945
2053
  }
@@ -2502,18 +2610,27 @@ async function startWorker(options) {
2502
2610
  }
2503
2611
  if (message.type === "cancel") {
2504
2612
  const active = activeProcesses.get(message.runId);
2613
+ let cancelled = true;
2614
+ let cancelMessage;
2505
2615
  if (active) {
2506
- active.process.kill("SIGTERM");
2616
+ try {
2617
+ await terminateProcessTree(active.process);
2618
+ cancelMessage = `Stopped ${active.command}`;
2619
+ } catch (error) {
2620
+ cancelled = false;
2621
+ cancelMessage = `Failed to stop ${active.command}: ${error instanceof Error ? error.message : String(error)}`;
2622
+ }
2507
2623
  } else {
2508
2624
  cancelledProcessRuns.add(message.runId);
2625
+ cancelMessage = "Cancellation queued before command start";
2509
2626
  }
2510
2627
  ws.send(
2511
2628
  JSON.stringify({
2512
2629
  type: "cancel_result",
2513
2630
  requestId: message.requestId,
2514
2631
  runId: message.runId,
2515
- cancelled: true,
2516
- message: active ? `Sent SIGTERM to ${active.command}` : "Cancellation queued before command start"
2632
+ cancelled,
2633
+ message: cancelMessage
2517
2634
  })
2518
2635
  );
2519
2636
  return;
@@ -2752,11 +2869,17 @@ if (isCliEntrypoint()) {
2752
2869
  export {
2753
2870
  ensureVisibleGitCheckout,
2754
2871
  findRepositorySyncBlockers,
2872
+ findWorkerFiles,
2873
+ grepWorkerFiles,
2755
2874
  isArtifactEnvPath,
2875
+ listWorkerDirectory,
2756
2876
  prepareArtifactEnvForShell,
2757
2877
  preparePlanEnvForShell,
2878
+ readWorkerImageFile,
2879
+ readWorkerTextFile,
2758
2880
  resolveHostShell,
2759
2881
  resolveProjectFilePath,
2882
+ resolveWorkerFilePath,
2760
2883
  syncManifestProjectsFromInternal,
2761
2884
  syncProjectPlans,
2762
2885
  syncSessionArtifacts
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.29",
3
+ "version": "0.0.31",
4
4
  "type": "module"
5
5
  }