@ricsam/r5d-worker 0.0.81 → 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
  }
@@ -2333,7 +2439,9 @@ async function executeCommand(input) {
2333
2439
  });
2334
2440
  spawnedProcess = subprocess;
2335
2441
  credentialBearingProcessGroups.set(subprocess.pid, subprocess);
2336
- credentialBearingProcessGroupTargets.set(subprocess.pid, input.resolvedTarget.target);
2442
+ if (workerCommandHasWorkspaceEffect(input.message)) {
2443
+ credentialBearingProcessGroupTargets.set(subprocess.pid, input.resolvedTarget.target);
2444
+ }
2337
2445
  activeProcesses.set(input.message.runId, {
2338
2446
  process: subprocess,
2339
2447
  target: input.resolvedTarget.target,
@@ -2344,7 +2452,8 @@ async function executeCommand(input) {
2344
2452
  argv: input.message.argv,
2345
2453
  command: input.message.argv.join(" "),
2346
2454
  cwd,
2347
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
2455
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2456
+ ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2348
2457
  });
2349
2458
  if (input.message.timeoutMs) {
2350
2459
  timeout = setTimeout(() => {
@@ -2430,7 +2539,9 @@ async function executeStreamingCommand(input) {
2430
2539
  });
2431
2540
  spawnedProcess = subprocess;
2432
2541
  credentialBearingProcessGroups.set(subprocess.pid, subprocess);
2433
- credentialBearingProcessGroupTargets.set(subprocess.pid, input.resolvedTarget.target);
2542
+ if (workerCommandHasWorkspaceEffect(input.message)) {
2543
+ credentialBearingProcessGroupTargets.set(subprocess.pid, input.resolvedTarget.target);
2544
+ }
2434
2545
  activeProcesses.set(input.message.runId, {
2435
2546
  process: subprocess,
2436
2547
  target: input.resolvedTarget.target,
@@ -2443,7 +2554,8 @@ async function executeStreamingCommand(input) {
2443
2554
  command: input.message.command,
2444
2555
  cwd,
2445
2556
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2446
- ...interactive ? { interactive: true, stdin: subprocess.stdin } : {}
2557
+ ...interactive ? { interactive: true, stdin: subprocess.stdin } : {},
2558
+ ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2447
2559
  });
2448
2560
  started = true;
2449
2561
  sendWorkerMessage(input.ws, {
@@ -2490,7 +2602,8 @@ async function executeStreamingCommand(input) {
2490
2602
  runId: input.message.runId,
2491
2603
  exitCode,
2492
2604
  durationMs: Date.now() - startedAt,
2493
- ...timedOut ? { timedOut: true } : {}
2605
+ ...timedOut ? { timedOut: true } : {},
2606
+ ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2494
2607
  };
2495
2608
  pendingProcessTerminals.set(input.message.runId, terminal);
2496
2609
  sendWorkerMessage(input.ws, terminal);
@@ -2502,7 +2615,8 @@ async function executeStreamingCommand(input) {
2502
2615
  type: "exec_error",
2503
2616
  runId: input.message.runId,
2504
2617
  error: message,
2505
- durationMs: Date.now() - startedAt
2618
+ durationMs: Date.now() - startedAt,
2619
+ ...input.message.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2506
2620
  };
2507
2621
  pendingProcessTerminals.set(input.message.runId, terminal);
2508
2622
  sendWorkerMessage(input.ws, terminal);
@@ -2583,7 +2697,8 @@ function buildActiveProcessReports() {
2583
2697
  command: active.command,
2584
2698
  ...active.cwd ? { cwd: active.cwd } : {},
2585
2699
  startedAt: active.startedAt,
2586
- ...active.interactive ? { interactive: true } : {}
2700
+ ...active.interactive ? { interactive: true } : {},
2701
+ ...active.workspaceEffect === "none" ? { workspaceEffect: "none" } : {}
2587
2702
  }));
2588
2703
  }
2589
2704
  function sendActiveProcessReport(ws) {
@@ -2595,8 +2710,13 @@ function sendActiveProcessReport(ws) {
2595
2710
  const PTY_BRIDGE_SCRIPT = String.raw`
2596
2711
  const readline = require("node:readline");
2597
2712
  const nodePty = require("node-pty");
2713
+ const fs = require("node:fs");
2714
+ const { execFile } = require("node:child_process");
2598
2715
 
2599
2716
  let ptyProcess = null;
2717
+ let foregroundTimer = null;
2718
+ let foregroundPollInFlight = false;
2719
+ let lastForegroundBusy = null;
2600
2720
 
2601
2721
  function send(message, callback) {
2602
2722
  process.stdout.write(JSON.stringify(message) + "\n", callback);
@@ -2606,6 +2726,46 @@ function decode(data) {
2606
2726
  return Buffer.from(data, "base64").toString("utf8");
2607
2727
  }
2608
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
+
2609
2769
  const rl = readline.createInterface({ input: process.stdin });
2610
2770
 
2611
2771
  rl.on("line", (line) => {
@@ -2624,9 +2784,14 @@ rl.on("line", (line) => {
2624
2784
  send({ type: "output", data: Buffer.from(data, "utf8").toString("base64") });
2625
2785
  });
2626
2786
  ptyProcess.onExit((event) => {
2787
+ if (foregroundTimer) clearInterval(foregroundTimer);
2788
+ foregroundTimer = null;
2627
2789
  send({ type: "exit", exitCode: event.exitCode, signal: event.signal }, () => process.exit(0));
2628
2790
  });
2629
- send({ type: "opened" });
2791
+ send({ type: "opened", pid: ptyProcess.pid });
2792
+ pollForeground();
2793
+ foregroundTimer = setInterval(pollForeground, ${PTY_FOREGROUND_POLL_MS});
2794
+ foregroundTimer.unref();
2630
2795
  return;
2631
2796
  }
2632
2797
 
@@ -2719,7 +2884,11 @@ function createNodePtyBridge(options) {
2719
2884
  };
2720
2885
  const handleEvent = (event) => {
2721
2886
  if (event.type === "opened") {
2722
- options.onOpened();
2887
+ options.onOpened(event.pid);
2888
+ return;
2889
+ }
2890
+ if (event.type === "foreground") {
2891
+ options.onForeground(event.busy);
2723
2892
  return;
2724
2893
  }
2725
2894
  if (event.type === "output") {
@@ -2800,6 +2969,9 @@ function createNodePtyBridge(options) {
2800
2969
  }
2801
2970
  };
2802
2971
  }
2972
+ const workerPtyBridgeTestHarness = {
2973
+ create: createNodePtyBridge
2974
+ };
2803
2975
  function resolveHostShell(command, platform = process.platform) {
2804
2976
  if (platform === "win32") {
2805
2977
  const file2 = process.env.COMSPEC || "powershell.exe";
@@ -2835,64 +3007,90 @@ async function openPty(input) {
2835
3007
  }
2836
3008
  });
2837
3009
  input.assertAdmission();
2838
- const ptyProcess = createNodePtyBridge({
2839
- file: shell.file,
2840
- args: shell.args,
2841
- ptyOptions: {
2842
- name: "xterm-256color",
2843
- cols: Math.max(1, Math.min(Math.floor(input.message.cols || 80), 500)),
2844
- rows: Math.max(1, Math.min(Math.floor(input.message.rows || 24), 500)),
2845
- cwd: input.resolvedTarget.rootPath,
2846
- env: workerChildProcessEnvironment([githubProcessEnv(), input.message.env ?? {}, planProcessEnv, targetProcessEnv, shell.env ?? {}])
2847
- },
2848
- onOpened: () => {
2849
- sendWorkerMessage(input.ws, {
2850
- type: "pty_opened",
2851
- requestId: input.message.requestId,
2852
- ptyId: input.message.ptyId
2853
- });
2854
- },
2855
- onOutput: (data) => {
2856
- outputCoalescer.push(data);
2857
- },
2858
- onExit: (event) => {
2859
- outputCoalescer.flush();
2860
- input.releaseWorkspaceMutation?.();
2861
- activePtys.delete(input.message.ptyId);
2862
- input.onTerminal?.();
2863
- sendWorkerMessage(input.ws, {
2864
- type: "pty_exit",
2865
- ptyId: input.message.ptyId,
2866
- exitCode: event.exitCode,
2867
- signal: event.signal
2868
- });
2869
- },
2870
- onError: (error) => {
2871
- outputCoalescer.flush();
2872
- input.releaseWorkspaceMutation?.();
2873
- activePtys.delete(input.message.ptyId);
2874
- input.onTerminal?.();
2875
- sendWorkerMessage(input.ws, {
2876
- type: "pty_error",
2877
- requestId: input.message.requestId,
2878
- ptyId: input.message.ptyId,
2879
- error: error.message
2880
- });
2881
- },
2882
- onTerminationError: (error) => {
2883
- sendWorkerMessage(input.ws, {
2884
- type: "pty_error",
2885
- requestId: input.message.requestId,
2886
- ptyId: input.message.ptyId,
2887
- error: error.message
2888
- });
2889
- }
2890
- });
2891
- activePtys.set(input.message.ptyId, {
2892
- ...ptyProcess,
2893
- target: input.resolvedTarget.target,
2894
- ...input.releaseWorkspaceMutation ? { releaseWorkspaceMutation: input.releaseWorkspaceMutation } : {}
2895
- });
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
+ }
2896
3094
  }
2897
3095
  function writePty(ws, message) {
2898
3096
  const ptyProcess = activePtys.get(message.ptyId);
@@ -2904,6 +3102,7 @@ function writePty(ws, message) {
2904
3102
  });
2905
3103
  return;
2906
3104
  }
3105
+ ptyProcess.lastInputAt = Date.now();
2907
3106
  ptyProcess.write(message.data);
2908
3107
  }
2909
3108
  function resizePty(message) {
@@ -3341,13 +3540,24 @@ async function startWorker(options) {
3341
3540
  });
3342
3541
  };
3343
3542
  const activeWorkspaceMutationTargets = () => [
3344
- ...Array.from(activeProcesses.values(), ({ target }) => target),
3543
+ ...Array.from(activeProcesses.values()).filter(workerCommandHasWorkspaceEffect).map(({ target }) => target),
3345
3544
  ...credentialBearingProcessGroupTargets.values(),
3346
3545
  ...workspaceSyncPriorityProcessTargets.values(),
3347
3546
  ...workspaceSyncPriorityOperationTargets.values(),
3348
- ...Array.from(activePtys.values(), ({ target }) => target),
3547
+ ...Array.from(activePtys.values()).filter((pty) => workerPtyIsWorkspaceBusy(pty)).map(({ target }) => target),
3349
3548
  ...workspaceSyncPriorityPtyTargets.values()
3350
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}`;
3351
3561
  const canonicalWorkspaceMutationIsActive = (targets) => targets.some((target) => target.type === "workspace" && target.rootProfile === "canonical_sync");
3352
3562
  const projectBranchMountActivityBusy = (projectId, branchName, branchPath) => {
3353
3563
  const activeTargets = activeWorkspaceMutationTargets();
@@ -3384,6 +3594,7 @@ async function startWorker(options) {
3384
3594
  preserveLocalOnInitialOuterAbsence: creatorLocalIncarnation,
3385
3595
  preserveLocalOnHydrationBasisChange: preservesCheckoutPathMove,
3386
3596
  busy: () => projectBranchMountBusy(project.projectId, branch.branchName, branchPath),
3597
+ mutationToken: () => projectWorkspaceMutationToken(project.projectId, branch.branchName),
3387
3598
  busyForRecovery: () => projectBranchMountActivityBusy(project.projectId, branch.branchName, branchPath)
3388
3599
  });
3389
3600
  mounts.push({
@@ -3397,6 +3608,7 @@ async function startWorker(options) {
3397
3608
  preserveLocalOnInitialOuterAbsence: creatorLocalIncarnation,
3398
3609
  preserveLocalOnHydrationBasisChange: preservesCheckoutPathMove,
3399
3610
  busy: () => projectBranchMountBusy(project.projectId, branch.branchName, branchPath),
3611
+ mutationToken: () => projectWorkspaceMutationToken(project.projectId, branch.branchName),
3400
3612
  busyForRecovery: () => projectBranchMountActivityBusy(project.projectId, branch.branchName, branchPath)
3401
3613
  });
3402
3614
  }
@@ -3417,6 +3629,7 @@ async function startWorker(options) {
3417
3629
  const activeTargets = activeWorkspaceMutationTargets();
3418
3630
  return canonicalWorkspaceMutationIsActive(activeTargets) || (0, import_workspace_automatic_sync_policy.hasActiveVisibleProjectsWorkspaceTarget)(activeTargets);
3419
3631
  },
3632
+ mutationToken: () => String(visibleWorkspaceMutationEpoch),
3420
3633
  busyForRecovery: () => {
3421
3634
  const activeTargets = activeWorkspaceMutationTargets();
3422
3635
  return canonicalWorkspaceMutationIsActive(activeTargets) || (0, import_workspace_automatic_sync_policy.hasActiveVisibleProjectsWorkspaceTarget)(activeTargets);
@@ -3774,6 +3987,7 @@ async function startWorker(options) {
3774
3987
  localChangesDiscarded: false,
3775
3988
  ...result.conflictPaths ? { conflictPaths: result.conflictPaths } : {},
3776
3989
  ...result.conflictSnapshotRefs ? { conflictSnapshotRefs: result.conflictSnapshotRefs } : {},
3990
+ ...result.conflictKind ? { conflictKind: result.conflictKind } : {},
3777
3991
  ...result.error ? { error: result.error } : {}
3778
3992
  };
3779
3993
  };
@@ -3840,10 +4054,7 @@ async function startWorker(options) {
3840
4054
  };
3841
4055
  }
3842
4056
  const outerRemediation = input.trigger.type === "remediation" || input.trigger.type === "remediation_confirm";
3843
- const ordinaryCycleHasBusyLiveMount = mounts.some(
3844
- (mount) => !(mount.deleteWhenSourceMissing && !import_node_fs.default.existsSync(mount.sourcePath)) && mount.busy?.()
3845
- );
3846
- const observedProjectHeads = outerRemediation || ordinaryCycleHasBusyLiveMount ? [] : observeProjectHeadsBeforeOuterWorkspace({ allowNonFastForward: false, failClosed: false });
4057
+ const observedProjectHeads = outerRemediation ? [] : observeProjectHeadsBeforeOuterWorkspace({ allowNonFastForward: false, failClosed: false });
3847
4058
  const inboundMoveMountIds = new Set(
3848
4059
  observedProjectHeads.flatMap(
3849
4060
  ({ projectId, observations }) => observations.filter(({ shouldMove }) => shouldMove).map(({ branchName }) => projectMountId(projectId, branchName))
@@ -3999,7 +4210,8 @@ async function startWorker(options) {
3999
4210
  );
4000
4211
  workspaceAutomaticTimer.unref();
4001
4212
  };
4002
- const markWorkspaceDirty = (trigger, force = false) => {
4213
+ const markWorkspaceDirty = (trigger, force = false, target) => {
4214
+ if (target) recordVisibleWorkspaceMutation(target);
4003
4215
  scheduleAutomaticWorkspaceSync(trigger, force ? 0 : WORKSPACE_GIT_QUIET_MS);
4004
4216
  };
4005
4217
  const targetMayMutateVisibleWorkspace = (target) => target.type === "project" || target.rootProfile === "visible_projects";
@@ -4260,7 +4472,7 @@ async function startWorker(options) {
4260
4472
  sourcePath: recoverySourceOverrides.get(mount.id) ?? mount.sourcePath,
4261
4473
  busy: mount.busyForRecovery ?? mount.busy
4262
4474
  }));
4263
- (0, import_workspace_git_sync.recoverWorkspaceGitHydration)(workspaceShadowRoot, recoveryMounts);
4475
+ (0, import_workspace_git_sync.recoverWorkspaceGitHydration)(workspaceShadowRoot, recoveryMounts, { preserveStaleBases: true });
4264
4476
  for (const project of message.projects) {
4265
4477
  for (const { branchName } of project.preserveOnlyBranches) {
4266
4478
  if (stillPendingCreatedBranches.has((0, import_workspace_automatic_sync_policy.pendingCreatedBranchKey)(project.projectId, branchName))) {
@@ -4436,7 +4648,8 @@ async function startWorker(options) {
4436
4648
  capabilities: {
4437
4649
  updateClis: true,
4438
4650
  browserPortForwarding: true,
4439
- execStdinV1: true
4651
+ execStdinV1: true,
4652
+ ptyEnvFilesV1: true
4440
4653
  },
4441
4654
  projectRoot: projectsRoot,
4442
4655
  artifactRoot,
@@ -4811,6 +5024,9 @@ async function startWorker(options) {
4811
5024
  });
4812
5025
  return;
4813
5026
  }
5027
+ if (active.workspaceEffect !== "none" && targetMayMutateVisibleWorkspace(active.target)) {
5028
+ recordVisibleWorkspaceMutation(active.target);
5029
+ }
4814
5030
  try {
4815
5031
  let bytesWritten = 0;
4816
5032
  if (message.data !== void 0 && message.data.length > 0) {
@@ -4821,8 +5037,8 @@ async function startWorker(options) {
4821
5037
  await active.stdin.end();
4822
5038
  active.stdin = void 0;
4823
5039
  }
4824
- if (targetMayMutateVisibleWorkspace(active.target)) {
4825
- 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);
4826
5042
  }
4827
5043
  sendAck({
4828
5044
  result: {
@@ -4849,6 +5065,7 @@ async function startWorker(options) {
4849
5065
  }
4850
5066
  await (0, import_workspace_command_sync_policy.reserveWorkspaceCommandAfterCurrentSync)(message.target, workspaceSyncSingleFlight, () => {
4851
5067
  workspaceSyncPriorityPtyTargets.set(message.ptyId, message.target);
5068
+ if (targetMayMutateVisibleWorkspace(message.target)) recordVisibleWorkspaceMutation(message.target);
4852
5069
  });
4853
5070
  let releaseWorkspaceMutation;
4854
5071
  let mutationLeaseTransferred = false;
@@ -4866,7 +5083,7 @@ async function startWorker(options) {
4866
5083
  ...releaseWorkspaceMutation ? { releaseWorkspaceMutation } : {},
4867
5084
  ...targetMayMutateVisibleWorkspace(message.target) ? {
4868
5085
  onTerminal: () => {
4869
- markWorkspaceDirty({ type: "process_terminal", detail: `pty ${message.ptyId} completed` }, true);
5086
+ markWorkspaceDirty({ type: "process_terminal", detail: `pty ${message.ptyId} completed` }, true, message.target);
4870
5087
  }
4871
5088
  } : {}
4872
5089
  });
@@ -4887,7 +5104,7 @@ async function startWorker(options) {
4887
5104
  if (message.type === "pty_input") {
4888
5105
  const activePty = activePtys.get(message.ptyId);
4889
5106
  if (activePty && targetMayMutateVisibleWorkspace(activePty.target)) {
4890
- markWorkspaceDirty({ type: "shell_inline", detail: `pty ${message.ptyId}` });
5107
+ markWorkspaceDirty({ type: "shell_inline", detail: `pty ${message.ptyId}` }, false, activePty.target);
4891
5108
  }
4892
5109
  writePty(ws, message);
4893
5110
  return;
@@ -4900,7 +5117,7 @@ async function startWorker(options) {
4900
5117
  const activePty = activePtys.get(message.ptyId);
4901
5118
  closePty(message);
4902
5119
  if (activePty && targetMayMutateVisibleWorkspace(activePty.target)) {
4903
- markWorkspaceDirty({ type: "process_terminal", detail: `pty ${message.ptyId} closed` }, true);
5120
+ markWorkspaceDirty({ type: "process_terminal", detail: `pty ${message.ptyId} closed` }, true, activePty.target);
4904
5121
  }
4905
5122
  return;
4906
5123
  }
@@ -4917,11 +5134,15 @@ async function startWorker(options) {
4917
5134
  }
4918
5135
  let result;
4919
5136
  let targetReserved = false;
5137
+ const hasWorkspaceEffect = workerCommandHasWorkspaceEffect(message);
4920
5138
  try {
4921
- await (0, import_workspace_command_sync_policy.reserveWorkspaceCommandAfterCurrentSync)(message.target, workspaceSyncSingleFlight, () => {
4922
- workspaceSyncPriorityProcessTargets.set(message.runId, message.target);
4923
- targetReserved = true;
4924
- });
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
+ }
4925
5146
  const runCommand = async () => {
4926
5147
  const resolvedTarget = resolveMessageTarget(message.target);
4927
5148
  process.stdout.write(`[r5d-worker] exec ${message.runId}: ${message.argv.join(" ")}
@@ -4951,10 +5172,11 @@ async function startWorker(options) {
4951
5172
  if (targetReserved) workspaceSyncPriorityProcessTargets.delete(message.runId);
4952
5173
  }
4953
5174
  ws.send(JSON.stringify(result));
4954
- if (targetMayMutateVisibleWorkspace(message.target)) {
5175
+ if (hasWorkspaceEffect && targetMayMutateVisibleWorkspace(message.target)) {
4955
5176
  markWorkspaceDirty(
4956
5177
  { type: "shell_inline", sessionId: message.sessionId, processRunId: message.runId, detail: "foreground command completed" },
4957
- true
5178
+ true,
5179
+ message.target
4958
5180
  );
4959
5181
  }
4960
5182
  return;
@@ -4974,6 +5196,7 @@ async function startWorker(options) {
4974
5196
  requestId: message.requestId,
4975
5197
  runId: message.runId
4976
5198
  });
5199
+ const hasWorkspaceEffect = workerCommandHasWorkspaceEffect(message);
4977
5200
  const runCommand = async () => {
4978
5201
  try {
4979
5202
  const resolvedTarget = resolveMessageTarget(message.target);
@@ -4990,7 +5213,7 @@ async function startWorker(options) {
4990
5213
  assertAdmission: assertMessageAdmission
4991
5214
  });
4992
5215
  } finally {
4993
- if (targetMayMutateVisibleWorkspace(message.target)) {
5216
+ if (hasWorkspaceEffect && targetMayMutateVisibleWorkspace(message.target)) {
4994
5217
  markWorkspaceDirty(
4995
5218
  {
4996
5219
  type: "process_terminal",
@@ -4998,7 +5221,8 @@ async function startWorker(options) {
4998
5221
  processRunId: message.runId,
4999
5222
  detail: "process completed"
5000
5223
  },
5001
- true
5224
+ true,
5225
+ message.target
5002
5226
  );
5003
5227
  }
5004
5228
  }
@@ -5006,10 +5230,13 @@ async function startWorker(options) {
5006
5230
  const execution = (async () => {
5007
5231
  let targetReserved = false;
5008
5232
  try {
5009
- await (0, import_workspace_command_sync_policy.reserveWorkspaceCommandAfterCurrentSync)(message.target, workspaceSyncSingleFlight, () => {
5010
- workspaceSyncPriorityProcessTargets.set(message.runId, message.target);
5011
- targetReserved = true;
5012
- });
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
+ }
5013
5240
  await (0, import_workspace_command_sync_policy.runWorkspaceCommand)(message.target, workspaceSyncSingleFlight, runCommand);
5014
5241
  } finally {
5015
5242
  if (targetReserved) workspaceSyncPriorityProcessTargets.delete(message.runId);
@@ -5032,6 +5259,7 @@ async function startWorker(options) {
5032
5259
  if (reservesVisibleWorkspace) {
5033
5260
  await (0, import_workspace_command_sync_policy.reserveWorkspaceCommandAfterCurrentSync)(message.target, workspaceSyncSingleFlight, () => {
5034
5261
  workspaceSyncPriorityOperationTargets.set(message.requestId, message.target);
5262
+ if (mutatesVisibleWorkspace) recordVisibleWorkspaceMutation(message.target);
5035
5263
  });
5036
5264
  }
5037
5265
  let dirtyTrigger;
@@ -5061,7 +5289,7 @@ async function startWorker(options) {
5061
5289
  toolCallId: message.requestId,
5062
5290
  ...message.target.type === "project" ? { projectId: message.target.projectId, branchName: message.target.branchName } : {}
5063
5291
  };
5064
- markWorkspaceDirty(dirtyTrigger);
5292
+ markWorkspaceDirty(dirtyTrigger, false, message.target);
5065
5293
  }
5066
5294
  } catch (error) {
5067
5295
  ws.send(
@@ -5222,5 +5450,7 @@ if (isCliEntrypoint()) {
5222
5450
  syncSessionArtifacts,
5223
5451
  workerChildProcessEnvironment,
5224
5452
  workerGitSecurityTestHarness,
5453
+ workerPtyBridgeTestHarness,
5454
+ workerPtyTestHarness,
5225
5455
  writeWorkerTextFile
5226
5456
  });