@ricsam/r5d-worker 0.0.80 → 0.0.82

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
@@ -50,6 +50,8 @@ __export(main_exports, {
50
50
  syncSessionArtifacts: () => syncSessionArtifacts,
51
51
  workerChildProcessEnvironment: () => workerChildProcessEnvironment,
52
52
  workerGitSecurityTestHarness: () => workerGitSecurityTestHarness,
53
+ workerPtyBridgeTestHarness: () => workerPtyBridgeTestHarness,
54
+ workerPtyTestHarness: () => workerPtyTestHarness,
53
55
  writeWorkerTextFile: () => writeWorkerTextFile
54
56
  });
55
57
  module.exports = __toCommonJS(main_exports);
@@ -95,6 +97,10 @@ const DEFAULT_READ_MAX_BYTES = 5e4;
95
97
  const MAX_LINE_LENGTH = 2e3;
96
98
  const WORKSPACE_GIT_QUIET_MS = 5e3;
97
99
  const WORKSPACE_GIT_PERIODIC_MS = 6e4;
100
+ const PTY_INPUT_BUSY_GRACE_MS = 3e3;
101
+ const PTY_FOREGROUND_POLL_MS = 1e3;
102
+ const PTY_FOREGROUND_IDLE_ENABLED = process.env.R5D_PTY_FOREGROUND_IDLE !== "0";
103
+ const PTY_TMP_PATH_PREFIX = "r5d-worker-tmp://";
98
104
  const activeProcesses = /* @__PURE__ */ new Map();
99
105
  const credentialBearingProcessGroups = /* @__PURE__ */ new Map();
100
106
  const credentialBearingProcessGroupTargets = /* @__PURE__ */ new Map();
@@ -152,6 +158,106 @@ function assertWorkerChildAdmission(input) {
152
158
  throw new StaleWorkerAdmissionError();
153
159
  }
154
160
  }
161
+ function pathIsInsideRoot(rootPath, candidatePath) {
162
+ const relative = import_node_path.default.relative(rootPath, candidatePath);
163
+ return relative === "" || !relative.startsWith(`..${import_node_path.default.sep}`) && relative !== ".." && !import_node_path.default.isAbsolute(relative);
164
+ }
165
+ function resolvePtyEnvFilePath(requestedPath, temporaryRoot = import_node_os.default.tmpdir()) {
166
+ const canonicalTemporaryRoot = import_node_fs.default.realpathSync.native(temporaryRoot);
167
+ if (requestedPath.startsWith(PTY_TMP_PATH_PREFIX)) {
168
+ const filename = requestedPath.slice(PTY_TMP_PATH_PREFIX.length);
169
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(filename)) {
170
+ throw new Error("PTY environment file temporary token must contain one safe filename");
171
+ }
172
+ return import_node_path.default.join(canonicalTemporaryRoot, filename);
173
+ }
174
+ if (!import_node_path.default.isAbsolute(requestedPath)) {
175
+ throw new Error(`PTY environment file path must be absolute or use ${PTY_TMP_PATH_PREFIX}`);
176
+ }
177
+ const resolvedPath = import_node_path.default.resolve(requestedPath);
178
+ const canonicalParent = import_node_fs.default.realpathSync.native(import_node_path.default.dirname(resolvedPath));
179
+ const canonicalPath = import_node_path.default.join(canonicalParent, import_node_path.default.basename(resolvedPath));
180
+ if (!pathIsInsideRoot(canonicalTemporaryRoot, canonicalPath) || canonicalPath === canonicalTemporaryRoot) {
181
+ throw new Error(`PTY environment file path must be inside ${canonicalTemporaryRoot}`);
182
+ }
183
+ return canonicalPath;
184
+ }
185
+ function removePtyEnvFiles(paths) {
186
+ for (const filePath of paths) {
187
+ try {
188
+ import_node_fs.default.rmSync(filePath, { force: true });
189
+ } catch (error) {
190
+ process.stderr.write(
191
+ `[r5d-worker] failed to remove PTY environment file ${filePath}: ${error instanceof Error ? error.message : String(error)}
192
+ `
193
+ );
194
+ }
195
+ }
196
+ }
197
+ function stagePtyEnvFiles(envFiles, temporaryRoot = import_node_os.default.tmpdir()) {
198
+ const staged = { paths: [], resolvedByRequestedPath: /* @__PURE__ */ new Map() };
199
+ try {
200
+ for (const envFile of envFiles ?? []) {
201
+ if (!envFile || typeof envFile.path !== "string" || typeof envFile.content !== "string") {
202
+ throw new Error("PTY environment files require string path and content values");
203
+ }
204
+ if (envFile.mode !== void 0 && envFile.mode !== 384) {
205
+ throw new Error("PTY environment files must use mode 0600");
206
+ }
207
+ const resolvedPath = resolvePtyEnvFilePath(envFile.path, temporaryRoot);
208
+ if (staged.resolvedByRequestedPath.has(envFile.path) || staged.paths.includes(resolvedPath)) {
209
+ throw new Error(`Duplicate PTY environment file path: ${envFile.path}`);
210
+ }
211
+ const descriptor = import_node_fs.default.openSync(
212
+ resolvedPath,
213
+ import_node_fs.default.constants.O_WRONLY | import_node_fs.default.constants.O_CREAT | import_node_fs.default.constants.O_EXCL | (typeof import_node_fs.default.constants.O_NOFOLLOW === "number" ? import_node_fs.default.constants.O_NOFOLLOW : 0),
214
+ 384
215
+ );
216
+ staged.paths.push(resolvedPath);
217
+ staged.resolvedByRequestedPath.set(envFile.path, resolvedPath);
218
+ try {
219
+ import_node_fs.default.writeFileSync(descriptor, envFile.content, "utf8");
220
+ import_node_fs.default.fchmodSync(descriptor, 384);
221
+ import_node_fs.default.fsyncSync(descriptor);
222
+ } finally {
223
+ import_node_fs.default.closeSync(descriptor);
224
+ }
225
+ }
226
+ return staged;
227
+ } catch (error) {
228
+ removePtyEnvFiles(staged.paths);
229
+ throw error;
230
+ }
231
+ }
232
+ function resolvePtyEnvFileReferences(env, staged) {
233
+ return Object.fromEntries(Object.entries(env).map(([name, value]) => [name, staged.resolvedByRequestedPath.get(value) ?? value]));
234
+ }
235
+ function workerCommandHasWorkspaceEffect(message) {
236
+ return message.workspaceEffect !== "none";
237
+ }
238
+ function workerPtyIsWorkspaceBusy(pty, now = Date.now(), foregroundIdleEnabled = PTY_FOREGROUND_IDLE_ENABLED) {
239
+ return !foregroundIdleEnabled || pty.foregroundBusy || now - pty.lastInputAt < PTY_INPUT_BUSY_GRACE_MS;
240
+ }
241
+ function parseLinuxPtyForegroundBusy(stat) {
242
+ const commandEnd = stat.lastIndexOf(")");
243
+ if (commandEnd < 0) return null;
244
+ const fields = stat.slice(commandEnd + 1).trim().split(/\s+/);
245
+ const processGroup = Number(fields[2]);
246
+ const foregroundProcessGroup = Number(fields[5]);
247
+ if (!Number.isSafeInteger(processGroup) || processGroup <= 0 || !Number.isSafeInteger(foregroundProcessGroup)) return null;
248
+ return foregroundProcessGroup !== processGroup;
249
+ }
250
+ const workerPtyTestHarness = {
251
+ temporaryPathPrefix: PTY_TMP_PATH_PREFIX,
252
+ inputBusyGraceMs: PTY_INPUT_BUSY_GRACE_MS,
253
+ resolveEnvFilePath: resolvePtyEnvFilePath,
254
+ stageEnvFiles: stagePtyEnvFiles,
255
+ removeEnvFiles: removePtyEnvFiles,
256
+ resolveEnvFileReferences: resolvePtyEnvFileReferences,
257
+ commandHasWorkspaceEffect: workerCommandHasWorkspaceEffect,
258
+ ptyIsWorkspaceBusy: workerPtyIsWorkspaceBusy,
259
+ parseLinuxForegroundBusy: parseLinuxPtyForegroundBusy
260
+ };
155
261
  function defaultConfigPath() {
156
262
  return import_node_path.default.join(import_node_os.default.homedir(), ".config", "r5d", "r5dctl", "config.json");
157
263
  }
@@ -629,7 +735,7 @@ function preparePlanEnvForShell(input) {
629
735
  import_node_fs.default.mkdirSync(plansDir, { recursive: true });
630
736
  return planEnv(input.planRoot, input.target, input.activePlanId);
631
737
  }
632
- async function verifyR5dctlAuth(baseUrl, token) {
738
+ async function verifyR5dctlAuth(baseUrl, token, workerLabel) {
633
739
  const statusUrl = new URL("/api/r5dctl/auth/status", baseUrl);
634
740
  let response;
635
741
  try {
@@ -658,13 +764,16 @@ async function verifyR5dctlAuth(baseUrl, token) {
658
764
  throw new WorkerServerUnavailableError(`Server unavailable at ${baseUrl} (${response.status})${detail}`);
659
765
  }
660
766
  throw new Error(
661
- `Authentication failed against ${baseUrl} (${response.status})${detail}. Run \`r5dctl auth login --base-url ${baseUrl}\` or pass --token/--api-key for this server.`
767
+ `Authentication failed against ${baseUrl} (${response.status})${detail}. Run \`r5dctl auth login --base-url ${baseUrl} --worker-label ${workerLabel}\` or pass that exact label-bound worker token.`
662
768
  );
663
769
  }
664
770
  function resolveCredentials(options, config) {
665
771
  const token = options.token ?? process.env.R5D_WORKER_TOKEN ?? process.env.R5D_TOKEN ?? process.env.R5DCTL_TOKEN ?? config.token ?? options.apiKey ?? process.env.R5D_API_KEY ?? process.env.R5DCTL_API_KEY ?? config.apiKey;
666
772
  if (!token) {
667
- throw new Error("Authentication required. Run `r5dctl auth login` or set R5D_WORKER_TOKEN/R5D_API_KEY.");
773
+ const workerLabel = options.label ?? "<label>";
774
+ throw new Error(
775
+ `Authentication required. Run \`r5dctl auth login --worker-label ${workerLabel}\` or set R5D_WORKER_TOKEN to that label-bound token.`
776
+ );
668
777
  }
669
778
  return {
670
779
  baseUrl: normalizeBaseUrl(
@@ -2330,7 +2439,9 @@ async function executeCommand(input) {
2330
2439
  });
2331
2440
  spawnedProcess = subprocess;
2332
2441
  credentialBearingProcessGroups.set(subprocess.pid, subprocess);
2333
- credentialBearingProcessGroupTargets.set(subprocess.pid, input.resolvedTarget.target);
2442
+ if (workerCommandHasWorkspaceEffect(input.message)) {
2443
+ credentialBearingProcessGroupTargets.set(subprocess.pid, input.resolvedTarget.target);
2444
+ }
2334
2445
  activeProcesses.set(input.message.runId, {
2335
2446
  process: subprocess,
2336
2447
  target: input.resolvedTarget.target,
@@ -2341,7 +2452,8 @@ async function executeCommand(input) {
2341
2452
  argv: input.message.argv,
2342
2453
  command: input.message.argv.join(" "),
2343
2454
  cwd,
2344
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
2455
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2456
+ ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2345
2457
  });
2346
2458
  if (input.message.timeoutMs) {
2347
2459
  timeout = setTimeout(() => {
@@ -2427,7 +2539,9 @@ async function executeStreamingCommand(input) {
2427
2539
  });
2428
2540
  spawnedProcess = subprocess;
2429
2541
  credentialBearingProcessGroups.set(subprocess.pid, subprocess);
2430
- credentialBearingProcessGroupTargets.set(subprocess.pid, input.resolvedTarget.target);
2542
+ if (workerCommandHasWorkspaceEffect(input.message)) {
2543
+ credentialBearingProcessGroupTargets.set(subprocess.pid, input.resolvedTarget.target);
2544
+ }
2431
2545
  activeProcesses.set(input.message.runId, {
2432
2546
  process: subprocess,
2433
2547
  target: input.resolvedTarget.target,
@@ -2440,7 +2554,8 @@ async function executeStreamingCommand(input) {
2440
2554
  command: input.message.command,
2441
2555
  cwd,
2442
2556
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2443
- ...interactive ? { interactive: true, stdin: subprocess.stdin } : {}
2557
+ ...interactive ? { interactive: true, stdin: subprocess.stdin } : {},
2558
+ ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2444
2559
  });
2445
2560
  started = true;
2446
2561
  sendWorkerMessage(input.ws, {
@@ -2487,7 +2602,8 @@ async function executeStreamingCommand(input) {
2487
2602
  runId: input.message.runId,
2488
2603
  exitCode,
2489
2604
  durationMs: Date.now() - startedAt,
2490
- ...timedOut ? { timedOut: true } : {}
2605
+ ...timedOut ? { timedOut: true } : {},
2606
+ ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2491
2607
  };
2492
2608
  pendingProcessTerminals.set(input.message.runId, terminal);
2493
2609
  sendWorkerMessage(input.ws, terminal);
@@ -2499,7 +2615,8 @@ async function executeStreamingCommand(input) {
2499
2615
  type: "exec_error",
2500
2616
  runId: input.message.runId,
2501
2617
  error: message,
2502
- durationMs: Date.now() - startedAt
2618
+ durationMs: Date.now() - startedAt,
2619
+ ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2503
2620
  };
2504
2621
  pendingProcessTerminals.set(input.message.runId, terminal);
2505
2622
  sendWorkerMessage(input.ws, terminal);
@@ -2580,7 +2697,8 @@ function buildActiveProcessReports() {
2580
2697
  command: active.command,
2581
2698
  ...active.cwd ? { cwd: active.cwd } : {},
2582
2699
  startedAt: active.startedAt,
2583
- ...active.interactive ? { interactive: true } : {}
2700
+ ...active.interactive ? { interactive: true } : {},
2701
+ ...active.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2584
2702
  }));
2585
2703
  }
2586
2704
  function sendActiveProcessReport(ws) {
@@ -2592,8 +2710,13 @@ function sendActiveProcessReport(ws) {
2592
2710
  const PTY_BRIDGE_SCRIPT = String.raw`
2593
2711
  const readline = require("node:readline");
2594
2712
  const nodePty = require("node-pty");
2713
+ const fs = require("node:fs");
2714
+ const { execFile } = require("node:child_process");
2595
2715
 
2596
2716
  let ptyProcess = null;
2717
+ let foregroundTimer = null;
2718
+ let foregroundPollInFlight = false;
2719
+ let lastForegroundBusy = null;
2597
2720
 
2598
2721
  function send(message, callback) {
2599
2722
  process.stdout.write(JSON.stringify(message) + "\n", callback);
@@ -2603,6 +2726,46 @@ function decode(data) {
2603
2726
  return Buffer.from(data, "base64").toString("utf8");
2604
2727
  }
2605
2728
 
2729
+ function emitForeground(busy) {
2730
+ if (busy === lastForegroundBusy) return;
2731
+ lastForegroundBusy = busy;
2732
+ send({ type: "foreground", busy });
2733
+ }
2734
+
2735
+ function linuxForegroundBusy(pid) {
2736
+ const stat = fs.readFileSync("/proc/" + pid + "/stat", "utf8");
2737
+ const commandEnd = stat.lastIndexOf(")");
2738
+ if (commandEnd < 0) throw new Error("invalid /proc stat");
2739
+ const fields = stat.slice(commandEnd + 1).trim().split(/\s+/);
2740
+ const processGroup = Number(fields[2]);
2741
+ const foregroundProcessGroup = Number(fields[5]);
2742
+ if (!Number.isSafeInteger(processGroup) || processGroup <= 0 || !Number.isSafeInteger(foregroundProcessGroup)) {
2743
+ throw new Error("invalid process group fields");
2744
+ }
2745
+ return foregroundProcessGroup !== processGroup;
2746
+ }
2747
+
2748
+ function pollForeground() {
2749
+ if (!ptyProcess || !Number.isSafeInteger(ptyProcess.pid) || ptyProcess.pid <= 0) return;
2750
+ if (process.platform === "linux") {
2751
+ try {
2752
+ emitForeground(linuxForegroundBusy(ptyProcess.pid));
2753
+ } catch {
2754
+ emitForeground(true);
2755
+ }
2756
+ return;
2757
+ }
2758
+ if (process.platform !== "darwin" || foregroundPollInFlight) return;
2759
+ foregroundPollInFlight = true;
2760
+ const pid = ptyProcess.pid;
2761
+ execFile("/bin/ps", ["-o", "tpgid=", "-p", String(pid)], (error, stdout) => {
2762
+ foregroundPollInFlight = false;
2763
+ if (!ptyProcess || ptyProcess.pid !== pid) return;
2764
+ const foregroundProcessGroup = Number(String(stdout).trim());
2765
+ emitForeground(Boolean(error) || !Number.isSafeInteger(foregroundProcessGroup) || foregroundProcessGroup !== pid);
2766
+ });
2767
+ }
2768
+
2606
2769
  const rl = readline.createInterface({ input: process.stdin });
2607
2770
 
2608
2771
  rl.on("line", (line) => {
@@ -2621,9 +2784,14 @@ rl.on("line", (line) => {
2621
2784
  send({ type: "output", data: Buffer.from(data, "utf8").toString("base64") });
2622
2785
  });
2623
2786
  ptyProcess.onExit((event) => {
2787
+ if (foregroundTimer) clearInterval(foregroundTimer);
2788
+ foregroundTimer = null;
2624
2789
  send({ type: "exit", exitCode: event.exitCode, signal: event.signal }, () => process.exit(0));
2625
2790
  });
2626
- send({ type: "opened" });
2791
+ send({ type: "opened", pid: ptyProcess.pid });
2792
+ pollForeground();
2793
+ foregroundTimer = setInterval(pollForeground, ${PTY_FOREGROUND_POLL_MS});
2794
+ foregroundTimer.unref();
2627
2795
  return;
2628
2796
  }
2629
2797
 
@@ -2716,7 +2884,11 @@ function createNodePtyBridge(options) {
2716
2884
  };
2717
2885
  const handleEvent = (event) => {
2718
2886
  if (event.type === "opened") {
2719
- options.onOpened();
2887
+ options.onOpened(event.pid);
2888
+ return;
2889
+ }
2890
+ if (event.type === "foreground") {
2891
+ options.onForeground(event.busy);
2720
2892
  return;
2721
2893
  }
2722
2894
  if (event.type === "output") {
@@ -2797,6 +2969,9 @@ function createNodePtyBridge(options) {
2797
2969
  }
2798
2970
  };
2799
2971
  }
2972
+ const workerPtyBridgeTestHarness = {
2973
+ create: createNodePtyBridge
2974
+ };
2800
2975
  function resolveHostShell(command, platform = process.platform) {
2801
2976
  if (platform === "win32") {
2802
2977
  const file2 = process.env.COMSPEC || "powershell.exe";
@@ -2832,64 +3007,90 @@ async function openPty(input) {
2832
3007
  }
2833
3008
  });
2834
3009
  input.assertAdmission();
2835
- const ptyProcess = createNodePtyBridge({
2836
- file: shell.file,
2837
- args: shell.args,
2838
- ptyOptions: {
2839
- name: "xterm-256color",
2840
- cols: Math.max(1, Math.min(Math.floor(input.message.cols || 80), 500)),
2841
- rows: Math.max(1, Math.min(Math.floor(input.message.rows || 24), 500)),
2842
- cwd: input.resolvedTarget.rootPath,
2843
- env: workerChildProcessEnvironment([githubProcessEnv(), input.message.env ?? {}, planProcessEnv, targetProcessEnv, shell.env ?? {}])
2844
- },
2845
- onOpened: () => {
2846
- sendWorkerMessage(input.ws, {
2847
- type: "pty_opened",
2848
- requestId: input.message.requestId,
2849
- ptyId: input.message.ptyId
2850
- });
2851
- },
2852
- onOutput: (data) => {
2853
- outputCoalescer.push(data);
2854
- },
2855
- onExit: (event) => {
2856
- outputCoalescer.flush();
2857
- input.releaseWorkspaceMutation?.();
2858
- activePtys.delete(input.message.ptyId);
2859
- input.onTerminal?.();
2860
- sendWorkerMessage(input.ws, {
2861
- type: "pty_exit",
2862
- ptyId: input.message.ptyId,
2863
- exitCode: event.exitCode,
2864
- signal: event.signal
2865
- });
2866
- },
2867
- onError: (error) => {
2868
- outputCoalescer.flush();
2869
- input.releaseWorkspaceMutation?.();
2870
- activePtys.delete(input.message.ptyId);
2871
- input.onTerminal?.();
2872
- sendWorkerMessage(input.ws, {
2873
- type: "pty_error",
2874
- requestId: input.message.requestId,
2875
- ptyId: input.message.ptyId,
2876
- error: error.message
2877
- });
2878
- },
2879
- onTerminationError: (error) => {
2880
- sendWorkerMessage(input.ws, {
2881
- type: "pty_error",
2882
- requestId: input.message.requestId,
2883
- ptyId: input.message.ptyId,
2884
- error: error.message
2885
- });
2886
- }
2887
- });
2888
- activePtys.set(input.message.ptyId, {
2889
- ...ptyProcess,
2890
- target: input.resolvedTarget.target,
2891
- ...input.releaseWorkspaceMutation ? { releaseWorkspaceMutation: input.releaseWorkspaceMutation } : {}
2892
- });
3010
+ const stagedEnvFiles = stagePtyEnvFiles(input.message.envFiles);
3011
+ let envFilesRemoved = false;
3012
+ const cleanupEnvFiles = () => {
3013
+ if (envFilesRemoved) return;
3014
+ envFilesRemoved = true;
3015
+ removePtyEnvFiles(stagedEnvFiles.paths);
3016
+ };
3017
+ try {
3018
+ const ptyProcess = createNodePtyBridge({
3019
+ file: shell.file,
3020
+ args: shell.args,
3021
+ ptyOptions: {
3022
+ name: "xterm-256color",
3023
+ cols: Math.max(1, Math.min(Math.floor(input.message.cols || 80), 500)),
3024
+ rows: Math.max(1, Math.min(Math.floor(input.message.rows || 24), 500)),
3025
+ cwd: input.resolvedTarget.rootPath,
3026
+ env: workerChildProcessEnvironment([
3027
+ githubProcessEnv(),
3028
+ resolvePtyEnvFileReferences(input.message.env ?? {}, stagedEnvFiles),
3029
+ planProcessEnv,
3030
+ targetProcessEnv,
3031
+ shell.env ?? {}
3032
+ ])
3033
+ },
3034
+ onOpened: () => {
3035
+ sendWorkerMessage(input.ws, {
3036
+ type: "pty_opened",
3037
+ requestId: input.message.requestId,
3038
+ ptyId: input.message.ptyId
3039
+ });
3040
+ },
3041
+ onForeground: (busy) => {
3042
+ const activePty = activePtys.get(input.message.ptyId);
3043
+ if (activePty && (busy || input.message.command === void 0)) activePty.foregroundBusy = busy;
3044
+ },
3045
+ onOutput: (data) => {
3046
+ outputCoalescer.push(data);
3047
+ },
3048
+ onExit: (event) => {
3049
+ outputCoalescer.flush();
3050
+ input.releaseWorkspaceMutation?.();
3051
+ activePtys.delete(input.message.ptyId);
3052
+ cleanupEnvFiles();
3053
+ input.onTerminal?.();
3054
+ sendWorkerMessage(input.ws, {
3055
+ type: "pty_exit",
3056
+ ptyId: input.message.ptyId,
3057
+ exitCode: event.exitCode,
3058
+ signal: event.signal
3059
+ });
3060
+ },
3061
+ onError: (error) => {
3062
+ outputCoalescer.flush();
3063
+ input.releaseWorkspaceMutation?.();
3064
+ activePtys.delete(input.message.ptyId);
3065
+ cleanupEnvFiles();
3066
+ input.onTerminal?.();
3067
+ sendWorkerMessage(input.ws, {
3068
+ type: "pty_error",
3069
+ requestId: input.message.requestId,
3070
+ ptyId: input.message.ptyId,
3071
+ error: error.message
3072
+ });
3073
+ },
3074
+ onTerminationError: (error) => {
3075
+ sendWorkerMessage(input.ws, {
3076
+ type: "pty_error",
3077
+ requestId: input.message.requestId,
3078
+ ptyId: input.message.ptyId,
3079
+ error: error.message
3080
+ });
3081
+ }
3082
+ });
3083
+ activePtys.set(input.message.ptyId, {
3084
+ ...ptyProcess,
3085
+ target: input.resolvedTarget.target,
3086
+ foregroundBusy: true,
3087
+ lastInputAt: 0,
3088
+ ...input.releaseWorkspaceMutation ? { releaseWorkspaceMutation: input.releaseWorkspaceMutation } : {}
3089
+ });
3090
+ } catch (error) {
3091
+ cleanupEnvFiles();
3092
+ throw error;
3093
+ }
2893
3094
  }
2894
3095
  function writePty(ws, message) {
2895
3096
  const ptyProcess = activePtys.get(message.ptyId);
@@ -2901,6 +3102,7 @@ function writePty(ws, message) {
2901
3102
  });
2902
3103
  return;
2903
3104
  }
3105
+ ptyProcess.lastInputAt = Date.now();
2904
3106
  ptyProcess.write(message.data);
2905
3107
  }
2906
3108
  function resizePty(message) {
@@ -3021,7 +3223,7 @@ async function startWorker(options) {
3021
3223
  process.stdout.write(`[r5d-worker] server: ${baseUrl}
3022
3224
  `);
3023
3225
  runGit(["--version"]);
3024
- await verifyR5dctlAuth(baseUrl, token);
3226
+ await verifyR5dctlAuth(baseUrl, token, label);
3025
3227
  const initializedWorkspaceState = await workspaceSyncSingleFlight.runExclusive(() => {
3026
3228
  if (!startupProjectSnapshotRecoveryCompleted) {
3027
3229
  const snapshotRecovery = (0, import_project_worktrees.recoverStaleProjectWorktreeSnapshots)({
@@ -3338,13 +3540,24 @@ async function startWorker(options) {
3338
3540
  });
3339
3541
  };
3340
3542
  const activeWorkspaceMutationTargets = () => [
3341
- ...Array.from(activeProcesses.values(), ({ target }) => target),
3543
+ ...Array.from(activeProcesses.values()).filter(workerCommandHasWorkspaceEffect).map(({ target }) => target),
3342
3544
  ...credentialBearingProcessGroupTargets.values(),
3343
3545
  ...workspaceSyncPriorityProcessTargets.values(),
3344
3546
  ...workspaceSyncPriorityOperationTargets.values(),
3345
- ...Array.from(activePtys.values(), ({ target }) => target),
3547
+ ...Array.from(activePtys.values()).filter((pty) => workerPtyIsWorkspaceBusy(pty)).map(({ target }) => target),
3346
3548
  ...workspaceSyncPriorityPtyTargets.values()
3347
3549
  ];
3550
+ let visibleWorkspaceMutationEpoch = 0;
3551
+ const projectWorkspaceMutationEpochs = /* @__PURE__ */ new Map();
3552
+ const recordVisibleWorkspaceMutation = (target) => {
3553
+ if (target.type === "workspace") {
3554
+ if (target.rootProfile === "visible_projects") visibleWorkspaceMutationEpoch += 1;
3555
+ return;
3556
+ }
3557
+ const key = projectBranchKey(target.projectId, target.branchName);
3558
+ projectWorkspaceMutationEpochs.set(key, (projectWorkspaceMutationEpochs.get(key) ?? 0) + 1);
3559
+ };
3560
+ const projectWorkspaceMutationToken = (projectId, branchName) => `${visibleWorkspaceMutationEpoch}:${projectWorkspaceMutationEpochs.get(projectBranchKey(projectId, branchName)) ?? 0}`;
3348
3561
  const canonicalWorkspaceMutationIsActive = (targets) => targets.some((target) => target.type === "workspace" && target.rootProfile === "canonical_sync");
3349
3562
  const projectBranchMountActivityBusy = (projectId, branchName, branchPath) => {
3350
3563
  const activeTargets = activeWorkspaceMutationTargets();
@@ -3381,6 +3594,7 @@ async function startWorker(options) {
3381
3594
  preserveLocalOnInitialOuterAbsence: creatorLocalIncarnation,
3382
3595
  preserveLocalOnHydrationBasisChange: preservesCheckoutPathMove,
3383
3596
  busy: () => projectBranchMountBusy(project.projectId, branch.branchName, branchPath),
3597
+ mutationToken: () => projectWorkspaceMutationToken(project.projectId, branch.branchName),
3384
3598
  busyForRecovery: () => projectBranchMountActivityBusy(project.projectId, branch.branchName, branchPath)
3385
3599
  });
3386
3600
  mounts.push({
@@ -3394,6 +3608,7 @@ async function startWorker(options) {
3394
3608
  preserveLocalOnInitialOuterAbsence: creatorLocalIncarnation,
3395
3609
  preserveLocalOnHydrationBasisChange: preservesCheckoutPathMove,
3396
3610
  busy: () => projectBranchMountBusy(project.projectId, branch.branchName, branchPath),
3611
+ mutationToken: () => projectWorkspaceMutationToken(project.projectId, branch.branchName),
3397
3612
  busyForRecovery: () => projectBranchMountActivityBusy(project.projectId, branch.branchName, branchPath)
3398
3613
  });
3399
3614
  }
@@ -3414,6 +3629,7 @@ async function startWorker(options) {
3414
3629
  const activeTargets = activeWorkspaceMutationTargets();
3415
3630
  return canonicalWorkspaceMutationIsActive(activeTargets) || (0, import_workspace_automatic_sync_policy.hasActiveVisibleProjectsWorkspaceTarget)(activeTargets);
3416
3631
  },
3632
+ mutationToken: () => String(visibleWorkspaceMutationEpoch),
3417
3633
  busyForRecovery: () => {
3418
3634
  const activeTargets = activeWorkspaceMutationTargets();
3419
3635
  return canonicalWorkspaceMutationIsActive(activeTargets) || (0, import_workspace_automatic_sync_policy.hasActiveVisibleProjectsWorkspaceTarget)(activeTargets);
@@ -3771,6 +3987,7 @@ async function startWorker(options) {
3771
3987
  localChangesDiscarded: false,
3772
3988
  ...result.conflictPaths ? { conflictPaths: result.conflictPaths } : {},
3773
3989
  ...result.conflictSnapshotRefs ? { conflictSnapshotRefs: result.conflictSnapshotRefs } : {},
3990
+ ...result.conflictKind ? { conflictKind: result.conflictKind } : {},
3774
3991
  ...result.error ? { error: result.error } : {}
3775
3992
  };
3776
3993
  };
@@ -3837,10 +4054,7 @@ async function startWorker(options) {
3837
4054
  };
3838
4055
  }
3839
4056
  const outerRemediation = input.trigger.type === "remediation" || input.trigger.type === "remediation_confirm";
3840
- const ordinaryCycleHasBusyLiveMount = mounts.some(
3841
- (mount) => !(mount.deleteWhenSourceMissing && !import_node_fs.default.existsSync(mount.sourcePath)) && mount.busy?.()
3842
- );
3843
- const observedProjectHeads = outerRemediation || ordinaryCycleHasBusyLiveMount ? [] : observeProjectHeadsBeforeOuterWorkspace({ allowNonFastForward: false, failClosed: false });
4057
+ const observedProjectHeads = outerRemediation ? [] : observeProjectHeadsBeforeOuterWorkspace({ allowNonFastForward: false, failClosed: false });
3844
4058
  const inboundMoveMountIds = new Set(
3845
4059
  observedProjectHeads.flatMap(
3846
4060
  ({ projectId, observations }) => observations.filter(({ shouldMove }) => shouldMove).map(({ branchName }) => projectMountId(projectId, branchName))
@@ -3996,7 +4210,8 @@ async function startWorker(options) {
3996
4210
  );
3997
4211
  workspaceAutomaticTimer.unref();
3998
4212
  };
3999
- const markWorkspaceDirty = (trigger, force = false) => {
4213
+ const markWorkspaceDirty = (trigger, force = false, target) => {
4214
+ if (target) recordVisibleWorkspaceMutation(target);
4000
4215
  scheduleAutomaticWorkspaceSync(trigger, force ? 0 : WORKSPACE_GIT_QUIET_MS);
4001
4216
  };
4002
4217
  const targetMayMutateVisibleWorkspace = (target) => target.type === "project" || target.rootProfile === "visible_projects";
@@ -4257,7 +4472,7 @@ async function startWorker(options) {
4257
4472
  sourcePath: recoverySourceOverrides.get(mount.id) ?? mount.sourcePath,
4258
4473
  busy: mount.busyForRecovery ?? mount.busy
4259
4474
  }));
4260
- (0, import_workspace_git_sync.recoverWorkspaceGitHydration)(workspaceShadowRoot, recoveryMounts);
4475
+ (0, import_workspace_git_sync.recoverWorkspaceGitHydration)(workspaceShadowRoot, recoveryMounts, { preserveStaleBases: true });
4261
4476
  for (const project of message.projects) {
4262
4477
  for (const { branchName } of project.preserveOnlyBranches) {
4263
4478
  if (stillPendingCreatedBranches.has((0, import_workspace_automatic_sync_policy.pendingCreatedBranchKey)(project.projectId, branchName))) {
@@ -4433,7 +4648,8 @@ async function startWorker(options) {
4433
4648
  capabilities: {
4434
4649
  updateClis: true,
4435
4650
  browserPortForwarding: true,
4436
- execStdinV1: true
4651
+ execStdinV1: true,
4652
+ ptyEnvFilesV1: true
4437
4653
  },
4438
4654
  projectRoot: projectsRoot,
4439
4655
  artifactRoot,
@@ -4808,6 +5024,9 @@ async function startWorker(options) {
4808
5024
  });
4809
5025
  return;
4810
5026
  }
5027
+ if (active.workspaceEffect !== "none" && targetMayMutateVisibleWorkspace(active.target)) {
5028
+ recordVisibleWorkspaceMutation(active.target);
5029
+ }
4811
5030
  try {
4812
5031
  let bytesWritten = 0;
4813
5032
  if (message.data !== void 0 && message.data.length > 0) {
@@ -4818,8 +5037,8 @@ async function startWorker(options) {
4818
5037
  await active.stdin.end();
4819
5038
  active.stdin = void 0;
4820
5039
  }
4821
- if (targetMayMutateVisibleWorkspace(active.target)) {
4822
- markWorkspaceDirty({ type: "shell_inline", detail: `exec stdin ${message.runId}` });
5040
+ if (active.workspaceEffect !== "none" && targetMayMutateVisibleWorkspace(active.target)) {
5041
+ markWorkspaceDirty({ type: "shell_inline", detail: `exec stdin ${message.runId}` }, false, active.target);
4823
5042
  }
4824
5043
  sendAck({
4825
5044
  result: {
@@ -4846,6 +5065,7 @@ async function startWorker(options) {
4846
5065
  }
4847
5066
  await (0, import_workspace_command_sync_policy.reserveWorkspaceCommandAfterCurrentSync)(message.target, workspaceSyncSingleFlight, () => {
4848
5067
  workspaceSyncPriorityPtyTargets.set(message.ptyId, message.target);
5068
+ if (targetMayMutateVisibleWorkspace(message.target)) recordVisibleWorkspaceMutation(message.target);
4849
5069
  });
4850
5070
  let releaseWorkspaceMutation;
4851
5071
  let mutationLeaseTransferred = false;
@@ -4863,7 +5083,7 @@ async function startWorker(options) {
4863
5083
  ...releaseWorkspaceMutation ? { releaseWorkspaceMutation } : {},
4864
5084
  ...targetMayMutateVisibleWorkspace(message.target) ? {
4865
5085
  onTerminal: () => {
4866
- markWorkspaceDirty({ type: "process_terminal", detail: `pty ${message.ptyId} completed` }, true);
5086
+ markWorkspaceDirty({ type: "process_terminal", detail: `pty ${message.ptyId} completed` }, true, message.target);
4867
5087
  }
4868
5088
  } : {}
4869
5089
  });
@@ -4884,7 +5104,7 @@ async function startWorker(options) {
4884
5104
  if (message.type === "pty_input") {
4885
5105
  const activePty = activePtys.get(message.ptyId);
4886
5106
  if (activePty && targetMayMutateVisibleWorkspace(activePty.target)) {
4887
- markWorkspaceDirty({ type: "shell_inline", detail: `pty ${message.ptyId}` });
5107
+ markWorkspaceDirty({ type: "shell_inline", detail: `pty ${message.ptyId}` }, false, activePty.target);
4888
5108
  }
4889
5109
  writePty(ws, message);
4890
5110
  return;
@@ -4897,7 +5117,7 @@ async function startWorker(options) {
4897
5117
  const activePty = activePtys.get(message.ptyId);
4898
5118
  closePty(message);
4899
5119
  if (activePty && targetMayMutateVisibleWorkspace(activePty.target)) {
4900
- markWorkspaceDirty({ type: "process_terminal", detail: `pty ${message.ptyId} closed` }, true);
5120
+ markWorkspaceDirty({ type: "process_terminal", detail: `pty ${message.ptyId} closed` }, true, activePty.target);
4901
5121
  }
4902
5122
  return;
4903
5123
  }
@@ -4914,11 +5134,15 @@ async function startWorker(options) {
4914
5134
  }
4915
5135
  let result;
4916
5136
  let targetReserved = false;
5137
+ const hasWorkspaceEffect = workerCommandHasWorkspaceEffect(message);
4917
5138
  try {
4918
- await (0, import_workspace_command_sync_policy.reserveWorkspaceCommandAfterCurrentSync)(message.target, workspaceSyncSingleFlight, () => {
4919
- workspaceSyncPriorityProcessTargets.set(message.runId, message.target);
4920
- targetReserved = true;
4921
- });
5139
+ if (hasWorkspaceEffect) {
5140
+ await (0, import_workspace_command_sync_policy.reserveWorkspaceCommandAfterCurrentSync)(message.target, workspaceSyncSingleFlight, () => {
5141
+ workspaceSyncPriorityProcessTargets.set(message.runId, message.target);
5142
+ targetReserved = true;
5143
+ if (targetMayMutateVisibleWorkspace(message.target)) recordVisibleWorkspaceMutation(message.target);
5144
+ });
5145
+ }
4922
5146
  const runCommand = async () => {
4923
5147
  const resolvedTarget = resolveMessageTarget(message.target);
4924
5148
  process.stdout.write(`[r5d-worker] exec ${message.runId}: ${message.argv.join(" ")}
@@ -4948,10 +5172,11 @@ async function startWorker(options) {
4948
5172
  if (targetReserved) workspaceSyncPriorityProcessTargets.delete(message.runId);
4949
5173
  }
4950
5174
  ws.send(JSON.stringify(result));
4951
- if (targetMayMutateVisibleWorkspace(message.target)) {
5175
+ if (hasWorkspaceEffect && targetMayMutateVisibleWorkspace(message.target)) {
4952
5176
  markWorkspaceDirty(
4953
5177
  { type: "shell_inline", sessionId: message.sessionId, processRunId: message.runId, detail: "foreground command completed" },
4954
- true
5178
+ true,
5179
+ message.target
4955
5180
  );
4956
5181
  }
4957
5182
  return;
@@ -4971,6 +5196,7 @@ async function startWorker(options) {
4971
5196
  requestId: message.requestId,
4972
5197
  runId: message.runId
4973
5198
  });
5199
+ const hasWorkspaceEffect = workerCommandHasWorkspaceEffect(message);
4974
5200
  const runCommand = async () => {
4975
5201
  try {
4976
5202
  const resolvedTarget = resolveMessageTarget(message.target);
@@ -4987,7 +5213,7 @@ async function startWorker(options) {
4987
5213
  assertAdmission: assertMessageAdmission
4988
5214
  });
4989
5215
  } finally {
4990
- if (targetMayMutateVisibleWorkspace(message.target)) {
5216
+ if (hasWorkspaceEffect && targetMayMutateVisibleWorkspace(message.target)) {
4991
5217
  markWorkspaceDirty(
4992
5218
  {
4993
5219
  type: "process_terminal",
@@ -4995,7 +5221,8 @@ async function startWorker(options) {
4995
5221
  processRunId: message.runId,
4996
5222
  detail: "process completed"
4997
5223
  },
4998
- true
5224
+ true,
5225
+ message.target
4999
5226
  );
5000
5227
  }
5001
5228
  }
@@ -5003,10 +5230,13 @@ async function startWorker(options) {
5003
5230
  const execution = (async () => {
5004
5231
  let targetReserved = false;
5005
5232
  try {
5006
- await (0, import_workspace_command_sync_policy.reserveWorkspaceCommandAfterCurrentSync)(message.target, workspaceSyncSingleFlight, () => {
5007
- workspaceSyncPriorityProcessTargets.set(message.runId, message.target);
5008
- targetReserved = true;
5009
- });
5233
+ if (hasWorkspaceEffect) {
5234
+ await (0, import_workspace_command_sync_policy.reserveWorkspaceCommandAfterCurrentSync)(message.target, workspaceSyncSingleFlight, () => {
5235
+ workspaceSyncPriorityProcessTargets.set(message.runId, message.target);
5236
+ targetReserved = true;
5237
+ if (targetMayMutateVisibleWorkspace(message.target)) recordVisibleWorkspaceMutation(message.target);
5238
+ });
5239
+ }
5010
5240
  await (0, import_workspace_command_sync_policy.runWorkspaceCommand)(message.target, workspaceSyncSingleFlight, runCommand);
5011
5241
  } finally {
5012
5242
  if (targetReserved) workspaceSyncPriorityProcessTargets.delete(message.runId);
@@ -5029,6 +5259,7 @@ async function startWorker(options) {
5029
5259
  if (reservesVisibleWorkspace) {
5030
5260
  await (0, import_workspace_command_sync_policy.reserveWorkspaceCommandAfterCurrentSync)(message.target, workspaceSyncSingleFlight, () => {
5031
5261
  workspaceSyncPriorityOperationTargets.set(message.requestId, message.target);
5262
+ if (mutatesVisibleWorkspace) recordVisibleWorkspaceMutation(message.target);
5032
5263
  });
5033
5264
  }
5034
5265
  let dirtyTrigger;
@@ -5058,7 +5289,7 @@ async function startWorker(options) {
5058
5289
  toolCallId: message.requestId,
5059
5290
  ...message.target.type === "project" ? { projectId: message.target.projectId, branchName: message.target.branchName } : {}
5060
5291
  };
5061
- markWorkspaceDirty(dirtyTrigger);
5292
+ markWorkspaceDirty(dirtyTrigger, false, message.target);
5062
5293
  }
5063
5294
  } catch (error) {
5064
5295
  ws.send(
@@ -5219,5 +5450,7 @@ if (isCliEntrypoint()) {
5219
5450
  syncSessionArtifacts,
5220
5451
  workerChildProcessEnvironment,
5221
5452
  workerGitSecurityTestHarness,
5453
+ workerPtyBridgeTestHarness,
5454
+ workerPtyTestHarness,
5222
5455
  writeWorkerTextFile
5223
5456
  });