@ricsam/r5d-worker 0.0.13 → 0.0.15

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/cjs/main.cjs CHANGED
@@ -32,6 +32,9 @@ __export(main_exports, {
32
32
  ensureVisibleGitCheckout: () => ensureVisibleGitCheckout,
33
33
  isArtifactEnvPath: () => isArtifactEnvPath,
34
34
  prepareArtifactEnvForShell: () => prepareArtifactEnvForShell,
35
+ preparePlanEnvForShell: () => preparePlanEnvForShell,
36
+ resolveProjectFilePath: () => resolveProjectFilePath,
37
+ syncProjectPlans: () => syncProjectPlans,
35
38
  syncSessionArtifacts: () => syncSessionArtifacts
36
39
  });
37
40
  module.exports = __toCommonJS(main_exports);
@@ -70,6 +73,9 @@ function defaultSyncRoot() {
70
73
  function defaultArtifactRoot() {
71
74
  return import_node_path.default.join(import_node_os.default.homedir(), ".r5d", "artifacts");
72
75
  }
76
+ function defaultPlanRoot() {
77
+ return import_node_path.default.join(import_node_os.default.homedir(), ".r5d", "plans");
78
+ }
73
79
  function printHelp() {
74
80
  process.stdout.write(`Usage:
75
81
  r5d-worker start --label <label> [--base-url <url>] [--token <token>] [--api-key <key>]
@@ -84,6 +90,7 @@ Options:
84
90
  --project-root <dir> Override projects checkout root (default ~/.r5d/projects)
85
91
  --sync-root <dir> Override r5d internal sync git root (default ~/.r5d/sync)
86
92
  --artifact-root <dir> Override session artifact root (default from R5D_ARTIFACTS_ROOT or ~/.r5d/artifacts)
93
+ --plan-root <dir> Override synced plan root (default from R5D_PLANS_ROOT or ~/.r5d/plans)
87
94
  -v, --version Show r5d-worker version
88
95
  -h, --help Show this help
89
96
  `);
@@ -205,6 +212,11 @@ function parseArgs(argv) {
205
212
  options.artifactRoot = artifactRoot;
206
213
  continue;
207
214
  }
215
+ const planRoot = readOption("--plan-root");
216
+ if (planRoot !== void 0) {
217
+ options.planRoot = planRoot;
218
+ continue;
219
+ }
208
220
  rest.push(arg);
209
221
  }
210
222
  if (options.version) {
@@ -248,6 +260,7 @@ async function readResponseText(response) {
248
260
  }
249
261
  }
250
262
  const R5D_ARTIFACTS_DIR_REF = "$R5D_ARTIFACTS_DIR";
263
+ const R5D_PLANS_DIR_ENV = "R5D_PLANS_DIR";
251
264
  function validateArtifactSessionId(sessionId) {
252
265
  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)) {
253
266
  throw new Error(`Invalid artifact session id: ${sessionId}`);
@@ -256,7 +269,7 @@ function validateArtifactSessionId(sessionId) {
256
269
  function assertInsideRoot(rootPath, candidatePath, label) {
257
270
  const relative = import_node_path.default.relative(rootPath, candidatePath);
258
271
  if (relative === ".." || relative.startsWith(`..${import_node_path.default.sep}`) || import_node_path.default.isAbsolute(relative)) {
259
- throw new Error(`${label} escapes the artifact directory`);
272
+ throw new Error(`${label} escapes sync directory`);
260
273
  }
261
274
  }
262
275
  function sessionArtifactDir(artifactRoot, sessionId) {
@@ -271,6 +284,23 @@ function artifactEnv(artifactRoot, sessionId) {
271
284
  R5D_ARTIFACTS_DIR: sessionArtifactDir(artifactRoot, sessionId)
272
285
  };
273
286
  }
287
+ function projectPlanDir(planRoot, projectId, branchName) {
288
+ validateProjectId(projectId);
289
+ validateBranchName(branchName);
290
+ return import_node_path.default.join(planRoot, projectId, branchName);
291
+ }
292
+ function planEnv(planRoot, projectId, branchName, activePlanId) {
293
+ const plansDir = projectPlanDir(planRoot, projectId, branchName);
294
+ const env = {
295
+ [R5D_PLANS_DIR_ENV]: plansDir
296
+ };
297
+ if (activePlanId) {
298
+ validatePlanId(activePlanId);
299
+ env.R5D_ACTIVE_PLAN_ID = activePlanId;
300
+ env.R5D_ACTIVE_PLAN_FILE = import_node_path.default.join(plansDir, `${activePlanId}.plan.md`);
301
+ }
302
+ return env;
303
+ }
274
304
  function isArtifactEnvPath(filePath) {
275
305
  return filePath === R5D_ARTIFACTS_DIR_REF || filePath.startsWith(`${R5D_ARTIFACTS_DIR_REF}/`);
276
306
  }
@@ -283,99 +313,150 @@ function artifactManifestUrl(baseUrl, projectId, branchName, sessionId) {
283
313
  function artifactDownloadUrl(baseUrl, projectId, branchName, sessionId, filename) {
284
314
  return new URL(`${artifactApiBasePath(projectId, branchName, sessionId)}/${encodeURIComponent(filename)}`, baseUrl);
285
315
  }
316
+ function planApiBasePath(projectId, branchName) {
317
+ return `/preview-api/${encodeURIComponent(projectId)}/${encodeURIComponent(branchName)}/plans`;
318
+ }
319
+ function planManifestUrl(baseUrl, projectId, branchName) {
320
+ return new URL(planApiBasePath(projectId, branchName), baseUrl);
321
+ }
322
+ function planDownloadUrl(baseUrl, projectId, branchName, filename) {
323
+ return new URL(`${planApiBasePath(projectId, branchName)}/${encodeURIComponent(filename)}`, baseUrl);
324
+ }
286
325
  function sha256Path(filePath) {
287
326
  return (0, import_node_crypto.createHash)("sha256").update(import_node_fs.default.readFileSync(filePath)).digest("hex");
288
327
  }
289
- function parseSessionArtifactManifest(value) {
290
- if (!value || typeof value !== "object" || !Array.isArray(value.artifacts)) {
291
- throw new Error("Artifact manifest response is invalid");
328
+ function parseRemoteFileManifest(value, collectionKey, label) {
329
+ if (!value || typeof value !== "object" || !Array.isArray(value[collectionKey])) {
330
+ throw new Error(`${label} manifest response is invalid`);
292
331
  }
293
- return value.artifacts.map((entry) => {
332
+ return value[collectionKey].map((entry) => {
294
333
  if (!entry || typeof entry !== "object") {
295
- throw new Error("Artifact manifest entry is invalid");
334
+ throw new Error(`${label} manifest entry is invalid`);
296
335
  }
297
- const artifact = entry;
298
- if (typeof artifact.filename !== "string" || artifact.filename.includes("/") || artifact.filename.includes("\\")) {
299
- throw new Error("Artifact manifest entry has an invalid filename");
336
+ const file = entry;
337
+ if (typeof file.filename !== "string" || file.filename.includes("/") || file.filename.includes("\\")) {
338
+ throw new Error(`${label} manifest entry has an invalid filename`);
300
339
  }
301
- if (typeof artifact.size !== "number" || !Number.isFinite(artifact.size) || artifact.size < 0) {
302
- throw new Error(`Artifact manifest entry has an invalid size: ${artifact.filename}`);
340
+ if (typeof file.size !== "number" || !Number.isFinite(file.size) || file.size < 0) {
341
+ throw new Error(`${label} manifest entry has an invalid size: ${file.filename}`);
303
342
  }
304
- if (typeof artifact.sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(artifact.sha256)) {
305
- throw new Error(`Artifact manifest entry has an invalid sha256: ${artifact.filename}`);
343
+ if (typeof file.sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(file.sha256)) {
344
+ throw new Error(`${label} manifest entry has an invalid sha256: ${file.filename}`);
306
345
  }
307
346
  return {
308
- filename: artifact.filename,
309
- size: artifact.size,
310
- sha256: artifact.sha256.toLowerCase()
347
+ filename: file.filename,
348
+ size: file.size,
349
+ sha256: file.sha256.toLowerCase()
311
350
  };
312
351
  });
313
352
  }
314
- async function fetchSessionArtifactManifest(input) {
315
- const response = await fetch(artifactManifestUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId), {
353
+ async function fetchRemoteFileManifest(input) {
354
+ const response = await fetch(input.url, {
316
355
  headers: {
317
356
  Authorization: `Bearer ${input.token}`
318
357
  }
319
358
  });
320
359
  if (!response.ok) {
321
360
  const detail = (await readResponseText(response)).trim();
322
- throw new Error(`Failed to fetch artifact manifest (${response.status})${detail ? `: ${detail}` : ""}`);
361
+ throw new Error(`Failed to fetch ${input.label} manifest (${response.status})${detail ? `: ${detail}` : ""}`);
323
362
  }
324
- return parseSessionArtifactManifest(await response.json());
363
+ return parseRemoteFileManifest(await response.json(), input.collectionKey, input.label);
325
364
  }
326
- async function downloadSessionArtifact(input) {
327
- const targetDir = sessionArtifactDir(input.artifactRoot, input.sessionId);
328
- const targetPath = import_node_path.default.join(targetDir, input.artifact.filename);
329
- assertInsideRoot(targetDir, targetPath, "Artifact path");
330
- const response = await fetch(
331
- artifactDownloadUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId, input.artifact.filename),
332
- {
333
- headers: {
334
- Authorization: `Bearer ${input.token}`
335
- }
365
+ async function downloadRemoteSyncedFile(input) {
366
+ const targetPath = import_node_path.default.join(input.targetDir, input.file.filename);
367
+ assertInsideRoot(input.targetDir, targetPath, input.label);
368
+ const response = await fetch(input.url, {
369
+ headers: {
370
+ Authorization: `Bearer ${input.token}`
336
371
  }
337
- );
372
+ });
338
373
  if (!response.ok) {
339
374
  const detail = (await readResponseText(response)).trim();
340
- throw new Error(`Failed to download artifact ${input.artifact.filename} (${response.status})${detail ? `: ${detail}` : ""}`);
375
+ throw new Error(`Failed to download ${input.label} ${input.file.filename} (${response.status})${detail ? `: ${detail}` : ""}`);
341
376
  }
342
377
  const buffer = Buffer.from(await response.arrayBuffer());
343
378
  const receivedHash = (0, import_node_crypto.createHash)("sha256").update(buffer).digest("hex");
344
- if (receivedHash !== input.artifact.sha256) {
345
- throw new Error(`Downloaded artifact ${input.artifact.filename} failed sha256 verification`);
379
+ if (receivedHash !== input.file.sha256) {
380
+ throw new Error(`Downloaded ${input.label} ${input.file.filename} failed sha256 verification`);
346
381
  }
347
- if (buffer.length !== input.artifact.size) {
348
- throw new Error(`Downloaded artifact ${input.artifact.filename} has unexpected size`);
382
+ if (buffer.length !== input.file.size) {
383
+ throw new Error(`Downloaded ${input.label} ${input.file.filename} has unexpected size`);
349
384
  }
350
385
  const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
351
386
  import_node_fs.default.writeFileSync(tempPath, buffer);
352
387
  import_node_fs.default.renameSync(tempPath, targetPath);
353
388
  }
354
- async function syncSessionArtifacts(input) {
355
- const targetDir = sessionArtifactDir(input.artifactRoot, input.sessionId);
356
- import_node_fs.default.mkdirSync(targetDir, { recursive: true });
357
- const artifacts = await fetchSessionArtifactManifest(input);
358
- const manifestNames = new Set(artifacts.map((artifact) => artifact.filename));
359
- for (const artifact of artifacts) {
360
- const localPath = import_node_path.default.join(targetDir, artifact.filename);
361
- assertInsideRoot(targetDir, localPath, "Artifact path");
389
+ async function syncRemoteFileSet(input) {
390
+ import_node_fs.default.mkdirSync(input.targetDir, { recursive: true });
391
+ const manifestNames = new Set(input.files.map((file) => file.filename));
392
+ for (const file of input.files) {
393
+ const localPath = import_node_path.default.join(input.targetDir, file.filename);
394
+ assertInsideRoot(input.targetDir, localPath, input.label);
362
395
  if (import_node_fs.default.existsSync(localPath)) {
363
396
  const stats = import_node_fs.default.statSync(localPath);
364
- if (stats.isFile() && stats.size === artifact.size && sha256Path(localPath) === artifact.sha256) {
397
+ if (stats.isFile() && stats.size === file.size && sha256Path(localPath) === file.sha256) {
365
398
  continue;
366
399
  }
367
400
  import_node_fs.default.rmSync(localPath, { recursive: true, force: true });
368
401
  }
369
- await downloadSessionArtifact({ ...input, artifact });
402
+ await input.download(file);
370
403
  }
371
- for (const localName of import_node_fs.default.readdirSync(targetDir)) {
404
+ for (const localName of import_node_fs.default.readdirSync(input.targetDir)) {
372
405
  if (!manifestNames.has(localName)) {
373
- const localPath = import_node_path.default.join(targetDir, localName);
374
- assertInsideRoot(targetDir, localPath, "Artifact path");
406
+ const localPath = import_node_path.default.join(input.targetDir, localName);
407
+ assertInsideRoot(input.targetDir, localPath, input.label);
375
408
  import_node_fs.default.rmSync(localPath, { recursive: true, force: true });
376
409
  }
377
410
  }
378
- return targetDir;
411
+ return input.targetDir;
412
+ }
413
+ async function fetchSessionArtifactManifest(input) {
414
+ return fetchRemoteFileManifest({
415
+ url: artifactManifestUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId),
416
+ token: input.token,
417
+ collectionKey: "artifacts",
418
+ label: "artifact"
419
+ });
420
+ }
421
+ async function syncSessionArtifacts(input) {
422
+ const targetDir = sessionArtifactDir(input.artifactRoot, input.sessionId);
423
+ const artifacts = await fetchSessionArtifactManifest(input);
424
+ return syncRemoteFileSet({
425
+ targetDir,
426
+ files: artifacts,
427
+ label: "Artifact path",
428
+ download: (artifact) => downloadRemoteSyncedFile({
429
+ url: artifactDownloadUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId, artifact.filename),
430
+ token: input.token,
431
+ targetDir,
432
+ file: artifact,
433
+ label: "artifact"
434
+ })
435
+ });
436
+ }
437
+ async function fetchProjectPlanManifest(input) {
438
+ return fetchRemoteFileManifest({
439
+ url: planManifestUrl(input.baseUrl, input.projectId, input.branchName),
440
+ token: input.token,
441
+ collectionKey: "plans",
442
+ label: "plan"
443
+ });
444
+ }
445
+ async function syncProjectPlans(input) {
446
+ const targetDir = projectPlanDir(input.planRoot, input.projectId, input.branchName);
447
+ const plans = await fetchProjectPlanManifest(input);
448
+ return syncRemoteFileSet({
449
+ targetDir,
450
+ files: plans,
451
+ label: "Plan path",
452
+ download: (plan) => downloadRemoteSyncedFile({
453
+ url: planDownloadUrl(input.baseUrl, input.projectId, input.branchName, plan.filename),
454
+ token: input.token,
455
+ targetDir,
456
+ file: plan,
457
+ label: "plan"
458
+ })
459
+ });
379
460
  }
380
461
  async function resolveArtifactEnvFilePath(input) {
381
462
  if (!isArtifactEnvPath(input.filePath)) {
@@ -434,6 +515,19 @@ async function prepareArtifactEnvForShell(input) {
434
515
  R5D_ARTIFACTS_DIR: artifactsDir
435
516
  };
436
517
  }
518
+ async function preparePlanEnvForShell(input) {
519
+ try {
520
+ await syncProjectPlans(input);
521
+ } catch (error) {
522
+ process.stderr.write(
523
+ `[r5d-worker] plan sync skipped for ${input.projectId}/${input.branchName}: ${error instanceof Error ? error.message : String(error)}
524
+ `
525
+ );
526
+ }
527
+ const plansDir = projectPlanDir(input.planRoot, input.projectId, input.branchName);
528
+ import_node_fs.default.mkdirSync(plansDir, { recursive: true });
529
+ return planEnv(input.planRoot, input.projectId, input.branchName, input.activePlanId);
530
+ }
437
531
  async function verifyR5dctlAuth(baseUrl, token) {
438
532
  const statusUrl = new URL("/api/r5dctl/auth/status", baseUrl);
439
533
  let response;
@@ -478,11 +572,24 @@ function validateLabel(label) {
478
572
  throw new Error("Worker label must start with a letter or number and may contain letters, numbers, dots, underscores, and hyphens");
479
573
  }
480
574
  }
575
+ function validateProjectId(projectId) {
576
+ if (projectId === "local" || projectId === "local-code") {
577
+ return;
578
+ }
579
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(projectId)) {
580
+ throw new Error(`Invalid project id from server: ${projectId}`);
581
+ }
582
+ }
481
583
  function validateBranchName(branchName) {
482
584
  if (branchName !== "main" && !BRANCH_RE.test(branchName)) {
483
585
  throw new Error(`Invalid branch name from server: ${branchName}`);
484
586
  }
485
587
  }
588
+ function validatePlanId(planId) {
589
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(planId)) {
590
+ throw new Error(`Invalid plan id from server: ${planId}`);
591
+ }
592
+ }
486
593
  function gitExtraHeaderConfigKey(extraHeaderUrl) {
487
594
  return `http.${normalizeBaseUrl(extraHeaderUrl)}/.extraHeader`;
488
595
  }
@@ -548,9 +655,12 @@ function getInternalStatus(context) {
548
655
  function hasInternalWorktreeChanges(context) {
549
656
  return getInternalStatus(context).trim().length > 0;
550
657
  }
658
+ function isInsideBranchPath(branchPath, filePath) {
659
+ const relative = import_node_path.default.relative(import_node_path.default.resolve(branchPath), import_node_path.default.resolve(filePath));
660
+ return relative === "" || relative !== ".." && !relative.startsWith(`..${import_node_path.default.sep}`) && !import_node_path.default.isAbsolute(relative);
661
+ }
551
662
  function assertInsideBranch(branchPath, filePath) {
552
- const relative = import_node_path.default.relative(branchPath, filePath);
553
- if (relative === ".." || relative.startsWith(`..${import_node_path.default.sep}`) || import_node_path.default.isAbsolute(relative)) {
663
+ if (!isInsideBranchPath(branchPath, filePath)) {
554
664
  throw new Error("File path escapes the project branch");
555
665
  }
556
666
  }
@@ -558,17 +668,27 @@ function resolveProjectFilePath(branchPath, virtualPath) {
558
668
  if (typeof virtualPath !== "string" || !virtualPath.startsWith("/") || virtualPath.includes("\0")) {
559
669
  throw new Error(`File path must be an absolute project path starting with /. Got: ${JSON.stringify(virtualPath)}`);
560
670
  }
671
+ const checkoutAbsolutePath = import_node_path.default.resolve(virtualPath);
672
+ if (isInsideBranchPath(branchPath, checkoutAbsolutePath)) {
673
+ const checkoutVirtualPath = toVirtualPath(import_node_path.default.resolve(branchPath), checkoutAbsolutePath);
674
+ if (checkoutVirtualPath !== virtualPath) {
675
+ return resolveProjectFilePath(branchPath, checkoutVirtualPath);
676
+ }
677
+ }
678
+ if (virtualPath.split("/").includes("..")) {
679
+ throw new Error(`Invalid project file path: ${virtualPath}`);
680
+ }
561
681
  const normalized = import_node_path.default.posix.normalize(virtualPath);
562
- if (normalized === "/" || normalized.startsWith("/../")) {
682
+ if (normalized.startsWith("/../")) {
563
683
  throw new Error(`Invalid project file path: ${virtualPath}`);
564
684
  }
565
- const repoRelativePath = normalized.slice(1);
685
+ const repoRelativePath = normalized === "/" ? "" : normalized.slice(1);
566
686
  if (repoRelativePath === ".git" || repoRelativePath.startsWith(".git/")) {
567
687
  throw new Error("Refusing to access .git through worker file tools");
568
688
  }
569
- const absolutePath = import_node_path.default.resolve(branchPath, ...repoRelativePath.split("/"));
689
+ const absolutePath = repoRelativePath ? import_node_path.default.resolve(branchPath, ...repoRelativePath.split("/")) : import_node_path.default.resolve(branchPath);
570
690
  assertInsideBranch(branchPath, absolutePath);
571
- return { absolutePath, virtualPath: `/${repoRelativePath}`, repoRelativePath };
691
+ return { absolutePath, virtualPath: repoRelativePath ? `/${repoRelativePath}` : "/", repoRelativePath };
572
692
  }
573
693
  function branchLockKey(projectId, branchName) {
574
694
  return `${projectId}:${branchName}`;
@@ -1518,6 +1638,14 @@ async function executeCommand(input) {
1518
1638
  sessionId: input.message.sessionId,
1519
1639
  artifactRoot: input.artifactRoot
1520
1640
  });
1641
+ const planProcessEnv = await preparePlanEnvForShell({
1642
+ baseUrl: input.baseUrl,
1643
+ token: input.token,
1644
+ projectId: input.projectId,
1645
+ branchName: input.message.branchName,
1646
+ planRoot: input.planRoot,
1647
+ activePlanId: input.message.env?.R5D_ACTIVE_PLAN_ID
1648
+ });
1521
1649
  const cwd = resolveCommandCwd(workspace.branchPath, input.message.cwd);
1522
1650
  const subprocess = Bun.spawn(input.message.argv, {
1523
1651
  cwd,
@@ -1528,7 +1656,8 @@ async function executeCommand(input) {
1528
1656
  ...containerRegistryEnv(),
1529
1657
  ...input.message.env ?? {},
1530
1658
  ...internalGitProcessEnv(workspace, input.message.env),
1531
- ...artifactProcessEnv
1659
+ ...artifactProcessEnv,
1660
+ ...planProcessEnv
1532
1661
  }
1533
1662
  });
1534
1663
  activeProcesses.set(input.message.runId, { process: subprocess, command: input.message.argv });
@@ -1599,6 +1728,14 @@ async function executeStreamingCommand(input) {
1599
1728
  sessionId: input.message.sessionId,
1600
1729
  artifactRoot: input.artifactRoot
1601
1730
  });
1731
+ const planProcessEnv = await preparePlanEnvForShell({
1732
+ baseUrl: input.baseUrl,
1733
+ token: input.token,
1734
+ projectId: input.projectId,
1735
+ branchName: input.message.branchName,
1736
+ planRoot: input.planRoot,
1737
+ activePlanId: input.message.env?.R5D_ACTIVE_PLAN_ID
1738
+ });
1602
1739
  const cwd = resolveCommandCwd(workspace.branchPath, input.message.cwd);
1603
1740
  const subprocess = Bun.spawn(input.message.argv, {
1604
1741
  cwd,
@@ -1609,7 +1746,8 @@ async function executeStreamingCommand(input) {
1609
1746
  ...containerRegistryEnv(),
1610
1747
  ...input.message.env ?? {},
1611
1748
  ...internalGitProcessEnv(workspace, input.message.env),
1612
- ...artifactProcessEnv
1749
+ ...artifactProcessEnv,
1750
+ ...planProcessEnv
1613
1751
  }
1614
1752
  });
1615
1753
  activeProcesses.set(input.message.runId, { process: subprocess, command: input.message.argv });
@@ -1861,7 +1999,7 @@ function resolveHostShell() {
1861
1999
  }
1862
2000
  return { file: process.env.SHELL || "/bin/bash", args: ["-l"] };
1863
2001
  }
1864
- function openPty(input) {
2002
+ async function openPty(input) {
1865
2003
  try {
1866
2004
  const branchPath = ensureBranchWorkspace({
1867
2005
  projectId: input.projectId,
@@ -1872,6 +2010,14 @@ function openPty(input) {
1872
2010
  branchName: input.message.branchName,
1873
2011
  manifest: input.manifest
1874
2012
  });
2013
+ const planProcessEnv = await preparePlanEnvForShell({
2014
+ baseUrl: input.baseUrl,
2015
+ token: input.token,
2016
+ projectId: input.projectId,
2017
+ branchName: input.message.branchName,
2018
+ planRoot: input.planRoot,
2019
+ activePlanId: input.message.env?.R5D_ACTIVE_PLAN_ID
2020
+ });
1875
2021
  const shell = resolveHostShell();
1876
2022
  const ptyProcess = createNodePtyBridge({
1877
2023
  file: shell.file,
@@ -1885,6 +2031,7 @@ function openPty(input) {
1885
2031
  ...process.env,
1886
2032
  ...containerRegistryEnv(),
1887
2033
  ...input.message.env ?? {},
2034
+ ...planProcessEnv,
1888
2035
  R5D_PROJECT_ID: input.projectId,
1889
2036
  R5D_BRANCH_NAME: input.message.branchName
1890
2037
  }
@@ -1985,6 +2132,7 @@ async function startWorker(options) {
1985
2132
  const projectsRoot = import_node_path.default.resolve(options.projectRoot ?? defaultProjectsRoot());
1986
2133
  const syncRoot = import_node_path.default.resolve(options.syncRoot ?? process.env.R5D_SYNC_ROOT ?? defaultSyncRoot());
1987
2134
  const artifactRoot = import_node_path.default.resolve(options.artifactRoot ?? process.env.R5D_ARTIFACTS_ROOT ?? defaultArtifactRoot());
2135
+ const planRoot = import_node_path.default.resolve(options.planRoot ?? process.env.R5D_PLANS_ROOT ?? defaultPlanRoot());
1988
2136
  process.stdout.write(`[r5d-worker] label: ${label}
1989
2137
  `);
1990
2138
  process.stdout.write(`[r5d-worker] projects root: ${projectsRoot}
@@ -1992,6 +2140,8 @@ async function startWorker(options) {
1992
2140
  process.stdout.write(`[r5d-worker] sync root: ${syncRoot}
1993
2141
  `);
1994
2142
  process.stdout.write(`[r5d-worker] artifact root: ${artifactRoot}
2143
+ `);
2144
+ process.stdout.write(`[r5d-worker] plan root: ${planRoot}
1995
2145
  `);
1996
2146
  process.stdout.write(`[r5d-worker] server: ${baseUrl}
1997
2147
  `);
@@ -2000,6 +2150,7 @@ async function startWorker(options) {
2000
2150
  import_node_fs.default.mkdirSync(projectsRoot, { recursive: true });
2001
2151
  import_node_fs.default.mkdirSync(syncRoot, { recursive: true });
2002
2152
  import_node_fs.default.mkdirSync(artifactRoot, { recursive: true });
2153
+ import_node_fs.default.mkdirSync(planRoot, { recursive: true });
2003
2154
  const manifestByProjectId = /* @__PURE__ */ new Map();
2004
2155
  const ws = new WebSocket(websocketUrl(baseUrl, label), {
2005
2156
  headers: {
@@ -2016,7 +2167,8 @@ async function startWorker(options) {
2016
2167
  pid: process.pid,
2017
2168
  version: getWorkerVersion(),
2018
2169
  projectRoot: projectsRoot,
2019
- artifactRoot
2170
+ artifactRoot,
2171
+ planRoot
2020
2172
  }
2021
2173
  };
2022
2174
  ws.send(JSON.stringify(hello));
@@ -2052,6 +2204,20 @@ async function startWorker(options) {
2052
2204
  branchName,
2053
2205
  manifest: project
2054
2206
  });
2207
+ try {
2208
+ await syncProjectPlans({
2209
+ baseUrl,
2210
+ token,
2211
+ projectId: project.projectId,
2212
+ branchName,
2213
+ planRoot
2214
+ });
2215
+ } catch (error) {
2216
+ process.stderr.write(
2217
+ `[r5d-worker] plan sync skipped for ${project.projectId}/${branchName}: ${error instanceof Error ? error.message : String(error)}
2218
+ `
2219
+ );
2220
+ }
2055
2221
  });
2056
2222
  } catch (error) {
2057
2223
  process.stderr.write(
@@ -2090,7 +2256,7 @@ async function startWorker(options) {
2090
2256
  const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
2091
2257
  process.stdout.write(`[r5d-worker] pty ${message.ptyId}: ${message.projectId}/${message.branchName}
2092
2258
  `);
2093
- openPty({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest });
2259
+ await openPty({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, planRoot, manifest });
2094
2260
  return;
2095
2261
  }
2096
2262
  if (message.type === "pty_input") {
@@ -2113,7 +2279,7 @@ async function startWorker(options) {
2113
2279
  const result = await withBranchLock(
2114
2280
  message.projectId,
2115
2281
  message.branchName,
2116
- () => executeCommand({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
2282
+ () => executeCommand({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, planRoot, manifest })
2117
2283
  );
2118
2284
  ws.send(JSON.stringify(result));
2119
2285
  return;
@@ -2132,7 +2298,7 @@ async function startWorker(options) {
2132
2298
  void withBranchLock(
2133
2299
  message.projectId,
2134
2300
  message.branchName,
2135
- () => executeStreamingCommand({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
2301
+ () => executeStreamingCommand({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, planRoot, manifest })
2136
2302
  ).catch((error) => {
2137
2303
  sendWorkerMessage(ws, {
2138
2304
  type: "exec_start_error",
@@ -2238,5 +2404,8 @@ if (isCliEntrypoint()) {
2238
2404
  ensureVisibleGitCheckout,
2239
2405
  isArtifactEnvPath,
2240
2406
  prepareArtifactEnvForShell,
2407
+ preparePlanEnvForShell,
2408
+ resolveProjectFilePath,
2409
+ syncProjectPlans,
2241
2410
  syncSessionArtifacts
2242
2411
  });
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "type": "commonjs"
5
5
  }
package/dist/mjs/main.mjs CHANGED
@@ -33,6 +33,9 @@ function defaultSyncRoot() {
33
33
  function defaultArtifactRoot() {
34
34
  return path.join(os.homedir(), ".r5d", "artifacts");
35
35
  }
36
+ function defaultPlanRoot() {
37
+ return path.join(os.homedir(), ".r5d", "plans");
38
+ }
36
39
  function printHelp() {
37
40
  process.stdout.write(`Usage:
38
41
  r5d-worker start --label <label> [--base-url <url>] [--token <token>] [--api-key <key>]
@@ -47,6 +50,7 @@ Options:
47
50
  --project-root <dir> Override projects checkout root (default ~/.r5d/projects)
48
51
  --sync-root <dir> Override r5d internal sync git root (default ~/.r5d/sync)
49
52
  --artifact-root <dir> Override session artifact root (default from R5D_ARTIFACTS_ROOT or ~/.r5d/artifacts)
53
+ --plan-root <dir> Override synced plan root (default from R5D_PLANS_ROOT or ~/.r5d/plans)
50
54
  -v, --version Show r5d-worker version
51
55
  -h, --help Show this help
52
56
  `);
@@ -168,6 +172,11 @@ function parseArgs(argv) {
168
172
  options.artifactRoot = artifactRoot;
169
173
  continue;
170
174
  }
175
+ const planRoot = readOption("--plan-root");
176
+ if (planRoot !== void 0) {
177
+ options.planRoot = planRoot;
178
+ continue;
179
+ }
171
180
  rest.push(arg);
172
181
  }
173
182
  if (options.version) {
@@ -211,6 +220,7 @@ async function readResponseText(response) {
211
220
  }
212
221
  }
213
222
  const R5D_ARTIFACTS_DIR_REF = "$R5D_ARTIFACTS_DIR";
223
+ const R5D_PLANS_DIR_ENV = "R5D_PLANS_DIR";
214
224
  function validateArtifactSessionId(sessionId) {
215
225
  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
226
  throw new Error(`Invalid artifact session id: ${sessionId}`);
@@ -219,7 +229,7 @@ function validateArtifactSessionId(sessionId) {
219
229
  function assertInsideRoot(rootPath, candidatePath, label) {
220
230
  const relative = path.relative(rootPath, candidatePath);
221
231
  if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
222
- throw new Error(`${label} escapes the artifact directory`);
232
+ throw new Error(`${label} escapes sync directory`);
223
233
  }
224
234
  }
225
235
  function sessionArtifactDir(artifactRoot, sessionId) {
@@ -234,6 +244,23 @@ function artifactEnv(artifactRoot, sessionId) {
234
244
  R5D_ARTIFACTS_DIR: sessionArtifactDir(artifactRoot, sessionId)
235
245
  };
236
246
  }
247
+ function projectPlanDir(planRoot, projectId, branchName) {
248
+ validateProjectId(projectId);
249
+ validateBranchName(branchName);
250
+ return path.join(planRoot, projectId, branchName);
251
+ }
252
+ function planEnv(planRoot, projectId, branchName, activePlanId) {
253
+ const plansDir = projectPlanDir(planRoot, projectId, branchName);
254
+ const env = {
255
+ [R5D_PLANS_DIR_ENV]: plansDir
256
+ };
257
+ if (activePlanId) {
258
+ validatePlanId(activePlanId);
259
+ env.R5D_ACTIVE_PLAN_ID = activePlanId;
260
+ env.R5D_ACTIVE_PLAN_FILE = path.join(plansDir, `${activePlanId}.plan.md`);
261
+ }
262
+ return env;
263
+ }
237
264
  function isArtifactEnvPath(filePath) {
238
265
  return filePath === R5D_ARTIFACTS_DIR_REF || filePath.startsWith(`${R5D_ARTIFACTS_DIR_REF}/`);
239
266
  }
@@ -246,99 +273,150 @@ function artifactManifestUrl(baseUrl, projectId, branchName, sessionId) {
246
273
  function artifactDownloadUrl(baseUrl, projectId, branchName, sessionId, filename) {
247
274
  return new URL(`${artifactApiBasePath(projectId, branchName, sessionId)}/${encodeURIComponent(filename)}`, baseUrl);
248
275
  }
276
+ function planApiBasePath(projectId, branchName) {
277
+ return `/preview-api/${encodeURIComponent(projectId)}/${encodeURIComponent(branchName)}/plans`;
278
+ }
279
+ function planManifestUrl(baseUrl, projectId, branchName) {
280
+ return new URL(planApiBasePath(projectId, branchName), baseUrl);
281
+ }
282
+ function planDownloadUrl(baseUrl, projectId, branchName, filename) {
283
+ return new URL(`${planApiBasePath(projectId, branchName)}/${encodeURIComponent(filename)}`, baseUrl);
284
+ }
249
285
  function sha256Path(filePath) {
250
286
  return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
251
287
  }
252
- function parseSessionArtifactManifest(value) {
253
- if (!value || typeof value !== "object" || !Array.isArray(value.artifacts)) {
254
- throw new Error("Artifact manifest response is invalid");
288
+ function parseRemoteFileManifest(value, collectionKey, label) {
289
+ if (!value || typeof value !== "object" || !Array.isArray(value[collectionKey])) {
290
+ throw new Error(`${label} manifest response is invalid`);
255
291
  }
256
- return value.artifacts.map((entry) => {
292
+ return value[collectionKey].map((entry) => {
257
293
  if (!entry || typeof entry !== "object") {
258
- throw new Error("Artifact manifest entry is invalid");
294
+ throw new Error(`${label} manifest entry is invalid`);
259
295
  }
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");
296
+ const file = entry;
297
+ if (typeof file.filename !== "string" || file.filename.includes("/") || file.filename.includes("\\")) {
298
+ throw new Error(`${label} manifest entry has an invalid filename`);
263
299
  }
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}`);
300
+ if (typeof file.size !== "number" || !Number.isFinite(file.size) || file.size < 0) {
301
+ throw new Error(`${label} manifest entry has an invalid size: ${file.filename}`);
266
302
  }
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}`);
303
+ if (typeof file.sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(file.sha256)) {
304
+ throw new Error(`${label} manifest entry has an invalid sha256: ${file.filename}`);
269
305
  }
270
306
  return {
271
- filename: artifact.filename,
272
- size: artifact.size,
273
- sha256: artifact.sha256.toLowerCase()
307
+ filename: file.filename,
308
+ size: file.size,
309
+ sha256: file.sha256.toLowerCase()
274
310
  };
275
311
  });
276
312
  }
277
- async function fetchSessionArtifactManifest(input) {
278
- const response = await fetch(artifactManifestUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId), {
313
+ async function fetchRemoteFileManifest(input) {
314
+ const response = await fetch(input.url, {
279
315
  headers: {
280
316
  Authorization: `Bearer ${input.token}`
281
317
  }
282
318
  });
283
319
  if (!response.ok) {
284
320
  const detail = (await readResponseText(response)).trim();
285
- throw new Error(`Failed to fetch artifact manifest (${response.status})${detail ? `: ${detail}` : ""}`);
321
+ throw new Error(`Failed to fetch ${input.label} manifest (${response.status})${detail ? `: ${detail}` : ""}`);
286
322
  }
287
- return parseSessionArtifactManifest(await response.json());
323
+ return parseRemoteFileManifest(await response.json(), input.collectionKey, input.label);
288
324
  }
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
- }
325
+ async function downloadRemoteSyncedFile(input) {
326
+ const targetPath = path.join(input.targetDir, input.file.filename);
327
+ assertInsideRoot(input.targetDir, targetPath, input.label);
328
+ const response = await fetch(input.url, {
329
+ headers: {
330
+ Authorization: `Bearer ${input.token}`
299
331
  }
300
- );
332
+ });
301
333
  if (!response.ok) {
302
334
  const detail = (await readResponseText(response)).trim();
303
- throw new Error(`Failed to download artifact ${input.artifact.filename} (${response.status})${detail ? `: ${detail}` : ""}`);
335
+ throw new Error(`Failed to download ${input.label} ${input.file.filename} (${response.status})${detail ? `: ${detail}` : ""}`);
304
336
  }
305
337
  const buffer = Buffer.from(await response.arrayBuffer());
306
338
  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`);
339
+ if (receivedHash !== input.file.sha256) {
340
+ throw new Error(`Downloaded ${input.label} ${input.file.filename} failed sha256 verification`);
309
341
  }
310
- if (buffer.length !== input.artifact.size) {
311
- throw new Error(`Downloaded artifact ${input.artifact.filename} has unexpected size`);
342
+ if (buffer.length !== input.file.size) {
343
+ throw new Error(`Downloaded ${input.label} ${input.file.filename} has unexpected size`);
312
344
  }
313
345
  const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
314
346
  fs.writeFileSync(tempPath, buffer);
315
347
  fs.renameSync(tempPath, targetPath);
316
348
  }
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");
349
+ async function syncRemoteFileSet(input) {
350
+ fs.mkdirSync(input.targetDir, { recursive: true });
351
+ const manifestNames = new Set(input.files.map((file) => file.filename));
352
+ for (const file of input.files) {
353
+ const localPath = path.join(input.targetDir, file.filename);
354
+ assertInsideRoot(input.targetDir, localPath, input.label);
325
355
  if (fs.existsSync(localPath)) {
326
356
  const stats = fs.statSync(localPath);
327
- if (stats.isFile() && stats.size === artifact.size && sha256Path(localPath) === artifact.sha256) {
357
+ if (stats.isFile() && stats.size === file.size && sha256Path(localPath) === file.sha256) {
328
358
  continue;
329
359
  }
330
360
  fs.rmSync(localPath, { recursive: true, force: true });
331
361
  }
332
- await downloadSessionArtifact({ ...input, artifact });
362
+ await input.download(file);
333
363
  }
334
- for (const localName of fs.readdirSync(targetDir)) {
364
+ for (const localName of fs.readdirSync(input.targetDir)) {
335
365
  if (!manifestNames.has(localName)) {
336
- const localPath = path.join(targetDir, localName);
337
- assertInsideRoot(targetDir, localPath, "Artifact path");
366
+ const localPath = path.join(input.targetDir, localName);
367
+ assertInsideRoot(input.targetDir, localPath, input.label);
338
368
  fs.rmSync(localPath, { recursive: true, force: true });
339
369
  }
340
370
  }
341
- return targetDir;
371
+ return input.targetDir;
372
+ }
373
+ async function fetchSessionArtifactManifest(input) {
374
+ return fetchRemoteFileManifest({
375
+ url: artifactManifestUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId),
376
+ token: input.token,
377
+ collectionKey: "artifacts",
378
+ label: "artifact"
379
+ });
380
+ }
381
+ async function syncSessionArtifacts(input) {
382
+ const targetDir = sessionArtifactDir(input.artifactRoot, input.sessionId);
383
+ const artifacts = await fetchSessionArtifactManifest(input);
384
+ return syncRemoteFileSet({
385
+ targetDir,
386
+ files: artifacts,
387
+ label: "Artifact path",
388
+ download: (artifact) => downloadRemoteSyncedFile({
389
+ url: artifactDownloadUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId, artifact.filename),
390
+ token: input.token,
391
+ targetDir,
392
+ file: artifact,
393
+ label: "artifact"
394
+ })
395
+ });
396
+ }
397
+ async function fetchProjectPlanManifest(input) {
398
+ return fetchRemoteFileManifest({
399
+ url: planManifestUrl(input.baseUrl, input.projectId, input.branchName),
400
+ token: input.token,
401
+ collectionKey: "plans",
402
+ label: "plan"
403
+ });
404
+ }
405
+ async function syncProjectPlans(input) {
406
+ const targetDir = projectPlanDir(input.planRoot, input.projectId, input.branchName);
407
+ const plans = await fetchProjectPlanManifest(input);
408
+ return syncRemoteFileSet({
409
+ targetDir,
410
+ files: plans,
411
+ label: "Plan path",
412
+ download: (plan) => downloadRemoteSyncedFile({
413
+ url: planDownloadUrl(input.baseUrl, input.projectId, input.branchName, plan.filename),
414
+ token: input.token,
415
+ targetDir,
416
+ file: plan,
417
+ label: "plan"
418
+ })
419
+ });
342
420
  }
343
421
  async function resolveArtifactEnvFilePath(input) {
344
422
  if (!isArtifactEnvPath(input.filePath)) {
@@ -397,6 +475,19 @@ async function prepareArtifactEnvForShell(input) {
397
475
  R5D_ARTIFACTS_DIR: artifactsDir
398
476
  };
399
477
  }
478
+ async function preparePlanEnvForShell(input) {
479
+ try {
480
+ await syncProjectPlans(input);
481
+ } catch (error) {
482
+ process.stderr.write(
483
+ `[r5d-worker] plan sync skipped for ${input.projectId}/${input.branchName}: ${error instanceof Error ? error.message : String(error)}
484
+ `
485
+ );
486
+ }
487
+ const plansDir = projectPlanDir(input.planRoot, input.projectId, input.branchName);
488
+ fs.mkdirSync(plansDir, { recursive: true });
489
+ return planEnv(input.planRoot, input.projectId, input.branchName, input.activePlanId);
490
+ }
400
491
  async function verifyR5dctlAuth(baseUrl, token) {
401
492
  const statusUrl = new URL("/api/r5dctl/auth/status", baseUrl);
402
493
  let response;
@@ -441,11 +532,24 @@ function validateLabel(label) {
441
532
  throw new Error("Worker label must start with a letter or number and may contain letters, numbers, dots, underscores, and hyphens");
442
533
  }
443
534
  }
535
+ function validateProjectId(projectId) {
536
+ if (projectId === "local" || projectId === "local-code") {
537
+ return;
538
+ }
539
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(projectId)) {
540
+ throw new Error(`Invalid project id from server: ${projectId}`);
541
+ }
542
+ }
444
543
  function validateBranchName(branchName) {
445
544
  if (branchName !== "main" && !BRANCH_RE.test(branchName)) {
446
545
  throw new Error(`Invalid branch name from server: ${branchName}`);
447
546
  }
448
547
  }
548
+ function validatePlanId(planId) {
549
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(planId)) {
550
+ throw new Error(`Invalid plan id from server: ${planId}`);
551
+ }
552
+ }
449
553
  function gitExtraHeaderConfigKey(extraHeaderUrl) {
450
554
  return `http.${normalizeBaseUrl(extraHeaderUrl)}/.extraHeader`;
451
555
  }
@@ -511,9 +615,12 @@ function getInternalStatus(context) {
511
615
  function hasInternalWorktreeChanges(context) {
512
616
  return getInternalStatus(context).trim().length > 0;
513
617
  }
618
+ function isInsideBranchPath(branchPath, filePath) {
619
+ const relative = path.relative(path.resolve(branchPath), path.resolve(filePath));
620
+ return relative === "" || relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
621
+ }
514
622
  function assertInsideBranch(branchPath, filePath) {
515
- const relative = path.relative(branchPath, filePath);
516
- if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
623
+ if (!isInsideBranchPath(branchPath, filePath)) {
517
624
  throw new Error("File path escapes the project branch");
518
625
  }
519
626
  }
@@ -521,17 +628,27 @@ function resolveProjectFilePath(branchPath, virtualPath) {
521
628
  if (typeof virtualPath !== "string" || !virtualPath.startsWith("/") || virtualPath.includes("\0")) {
522
629
  throw new Error(`File path must be an absolute project path starting with /. Got: ${JSON.stringify(virtualPath)}`);
523
630
  }
631
+ const checkoutAbsolutePath = path.resolve(virtualPath);
632
+ if (isInsideBranchPath(branchPath, checkoutAbsolutePath)) {
633
+ const checkoutVirtualPath = toVirtualPath(path.resolve(branchPath), checkoutAbsolutePath);
634
+ if (checkoutVirtualPath !== virtualPath) {
635
+ return resolveProjectFilePath(branchPath, checkoutVirtualPath);
636
+ }
637
+ }
638
+ if (virtualPath.split("/").includes("..")) {
639
+ throw new Error(`Invalid project file path: ${virtualPath}`);
640
+ }
524
641
  const normalized = path.posix.normalize(virtualPath);
525
- if (normalized === "/" || normalized.startsWith("/../")) {
642
+ if (normalized.startsWith("/../")) {
526
643
  throw new Error(`Invalid project file path: ${virtualPath}`);
527
644
  }
528
- const repoRelativePath = normalized.slice(1);
645
+ const repoRelativePath = normalized === "/" ? "" : normalized.slice(1);
529
646
  if (repoRelativePath === ".git" || repoRelativePath.startsWith(".git/")) {
530
647
  throw new Error("Refusing to access .git through worker file tools");
531
648
  }
532
- const absolutePath = path.resolve(branchPath, ...repoRelativePath.split("/"));
649
+ const absolutePath = repoRelativePath ? path.resolve(branchPath, ...repoRelativePath.split("/")) : path.resolve(branchPath);
533
650
  assertInsideBranch(branchPath, absolutePath);
534
- return { absolutePath, virtualPath: `/${repoRelativePath}`, repoRelativePath };
651
+ return { absolutePath, virtualPath: repoRelativePath ? `/${repoRelativePath}` : "/", repoRelativePath };
535
652
  }
536
653
  function branchLockKey(projectId, branchName) {
537
654
  return `${projectId}:${branchName}`;
@@ -1481,6 +1598,14 @@ async function executeCommand(input) {
1481
1598
  sessionId: input.message.sessionId,
1482
1599
  artifactRoot: input.artifactRoot
1483
1600
  });
1601
+ const planProcessEnv = await preparePlanEnvForShell({
1602
+ baseUrl: input.baseUrl,
1603
+ token: input.token,
1604
+ projectId: input.projectId,
1605
+ branchName: input.message.branchName,
1606
+ planRoot: input.planRoot,
1607
+ activePlanId: input.message.env?.R5D_ACTIVE_PLAN_ID
1608
+ });
1484
1609
  const cwd = resolveCommandCwd(workspace.branchPath, input.message.cwd);
1485
1610
  const subprocess = Bun.spawn(input.message.argv, {
1486
1611
  cwd,
@@ -1491,7 +1616,8 @@ async function executeCommand(input) {
1491
1616
  ...containerRegistryEnv(),
1492
1617
  ...input.message.env ?? {},
1493
1618
  ...internalGitProcessEnv(workspace, input.message.env),
1494
- ...artifactProcessEnv
1619
+ ...artifactProcessEnv,
1620
+ ...planProcessEnv
1495
1621
  }
1496
1622
  });
1497
1623
  activeProcesses.set(input.message.runId, { process: subprocess, command: input.message.argv });
@@ -1562,6 +1688,14 @@ async function executeStreamingCommand(input) {
1562
1688
  sessionId: input.message.sessionId,
1563
1689
  artifactRoot: input.artifactRoot
1564
1690
  });
1691
+ const planProcessEnv = await preparePlanEnvForShell({
1692
+ baseUrl: input.baseUrl,
1693
+ token: input.token,
1694
+ projectId: input.projectId,
1695
+ branchName: input.message.branchName,
1696
+ planRoot: input.planRoot,
1697
+ activePlanId: input.message.env?.R5D_ACTIVE_PLAN_ID
1698
+ });
1565
1699
  const cwd = resolveCommandCwd(workspace.branchPath, input.message.cwd);
1566
1700
  const subprocess = Bun.spawn(input.message.argv, {
1567
1701
  cwd,
@@ -1572,7 +1706,8 @@ async function executeStreamingCommand(input) {
1572
1706
  ...containerRegistryEnv(),
1573
1707
  ...input.message.env ?? {},
1574
1708
  ...internalGitProcessEnv(workspace, input.message.env),
1575
- ...artifactProcessEnv
1709
+ ...artifactProcessEnv,
1710
+ ...planProcessEnv
1576
1711
  }
1577
1712
  });
1578
1713
  activeProcesses.set(input.message.runId, { process: subprocess, command: input.message.argv });
@@ -1824,7 +1959,7 @@ function resolveHostShell() {
1824
1959
  }
1825
1960
  return { file: process.env.SHELL || "/bin/bash", args: ["-l"] };
1826
1961
  }
1827
- function openPty(input) {
1962
+ async function openPty(input) {
1828
1963
  try {
1829
1964
  const branchPath = ensureBranchWorkspace({
1830
1965
  projectId: input.projectId,
@@ -1835,6 +1970,14 @@ function openPty(input) {
1835
1970
  branchName: input.message.branchName,
1836
1971
  manifest: input.manifest
1837
1972
  });
1973
+ const planProcessEnv = await preparePlanEnvForShell({
1974
+ baseUrl: input.baseUrl,
1975
+ token: input.token,
1976
+ projectId: input.projectId,
1977
+ branchName: input.message.branchName,
1978
+ planRoot: input.planRoot,
1979
+ activePlanId: input.message.env?.R5D_ACTIVE_PLAN_ID
1980
+ });
1838
1981
  const shell = resolveHostShell();
1839
1982
  const ptyProcess = createNodePtyBridge({
1840
1983
  file: shell.file,
@@ -1848,6 +1991,7 @@ function openPty(input) {
1848
1991
  ...process.env,
1849
1992
  ...containerRegistryEnv(),
1850
1993
  ...input.message.env ?? {},
1994
+ ...planProcessEnv,
1851
1995
  R5D_PROJECT_ID: input.projectId,
1852
1996
  R5D_BRANCH_NAME: input.message.branchName
1853
1997
  }
@@ -1948,6 +2092,7 @@ async function startWorker(options) {
1948
2092
  const projectsRoot = path.resolve(options.projectRoot ?? defaultProjectsRoot());
1949
2093
  const syncRoot = path.resolve(options.syncRoot ?? process.env.R5D_SYNC_ROOT ?? defaultSyncRoot());
1950
2094
  const artifactRoot = path.resolve(options.artifactRoot ?? process.env.R5D_ARTIFACTS_ROOT ?? defaultArtifactRoot());
2095
+ const planRoot = path.resolve(options.planRoot ?? process.env.R5D_PLANS_ROOT ?? defaultPlanRoot());
1951
2096
  process.stdout.write(`[r5d-worker] label: ${label}
1952
2097
  `);
1953
2098
  process.stdout.write(`[r5d-worker] projects root: ${projectsRoot}
@@ -1955,6 +2100,8 @@ async function startWorker(options) {
1955
2100
  process.stdout.write(`[r5d-worker] sync root: ${syncRoot}
1956
2101
  `);
1957
2102
  process.stdout.write(`[r5d-worker] artifact root: ${artifactRoot}
2103
+ `);
2104
+ process.stdout.write(`[r5d-worker] plan root: ${planRoot}
1958
2105
  `);
1959
2106
  process.stdout.write(`[r5d-worker] server: ${baseUrl}
1960
2107
  `);
@@ -1963,6 +2110,7 @@ async function startWorker(options) {
1963
2110
  fs.mkdirSync(projectsRoot, { recursive: true });
1964
2111
  fs.mkdirSync(syncRoot, { recursive: true });
1965
2112
  fs.mkdirSync(artifactRoot, { recursive: true });
2113
+ fs.mkdirSync(planRoot, { recursive: true });
1966
2114
  const manifestByProjectId = /* @__PURE__ */ new Map();
1967
2115
  const ws = new WebSocket(websocketUrl(baseUrl, label), {
1968
2116
  headers: {
@@ -1979,7 +2127,8 @@ async function startWorker(options) {
1979
2127
  pid: process.pid,
1980
2128
  version: getWorkerVersion(),
1981
2129
  projectRoot: projectsRoot,
1982
- artifactRoot
2130
+ artifactRoot,
2131
+ planRoot
1983
2132
  }
1984
2133
  };
1985
2134
  ws.send(JSON.stringify(hello));
@@ -2015,6 +2164,20 @@ async function startWorker(options) {
2015
2164
  branchName,
2016
2165
  manifest: project
2017
2166
  });
2167
+ try {
2168
+ await syncProjectPlans({
2169
+ baseUrl,
2170
+ token,
2171
+ projectId: project.projectId,
2172
+ branchName,
2173
+ planRoot
2174
+ });
2175
+ } catch (error) {
2176
+ process.stderr.write(
2177
+ `[r5d-worker] plan sync skipped for ${project.projectId}/${branchName}: ${error instanceof Error ? error.message : String(error)}
2178
+ `
2179
+ );
2180
+ }
2018
2181
  });
2019
2182
  } catch (error) {
2020
2183
  process.stderr.write(
@@ -2053,7 +2216,7 @@ async function startWorker(options) {
2053
2216
  const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
2054
2217
  process.stdout.write(`[r5d-worker] pty ${message.ptyId}: ${message.projectId}/${message.branchName}
2055
2218
  `);
2056
- openPty({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest });
2219
+ await openPty({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, planRoot, manifest });
2057
2220
  return;
2058
2221
  }
2059
2222
  if (message.type === "pty_input") {
@@ -2076,7 +2239,7 @@ async function startWorker(options) {
2076
2239
  const result = await withBranchLock(
2077
2240
  message.projectId,
2078
2241
  message.branchName,
2079
- () => executeCommand({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
2242
+ () => executeCommand({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, planRoot, manifest })
2080
2243
  );
2081
2244
  ws.send(JSON.stringify(result));
2082
2245
  return;
@@ -2095,7 +2258,7 @@ async function startWorker(options) {
2095
2258
  void withBranchLock(
2096
2259
  message.projectId,
2097
2260
  message.branchName,
2098
- () => executeStreamingCommand({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
2261
+ () => executeStreamingCommand({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, planRoot, manifest })
2099
2262
  ).catch((error) => {
2100
2263
  sendWorkerMessage(ws, {
2101
2264
  type: "exec_start_error",
@@ -2200,5 +2363,8 @@ export {
2200
2363
  ensureVisibleGitCheckout,
2201
2364
  isArtifactEnvPath,
2202
2365
  prepareArtifactEnvForShell,
2366
+ preparePlanEnvForShell,
2367
+ resolveProjectFilePath,
2368
+ syncProjectPlans,
2203
2369
  syncSessionArtifacts
2204
2370
  };
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "type": "module"
5
5
  }
@@ -8,6 +8,13 @@ export declare function syncSessionArtifacts(input: {
8
8
  sessionId: string;
9
9
  artifactRoot: string;
10
10
  }): Promise<string>;
11
+ export declare function syncProjectPlans(input: {
12
+ baseUrl: string;
13
+ token: string;
14
+ projectId: string;
15
+ branchName: string;
16
+ planRoot: string;
17
+ }): Promise<string>;
11
18
  export declare function prepareArtifactEnvForShell(input: {
12
19
  baseUrl: string;
13
20
  token: string;
@@ -16,10 +23,23 @@ export declare function prepareArtifactEnvForShell(input: {
16
23
  sessionId?: string;
17
24
  artifactRoot: string;
18
25
  }): Promise<Record<string, string>>;
26
+ export declare function preparePlanEnvForShell(input: {
27
+ baseUrl: string;
28
+ token: string;
29
+ projectId: string;
30
+ branchName: string;
31
+ planRoot: string;
32
+ activePlanId?: string;
33
+ }): Promise<Record<string, string>>;
19
34
  type GitAuth = {
20
35
  extraHeaderUrl: string;
21
36
  header: string;
22
37
  };
38
+ export declare function resolveProjectFilePath(branchPath: string, virtualPath: string): {
39
+ absolutePath: string;
40
+ virtualPath: string;
41
+ repoRelativePath: string;
42
+ };
23
43
  export declare function ensureVisibleGitCheckout(input: {
24
44
  projectRoot: string;
25
45
  branchName: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/main.cjs",
6
6
  "module": "./dist/mjs/main.mjs",