@rallycry/conveyor-agent 10.2.1 → 10.2.4

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.
@@ -1,3 +1,7 @@
1
+ import {
2
+ LoopLagMonitor
3
+ } from "./chunk-7TQO4ZF4.js";
4
+
1
5
  // src/setup/bootstrap.ts
2
6
  var BOOTSTRAP_TIMEOUT_MS = 3e4;
3
7
  var RETRY_DELAYS_MS = [5e3, 1e4, 2e4];
@@ -115,6 +119,9 @@ function applyBootstrapToEnv(config) {
115
119
  }
116
120
 
117
121
  // src/connection/agent-connection.ts
122
+ import { existsSync } from "fs";
123
+ import { fileURLToPath } from "url";
124
+ import { Worker } from "worker_threads";
118
125
  import { io } from "socket.io-client";
119
126
 
120
127
  // src/setup/bootstrap-poll.ts
@@ -188,6 +195,8 @@ var AgentConnection = class _AgentConnection {
188
195
  finalizeSnapshotCallback = null;
189
196
  runStartCommandCallback = null;
190
197
  earlyPullBranches = [];
198
+ spawnReviewCallback = null;
199
+ earlySpawnReviews = [];
191
200
  // PTY relay (S5 terminal). Single-slot callbacks, set per PtySession run.
192
201
  ptyInputCallback = null;
193
202
  ptyResizeCallback = null;
@@ -391,6 +400,10 @@ var AgentConnection = class _AgentConnection {
391
400
  if (this.pullBranchCallback) this.pullBranchCallback(data);
392
401
  else this.earlyPullBranches.push(data);
393
402
  });
403
+ this.socket.on("session:spawnReview", (data) => {
404
+ if (this.spawnReviewCallback) this.spawnReviewCallback(data);
405
+ else this.earlySpawnReviews.push(data);
406
+ });
394
407
  this.socket.on("session:finalizeSnapshot", () => {
395
408
  this.finalizeSnapshotCallback?.();
396
409
  });
@@ -445,8 +458,12 @@ var AgentConnection = class _AgentConnection {
445
458
  void this.refreshTaskTokenFromBootstrap().catch(() => {
446
459
  });
447
460
  });
448
- this.socket.io.on("reconnect", () => {
449
- process.stderr.write("[conveyor-agent] Reconnected\n");
461
+ this.socket.io.on("reconnect", (reconnectAttempts) => {
462
+ process.stderr.write(
463
+ `[conveyor-agent] Reconnected (attempts: ${reconnectAttempts}, ${(/* @__PURE__ */ new Date()).toISOString()})
464
+ `
465
+ );
466
+ this.sendHeartbeat();
450
467
  void this.reconnectToSession();
451
468
  });
452
469
  this.socket.io.on("reconnect_attempt", () => {
@@ -456,6 +473,7 @@ var AgentConnection = class _AgentConnection {
456
473
  disconnect() {
457
474
  this.stopProactiveTokenRefresh();
458
475
  this.clearSocketRecycle();
476
+ this.stopHeartbeatWorker();
459
477
  void this.flushEvents();
460
478
  if (this.socket) {
461
479
  this.socket.io.reconnection(false);
@@ -621,6 +639,26 @@ var AgentConnection = class _AgentConnection {
621
639
  for (const data of this.earlyPullBranches) callback(data);
622
640
  this.earlyPullBranches = [];
623
641
  }
642
+ onSpawnReview(callback) {
643
+ this.spawnReviewCallback = callback;
644
+ for (const data of this.earlySpawnReviews) callback(data);
645
+ this.earlySpawnReviews = [];
646
+ }
647
+ /**
648
+ * Report that a same-pod review child failed to spawn (fire-and-forget).
649
+ * The server Ends the orphaned review session and falls back to a dedicated
650
+ * review pod. sessionId is OUR (builder) session — the SEC-9 guard runs on
651
+ * it; the review session is identified separately.
652
+ */
653
+ reportReviewSpawnFailure(reviewSessionId, error) {
654
+ if (!this.socket) return;
655
+ void this.call("reportReviewSpawnFailure", {
656
+ sessionId: this.config.sessionId,
657
+ reviewSessionId,
658
+ ...error ? { error: error.slice(0, 2e3) } : {}
659
+ }).catch(() => {
660
+ });
661
+ }
624
662
  onFinalizeSnapshot(callback) {
625
663
  this.finalizeSnapshotCallback = callback;
626
664
  }
@@ -642,6 +680,19 @@ var AgentConnection = class _AgentConnection {
642
680
  }).catch(() => {
643
681
  });
644
682
  }
683
+ /**
684
+ * Forward one compact chat-proxy event derived from the transcript JSONL to
685
+ * the relay (fire-and-forget). Feeds the experimental chat PTY proxy ring.
686
+ * Old servers that don't know the method reject harmlessly.
687
+ */
688
+ sendPtyChatEvent(event) {
689
+ if (!this.socket) return;
690
+ void this.call("ptyChatEvent", {
691
+ sessionId: this.config.sessionId,
692
+ event
693
+ }).catch(() => {
694
+ });
695
+ }
645
696
  /**
646
697
  * Report that the interactive CLI process for this session has died and no
647
698
  * respawn is imminent (fire-and-forget). The server clears the scrollback
@@ -751,7 +802,7 @@ var AgentConnection = class _AgentConnection {
751
802
  if (this.recentMessages.length > 3) this.recentMessages.shift();
752
803
  return { duplicate: false };
753
804
  }
754
- sendHeartbeat() {
805
+ sendHeartbeat(loopLagMs) {
755
806
  if (!this.socket) return;
756
807
  const statusMap = {
757
808
  running: "active",
@@ -764,10 +815,67 @@ var AgentConnection = class _AgentConnection {
764
815
  void this.call("heartbeat", {
765
816
  sessionId: this.config.sessionId,
766
817
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
767
- status: heartbeatStatus
818
+ status: heartbeatStatus,
819
+ ...loopLagMs !== void 0 && loopLagMs > 0 ? { loopLagMs: Math.round(loopLagMs) } : {}
768
820
  }).catch(() => {
769
821
  });
770
822
  }
823
+ // ── Starvation-proof heartbeat worker ────────────────────────────────
824
+ //
825
+ // A worker thread with its own event loop + Socket.IO connection keeps the
826
+ // v3 session lease renewed even when the MAIN loop is stalled (the failure
827
+ // mode where a heavy gate got the session declared stranded and restarted
828
+ // mid-run). Best-effort by design: any spawn/runtime failure just degrades
829
+ // heartbeats to main-loop-only. See heartbeat-worker.ts for the policy.
830
+ heartbeatWorker = null;
831
+ startHeartbeatWorker(sharedBuffer, intervalMs = 3e4) {
832
+ if (this.heartbeatWorker) return;
833
+ try {
834
+ const workerUrl = new URL("./heartbeat-worker.js", import.meta.url);
835
+ if (!existsSync(fileURLToPath(workerUrl))) {
836
+ process.stderr.write(
837
+ "[conveyor-agent] heartbeat worker bundle not found \u2014 main-loop heartbeat only\n"
838
+ );
839
+ return;
840
+ }
841
+ const worker = new Worker(workerUrl, {
842
+ workerData: {
843
+ apiUrl: this.config.apiUrl,
844
+ taskToken: this.config.taskToken,
845
+ sessionId: this.config.sessionId,
846
+ runnerMode: this.config.runnerMode ?? "task",
847
+ sharedBuffer,
848
+ intervalMs
849
+ }
850
+ });
851
+ worker.unref();
852
+ worker.on("error", (err) => {
853
+ process.stderr.write(`[conveyor-agent] heartbeat worker error: ${err.message}
854
+ `);
855
+ this.heartbeatWorker = null;
856
+ });
857
+ worker.on("exit", (code) => {
858
+ if (code !== 0) {
859
+ process.stderr.write(`[conveyor-agent] heartbeat worker exited (code ${code})
860
+ `);
861
+ }
862
+ this.heartbeatWorker = null;
863
+ });
864
+ this.heartbeatWorker = worker;
865
+ process.stderr.write("[conveyor-agent] heartbeat worker started\n");
866
+ } catch (err) {
867
+ process.stderr.write(
868
+ `[conveyor-agent] heartbeat worker failed to start: ${err instanceof Error ? err.message : String(err)}
869
+ `
870
+ );
871
+ this.heartbeatWorker = null;
872
+ }
873
+ }
874
+ stopHeartbeatWorker() {
875
+ const worker = this.heartbeatWorker;
876
+ this.heartbeatWorker = null;
877
+ if (worker) void worker.terminate();
878
+ }
771
879
  emitModeChanged(agentMode) {
772
880
  this.sendEvent({ type: "mode_changed", agentMode });
773
881
  }
@@ -901,6 +1009,7 @@ ${q.question}${q.options.length ? "\n" + q.options.map((o) => `- ${o.label}: ${o
901
1009
  auth.taskToken = result.config.taskToken;
902
1010
  }
903
1011
  }
1012
+ this.heartbeatWorker?.postMessage({ taskToken: result.config.taskToken });
904
1013
  }
905
1014
  const refreshedClaude = Boolean(result.config.envVars?.CLAUDE_CODE_OAUTH_TOKEN);
906
1015
  return { refreshedClaude, refreshedTaskToken };
@@ -933,6 +1042,7 @@ ${q.question}${q.options.length ? "\n" + q.options.map((o) => `- ${o.label}: ${o
933
1042
  auth.taskToken = bundle.sessionJwt;
934
1043
  }
935
1044
  }
1045
+ this.heartbeatWorker?.postMessage({ taskToken: bundle.sessionJwt });
936
1046
  }
937
1047
  const refreshedClaude = Boolean(bundle.envVars?.CLAUDE_CODE_OAUTH_TOKEN);
938
1048
  return { refreshedClaude, refreshedTaskToken };
@@ -1014,14 +1124,25 @@ function createServiceLogger(service) {
1014
1124
  }
1015
1125
 
1016
1126
  // src/runner/git-utils.ts
1017
- import { execSync, exec } from "child_process";
1127
+ import { execFile } from "child_process";
1018
1128
  import { realpathSync } from "fs";
1019
1129
  import { promisify } from "util";
1020
- var execAsync = promisify(exec);
1021
- function syncWithBaseBranch(cwd, baseBranch) {
1130
+ var execFileAsync = promisify(execFile);
1131
+ var GIT_TIMEOUT_MS = 6e4;
1132
+ var GIT_SLOW_TIMEOUT_MS = 12e4;
1133
+ var GIT_MAX_BUFFER = 16 * 1024 * 1024;
1134
+ async function git(cwd, args, timeoutMs = GIT_TIMEOUT_MS) {
1135
+ const { stdout } = await execFileAsync("git", args, {
1136
+ cwd,
1137
+ timeout: timeoutMs,
1138
+ maxBuffer: GIT_MAX_BUFFER
1139
+ });
1140
+ return stdout.toString().trim();
1141
+ }
1142
+ async function syncWithBaseBranch(cwd, baseBranch) {
1022
1143
  if (!baseBranch) return true;
1023
1144
  try {
1024
- execSync(`git fetch origin ${baseBranch}`, { cwd, stdio: "ignore", timeout: 6e4 });
1145
+ await git(cwd, ["fetch", "origin", baseBranch]);
1025
1146
  } catch {
1026
1147
  process.stderr.write(
1027
1148
  `[conveyor-agent] Warning: git fetch origin ${baseBranch} failed, continuing with current base
@@ -1030,14 +1151,14 @@ function syncWithBaseBranch(cwd, baseBranch) {
1030
1151
  return false;
1031
1152
  }
1032
1153
  try {
1033
- execSync(`git merge origin/${baseBranch} --no-edit`, { cwd, stdio: "ignore", timeout: 3e4 });
1154
+ await git(cwd, ["merge", `origin/${baseBranch}`, "--no-edit"], 3e4);
1034
1155
  } catch {
1035
1156
  process.stderr.write(
1036
1157
  `[conveyor-agent] Warning: merge origin/${baseBranch} failed, aborting merge and continuing
1037
1158
  `
1038
1159
  );
1039
1160
  try {
1040
- execSync("git merge --abort", { cwd, stdio: "ignore" });
1161
+ await git(cwd, ["merge", "--abort"], 3e4);
1041
1162
  } catch {
1042
1163
  }
1043
1164
  return false;
@@ -1046,23 +1167,19 @@ function syncWithBaseBranch(cwd, baseBranch) {
1046
1167
  `);
1047
1168
  return true;
1048
1169
  }
1049
- function ensureOnTaskBranch(cwd, taskBranch) {
1170
+ async function ensureOnTaskBranch(cwd, taskBranch) {
1050
1171
  if (!taskBranch) return true;
1051
- const current = getCurrentBranch(cwd);
1172
+ const current = await getCurrentBranch(cwd);
1052
1173
  if (current === taskBranch) return true;
1053
1174
  try {
1054
- execSync(`git fetch origin ${taskBranch}`, { cwd, stdio: "ignore", timeout: 6e4 });
1175
+ await git(cwd, ["fetch", "origin", taskBranch]);
1055
1176
  } catch {
1056
1177
  process.stderr.write(`[conveyor-agent] Warning: git fetch origin ${taskBranch} failed
1057
1178
  `);
1058
1179
  return false;
1059
1180
  }
1060
1181
  try {
1061
- execSync(`git checkout -B ${taskBranch} origin/${taskBranch}`, {
1062
- cwd,
1063
- stdio: "ignore",
1064
- timeout: 3e4
1065
- });
1182
+ await git(cwd, ["checkout", "-B", taskBranch, `origin/${taskBranch}`], 3e4);
1066
1183
  } catch {
1067
1184
  process.stderr.write(`[conveyor-agent] Warning: git checkout ${taskBranch} failed
1068
1185
  `);
@@ -1072,74 +1189,62 @@ function ensureOnTaskBranch(cwd, taskBranch) {
1072
1189
  `);
1073
1190
  return true;
1074
1191
  }
1075
- function hasUncommittedChanges(cwd) {
1076
- const status = execSync("git status --porcelain", {
1077
- cwd,
1078
- stdio: ["ignore", "pipe", "ignore"]
1079
- }).toString().trim();
1192
+ async function hasUncommittedChanges(cwd) {
1193
+ const status = await git(cwd, ["status", "--porcelain"], GIT_SLOW_TIMEOUT_MS);
1080
1194
  return status.length > 0;
1081
1195
  }
1082
- function getCurrentBranch(cwd) {
1196
+ async function getCurrentBranch(cwd) {
1083
1197
  try {
1084
- const branch = execSync("git branch --show-current", {
1085
- cwd,
1086
- stdio: ["ignore", "pipe", "ignore"]
1087
- }).toString().trim();
1198
+ const branch = await git(cwd, ["branch", "--show-current"]);
1088
1199
  return branch || null;
1089
1200
  } catch {
1090
1201
  return null;
1091
1202
  }
1092
1203
  }
1093
- function hasUnpushedCommits(cwd) {
1204
+ async function hasUnpushedCommits(cwd) {
1094
1205
  try {
1095
- const currentBranch = getCurrentBranch(cwd);
1206
+ const currentBranch = await getCurrentBranch(cwd);
1096
1207
  if (!currentBranch) return false;
1097
1208
  try {
1098
- execSync(`git rev-parse origin/${currentBranch}`, {
1099
- cwd,
1100
- stdio: ["ignore", "pipe", "ignore"]
1101
- });
1209
+ await git(cwd, ["rev-parse", `origin/${currentBranch}`]);
1102
1210
  } catch {
1103
1211
  try {
1104
- execSync("git rev-parse HEAD", { cwd, stdio: ["ignore", "pipe", "ignore"] });
1212
+ await git(cwd, ["rev-parse", "HEAD"]);
1105
1213
  return true;
1106
1214
  } catch {
1107
1215
  return false;
1108
1216
  }
1109
1217
  }
1110
- const ahead = execSync(`git rev-list --count HEAD --not origin/${currentBranch}`, {
1111
- cwd,
1112
- stdio: ["ignore", "pipe", "ignore"]
1113
- }).toString().trim();
1218
+ const ahead = await git(cwd, [
1219
+ "rev-list",
1220
+ "--count",
1221
+ "HEAD",
1222
+ "--not",
1223
+ `origin/${currentBranch}`
1224
+ ]);
1114
1225
  return parseInt(ahead, 10) > 0;
1115
1226
  } catch {
1116
1227
  return false;
1117
1228
  }
1118
1229
  }
1119
- function stageAndCommit(cwd, message) {
1230
+ async function stageAndCommit(cwd, message) {
1120
1231
  try {
1121
- execSync("git add -A", { cwd, stdio: ["ignore", "pipe", "ignore"] });
1122
- if (!hasUncommittedChanges(cwd)) return null;
1123
- execSync(`git commit -m ${JSON.stringify(message)}`, {
1124
- cwd,
1125
- stdio: ["ignore", "pipe", "ignore"]
1126
- });
1127
- return execSync("git rev-parse HEAD", { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
1232
+ await git(cwd, ["add", "-A"], GIT_SLOW_TIMEOUT_MS);
1233
+ if (!await hasUncommittedChanges(cwd)) return null;
1234
+ await git(cwd, ["commit", "-m", message], GIT_SLOW_TIMEOUT_MS);
1235
+ return await git(cwd, ["rev-parse", "HEAD"]);
1128
1236
  } catch {
1129
1237
  return null;
1130
1238
  }
1131
1239
  }
1132
1240
  async function tryPush(cwd, branch, skipVerify = false) {
1133
- const noVerify = skipVerify ? "--no-verify " : "";
1241
+ const noVerify = skipVerify ? ["--no-verify"] : [];
1134
1242
  try {
1135
- await execAsync(`git push ${noVerify}origin ${branch}`, { cwd, timeout: 3e4 });
1243
+ await git(cwd, ["push", ...noVerify, "origin", branch], 3e4);
1136
1244
  return true;
1137
1245
  } catch {
1138
1246
  try {
1139
- await execAsync(`git push ${noVerify}--force-with-lease origin ${branch}`, {
1140
- cwd,
1141
- timeout: 3e4
1142
- });
1247
+ await git(cwd, ["push", ...noVerify, "--force-with-lease", "origin", branch], 3e4);
1143
1248
  return true;
1144
1249
  } catch {
1145
1250
  return false;
@@ -1148,7 +1253,7 @@ async function tryPush(cwd, branch, skipVerify = false) {
1148
1253
  }
1149
1254
  async function isAuthError(cwd) {
1150
1255
  try {
1151
- await execAsync("git push --dry-run", { cwd, timeout: 3e4 });
1256
+ await git(cwd, ["push", "--dry-run"], 3e4);
1152
1257
  return false;
1153
1258
  } catch (err) {
1154
1259
  if (err.killed) return true;
@@ -1158,16 +1263,18 @@ async function isAuthError(cwd) {
1158
1263
  return /authentication|authorization|403|401|token/i.test(msg);
1159
1264
  }
1160
1265
  }
1161
- function updateRemoteToken(cwd, token) {
1266
+ async function updateRemoteToken(cwd, token) {
1162
1267
  try {
1163
- const url = execSync("git remote get-url origin", { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
1268
+ const url = await git(cwd, ["remote", "get-url", "origin"]);
1164
1269
  const match = url.match(/github\.com[/:]([^/]+\/[^/.]+)/);
1165
1270
  if (match) {
1166
1271
  const repo = match[1].replace(/\.git$/, "");
1167
- execSync(
1168
- `git remote set-url origin "https://x-access-token:${token}@github.com/${repo}.git"`,
1169
- { cwd, stdio: ["ignore", "pipe", "ignore"] }
1170
- );
1272
+ await git(cwd, [
1273
+ "remote",
1274
+ "set-url",
1275
+ "origin",
1276
+ `https://x-access-token:${token}@github.com/${repo}.git`
1277
+ ]);
1171
1278
  }
1172
1279
  } catch {
1173
1280
  }
@@ -1176,30 +1283,24 @@ function wipRefForBranch(branch) {
1176
1283
  return `conveyor-wip/${branch}`;
1177
1284
  }
1178
1285
  var wipRefPushed = /* @__PURE__ */ new Set();
1179
- function createWipSnapshot(cwd, message) {
1286
+ async function createWipSnapshot(cwd, message) {
1180
1287
  try {
1181
- execSync("git add -A", { cwd, stdio: ["ignore", "pipe", "ignore"] });
1182
- const sha = execSync(`git stash create ${JSON.stringify(message)}`, {
1183
- cwd,
1184
- stdio: ["ignore", "pipe", "ignore"]
1185
- }).toString().trim();
1288
+ await git(cwd, ["add", "-A"], GIT_SLOW_TIMEOUT_MS);
1289
+ const sha = await git(cwd, ["stash", "create", message], GIT_SLOW_TIMEOUT_MS);
1186
1290
  return sha || null;
1187
1291
  } catch {
1188
1292
  return null;
1189
1293
  } finally {
1190
1294
  try {
1191
- execSync("git reset -q", { cwd, stdio: ["ignore", "pipe", "ignore"] });
1295
+ await git(cwd, ["reset", "-q"], GIT_SLOW_TIMEOUT_MS);
1192
1296
  } catch {
1193
1297
  }
1194
1298
  }
1195
1299
  }
1196
- function tryPushRefspec(cwd, refspec, force = false) {
1300
+ async function tryPushRefspec(cwd, refspec, force = false) {
1197
1301
  try {
1198
- execSync(`git push --no-verify ${force ? "--force " : ""}origin ${JSON.stringify(refspec)}`, {
1199
- cwd,
1200
- stdio: ["ignore", "pipe", "pipe"],
1201
- timeout: 3e4
1202
- });
1302
+ const forceArgs = force ? ["--force"] : [];
1303
+ await git(cwd, ["push", "--no-verify", ...forceArgs, "origin", refspec], 3e4);
1203
1304
  return true;
1204
1305
  } catch {
1205
1306
  return false;
@@ -1210,49 +1311,39 @@ async function refreshRemoteToken(cwd, refreshToken) {
1210
1311
  try {
1211
1312
  const token = await refreshToken();
1212
1313
  if (token) {
1213
- updateRemoteToken(cwd, token);
1314
+ await updateRemoteToken(cwd, token);
1214
1315
  process.env.GITHUB_TOKEN = token;
1215
1316
  process.env.GH_TOKEN = token;
1216
1317
  }
1217
1318
  } catch {
1218
1319
  }
1219
1320
  }
1220
- function restoreWipSnapshot(cwd, branch) {
1321
+ async function restoreWipSnapshot(cwd, branch) {
1221
1322
  if (!branch) return "none";
1222
1323
  const ref = wipRefForBranch(branch);
1223
1324
  try {
1224
- execSync(`git fetch origin +refs/heads/${ref}:refs/remotes/origin/${ref}`, {
1225
- cwd,
1226
- stdio: "ignore",
1227
- timeout: 6e4
1228
- });
1325
+ await git(cwd, ["fetch", "origin", `+refs/heads/${ref}:refs/remotes/origin/${ref}`]);
1229
1326
  } catch {
1230
1327
  return "none";
1231
1328
  }
1232
1329
  wipRefPushed.add(cwd);
1233
1330
  try {
1234
- const sha = execSync(`git rev-parse refs/remotes/origin/${ref}`, {
1235
- cwd,
1236
- stdio: ["ignore", "pipe", "ignore"]
1237
- }).toString().trim();
1238
- const parent = execSync(`git rev-parse "${sha}^"`, {
1239
- cwd,
1240
- stdio: ["ignore", "pipe", "ignore"]
1241
- }).toString().trim();
1242
- const head = execSync("git rev-parse HEAD", { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
1331
+ const sha = await git(cwd, ["rev-parse", `refs/remotes/origin/${ref}`]);
1332
+ const parent = await git(cwd, ["rev-parse", `${sha}^`]);
1333
+ const head = await git(cwd, ["rev-parse", "HEAD"]);
1243
1334
  if (parent !== head) {
1244
1335
  try {
1245
- execSync(`git stash apply ${sha}`, { cwd, stdio: ["ignore", "pipe", "pipe"] });
1336
+ await git(cwd, ["stash", "apply", sha], GIT_SLOW_TIMEOUT_MS);
1246
1337
  return "applied";
1247
1338
  } catch {
1248
1339
  try {
1249
- execSync("git reset --merge", { cwd, stdio: "ignore" });
1340
+ await git(cwd, ["reset", "--merge"], GIT_SLOW_TIMEOUT_MS);
1250
1341
  } catch {
1251
1342
  }
1252
1343
  return "stale";
1253
1344
  }
1254
1345
  }
1255
- execSync(`git stash apply ${sha}`, { cwd, stdio: ["ignore", "pipe", "pipe"] });
1346
+ await git(cwd, ["stash", "apply", sha], GIT_SLOW_TIMEOUT_MS);
1256
1347
  return "applied";
1257
1348
  } catch {
1258
1349
  return "failed";
@@ -1263,10 +1354,10 @@ async function flushPendingChanges(cwd, opts) {
1263
1354
  let pushed = false;
1264
1355
  let hadWork = false;
1265
1356
  try {
1266
- const branch = getCurrentBranch(cwd);
1357
+ const branch = await getCurrentBranch(cwd);
1267
1358
  if (!branch) return { committed, pushed, hadWork };
1268
- const dirty = hasUncommittedChanges(cwd);
1269
- const unpushed = hasUnpushedCommits(cwd);
1359
+ const dirty = await hasUncommittedChanges(cwd);
1360
+ const unpushed = await hasUnpushedCommits(cwd);
1270
1361
  if (!dirty && !unpushed) {
1271
1362
  await dropStaleWipRef(cwd, branch, opts?.refreshToken);
1272
1363
  return { committed, pushed, hadWork };
@@ -1278,9 +1369,9 @@ async function flushPendingChanges(cwd, opts) {
1278
1369
  }
1279
1370
  if (dirty) {
1280
1371
  const message = opts?.wipMessage ?? "WIP: conveyor-agent snapshot";
1281
- const sha = createWipSnapshot(cwd, message);
1372
+ const sha = await createWipSnapshot(cwd, message);
1282
1373
  if (sha) {
1283
- committed = tryPushRefspec(cwd, `${sha}:refs/heads/${wipRefForBranch(branch)}`, true);
1374
+ committed = await tryPushRefspec(cwd, `${sha}:refs/heads/${wipRefForBranch(branch)}`, true);
1284
1375
  if (committed) wipRefPushed.add(cwd);
1285
1376
  }
1286
1377
  }
@@ -1291,19 +1382,19 @@ async function flushPendingChanges(cwd, opts) {
1291
1382
  async function dropStaleWipRef(cwd, branch, refreshToken) {
1292
1383
  if (!wipRefPushed.has(cwd)) return;
1293
1384
  await refreshRemoteToken(cwd, refreshToken);
1294
- if (tryPushRefspec(cwd, `:refs/heads/${wipRefForBranch(branch)}`)) {
1385
+ if (await tryPushRefspec(cwd, `:refs/heads/${wipRefForBranch(branch)}`)) {
1295
1386
  wipRefPushed.delete(cwd);
1296
1387
  }
1297
1388
  }
1298
1389
  async function pushToOrigin(cwd, refreshToken, skipVerify = false) {
1299
1390
  try {
1300
- const currentBranch = getCurrentBranch(cwd);
1391
+ const currentBranch = await getCurrentBranch(cwd);
1301
1392
  if (!currentBranch) return false;
1302
1393
  if (refreshToken) {
1303
1394
  try {
1304
1395
  const token = await refreshToken();
1305
1396
  if (token) {
1306
- updateRemoteToken(cwd, token);
1397
+ await updateRemoteToken(cwd, token);
1307
1398
  process.env.GITHUB_TOKEN = token;
1308
1399
  process.env.GH_TOKEN = token;
1309
1400
  }
@@ -1314,7 +1405,7 @@ async function pushToOrigin(cwd, refreshToken, skipVerify = false) {
1314
1405
  if (refreshToken && await isAuthError(cwd)) {
1315
1406
  const token = await refreshToken();
1316
1407
  if (token) {
1317
- updateRemoteToken(cwd, token);
1408
+ await updateRemoteToken(cwd, token);
1318
1409
  process.env.GITHUB_TOKEN = token;
1319
1410
  process.env.GH_TOKEN = token;
1320
1411
  return await tryPush(cwd, currentBranch, skipVerify);
@@ -1328,12 +1419,9 @@ async function pushToOrigin(cwd, refreshToken, skipVerify = false) {
1328
1419
  function branchBackupRef(branch) {
1329
1420
  return `conveyor-wip/branches/${branch}`;
1330
1421
  }
1331
- function listWorktrees(cwd) {
1422
+ async function listWorktrees(cwd) {
1332
1423
  try {
1333
- const out = execSync("git worktree list --porcelain", {
1334
- cwd,
1335
- stdio: ["ignore", "pipe", "ignore"]
1336
- }).toString();
1424
+ const out = await git(cwd, ["worktree", "list", "--porcelain"]);
1337
1425
  const result = [];
1338
1426
  for (const entry of out.split("\n\n")) {
1339
1427
  const lines = entry.trim().split("\n");
@@ -1350,22 +1438,17 @@ function listWorktrees(cwd) {
1350
1438
  return [];
1351
1439
  }
1352
1440
  }
1353
- function listLocalBranches(cwd) {
1441
+ async function listLocalBranches(cwd) {
1354
1442
  try {
1355
- return execSync("git for-each-ref --format='%(refname:short)' refs/heads/", {
1356
- cwd,
1357
- stdio: ["ignore", "pipe", "ignore"]
1358
- }).toString().split("\n").map((s) => s.trim()).filter(Boolean);
1443
+ const out = await git(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads/"]);
1444
+ return out.split("\n").map((s) => s.trim()).filter(Boolean);
1359
1445
  } catch {
1360
1446
  return [];
1361
1447
  }
1362
1448
  }
1363
- function branchUnpushedCount(cwd, branch) {
1449
+ async function branchUnpushedCount(cwd, branch) {
1364
1450
  try {
1365
- const n = execSync(`git rev-list --count ${JSON.stringify(branch)} --not --remotes=origin`, {
1366
- cwd,
1367
- stdio: ["ignore", "pipe", "ignore"]
1368
- }).toString().trim();
1451
+ const n = await git(cwd, ["rev-list", "--count", branch, "--not", "--remotes=origin"]);
1369
1452
  return Number.parseInt(n, 10) || 0;
1370
1453
  } catch {
1371
1454
  return 0;
@@ -1378,392 +1461,56 @@ function samePath(a, b) {
1378
1461
  return a === b;
1379
1462
  }
1380
1463
  }
1381
- async function flushAllPendingWork(cwd, opts) {
1382
- try {
1383
- const primary = await flushPendingChanges(cwd, opts);
1384
- await refreshRemoteToken(cwd, opts?.refreshToken);
1385
- const currentBranch = getCurrentBranch(cwd);
1386
- const worktreesSnapshotted = snapshotOtherWorktrees(cwd, opts?.wipMessage);
1387
- const branchesBackedUp = backupOtherBranches(cwd, currentBranch);
1388
- return {
1389
- hadWork: primary.hadWork || worktreesSnapshotted > 0 || branchesBackedUp > 0,
1390
- branchesBackedUp,
1391
- worktreesSnapshotted
1392
- };
1393
- } catch {
1394
- return { hadWork: false, branchesBackedUp: 0, worktreesSnapshotted: 0 };
1395
- }
1396
- }
1397
- function snapshotOtherWorktrees(cwd, wipMessage) {
1398
- let count = 0;
1399
- for (const wt of listWorktrees(cwd)) {
1400
- if (samePath(wt.path, cwd) || !wt.branch) continue;
1401
- try {
1402
- if (!hasUncommittedChanges(wt.path)) continue;
1403
- const sha = createWipSnapshot(wt.path, wipMessage ?? "WIP: conveyor-agent snapshot");
1404
- if (sha && tryPushRefspec(wt.path, `${sha}:refs/heads/${wipRefForBranch(wt.branch)}`, true)) {
1405
- wipRefPushed.add(wt.path);
1406
- count++;
1407
- }
1408
- } catch {
1409
- }
1410
- }
1411
- return count;
1412
- }
1413
- function backupOtherBranches(cwd, currentBranch) {
1414
- let count = 0;
1415
- for (const branch of listLocalBranches(cwd)) {
1416
- if (branch === currentBranch || branch.startsWith("conveyor-wip/")) continue;
1417
- try {
1418
- if (branchUnpushedCount(cwd, branch) === 0) continue;
1419
- if (tryPushRefspec(cwd, `refs/heads/${branch}:refs/heads/${branchBackupRef(branch)}`, true)) {
1420
- count++;
1421
- }
1422
- } catch {
1423
- }
1424
- }
1425
- return count;
1426
- }
1427
-
1428
- // src/runner/plan-sync.ts
1429
- import { readdirSync, statSync, readFileSync } from "fs";
1430
- import { join as join2 } from "path";
1431
-
1432
- // src/harness/pty/settings.ts
1433
- import { mkdir, writeFile, chmod } from "fs/promises";
1434
- import { homedir } from "os";
1435
- import { join } from "path";
1436
- function claudeConfigHome() {
1437
- return process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
1438
- }
1439
- function projectSlug(cwd) {
1440
- return cwd.replace(/\//g, "-");
1441
- }
1442
- function sessionTranscriptPath(cwd, sessionId) {
1443
- return join(claudeConfigHome(), "projects", projectSlug(cwd), `${sessionId}.jsonl`);
1444
- }
1445
- function configHomePlansDir() {
1446
- return join(claudeConfigHome(), "plans");
1447
- }
1448
- var ALLOW_RULES = [
1449
- "Bash",
1450
- "BashOutput",
1451
- "Edit",
1452
- "Write",
1453
- "Read",
1454
- "Glob",
1455
- "Grep",
1456
- "WebFetch",
1457
- "WebSearch",
1458
- "NotebookEdit",
1459
- "Task",
1460
- "TodoWrite",
1461
- "ToolSearch",
1462
- "KillShell",
1463
- "SlashCommand",
1464
- "Skill",
1465
- "mcp__conveyor__*"
1466
- ];
1467
- var HOOK_HELPER_SOURCE = `"use strict";
1468
- const net = require("node:net");
1469
- const crypto = require("node:crypto");
1470
-
1471
- const PRE_TOOL_USE_TIMEOUT_MS = 110000;
1472
- const ASK_USER_QUESTION_TIMEOUT_MS = 5000;
1473
-
1474
- let raw = "";
1475
- process.stdin.setEncoding("utf8");
1476
- process.stdin.on("data", (chunk) => {
1477
- raw += chunk;
1478
- });
1479
- process.stdin.on("end", () => {
1480
- let payload = {};
1481
- try {
1482
- const parsed = JSON.parse(raw);
1483
- if (parsed && typeof parsed === "object") payload = parsed;
1484
- } catch {
1485
- payload = {};
1486
- }
1487
- if (payload.hook_event_name === "PreToolUse") {
1488
- preToolUse(payload);
1489
- } else {
1490
- relayProgress(typeof payload.tool_name === "string" ? payload.tool_name : "");
1491
- }
1492
- });
1493
-
1494
- function relayProgress(toolName) {
1495
- const socketPath = process.env.CONVEYOR_HOOK_SOCKET;
1496
- let done = false;
1497
- const finish = () => {
1498
- if (done) return;
1499
- done = true;
1500
- process.stdout.write(JSON.stringify({ continue: true }));
1501
- process.exit(0);
1502
- };
1503
- const fallback = setTimeout(finish, 250);
1504
- if (!socketPath) {
1505
- clearTimeout(fallback);
1506
- finish();
1507
- return;
1508
- }
1509
- const client = net.connect(socketPath, () => {
1510
- // PostToolUse does not supply tool duration, so elapsed_time_seconds is
1511
- // omitted rather than reported as a misleading 0 (the field is optional).
1512
- const line = JSON.stringify({ tool_name: toolName }) + "\\n";
1513
- client.write(line, () => {
1514
- client.end();
1515
- });
1516
- });
1517
- client.on("close", () => {
1518
- clearTimeout(fallback);
1519
- finish();
1520
- });
1521
- client.on("error", () => {
1522
- clearTimeout(fallback);
1523
- finish();
1524
- });
1525
- }
1526
-
1527
- function preToolUse(payload) {
1528
- const socketPath = process.env.CONVEYOR_HOOK_SOCKET;
1529
- let done = false;
1530
- const respond = (decision, reason) => {
1531
- if (done) return;
1532
- done = true;
1533
- process.stdout.write(
1534
- JSON.stringify({
1535
- hookSpecificOutput: {
1536
- hookEventName: "PreToolUse",
1537
- permissionDecision: decision,
1538
- ...(reason ? { permissionDecisionReason: reason } : {}),
1539
- },
1540
- }),
1541
- );
1542
- process.exit(0);
1543
- };
1544
- // MCP tools are auto-allowed locally \u2014 no socket round trip, no fail-mode.
1545
- // Plan mode prompts for them despite permissions.allow (the CLI can't
1546
- // classify MCP tools as read-only), and the pod is the sandbox.
1547
- if (typeof payload.tool_name === "string" && payload.tool_name.startsWith("mcp__")) {
1548
- respond("allow");
1549
- return;
1550
- }
1551
- // AskUserQuestion is observe-only (status reporting) \u2014 fail OPEN so a
1552
- // missing/slow socket never denies the questionnaire. Everything else
1553
- // (ExitPlanMode) is a Conveyor gate \u2014 fail CLOSED.
1554
- const failOpen = payload.tool_name === "AskUserQuestion";
1555
- const respondUnavailable = () =>
1556
- failOpen
1557
- ? respond("allow")
1558
- : respond(
1559
- "deny",
1560
- "Conveyor validation is unavailable right now. Wait a moment and call the tool again.",
1561
- );
1562
- if (!socketPath) {
1563
- respondUnavailable();
1564
- return;
1565
- }
1566
- const id = crypto.randomBytes(8).toString("hex");
1567
- const timer = setTimeout(
1568
- respondUnavailable,
1569
- failOpen ? ASK_USER_QUESTION_TIMEOUT_MS : PRE_TOOL_USE_TIMEOUT_MS,
1570
- );
1571
- const client = net.connect(socketPath, () => {
1572
- client.write(
1573
- JSON.stringify({
1574
- kind: "pre_tool_use",
1575
- id,
1576
- tool_name: typeof payload.tool_name === "string" ? payload.tool_name : "",
1577
- tool_input:
1578
- payload.tool_input && typeof payload.tool_input === "object" ? payload.tool_input : {},
1579
- }) + "\\n",
1580
- );
1581
- });
1582
- let buffer = "";
1583
- client.on("data", (chunk) => {
1584
- buffer += chunk.toString("utf8");
1585
- let index = buffer.indexOf("\\n");
1586
- while (index >= 0) {
1587
- const line = buffer.slice(0, index);
1588
- buffer = buffer.slice(index + 1);
1589
- try {
1590
- const verdict = JSON.parse(line);
1591
- if (verdict && verdict.id === id) {
1592
- clearTimeout(timer);
1593
- client.end();
1594
- respond(verdict.decision === "allow" ? "allow" : "deny", verdict.reason);
1595
- return;
1596
- }
1597
- } catch {
1598
- /* keep scanning */
1599
- }
1600
- index = buffer.indexOf("\\n");
1601
- }
1602
- });
1603
- client.on("error", () => {
1604
- clearTimeout(timer);
1605
- respondUnavailable();
1606
- });
1607
- client.on("close", () => {
1608
- clearTimeout(timer);
1609
- respondUnavailable();
1610
- });
1611
- }
1612
- `;
1613
- async function writeHookSettings(dir) {
1614
- const helperPath = join(dir, "hook-helper.cjs");
1615
- const settingsPath = join(dir, "settings.json");
1616
- await mkdir(dir, { recursive: true });
1617
- await writeFile(helperPath, HOOK_HELPER_SOURCE, "utf8");
1618
- await chmod(helperPath, 493);
1619
- const settings = {
1620
- // Pre-accept Claude Code's "Bypass Permissions mode" disclaimer. Build-capable
1621
- // spawns pass `--dangerously-skip-permissions`; on a real PTY the CLI otherwise
1622
- // parks on the interactive "Yes, I accept / No, exit" dialog whose default focus
1623
- // is "No, exit" — so it cannot be auto-dismissed by an Enter nudge and the run
1624
- // stalls until the auto-nudge watchdog shuts it down. The CLI reads this key
1625
- // from the `--settings` (flagSettings) layer via its bypass-mode gate, so
1626
- // setting it here suppresses the dialog deterministically on every spawn —
1627
- // independent of the `bypassPermissionsModeAccepted` seed in credentials.ts,
1628
- // which the CLI's one-time migration consumes and which is subject to
1629
- // persistence races on the codespace user-home.
1630
- skipDangerousModePermissionPrompt: true,
1631
- // Auto-approve tool calls so the interactive (PTY) agent never stops to
1632
- // prompt the viewer. This only suppresses per-tool approval prompts — it
1633
- // does NOT relax the planning gate: in discovery/plan mode the spawn passes
1634
- // `--permission-mode plan`, which keeps the agent read-only (no edits/commits
1635
- // until it exits plan mode) regardless of this allow-list. In build mode the
1636
- // spawn already bypasses prompts via `--dangerously-skip-permissions`.
1637
- // NOTE: a bare "*" rule is INVALID (the CLI rejects it and parks the TUI on
1638
- // a Settings Warning dialog at boot) — hence the explicit ALLOW_RULES list.
1639
- permissions: {
1640
- allow: ALLOW_RULES
1641
- },
1642
- hooks: {
1643
- PreToolUse: [
1644
- {
1645
- matcher: "ExitPlanMode",
1646
- hooks: [{ type: "command", command: `node ${JSON.stringify(helperPath)}`, timeout: 120 }]
1647
- },
1648
- {
1649
- // Observe-only: lets the session report waiting_for_input the moment
1650
- // the planning questionnaire renders. The helper fails open for this
1651
- // tool, so the questionnaire is never blocked by Conveyor.
1652
- matcher: "AskUserQuestion",
1653
- hooks: [{ type: "command", command: `node ${JSON.stringify(helperPath)}`, timeout: 30 }]
1654
- },
1655
- {
1656
- // Plan-permission spawns prompt for MCP tools even when they're in
1657
- // permissions.allow — the CLI can't classify them as read-only. A
1658
- // hook allow bypasses the permission engine in every mode. Loose by
1659
- // design: the pod is the sandbox, and planning agents must call
1660
- // update_task etc. The helper answers locally (no socket).
1661
- matcher: "mcp__.*",
1662
- hooks: [{ type: "command", command: `node ${JSON.stringify(helperPath)}`, timeout: 30 }]
1663
- }
1664
- ],
1665
- PostToolUse: [
1666
- {
1667
- matcher: "*",
1668
- hooks: [{ type: "command", command: `node ${JSON.stringify(helperPath)}` }]
1669
- }
1670
- ]
1671
- }
1672
- };
1673
- await writeFile(settingsPath, JSON.stringify(settings, null, 2), "utf8");
1674
- return { settingsPath, helperPath };
1675
- }
1676
-
1677
- // src/runner/plan-sync.ts
1678
- var PlanSync = class {
1679
- planFileSnapshot = /* @__PURE__ */ new Map();
1680
- lockedPlanFile = null;
1681
- workspaceDir;
1682
- connection;
1683
- constructor(workspaceDir, connection) {
1684
- this.workspaceDir = workspaceDir;
1685
- this.connection = connection;
1686
- }
1687
- updateWorkspaceDir(workspaceDir) {
1688
- this.workspaceDir = workspaceDir;
1689
- }
1690
- getPlanDirs() {
1691
- return [join2(this.workspaceDir, ".claude", "plans"), configHomePlansDir()];
1692
- }
1693
- snapshotPlanFiles() {
1694
- this.planFileSnapshot.clear();
1695
- this.lockedPlanFile = null;
1696
- for (const plansDir of this.getPlanDirs()) {
1697
- try {
1698
- for (const file of readdirSync(plansDir).filter((f) => f.endsWith(".md"))) {
1699
- try {
1700
- const fullPath = join2(plansDir, file);
1701
- const stat6 = statSync(fullPath);
1702
- this.planFileSnapshot.set(fullPath, stat6.mtimeMs);
1703
- } catch {
1704
- continue;
1705
- }
1706
- }
1707
- } catch {
1708
- }
1709
- }
1464
+ async function flushAllPendingWork(cwd, opts) {
1465
+ try {
1466
+ const primary = await flushPendingChanges(cwd, opts);
1467
+ await refreshRemoteToken(cwd, opts?.refreshToken);
1468
+ const currentBranch = await getCurrentBranch(cwd);
1469
+ const worktreesSnapshotted = await snapshotOtherWorktrees(cwd, opts?.wipMessage);
1470
+ const branchesBackedUp = await backupOtherBranches(cwd, currentBranch);
1471
+ return {
1472
+ hadWork: primary.hadWork || worktreesSnapshotted > 0 || branchesBackedUp > 0,
1473
+ branchesBackedUp,
1474
+ worktreesSnapshotted
1475
+ };
1476
+ } catch {
1477
+ return { hadWork: false, branchesBackedUp: 0, worktreesSnapshotted: 0 };
1710
1478
  }
1711
- findNewestPlanFile() {
1712
- let newest = null;
1713
- for (const plansDir of this.getPlanDirs()) {
1714
- let files;
1715
- try {
1716
- files = readdirSync(plansDir).filter((f) => f.endsWith(".md"));
1717
- } catch {
1718
- continue;
1719
- }
1720
- for (const file of files) {
1721
- const fullPath = join2(plansDir, file);
1722
- try {
1723
- const stat6 = statSync(fullPath);
1724
- const prevMtime = this.planFileSnapshot.get(fullPath);
1725
- const isNew = prevMtime === void 0 || stat6.mtimeMs > prevMtime;
1726
- if (isNew && (!newest || stat6.mtimeMs > newest.mtime)) {
1727
- newest = { path: fullPath, mtime: stat6.mtimeMs };
1728
- }
1729
- } catch {
1730
- continue;
1731
- }
1479
+ }
1480
+ async function snapshotOtherWorktrees(cwd, wipMessage) {
1481
+ let count = 0;
1482
+ for (const wt of await listWorktrees(cwd)) {
1483
+ if (samePath(wt.path, cwd) || !wt.branch) continue;
1484
+ try {
1485
+ if (!await hasUncommittedChanges(wt.path)) continue;
1486
+ const sha = await createWipSnapshot(wt.path, wipMessage ?? "WIP: conveyor-agent snapshot");
1487
+ if (sha && await tryPushRefspec(wt.path, `${sha}:refs/heads/${wipRefForBranch(wt.branch)}`, true)) {
1488
+ wipRefPushed.add(wt.path);
1489
+ count++;
1732
1490
  }
1491
+ } catch {
1733
1492
  }
1734
- return newest;
1735
1493
  }
1736
- async syncPlanFile() {
1737
- const isInitialDetection = this.lockedPlanFile === null;
1738
- const target = this.lockedPlanFile ?? this.findNewestPlanFile()?.path ?? null;
1739
- if (!target) return;
1740
- if (isInitialDetection) {
1741
- this.lockedPlanFile = target;
1742
- }
1743
- const fileName = target.split("/").pop() ?? "plan";
1744
- let content;
1494
+ return count;
1495
+ }
1496
+ async function backupOtherBranches(cwd, currentBranch) {
1497
+ let count = 0;
1498
+ for (const branch of await listLocalBranches(cwd)) {
1499
+ if (branch === currentBranch || branch.startsWith("conveyor-wip/")) continue;
1745
1500
  try {
1746
- content = readFileSync(target, "utf-8").trim();
1747
- } catch (err) {
1748
- const reason = err instanceof Error ? err.message : String(err);
1749
- this.connection.postChatMessage(
1750
- `Plan sync warning: could not read local plan file (${fileName}): ${reason}. The task plan was not updated.`
1751
- );
1752
- return;
1753
- }
1754
- if (!content) return;
1755
- const result = await this.connection.updateTaskFields({ plan: content });
1756
- if (!result.ok) {
1757
- this.connection.postChatMessage(
1758
- `Plan sync failed: the local plan file (${fileName}) was not persisted to the task (${result.error ?? "unknown error"}). Retry update_task_plan or re-run ExitPlanMode.`
1759
- );
1760
- return;
1501
+ if (await branchUnpushedCount(cwd, branch) === 0) continue;
1502
+ if (await tryPushRefspec(
1503
+ cwd,
1504
+ `refs/heads/${branch}:refs/heads/${branchBackupRef(branch)}`,
1505
+ true
1506
+ )) {
1507
+ count++;
1508
+ }
1509
+ } catch {
1761
1510
  }
1762
- const verb = isInitialDetection ? "Detected local plan file" : "Synced local plan file";
1763
- const suffix = isInitialDetection ? " and synced it to the task plan." : " to the task plan.";
1764
- this.connection.postChatMessage(`${verb} (${fileName})${suffix}`);
1765
1511
  }
1766
- };
1512
+ return count;
1513
+ }
1767
1514
 
1768
1515
  // src/setup/git-ready.ts
1769
1516
  import { access, stat } from "fs/promises";
@@ -1814,69 +1561,6 @@ async function pollForMarkers(markerPath, failedPath, timeoutMs, pollMs, onLog)
1814
1561
  // src/runner/work-preservation/index.ts
1815
1562
  import { rm as rm3 } from "fs/promises";
1816
1563
 
1817
- // src/runner/work-preservation/pg-dump.ts
1818
- import { execSync as execSync2 } from "child_process";
1819
- import { randomUUID } from "crypto";
1820
- import { tmpdir } from "os";
1821
- import path from "path";
1822
- var PG_DUMP_FILENAME = ".conveyor-pgdump.sql";
1823
- function connectionDefaults(opts) {
1824
- return {
1825
- host: opts.host ?? "localhost",
1826
- port: opts.port ?? 5432,
1827
- db: opts.db ?? "conveyor"
1828
- };
1829
- }
1830
- function buildPgDumpCommand(opts) {
1831
- const { host, port, db } = connectionDefaults(opts);
1832
- return `pg_dump -h ${host} -p ${port} -U postgres ${db} -f ${JSON.stringify(
1833
- opts.outputPath
1834
- )} --no-owner --no-privileges`;
1835
- }
1836
- function buildPsqlRestoreCommand(opts) {
1837
- const { host, port, db } = connectionDefaults(opts);
1838
- return `psql -h ${host} -p ${port} -U postgres -d ${db} -f ${JSON.stringify(opts.dumpPath)}`;
1839
- }
1840
- function hasPostgresDep() {
1841
- return (process.env.CONVEYOR_DEPS ?? "").split(",").map((dep) => dep.trim()).includes("postgresql");
1842
- }
1843
- function execErrorDetail(err) {
1844
- const withStreams = err;
1845
- const stderr = withStreams.stderr instanceof Buffer ? withStreams.stderr.toString("utf8").trim() : typeof withStreams.stderr === "string" ? withStreams.stderr.trim() : "";
1846
- const message = withStreams.message ?? String(err);
1847
- return stderr ? `${message} \u2014 stderr: ${stderr}` : message;
1848
- }
1849
- function dumpPostgresIfPresent(exec2 = execSync2) {
1850
- if (!hasPostgresDep()) return Promise.resolve({ dumped: false });
1851
- const outputPath = path.join(tmpdir(), `conveyor-pgdump-${randomUUID()}.sql`);
1852
- try {
1853
- exec2(buildPgDumpCommand({ outputPath }), {
1854
- stdio: ["ignore", "pipe", "pipe"],
1855
- timeout: 6e4
1856
- });
1857
- return Promise.resolve({ dumped: true, path: outputPath });
1858
- } catch (err) {
1859
- process.stderr.write(`[conveyor-agent] pg_dump failed: ${execErrorDetail(err)}
1860
- `);
1861
- return Promise.resolve({ dumped: false });
1862
- }
1863
- }
1864
- function applyPgDumpIfConfigured(dumpPath, exec2 = execSync2) {
1865
- if (!hasPostgresDep()) return false;
1866
- try {
1867
- exec2(buildPsqlRestoreCommand({ dumpPath }), {
1868
- stdio: ["ignore", "pipe", "pipe"],
1869
- timeout: 12e4
1870
- });
1871
- process.stderr.write("[conveyor-agent] Restored postgres state from snapshot pg dump\n");
1872
- return true;
1873
- } catch (err) {
1874
- process.stderr.write(`[conveyor-agent] psql dump restore failed: ${execErrorDetail(err)}
1875
- `);
1876
- return false;
1877
- }
1878
- }
1879
-
1880
1564
  // src/runner/work-preservation/restore-precedence.ts
1881
1565
  function selectRestoreSource(probe) {
1882
1566
  if (probe.gcsAvailable) return "gcs";
@@ -1885,25 +1569,27 @@ function selectRestoreSource(probe) {
1885
1569
  }
1886
1570
 
1887
1571
  // src/runner/work-preservation/snapshot-tar.ts
1888
- import { execSync as execSync3 } from "child_process";
1889
- import { createReadStream, createWriteStream, mkdtempSync } from "fs";
1890
- import { copyFile, mkdtemp, rm, stat as stat2, writeFile as writeFile2 } from "fs/promises";
1891
- import { tmpdir as tmpdir2 } from "os";
1892
- import path2 from "path";
1572
+ import { execFile as execFile2 } from "child_process";
1573
+ import { createReadStream, createWriteStream } from "fs";
1574
+ import { mkdtemp, rm, stat as stat2, writeFile } from "fs/promises";
1575
+ import { tmpdir } from "os";
1576
+ import path from "path";
1893
1577
  import { pipeline } from "stream/promises";
1578
+ import { promisify as promisify2 } from "util";
1894
1579
  import { createGzip } from "zlib";
1895
1580
  import * as tar from "tar";
1896
1581
 
1897
1582
  // src/runner/work-preservation/snapshot-artifact.ts
1898
- function planSnapshotFiles(git) {
1899
- const include = git.trackedAndUntracked();
1583
+ function planSnapshotFiles(git2) {
1584
+ const include = git2.trackedAndUntracked();
1900
1585
  const includeSet = new Set(include);
1901
- const deletions = [...new Set(git.deletedSinceHead())].filter((path4) => !includeSet.has(path4));
1586
+ const deletions = [...new Set(git2.deletedSinceHead())].filter((path4) => !includeSet.has(path4));
1902
1587
  return { include, deletions };
1903
1588
  }
1904
1589
 
1905
1590
  // src/runner/work-preservation/snapshot-tar.ts
1906
1591
  var SNAPSHOT_MANIFEST_NAME = ".conveyor-snapshot-manifest.json";
1592
+ var LEGACY_PG_DUMP_FILENAME = ".conveyor-pgdump.sql";
1907
1593
  var STATUS_MAX_BUFFER = 64 * 1024 * 1024;
1908
1594
  function parseStatusPorcelainZ(raw) {
1909
1595
  const includes = [];
@@ -1926,60 +1612,55 @@ function parseStatusPorcelainZ(raw) {
1926
1612
  }
1927
1613
  return { includes, deletions };
1928
1614
  }
1929
- function realGitSurface(cwd) {
1930
- let cached = null;
1931
- const load = () => {
1932
- if (!cached) {
1933
- const out = execSync3("git status --porcelain=v1 -z -uall", {
1934
- cwd,
1935
- stdio: ["ignore", "pipe", "ignore"],
1936
- maxBuffer: STATUS_MAX_BUFFER
1937
- }).toString("utf8");
1938
- cached = parseStatusPorcelainZ(out);
1939
- }
1940
- return cached;
1941
- };
1615
+ var execFileAsync2 = promisify2(execFile2);
1616
+ var GIT_TIMEOUT_MS2 = 12e4;
1617
+ async function realGitSurface(cwd) {
1618
+ const { stdout } = await execFileAsync2("git", ["status", "--porcelain=v1", "-z", "-uall"], {
1619
+ cwd,
1620
+ timeout: GIT_TIMEOUT_MS2,
1621
+ maxBuffer: STATUS_MAX_BUFFER
1622
+ });
1623
+ const cached = parseStatusPorcelainZ(stdout.toString());
1942
1624
  return {
1943
- trackedAndUntracked: () => load().includes,
1944
- deletedSinceHead: () => load().deletions
1625
+ trackedAndUntracked: () => cached.includes,
1626
+ deletedSinceHead: () => cached.deletions
1945
1627
  };
1946
1628
  }
1947
- function gitHead(cwd) {
1629
+ async function gitHead(cwd) {
1948
1630
  try {
1949
- return execSync3("git rev-parse HEAD", { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || null;
1631
+ const { stdout } = await execFileAsync2("git", ["rev-parse", "HEAD"], {
1632
+ cwd,
1633
+ timeout: GIT_TIMEOUT_MS2
1634
+ });
1635
+ return stdout.toString().trim() || null;
1950
1636
  } catch {
1951
1637
  return null;
1952
1638
  }
1953
1639
  }
1954
- async function buildSnapshotTar(cwd, opts) {
1955
- const staging = await mkdtemp(path2.join(tmpdir2(), "conveyor-snapshot-"));
1640
+ async function buildSnapshotTar(cwd) {
1641
+ const staging = await mkdtemp(path.join(tmpdir(), "conveyor-snapshot-"));
1956
1642
  const cleanup = async () => {
1957
1643
  await rm(staging, { recursive: true, force: true }).catch(() => {
1958
1644
  });
1959
1645
  };
1960
1646
  try {
1961
- const plan = planSnapshotFiles(realGitSurface(cwd));
1962
- const head = gitHead(cwd);
1647
+ const plan = planSnapshotFiles(await realGitSurface(cwd));
1648
+ const head = await gitHead(cwd);
1963
1649
  if (!head) throw new Error("cannot snapshot a repo without a resolvable HEAD");
1964
1650
  const capturedAt = Date.now();
1965
1651
  const manifest = {
1966
1652
  head,
1967
1653
  deletions: plan.deletions,
1968
- capturedAt,
1969
- pgDump: Boolean(opts?.pgDumpPath)
1654
+ capturedAt
1970
1655
  };
1971
- await writeFile2(path2.join(staging, SNAPSHOT_MANIFEST_NAME), JSON.stringify(manifest), "utf8");
1656
+ await writeFile(path.join(staging, SNAPSHOT_MANIFEST_NAME), JSON.stringify(manifest), "utf8");
1972
1657
  const stagedEntries = [SNAPSHOT_MANIFEST_NAME];
1973
- if (opts?.pgDumpPath) {
1974
- await copyFile(opts.pgDumpPath, path2.join(staging, PG_DUMP_FILENAME));
1975
- stagedEntries.push(PG_DUMP_FILENAME);
1976
- }
1977
- const rawTarPath = path2.join(staging, "snapshot.tar");
1658
+ const rawTarPath = path.join(staging, "snapshot.tar");
1978
1659
  await tar.create({ cwd: staging, file: rawTarPath, portable: true }, stagedEntries);
1979
1660
  if (plan.include.length > 0) {
1980
1661
  await tar.replace({ file: rawTarPath, cwd, portable: true }, plan.include);
1981
1662
  }
1982
- const tarPath = path2.join(staging, "snapshot.tar.gz");
1663
+ const tarPath = path.join(staging, "snapshot.tar.gz");
1983
1664
  await pipeline(createReadStream(rawTarPath), createGzip(), createWriteStream(tarPath));
1984
1665
  await rm(rawTarPath, { force: true });
1985
1666
  const { size } = await stat2(tarPath);
@@ -1997,20 +1678,13 @@ async function buildSnapshotTar(cwd, opts) {
1997
1678
  }
1998
1679
  }
1999
1680
  function isWithinWorkspace(cwd, rel) {
2000
- if (!rel || path2.isAbsolute(rel)) return false;
2001
- const root = path2.resolve(cwd);
2002
- const abs = path2.resolve(root, rel);
2003
- return abs !== root && abs.startsWith(root + path2.sep);
1681
+ if (!rel || path.isAbsolute(rel)) return false;
1682
+ const root = path.resolve(cwd);
1683
+ const abs = path.resolve(root, rel);
1684
+ return abs !== root && abs.startsWith(root + path.sep);
2004
1685
  }
2005
1686
  async function readSnapshotArchive(tarPath) {
2006
1687
  let manifestRaw = null;
2007
- let pgDumpDir = null;
2008
- let pgDumpPath;
2009
- let pgDumpWritten = Promise.resolve();
2010
- const cleanup = async () => {
2011
- if (pgDumpDir) await rm(pgDumpDir, { recursive: true, force: true }).catch(() => {
2012
- });
2013
- };
2014
1688
  try {
2015
1689
  await tar.list({
2016
1690
  file: tarPath,
@@ -2021,86 +1695,59 @@ async function readSnapshotArchive(tarPath) {
2021
1695
  entry.on("end", () => {
2022
1696
  manifestRaw = Buffer.concat(chunks);
2023
1697
  });
2024
- } else if (entry.path === PG_DUMP_FILENAME) {
2025
- pgDumpDir = mkdtempSync(path2.join(tmpdir2(), "conveyor-pgdump-restore-"));
2026
- pgDumpPath = path2.join(pgDumpDir, "dump.sql");
2027
- const dest = createWriteStream(pgDumpPath);
2028
- pgDumpWritten = new Promise((resolve, reject) => {
2029
- dest.on("finish", () => resolve());
2030
- dest.on("error", reject);
2031
- });
2032
- entry.pipe(dest);
2033
1698
  }
2034
1699
  }
2035
1700
  });
2036
- await pgDumpWritten;
2037
1701
  } catch {
2038
- await cleanup();
2039
- return null;
2040
- }
2041
- if (!manifestRaw) {
2042
- await cleanup();
2043
1702
  return null;
2044
1703
  }
1704
+ if (!manifestRaw) return null;
2045
1705
  try {
2046
1706
  const parsed = JSON.parse(manifestRaw.toString("utf8"));
2047
- if (typeof parsed.head !== "string" || !parsed.head) {
2048
- await cleanup();
2049
- return null;
2050
- }
1707
+ if (typeof parsed.head !== "string" || !parsed.head) return null;
2051
1708
  return {
2052
- manifest: {
2053
- head: parsed.head,
2054
- deletions: Array.isArray(parsed.deletions) ? parsed.deletions : [],
2055
- capturedAt: typeof parsed.capturedAt === "number" ? parsed.capturedAt : 0,
2056
- pgDump: parsed.pgDump === true
2057
- },
2058
- ...pgDumpPath ? { pgDumpPath } : {},
2059
- cleanup
1709
+ head: parsed.head,
1710
+ deletions: Array.isArray(parsed.deletions) ? parsed.deletions : [],
1711
+ capturedAt: typeof parsed.capturedAt === "number" ? parsed.capturedAt : 0
2060
1712
  };
2061
1713
  } catch {
2062
- await cleanup();
2063
1714
  return null;
2064
1715
  }
2065
1716
  }
2066
- async function extractSnapshotTar(cwd, tarPath, opts) {
2067
- const read = await readSnapshotArchive(tarPath);
2068
- if (!read) return { status: "invalid" };
2069
- try {
2070
- const { manifest, pgDumpPath } = read;
2071
- const currentHead = gitHead(cwd);
2072
- if (manifest.head !== currentHead) {
2073
- return { status: "stale-head", snapshotHead: manifest.head, currentHead };
2074
- }
2075
- let filesExtracted = 0;
2076
- await tar.extract({
2077
- file: tarPath,
2078
- cwd,
2079
- filter: (entryPath) => {
2080
- if (entryPath === SNAPSHOT_MANIFEST_NAME || entryPath === PG_DUMP_FILENAME) return false;
2081
- filesExtracted++;
2082
- return true;
1717
+ async function extractSnapshotTar(cwd, tarPath) {
1718
+ const manifest = await readSnapshotArchive(tarPath);
1719
+ if (!manifest) return { status: "invalid" };
1720
+ const currentHead = await gitHead(cwd);
1721
+ if (manifest.head !== currentHead) {
1722
+ return { status: "stale-head", snapshotHead: manifest.head, currentHead };
1723
+ }
1724
+ let filesExtracted = 0;
1725
+ await tar.extract({
1726
+ file: tarPath,
1727
+ cwd,
1728
+ filter: (entryPath) => {
1729
+ if (entryPath === SNAPSHOT_MANIFEST_NAME || entryPath === LEGACY_PG_DUMP_FILENAME) {
1730
+ return false;
2083
1731
  }
2084
- });
2085
- let deletionsApplied = 0;
2086
- for (const rel of manifest.deletions) {
2087
- if (typeof rel !== "string" || !isWithinWorkspace(cwd, rel)) continue;
2088
- await rm(path2.resolve(cwd, rel), { recursive: true, force: true });
2089
- deletionsApplied++;
2090
- }
2091
- const pgDumpApplied = manifest.pgDump && pgDumpPath ? applyPgDumpIfConfigured(pgDumpPath, opts?.pgExec) : false;
2092
- return { status: "extracted", filesExtracted, deletionsApplied, pgDumpApplied };
2093
- } finally {
2094
- await read.cleanup();
1732
+ filesExtracted++;
1733
+ return true;
1734
+ }
1735
+ });
1736
+ let deletionsApplied = 0;
1737
+ for (const rel of manifest.deletions) {
1738
+ if (typeof rel !== "string" || !isWithinWorkspace(cwd, rel)) continue;
1739
+ await rm(path.resolve(cwd, rel), { recursive: true, force: true });
1740
+ deletionsApplied++;
2095
1741
  }
1742
+ return { status: "extracted", filesExtracted, deletionsApplied };
2096
1743
  }
2097
1744
 
2098
1745
  // src/runner/work-preservation/snapshot-transfer.ts
2099
- import { randomUUID as randomUUID2 } from "crypto";
1746
+ import { randomUUID } from "crypto";
2100
1747
  import { createReadStream as createReadStream2, createWriteStream as createWriteStream2 } from "fs";
2101
1748
  import { rm as rm2 } from "fs/promises";
2102
- import { tmpdir as tmpdir3 } from "os";
2103
- import path3 from "path";
1749
+ import { tmpdir as tmpdir2 } from "os";
1750
+ import path2 from "path";
2104
1751
  import { Readable } from "stream";
2105
1752
  import { pipeline as pipeline2 } from "stream/promises";
2106
1753
  var SNAPSHOT_HTTP_TIMEOUT_MS = 6e4;
@@ -2125,7 +1772,7 @@ async function putSnapshotToGcs(url, tarResult) {
2125
1772
  }
2126
1773
  }
2127
1774
  async function downloadSnapshotToTemp(url) {
2128
- const tmpTar = path3.join(tmpdir3(), `conveyor-snapshot-restore-${randomUUID2()}.tar.gz`);
1775
+ const tmpTar = path2.join(tmpdir2(), `conveyor-snapshot-restore-${randomUUID()}.tar.gz`);
2129
1776
  try {
2130
1777
  const res = await fetch(url, {
2131
1778
  method: "GET",
@@ -2152,13 +1799,12 @@ var EMPTY_GCS_RESULT = {
2152
1799
  uploaded: false,
2153
1800
  fileCount: 0,
2154
1801
  deletionCount: 0,
2155
- sizeBytes: 0,
2156
- pgDumped: false
1802
+ sizeBytes: 0
2157
1803
  };
2158
1804
  async function doUploadSnapshotToGcs(cwd, uploadUrl, opts) {
2159
1805
  let tarResult;
2160
1806
  try {
2161
- tarResult = await buildSnapshotTar(cwd, { pgDumpPath: opts?.pgDumpPath });
1807
+ tarResult = await buildSnapshotTar(cwd);
2162
1808
  } catch {
2163
1809
  return EMPTY_GCS_RESULT;
2164
1810
  }
@@ -2168,8 +1814,7 @@ async function doUploadSnapshotToGcs(cwd, uploadUrl, opts) {
2168
1814
  uploaded,
2169
1815
  fileCount: tarResult.fileCount,
2170
1816
  deletionCount: tarResult.deletionCount,
2171
- sizeBytes: tarResult.sizeBytes,
2172
- pgDumped: Boolean(opts?.pgDumpPath)
1817
+ sizeBytes: tarResult.sizeBytes
2173
1818
  };
2174
1819
  } finally {
2175
1820
  await tarResult.cleanup();
@@ -2191,7 +1836,6 @@ async function uploadSnapshotToGcs(cwd, uploadUrl, opts) {
2191
1836
  }
2192
1837
  async function captureSnapshot(ctx) {
2193
1838
  const gcs = await uploadSnapshotToGcs(ctx.cwd, ctx.snapshotUploadUrl, {
2194
- pgDumpPath: ctx.pgDumpPath,
2195
1839
  forceUpload: ctx.forceUpload
2196
1840
  });
2197
1841
  const wipResult = await flushPendingChanges(ctx.cwd, {
@@ -2203,8 +1847,7 @@ async function captureSnapshot(ctx) {
2203
1847
  deletionCount: gcs.deletionCount,
2204
1848
  sizeBytes: gcs.sizeBytes,
2205
1849
  gcsUploaded: gcs.uploaded,
2206
- wipPushed: wipResult.committed || wipResult.pushed,
2207
- pgDumped: gcs.pgDumped
1850
+ wipPushed: wipResult.committed || wipResult.pushed
2208
1851
  };
2209
1852
  }
2210
1853
  function startPeriodic(intervalMs, ctx) {
@@ -2229,21 +1872,13 @@ async function finalizeForSleep(ctx) {
2229
1872
  stop();
2230
1873
  if (inFlightCapture) await inFlightCapture.catch(() => {
2231
1874
  });
2232
- const dump = await dumpPostgresIfPresent();
2233
- try {
2234
- const artifact = await captureSnapshot({
2235
- ...ctx,
2236
- pgDumpPath: dump.path,
2237
- wipMessage: ctx.wipMessage ?? "WIP: WorkPreservation sleep finalize snapshot",
2238
- // Always stamp snapshotAt on sleep — even an empty workspace must confirm
2239
- // the finalize so teardown doesn't wait out the reconciler's cap.
2240
- forceUpload: true
2241
- });
2242
- return { ...artifact, pgDumped: dump.dumped };
2243
- } finally {
2244
- if (dump.path) await rm3(dump.path, { force: true }).catch(() => {
2245
- });
2246
- }
1875
+ return captureSnapshot({
1876
+ ...ctx,
1877
+ wipMessage: ctx.wipMessage ?? "WIP: WorkPreservation sleep finalize snapshot",
1878
+ // Always stamp snapshotAt on sleep — even an empty workspace must confirm
1879
+ // the finalize so teardown doesn't wait out the reconciler's cap.
1880
+ forceUpload: true
1881
+ });
2247
1882
  }
2248
1883
  async function restoreOnBoot(bundle, cwd) {
2249
1884
  let gcsAvailable = false;
@@ -2265,7 +1900,7 @@ async function restoreOnBoot(bundle, cwd) {
2265
1900
  }
2266
1901
  }
2267
1902
  }
2268
- const wipRestoreResult = gcsAvailable ? "none" : restoreWipSnapshot(cwd, bundle.gitPlan.branch);
1903
+ const wipRestoreResult = gcsAvailable ? "none" : await restoreWipSnapshot(cwd, bundle.gitPlan.branch);
2269
1904
  const source = selectRestoreSource({ gcsAvailable, wipRestoreResult });
2270
1905
  return source === "gcs" ? { source, fileCount: gcsFileCount } : { source };
2271
1906
  }
@@ -2282,7 +1917,9 @@ var AgentHeartbeatSchema = z.object({
2282
1917
  sessionId: z.string().optional(),
2283
1918
  timestamp: z.string(),
2284
1919
  status: z.enum(["active", "idle", "building"]),
2285
- currentAction: z.string().optional()
1920
+ currentAction: z.string().optional(),
1921
+ /** Sender-observed main event-loop lag (ms) — see AgentHeartbeat.loopLagMs. */
1922
+ loopLagMs: z.number().nonnegative().optional()
2286
1923
  });
2287
1924
  var CreatePRInputSchema = z.object({
2288
1925
  title: z.string().min(1),
@@ -2564,6 +2201,10 @@ var ListActivePtySessionsRequestSchema = z.object({
2564
2201
  var SendSoftStopRequestSchema = z.object({
2565
2202
  taskId: z.string()
2566
2203
  });
2204
+ var StopTaskSessionRequestSchema = z.object({
2205
+ taskId: z.string(),
2206
+ sessionId: z.string()
2207
+ });
2567
2208
  var FlushTaskQueueRequestSchema = z.object({
2568
2209
  taskId: z.string(),
2569
2210
  softStop: z.boolean().optional()
@@ -2612,6 +2253,11 @@ var EmitAgentEventRequestSchema = z.object({
2612
2253
  var RefreshGithubTokenRequestSchema = z.object({
2613
2254
  sessionId: z.string()
2614
2255
  });
2256
+ var ReportReviewSpawnFailureRequestSchema = z.object({
2257
+ sessionId: z.string(),
2258
+ reviewSessionId: z.string(),
2259
+ error: z.string().max(2e3).optional()
2260
+ });
2615
2261
  var RefreshGithubTokenResponseSchema = z.object({
2616
2262
  token: z.string()
2617
2263
  });
@@ -2638,6 +2284,29 @@ var PtyResizeRequestSchema = z.object({
2638
2284
  var PtyAttachRequestSchema = z.object({
2639
2285
  sessionId: z.string()
2640
2286
  });
2287
+ var PtyChatEventPayloadSchema = z.discriminatedUnion("kind", [
2288
+ z.object({
2289
+ kind: z.literal("init"),
2290
+ model: z.string().max(200),
2291
+ claudeSessionId: z.string().max(100).optional()
2292
+ }),
2293
+ z.object({ kind: z.literal("user_text"), text: z.string().max(16384) }),
2294
+ z.object({ kind: z.literal("assistant_text"), text: z.string().max(16384) }),
2295
+ z.object({
2296
+ kind: z.literal("tool_use"),
2297
+ name: z.string().max(200),
2298
+ // Compact preview: JSON.stringify(input) truncated agent-side.
2299
+ input: z.string().max(2e3)
2300
+ }),
2301
+ z.object({ kind: z.literal("turn_end") })
2302
+ ]);
2303
+ var PtyChatEventRequestSchema = z.object({
2304
+ sessionId: z.string(),
2305
+ event: PtyChatEventPayloadSchema
2306
+ });
2307
+ var PtyChatAttachRequestSchema = z.object({
2308
+ sessionId: z.string()
2309
+ });
2641
2310
  var CreatePRResponseSchema = z.object({
2642
2311
  prNumber: z.number().int().positive(),
2643
2312
  prUrl: z.string().url()
@@ -2778,6 +2447,13 @@ var ListMyLiveSessionsRequestSchema = z2.object({
2778
2447
  var StartAdhocSessionRequestSchema = z2.object({
2779
2448
  projectId: z2.string(),
2780
2449
  label: z2.string().max(200).optional(),
2450
+ /**
2451
+ * Session role. Constrained: other task-less modes fall through to the pm
2452
+ * runner in the pod entrypoint, and "review" would crash without a task.
2453
+ */
2454
+ mode: z2.enum(["adhoc", "pm"]).optional(),
2455
+ /** Base branch to check out (defaults to the project's dev branch). */
2456
+ branch: z2.string().max(300).optional(),
2781
2457
  requestingUserId: z2.string().optional()
2782
2458
  });
2783
2459
  var StopAdhocSessionRequestSchema = z2.object({
@@ -3302,8 +2978,8 @@ var ClaudeCodeHarness = class {
3302
2978
 
3303
2979
  // src/harness/pty/session.ts
3304
2980
  import { mkdtemp as mkdtemp2, mkdir as mkdir2, rm as rm4 } from "fs/promises";
3305
- import { tmpdir as tmpdir4 } from "os";
3306
- import { join as join4, dirname } from "path";
2981
+ import { tmpdir as tmpdir3 } from "os";
2982
+ import { join as join3, dirname } from "path";
3307
2983
 
3308
2984
  // src/harness/pty/event-queue.ts
3309
2985
  var AsyncEventQueue = class {
@@ -3584,12 +3260,14 @@ function mapTranscriptRecord(raw) {
3584
3260
  // src/harness/pty/jsonl-tailer.ts
3585
3261
  var POLL_INTERVAL_MS = 25;
3586
3262
  var JsonlTailer = class {
3587
- constructor(path4, onEvent) {
3263
+ constructor(path4, onEvent, onRawRecord) {
3588
3264
  this.path = path4;
3589
3265
  this.onEvent = onEvent;
3266
+ this.onRawRecord = onRawRecord;
3590
3267
  }
3591
3268
  path;
3592
3269
  onEvent;
3270
+ onRawRecord;
3593
3271
  offset = 0;
3594
3272
  buffer = "";
3595
3273
  timer = null;
@@ -3661,11 +3339,109 @@ var JsonlTailer = class {
3661
3339
  } catch {
3662
3340
  return;
3663
3341
  }
3342
+ this.onRawRecord?.(parsed);
3664
3343
  const event = mapTranscriptRecord(parsed);
3665
3344
  if (event) this.onEvent(event);
3666
3345
  }
3667
3346
  };
3668
3347
 
3348
+ // src/harness/pty/chat-record-mapper.ts
3349
+ var TEXT_MAX = 16e3;
3350
+ var TOOL_INPUT_MAX = 1900;
3351
+ function isRecord2(value) {
3352
+ return typeof value === "object" && value !== null;
3353
+ }
3354
+ function isUnknownArray2(value) {
3355
+ return Array.isArray(value);
3356
+ }
3357
+ function stringField2(record, ...keys) {
3358
+ for (const key of keys) {
3359
+ const value = record[key];
3360
+ if (typeof value === "string") return value;
3361
+ }
3362
+ return void 0;
3363
+ }
3364
+ function truncate(text, max) {
3365
+ return text.length > max ? `${text.slice(0, max)}\u2026` : text;
3366
+ }
3367
+ function isCommandWrapperText(text) {
3368
+ const trimmed = text.trimStart();
3369
+ return trimmed.startsWith("<command-name>") || trimmed.startsWith("<local-command-");
3370
+ }
3371
+ function mapSystem2(record) {
3372
+ if (record.subtype === "init") {
3373
+ const event = {
3374
+ kind: "init",
3375
+ model: stringField2(record, "model") ?? ""
3376
+ };
3377
+ const sessionId = stringField2(record, "session_id", "sessionId");
3378
+ if (sessionId !== void 0) event.claudeSessionId = sessionId;
3379
+ return [event];
3380
+ }
3381
+ if (record.subtype === "turn_duration") return [{ kind: "turn_end" }];
3382
+ return [];
3383
+ }
3384
+ function mapAssistant2(record) {
3385
+ const message = record.message;
3386
+ if (!isRecord2(message)) return [];
3387
+ const content = isUnknownArray2(message.content) ? message.content : [];
3388
+ const events = [];
3389
+ for (const raw of content) {
3390
+ if (!isRecord2(raw)) continue;
3391
+ if (raw.type === "text") {
3392
+ const text = stringField2(raw, "text");
3393
+ if (text && text.length > 0) {
3394
+ events.push({ kind: "assistant_text", text: truncate(text, TEXT_MAX) });
3395
+ }
3396
+ } else if (raw.type === "tool_use") {
3397
+ const name = stringField2(raw, "name");
3398
+ if (name) {
3399
+ const input = "input" in raw ? raw.input : void 0;
3400
+ events.push({
3401
+ kind: "tool_use",
3402
+ name: truncate(name, 200),
3403
+ input: JSON.stringify(input ?? {}).slice(0, TOOL_INPUT_MAX)
3404
+ });
3405
+ }
3406
+ }
3407
+ }
3408
+ return events;
3409
+ }
3410
+ function mapUser(record) {
3411
+ const message = record.message;
3412
+ if (!isRecord2(message)) return [];
3413
+ const content = message.content;
3414
+ let text;
3415
+ if (typeof content === "string") {
3416
+ text = content;
3417
+ } else if (isUnknownArray2(content)) {
3418
+ const hasToolResult = content.some((b) => isRecord2(b) && b.type === "tool_result");
3419
+ if (hasToolResult) return [];
3420
+ text = content.filter((b) => isRecord2(b) && b.type === "text").map((b) => typeof b.text === "string" ? b.text : "").filter((t) => t.length > 0).join("\n");
3421
+ } else {
3422
+ return [];
3423
+ }
3424
+ const trimmed = text.trim();
3425
+ if (trimmed.length === 0 || isCommandWrapperText(trimmed)) return [];
3426
+ return [{ kind: "user_text", text: truncate(trimmed, TEXT_MAX) }];
3427
+ }
3428
+ function mapChatRecords(raw) {
3429
+ if (!isRecord2(raw)) return [];
3430
+ if (raw.isSidechain === true || raw.isMeta === true) return [];
3431
+ switch (raw.type) {
3432
+ case "system":
3433
+ return mapSystem2(raw);
3434
+ case "assistant":
3435
+ return mapAssistant2(raw);
3436
+ case "user":
3437
+ return mapUser(raw);
3438
+ case "result":
3439
+ return [{ kind: "turn_end" }];
3440
+ default:
3441
+ return [];
3442
+ }
3443
+ }
3444
+
3669
3445
  // src/harness/pty/spawn-args.ts
3670
3446
  function resolveClaudeBinary() {
3671
3447
  return process.env.CONVEYOR_CLAUDE_BIN ?? "claude";
@@ -3714,26 +3490,268 @@ function cleanTerminalOutput(raw, maxChars = 1200) {
3714
3490
  else if (code < 32 || code === 127) continue;
3715
3491
  else out += ch;
3716
3492
  }
3717
- const lines = out.split("\n").map((line) => line.trimEnd()).filter((line) => line.trim().length > 0);
3718
- const text = lines.join("\n").trim();
3719
- return text.length > maxChars ? `\u2026${text.slice(-maxChars)}` : text;
3720
- }
3721
- function isMissingBinaryFailure(tail) {
3722
- return /execvp\(\d+\) failed|no such file or directory|command not found/i.test(tail);
3493
+ const lines = out.split("\n").map((line) => line.trimEnd()).filter((line) => line.trim().length > 0);
3494
+ const text = lines.join("\n").trim();
3495
+ return text.length > maxChars ? `\u2026${text.slice(-maxChars)}` : text;
3496
+ }
3497
+ function isMissingBinaryFailure(tail) {
3498
+ return /execvp\(\d+\) failed|no such file or directory|command not found/i.test(tail);
3499
+ }
3500
+ function buildExitErrors(exitCode, rawOutput, binary) {
3501
+ const errors = [`claude exited (code ${exitCode}) without a result`];
3502
+ const tail = cleanTerminalOutput(rawOutput);
3503
+ if (isMissingBinaryFailure(tail)) {
3504
+ errors.push(
3505
+ `The \`${binary}\` CLI could not be started \u2014 it is not installed or not on PATH. Install the Claude Code CLI in this environment (npm i -g @anthropic-ai/claude-code) or set CONVEYOR_CLAUDE_BIN to its absolute path.`
3506
+ );
3507
+ }
3508
+ if (tail) {
3509
+ errors.push(`Last terminal output before exit:
3510
+ ${tail}`);
3511
+ }
3512
+ return errors;
3513
+ }
3514
+
3515
+ // src/harness/pty/settings.ts
3516
+ import { mkdir, writeFile as writeFile2, chmod } from "fs/promises";
3517
+ import { homedir } from "os";
3518
+ import { join } from "path";
3519
+ function claudeConfigHome() {
3520
+ return process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
3521
+ }
3522
+ function projectSlug(cwd) {
3523
+ return cwd.replace(/\//g, "-");
3524
+ }
3525
+ function sessionTranscriptPath(cwd, sessionId) {
3526
+ return join(claudeConfigHome(), "projects", projectSlug(cwd), `${sessionId}.jsonl`);
3527
+ }
3528
+ var ALLOW_RULES = [
3529
+ "Bash",
3530
+ "BashOutput",
3531
+ "Edit",
3532
+ "Write",
3533
+ "Read",
3534
+ "Glob",
3535
+ "Grep",
3536
+ "WebFetch",
3537
+ "WebSearch",
3538
+ "NotebookEdit",
3539
+ "Task",
3540
+ "TodoWrite",
3541
+ "ToolSearch",
3542
+ "KillShell",
3543
+ "SlashCommand",
3544
+ "Skill",
3545
+ "mcp__conveyor__*"
3546
+ ];
3547
+ var HOOK_HELPER_SOURCE = `"use strict";
3548
+ const net = require("node:net");
3549
+ const crypto = require("node:crypto");
3550
+
3551
+ const PRE_TOOL_USE_TIMEOUT_MS = 110000;
3552
+ const ASK_USER_QUESTION_TIMEOUT_MS = 5000;
3553
+
3554
+ let raw = "";
3555
+ process.stdin.setEncoding("utf8");
3556
+ process.stdin.on("data", (chunk) => {
3557
+ raw += chunk;
3558
+ });
3559
+ process.stdin.on("end", () => {
3560
+ let payload = {};
3561
+ try {
3562
+ const parsed = JSON.parse(raw);
3563
+ if (parsed && typeof parsed === "object") payload = parsed;
3564
+ } catch {
3565
+ payload = {};
3566
+ }
3567
+ if (payload.hook_event_name === "PreToolUse") {
3568
+ preToolUse(payload);
3569
+ } else {
3570
+ relayProgress(typeof payload.tool_name === "string" ? payload.tool_name : "");
3571
+ }
3572
+ });
3573
+
3574
+ function relayProgress(toolName) {
3575
+ const socketPath = process.env.CONVEYOR_HOOK_SOCKET;
3576
+ let done = false;
3577
+ const finish = () => {
3578
+ if (done) return;
3579
+ done = true;
3580
+ process.stdout.write(JSON.stringify({ continue: true }));
3581
+ process.exit(0);
3582
+ };
3583
+ const fallback = setTimeout(finish, 250);
3584
+ if (!socketPath) {
3585
+ clearTimeout(fallback);
3586
+ finish();
3587
+ return;
3588
+ }
3589
+ const client = net.connect(socketPath, () => {
3590
+ // PostToolUse does not supply tool duration, so elapsed_time_seconds is
3591
+ // omitted rather than reported as a misleading 0 (the field is optional).
3592
+ const line = JSON.stringify({ tool_name: toolName }) + "\\n";
3593
+ client.write(line, () => {
3594
+ client.end();
3595
+ });
3596
+ });
3597
+ client.on("close", () => {
3598
+ clearTimeout(fallback);
3599
+ finish();
3600
+ });
3601
+ client.on("error", () => {
3602
+ clearTimeout(fallback);
3603
+ finish();
3604
+ });
3723
3605
  }
3724
- function buildExitErrors(exitCode, rawOutput, binary) {
3725
- const errors = [`claude exited (code ${exitCode}) without a result`];
3726
- const tail = cleanTerminalOutput(rawOutput);
3727
- if (isMissingBinaryFailure(tail)) {
3728
- errors.push(
3729
- `The \`${binary}\` CLI could not be started \u2014 it is not installed or not on PATH. Install the Claude Code CLI in this environment (npm i -g @anthropic-ai/claude-code) or set CONVEYOR_CLAUDE_BIN to its absolute path.`
3606
+
3607
+ function preToolUse(payload) {
3608
+ const socketPath = process.env.CONVEYOR_HOOK_SOCKET;
3609
+ let done = false;
3610
+ const respond = (decision, reason) => {
3611
+ if (done) return;
3612
+ done = true;
3613
+ process.stdout.write(
3614
+ JSON.stringify({
3615
+ hookSpecificOutput: {
3616
+ hookEventName: "PreToolUse",
3617
+ permissionDecision: decision,
3618
+ ...(reason ? { permissionDecisionReason: reason } : {}),
3619
+ },
3620
+ }),
3730
3621
  );
3622
+ process.exit(0);
3623
+ };
3624
+ // MCP tools are auto-allowed locally \u2014 no socket round trip, no fail-mode.
3625
+ // Plan mode prompts for them despite permissions.allow (the CLI can't
3626
+ // classify MCP tools as read-only), and the pod is the sandbox.
3627
+ if (typeof payload.tool_name === "string" && payload.tool_name.startsWith("mcp__")) {
3628
+ respond("allow");
3629
+ return;
3731
3630
  }
3732
- if (tail) {
3733
- errors.push(`Last terminal output before exit:
3734
- ${tail}`);
3631
+ // AskUserQuestion is observe-only (status reporting) \u2014 fail OPEN so a
3632
+ // missing/slow socket never denies the questionnaire. Everything else
3633
+ // (ExitPlanMode) is a Conveyor gate \u2014 fail CLOSED.
3634
+ const failOpen = payload.tool_name === "AskUserQuestion";
3635
+ const respondUnavailable = () =>
3636
+ failOpen
3637
+ ? respond("allow")
3638
+ : respond(
3639
+ "deny",
3640
+ "Conveyor validation is unavailable right now. Wait a moment and call the tool again.",
3641
+ );
3642
+ if (!socketPath) {
3643
+ respondUnavailable();
3644
+ return;
3735
3645
  }
3736
- return errors;
3646
+ const id = crypto.randomBytes(8).toString("hex");
3647
+ const timer = setTimeout(
3648
+ respondUnavailable,
3649
+ failOpen ? ASK_USER_QUESTION_TIMEOUT_MS : PRE_TOOL_USE_TIMEOUT_MS,
3650
+ );
3651
+ const client = net.connect(socketPath, () => {
3652
+ client.write(
3653
+ JSON.stringify({
3654
+ kind: "pre_tool_use",
3655
+ id,
3656
+ tool_name: typeof payload.tool_name === "string" ? payload.tool_name : "",
3657
+ tool_input:
3658
+ payload.tool_input && typeof payload.tool_input === "object" ? payload.tool_input : {},
3659
+ }) + "\\n",
3660
+ );
3661
+ });
3662
+ let buffer = "";
3663
+ client.on("data", (chunk) => {
3664
+ buffer += chunk.toString("utf8");
3665
+ let index = buffer.indexOf("\\n");
3666
+ while (index >= 0) {
3667
+ const line = buffer.slice(0, index);
3668
+ buffer = buffer.slice(index + 1);
3669
+ try {
3670
+ const verdict = JSON.parse(line);
3671
+ if (verdict && verdict.id === id) {
3672
+ clearTimeout(timer);
3673
+ client.end();
3674
+ respond(verdict.decision === "allow" ? "allow" : "deny", verdict.reason);
3675
+ return;
3676
+ }
3677
+ } catch {
3678
+ /* keep scanning */
3679
+ }
3680
+ index = buffer.indexOf("\\n");
3681
+ }
3682
+ });
3683
+ client.on("error", () => {
3684
+ clearTimeout(timer);
3685
+ respondUnavailable();
3686
+ });
3687
+ client.on("close", () => {
3688
+ clearTimeout(timer);
3689
+ respondUnavailable();
3690
+ });
3691
+ }
3692
+ `;
3693
+ async function writeHookSettings(dir) {
3694
+ const helperPath = join(dir, "hook-helper.cjs");
3695
+ const settingsPath = join(dir, "settings.json");
3696
+ await mkdir(dir, { recursive: true });
3697
+ await writeFile2(helperPath, HOOK_HELPER_SOURCE, "utf8");
3698
+ await chmod(helperPath, 493);
3699
+ const settings = {
3700
+ // Pre-accept Claude Code's "Bypass Permissions mode" disclaimer. Build-capable
3701
+ // spawns pass `--dangerously-skip-permissions`; on a real PTY the CLI otherwise
3702
+ // parks on the interactive "Yes, I accept / No, exit" dialog whose default focus
3703
+ // is "No, exit" — so it cannot be auto-dismissed by an Enter nudge and the run
3704
+ // stalls until the auto-nudge watchdog shuts it down. The CLI reads this key
3705
+ // from the `--settings` (flagSettings) layer via its bypass-mode gate, so
3706
+ // setting it here suppresses the dialog deterministically on every spawn —
3707
+ // independent of the `bypassPermissionsModeAccepted` seed in credentials.ts,
3708
+ // which the CLI's one-time migration consumes and which is subject to
3709
+ // persistence races on the codespace user-home.
3710
+ skipDangerousModePermissionPrompt: true,
3711
+ // Auto-approve tool calls so the interactive (PTY) agent never stops to
3712
+ // prompt the viewer. This only suppresses per-tool approval prompts — it
3713
+ // does NOT relax the planning gate: in discovery/plan mode the spawn passes
3714
+ // `--permission-mode plan`, which keeps the agent read-only (no edits/commits
3715
+ // until it exits plan mode) regardless of this allow-list. In build mode the
3716
+ // spawn already bypasses prompts via `--dangerously-skip-permissions`.
3717
+ // NOTE: a bare "*" rule is INVALID (the CLI rejects it and parks the TUI on
3718
+ // a Settings Warning dialog at boot) — hence the explicit ALLOW_RULES list.
3719
+ permissions: {
3720
+ allow: ALLOW_RULES
3721
+ },
3722
+ hooks: {
3723
+ PreToolUse: [
3724
+ {
3725
+ matcher: "ExitPlanMode",
3726
+ hooks: [{ type: "command", command: `node ${JSON.stringify(helperPath)}`, timeout: 120 }]
3727
+ },
3728
+ {
3729
+ // Observe-only: lets the session report waiting_for_input the moment
3730
+ // the planning questionnaire renders. The helper fails open for this
3731
+ // tool, so the questionnaire is never blocked by Conveyor.
3732
+ matcher: "AskUserQuestion",
3733
+ hooks: [{ type: "command", command: `node ${JSON.stringify(helperPath)}`, timeout: 30 }]
3734
+ },
3735
+ {
3736
+ // Plan-permission spawns prompt for MCP tools even when they're in
3737
+ // permissions.allow — the CLI can't classify them as read-only. A
3738
+ // hook allow bypasses the permission engine in every mode. Loose by
3739
+ // design: the pod is the sandbox, and planning agents must call
3740
+ // update_task etc. The helper answers locally (no socket).
3741
+ matcher: "mcp__.*",
3742
+ hooks: [{ type: "command", command: `node ${JSON.stringify(helperPath)}`, timeout: 30 }]
3743
+ }
3744
+ ],
3745
+ PostToolUse: [
3746
+ {
3747
+ matcher: "*",
3748
+ hooks: [{ type: "command", command: `node ${JSON.stringify(helperPath)}` }]
3749
+ }
3750
+ ]
3751
+ }
3752
+ };
3753
+ await writeFile2(settingsPath, JSON.stringify(settings, null, 2), "utf8");
3754
+ return { settingsPath, helperPath };
3737
3755
  }
3738
3756
 
3739
3757
  // src/harness/pty/output-coalescer.ts
@@ -3791,7 +3809,7 @@ var PtyOutputCoalescer = class {
3791
3809
  // src/harness/pty/tool-server.ts
3792
3810
  import { createServer as createServer2 } from "http";
3793
3811
  import { writeFile as writeFile3 } from "fs/promises";
3794
- import { join as join3 } from "path";
3812
+ import { join as join2 } from "path";
3795
3813
  import { randomBytes } from "crypto";
3796
3814
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3797
3815
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
@@ -3959,7 +3977,7 @@ async function startToolServers(mcpServers, tempDir) {
3959
3977
  config[name] = { type: "http", url, headers: { Authorization: `Bearer ${token}` } };
3960
3978
  }
3961
3979
  if (Object.keys(config).length === 0) return { servers, mcpConfigPath: null };
3962
- const mcpConfigPath = join3(tempDir, "mcp-config.json");
3980
+ const mcpConfigPath = join2(tempDir, "mcp-config.json");
3963
3981
  await writeFile3(mcpConfigPath, JSON.stringify({ mcpServers: config }, null, 2), "utf8");
3964
3982
  return { servers, mcpConfigPath };
3965
3983
  }
@@ -3978,6 +3996,18 @@ var PLAN_DIALOG_INTERVAL_MS = 1500;
3978
3996
  var PLAN_DIALOG_SLOW_INTERVAL_MS = 5e3;
3979
3997
  var PLAN_DIALOG_FAST_WINDOW_MS = 1e4;
3980
3998
  var PLAN_DIALOG_WINDOW_MS = 9e4;
3999
+ function resolveSubmitNudgeTiming() {
4000
+ const envMs = (name, fallback) => {
4001
+ const raw = Number(process.env[name]);
4002
+ return Number.isFinite(raw) && raw > 0 ? raw : fallback;
4003
+ };
4004
+ return {
4005
+ intervalMs: envMs("CONVEYOR_PTY_NUDGE_INTERVAL_MS", SUBMIT_NUDGE_INTERVAL_MS),
4006
+ slowIntervalMs: envMs("CONVEYOR_PTY_NUDGE_SLOW_INTERVAL_MS", SUBMIT_NUDGE_SLOW_INTERVAL_MS),
4007
+ maxPresses: SUBMIT_NUDGE_MAX_PRESSES,
4008
+ windowMs: envMs("CONVEYOR_PTY_NUDGE_WINDOW_MS", SUBMIT_NUDGE_WINDOW_MS)
4009
+ };
4010
+ }
3981
4011
  function turnOptionsFrom(options) {
3982
4012
  return {
3983
4013
  canUseTool: options.canUseTool,
@@ -3986,14 +4016,14 @@ function turnOptionsFrom(options) {
3986
4016
  abortController: options.abortController
3987
4017
  };
3988
4018
  }
3989
- function isRecord2(value) {
4019
+ function isRecord3(value) {
3990
4020
  return typeof value === "object" && value !== null;
3991
4021
  }
3992
4022
  function extractSpawn(mod) {
3993
- if (!isRecord2(mod)) return null;
4023
+ if (!isRecord3(mod)) return null;
3994
4024
  if (typeof mod.spawn === "function") return mod.spawn;
3995
4025
  const def = mod.default;
3996
- if (isRecord2(def) && typeof def.spawn === "function") return def.spawn;
4026
+ if (isRecord3(def) && typeof def.spawn === "function") return def.spawn;
3997
4027
  return null;
3998
4028
  }
3999
4029
  async function loadPtySpawn() {
@@ -4015,6 +4045,16 @@ function inheritedEnv(socketPath) {
4015
4045
  function buildPromptBytes(text) {
4016
4046
  return `\x1B[200~${text}\x1B[201~`;
4017
4047
  }
4048
+ function renderPromptContentText(content) {
4049
+ return content.map((block) => {
4050
+ const b = block;
4051
+ if (b?.type === "text" && typeof b.text === "string") return b.text;
4052
+ if (b?.type === "image") {
4053
+ return `[Image attachment \u2014 use list_task_files / get_attachment to view]`;
4054
+ }
4055
+ return JSON.stringify(block);
4056
+ }).join("\n\n");
4057
+ }
4018
4058
  function sleep3(ms) {
4019
4059
  return new Promise((resolve) => {
4020
4060
  setTimeout(resolve, ms);
@@ -4031,9 +4071,9 @@ function parseUserQuestions(input) {
4031
4071
  if (!Array.isArray(input.questions)) return [];
4032
4072
  const questions = [];
4033
4073
  for (const entry of input.questions) {
4034
- if (!isRecord2(entry)) continue;
4074
+ if (!isRecord3(entry)) continue;
4035
4075
  if (typeof entry.question !== "string") continue;
4036
- const options = Array.isArray(entry.options) ? entry.options.filter(isRecord2).filter((o) => typeof o.label === "string").map((o) => ({
4076
+ const options = Array.isArray(entry.options) ? entry.options.filter(isRecord3).filter((o) => typeof o.label === "string").map((o) => ({
4037
4077
  label: o.label,
4038
4078
  description: typeof o.description === "string" ? o.description : ""
4039
4079
  })) : [];
@@ -4281,8 +4321,8 @@ var PtySession = class {
4281
4321
  return;
4282
4322
  }
4283
4323
  try {
4284
- this.tempDir = await mkdtemp2(join4(tmpdir4(), "conveyor-pty-"));
4285
- const socketPath = join4(this.tempDir, "hook.sock");
4324
+ this.tempDir = await mkdtemp2(join3(tmpdir3(), "conveyor-pty-"));
4325
+ const socketPath = join3(this.tempDir, "hook.sock");
4286
4326
  this.socket = new HookSocketServer(
4287
4327
  socketPath,
4288
4328
  (progress) => this.handleProgress(progress),
@@ -4294,7 +4334,14 @@ var PtySession = class {
4294
4334
  const transcriptPath = sessionTranscriptPath(this.options.cwd, sessionId);
4295
4335
  await mkdir2(dirname(transcriptPath), { recursive: true });
4296
4336
  const startOffset = this.resume ? await transcriptSize(transcriptPath) : 0;
4297
- this.tailer = new JsonlTailer(transcriptPath, (event) => this.handleTranscriptEvent(event));
4337
+ const sendChat = this.bridge?.sendChatEvent?.bind(this.bridge);
4338
+ this.tailer = new JsonlTailer(
4339
+ transcriptPath,
4340
+ (event) => this.handleTranscriptEvent(event),
4341
+ sendChat ? (raw) => {
4342
+ for (const chatEvent of mapChatRecords(raw)) sendChat(chatEvent);
4343
+ } : void 0
4344
+ );
4298
4345
  this.tailer.start(startOffset);
4299
4346
  await this.spawn(settingsPath, socketPath);
4300
4347
  if (signal) {
@@ -4454,7 +4501,7 @@ var PtySession = class {
4454
4501
  }
4455
4502
  for await (const message of this.turnPrompt) {
4456
4503
  const content = message.message.content;
4457
- const text = typeof content === "string" ? content : JSON.stringify(content);
4504
+ const text = typeof content === "string" ? content : renderPromptContentText(content);
4458
4505
  await this.deliverPrompt(text);
4459
4506
  }
4460
4507
  }
@@ -4477,20 +4524,21 @@ var PtySession = class {
4477
4524
  if (this.submitNudgeTimer) return;
4478
4525
  this.pendingSubmitNudge = true;
4479
4526
  const startedAt = Date.now();
4527
+ const { intervalMs, slowIntervalMs, maxPresses, windowMs } = resolveSubmitNudgeTiming();
4480
4528
  let presses = 0;
4481
4529
  const press = () => {
4482
- if (this._toreDown || !this.pendingSubmitNudge || Date.now() - startedAt >= SUBMIT_NUDGE_WINDOW_MS) {
4530
+ if (this._toreDown || !this.pendingSubmitNudge || Date.now() - startedAt >= windowMs) {
4483
4531
  this.disarmSubmitNudge();
4484
4532
  return;
4485
4533
  }
4486
4534
  presses++;
4487
4535
  this.writeStdin("\r");
4488
- if (presses === SUBMIT_NUDGE_MAX_PRESSES && this.submitNudgeTimer) {
4536
+ if (presses === maxPresses && this.submitNudgeTimer) {
4489
4537
  clearInterval(this.submitNudgeTimer);
4490
- this.submitNudgeTimer = setInterval(press, SUBMIT_NUDGE_SLOW_INTERVAL_MS);
4538
+ this.submitNudgeTimer = setInterval(press, slowIntervalMs);
4491
4539
  }
4492
4540
  };
4493
- this.submitNudgeTimer = setInterval(press, SUBMIT_NUDGE_INTERVAL_MS);
4541
+ this.submitNudgeTimer = setInterval(press, intervalMs);
4494
4542
  }
4495
4543
  disarmSubmitNudge() {
4496
4544
  this.pendingSubmitNudge = false;
@@ -4617,11 +4665,11 @@ var PtySession = class {
4617
4665
  // src/harness/pty/credentials.ts
4618
4666
  import { chmod as chmod2, mkdir as mkdir3, readFile, rm as rm5, writeFile as writeFile4 } from "fs/promises";
4619
4667
  import { homedir as homedir2 } from "os";
4620
- import { join as join5 } from "path";
4668
+ import { join as join4 } from "path";
4621
4669
  var SYNTH_TOKEN_TTL_MS = 365 * 24 * 60 * 60 * 1e3;
4622
4670
  var REFRESH_SKEW_MS = 30 * 24 * 60 * 60 * 1e3;
4623
4671
  function claudeCredentialsPath() {
4624
- return join5(claudeConfigHome(), ".credentials.json");
4672
+ return join4(claudeConfigHome(), ".credentials.json");
4625
4673
  }
4626
4674
  function isConveyorCloudEnv(env = process.env) {
4627
4675
  return Boolean(env.CLAUDESPACE_NAME || env.CODESPACE_NAME || env.CODESPACES);
@@ -4695,7 +4743,7 @@ async function ensureClaudeCredentials(env = process.env) {
4695
4743
  }
4696
4744
  function claudeJsonPath() {
4697
4745
  const configDir = process.env.CLAUDE_CONFIG_DIR;
4698
- return configDir ? join5(configDir, ".claude.json") : join5(homedir2(), ".claude.json");
4746
+ return configDir ? join4(configDir, ".claude.json") : join4(homedir2(), ".claude.json");
4699
4747
  }
4700
4748
  function asRecord(value) {
4701
4749
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
@@ -4951,7 +4999,7 @@ function createHarness(kind = "sdk", ptyBridge) {
4951
4999
 
4952
5000
  // src/execution/query-executor.ts
4953
5001
  import { createHash } from "crypto";
4954
- import { existsSync, readFileSync as readFileSync2, truncateSync } from "fs";
5002
+ import { existsSync as existsSync2, readFileSync, truncateSync } from "fs";
4955
5003
 
4956
5004
  // src/execution/pack-runner-prompt.ts
4957
5005
  function findLastAgentMessageIndex(history) {
@@ -5106,8 +5154,9 @@ function formatChatFile(file) {
5106
5154
  return [`[Attached: ${file.fileName} (${file.mimeType}${sizeStr})]: ${file.downloadUrl}`];
5107
5155
  }
5108
5156
  if (file.content && file.contentEncoding === "base64") {
5157
+ const link = file.downloadUrl ? ` or download: ${file.downloadUrl}` : "";
5109
5158
  return [
5110
- `[Attached image: ${file.fileName} (${file.mimeType}${sizeStr}) \u2014 use get_attachment("${file.fileId}") to view]`
5159
+ `[Attached image: ${file.fileName} (${file.mimeType}${sizeStr}) \u2014 use get_attachment("${file.fileId}") to view${link}]`
5111
5160
  ];
5112
5161
  }
5113
5162
  return [`[Attached: ${file.fileName} (${file.mimeType}${sizeStr})]`];
@@ -5119,8 +5168,9 @@ function formatTaskFile(file) {
5119
5168
  }
5120
5169
  if (file.content && file.contentEncoding === "base64") {
5121
5170
  const size = formatFileSize(file.fileSize);
5171
+ const link = file.downloadUrl ? ` or download: ${file.downloadUrl}` : "";
5122
5172
  return [
5123
- `- [Attached image: ${file.fileName} (${file.mimeType}${size ? `, ${size}` : ""}) \u2014 use get_attachment("${file.fileId}") to view]`
5173
+ `- [Attached image: ${file.fileName} (${file.mimeType}${size ? `, ${size}` : ""}) \u2014 use get_attachment("${file.fileId}") to view${link}]`
5124
5174
  ];
5125
5175
  }
5126
5176
  if (!file.content) {
@@ -5603,7 +5653,7 @@ function buildDiscoveryPrompt(context, runnerMode) {
5603
5653
  ## Mode: Discovery`,
5604
5654
  `You are in Discovery mode \u2014 helping plan and scope this task.`,
5605
5655
  `- You have read-only codebase access (can read files, run git commands, search code)`,
5606
- `- You can write plan files in .claude/plans/ only \u2014 no other file writes`,
5656
+ `- You can write plan files in .claude/plans/ only \u2014 no other file writes. Plan files on disk are NOT synced to the task; save the plan via update_task_plan`,
5607
5657
  `- Do NOT attempt to edit, write, or modify source code files \u2014 these operations will be denied`,
5608
5658
  `- If you identify code changes needed, describe them in the plan instead of implementing them`,
5609
5659
  `- You can create and manage subtasks`,
@@ -5653,7 +5703,7 @@ function buildDiscoveryPrompt(context, runnerMode) {
5653
5703
  ``,
5654
5704
  `### Plan Verification Requirements`,
5655
5705
  `Every plan MUST include a **Testing / Verification** section so the build agent has a clear definition of "done". Enumerate:`,
5656
- `- The quality gates the build agent should run: \`bun run lint\`, \`bun run typecheck\`, \`bun run test\` (or scoped equivalents)`,
5706
+ `- The scoped verification for the change: \`bun run check\` (lint + typecheck) plus \`bun run test:affected\` \u2014 name the specific package suites the diff will touch. Docs-only plans should state that no local gates are needed (CI validates on the PR).`,
5657
5707
  `- Any task-specific end-to-end checks (manual UI walk-through, API smoke test, migration dry-run, etc.)`,
5658
5708
  `You are NOT expected to run these gates yourself \u2014 discovery is read-only. Just describe them in the plan.`,
5659
5709
  ``,
@@ -5732,14 +5782,14 @@ function buildModePrompt(agentMode, context, runnerMode) {
5732
5782
  `- Goal: coordinate child task execution and ensure all children complete successfully`
5733
5783
  ] : [
5734
5784
  `- If this is a leaf task (no children): execute the plan directly`,
5735
- `- Goal: implement the plan, verify quality gates, open a PR when done`,
5785
+ `- Goal: implement the plan, run scoped verification, open a PR when done`,
5736
5786
  ``,
5737
5787
  `### Pre-PR Verification Checklist`,
5738
- `Before calling \`mcp__conveyor__create_pull_request\`, verify ALL of the following pass:`,
5739
- `1. \`bun run lint\` \u2014 no new lint errors`,
5740
- `2. \`bun run typecheck\` \u2014 no new type errors`,
5741
- `3. \`bun run test\` \u2014 relevant suites pass (scope to the affected package if the full run is prohibitively slow, and state the scope in the PR description)`,
5742
- `If a gate fails, fix it before opening the PR. Do NOT open PRs with known failing gates.`,
5788
+ `CI runs the FULL suite (lint, typecheck, all test shards) on every PR \u2014 do not duplicate it locally. Before calling \`mcp__conveyor__create_pull_request\`, scope verification to your diff:`,
5789
+ `1. \`bun run check\` \u2014 lint + typecheck (fast; run whenever you changed code)`,
5790
+ `2. \`bun run test:affected\` \u2014 runs only the tests your diff can affect (docs-only diffs run nothing; apps/api diffs run unit tests only since CI covers the int shards; shared/db diffs escalate to the full suite automatically)`,
5791
+ `Docs/markdown/.claude-only changes need NO local gates \u2014 open the PR and let CI validate.`,
5792
+ `If a gate fails, fix it before opening the PR. Do NOT open PRs with known failing gates. Never run the full \`bun run test\` for a diff confined to one package.`,
5743
5793
  `For refactors: also run \`git diff ${context?.baseBranch ?? "dev"}..HEAD\` and confirm the public API surface (exports, function signatures) has no unintended breaking changes.`
5744
5794
  ]
5745
5795
  ];
@@ -5840,12 +5890,13 @@ function buildPmPreamble(context) {
5840
5890
  `
5841
5891
  Environment (ready, no setup required):`,
5842
5892
  `- Repository is cloned at your current working directory.`,
5893
+ `- The shell cwd resets between Bash calls \u2014 always use absolute paths (or \`git -C\`); never spend a tool call on a bare \`cd\`.`,
5843
5894
  `- You can read files and run git commands to understand the codebase before writing task plans.`,
5844
5895
  `- Check the dev branch (e.g. run: git fetch && git checkout dev || git checkout main) to understand the current state of the codebase that agents will branch off of.`,
5845
5896
  `
5846
5897
  Workflow:`,
5847
- `- You can draft and iterate on plans in .claude/plans/*.md \u2014 these files are automatically synced to the task.`,
5848
- `- You can also use update_task_plan directly to save the plan to the task.`,
5898
+ `- You can draft and iterate on plans in .claude/plans/*.md, but files on disk are NOT synced to the task.`,
5899
+ `- Save the plan to the task with update_task_plan \u2014 this is the only way the plan is persisted.`,
5849
5900
  `- After saving the plan, call post_to_chat with a short summary for the team (your turn output is NOT posted to chat \u2014 they only see what you post), then end your turn. Do NOT attempt to execute the plan yourself.`,
5850
5901
  `- A separate task agent will handle execution after the team reviews and approves your plan.`
5851
5902
  ];
@@ -5878,6 +5929,7 @@ Environment (ready, no setup required):`,
5878
5929
  `- Repository is cloned at your current working directory.`,
5879
5930
  `- You can read, write, and edit files directly.`,
5880
5931
  `- You can run shell commands including git, build tools, and test runners.`,
5932
+ `- The shell cwd resets between Bash calls \u2014 always use absolute paths (or \`git -C\`, \`bun run --cwd\`); never spend a tool call on a bare \`cd\`.`,
5881
5933
  context.githubBranch ? `- You are working on branch: \`${context.githubBranch}\`` : "",
5882
5934
  `
5883
5935
  Safety rules:`,
@@ -5902,6 +5954,10 @@ Environment (fully ready \u2014 do NOT verify or set up):`,
5902
5954
  `- Branch \`${context.githubBranch}\` is already checked out.`,
5903
5955
  `- All dependencies are installed, database is migrated, and the dev server is running.`,
5904
5956
  `- Git is configured. Commit and push directly to this branch.`,
5957
+ `- The shell cwd resets between Bash calls \u2014 always use absolute paths (or \`git -C\`, \`bun run --cwd\`); never spend a tool call on a bare \`cd\`.`,
5958
+ `- When a build/lint/test run fails, capture its output to a file once and grep the file \u2014 never re-run the suite just to re-filter the same output. Don't poll background jobs with sleep/ps/tail loops.`,
5959
+ `- The \`gh\` CLI may not be installed \u2014 use the mcp__conveyor__* tools for PR and task state.`,
5960
+ `- Read a file before your first Write/Edit to it, and batch multiple changes to the same file into a single call instead of many sequential edits.`,
5905
5961
  `
5906
5962
  IMPORTANT \u2014 Skip all environment verification. Do NOT run any of the following:`,
5907
5963
  `- bun/npm install, pip install, or any dependency installation`,
@@ -6164,7 +6220,7 @@ CRITICAL: You are in Auto mode. Do NOT report status, ask for confirmation, or g
6164
6220
  `Your FIRST action should be reading the relevant source files mentioned in the plan, then writing code. Do NOT run install, build, lint, test, or dev server commands first \u2014 the environment is already set up.`,
6165
6221
  `Work on the git branch "${context.githubBranch}". Stay on this branch for the entire task. Do not checkout or create other branches.`,
6166
6222
  `Use post_to_chat to keep the team informed (your turn output is NOT shown in chat \u2014 post_to_chat is the only way they see it): briefly post what you're doing when you begin meaningful implementation, and again when the PR is ready.`,
6167
- `Before opening the PR, run the quality gates: \`bun run lint && bun run typecheck && bun run test\`. Fix any failures. Do NOT open a PR with known failing gates \u2014 scope the test run to affected packages if the full suite is prohibitively slow, and state the scope in the PR body.`,
6223
+ `Before opening the PR, verify with \`bun run check\` (lint + typecheck) and \`bun run test:affected\` (tests scoped to your diff \u2014 docs-only changes need no local gates). Fix any failures. Do NOT open a PR with known failing gates. CI runs the full suite on the PR \u2014 never run the full \`bun run test\` for a diff confined to one package.`,
6168
6224
  `When finished, use the mcp__conveyor__create_pull_request tool to open a PR. Do NOT use gh CLI or any other method to create PRs.`
6169
6225
  ];
6170
6226
  if (isAutoMode) {
@@ -6236,7 +6292,7 @@ New messages since your last run:`,
6236
6292
  ...newMessages.map((m) => `[${m.userName ?? "user"}]: ${m.content}`),
6237
6293
  `
6238
6294
  Address the requested changes directly. Do NOT re-investigate the codebase from scratch or write a new plan \u2014 go straight to implementing the feedback.`,
6239
- `Before pushing or opening a PR, re-run the quality gates: \`bun run lint && bun run typecheck && bun run test\`. Fix any failures \u2014 relaunches are the most common place verification gets skipped.`,
6295
+ `Before pushing or opening a PR, re-verify with \`bun run check\` and \`bun run test:affected\`. Fix any failures \u2014 relaunches are the most common place verification gets skipped. CI runs the full suite on the PR; keep local runs scoped to your diff.`,
6240
6296
  `Implement your updates and open a PR when finished.`
6241
6297
  ];
6242
6298
  if (context.githubPRUrl) {
@@ -6679,11 +6735,11 @@ function buildCreatePullRequestTool(connection, config) {
6679
6735
  async ({ title, body, branch, baseBranch, commitMessage, skipVerify }) => {
6680
6736
  try {
6681
6737
  const cwd = config.workspaceDir;
6682
- if (hasUncommittedChanges(cwd)) {
6738
+ if (await hasUncommittedChanges(cwd)) {
6683
6739
  const message = commitMessage || `${title}
6684
6740
 
6685
6741
  Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>`;
6686
- const commitHash = stageAndCommit(cwd, message);
6742
+ const commitHash = await stageAndCommit(cwd, message);
6687
6743
  if (commitHash) {
6688
6744
  connection.sendEvent({
6689
6745
  type: "message",
@@ -6695,7 +6751,7 @@ Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>`;
6695
6751
  );
6696
6752
  }
6697
6753
  }
6698
- if (hasUnpushedCommits(cwd)) {
6754
+ if (await hasUnpushedCommits(cwd)) {
6699
6755
  const pushSuccess = await pushToOrigin(
6700
6756
  cwd,
6701
6757
  async () => {
@@ -6894,7 +6950,7 @@ function buildMutationTools(connection, config) {
6894
6950
 
6895
6951
  // src/tools/attachment-tools.ts
6896
6952
  import { readFile as readFile3, stat as stat5 } from "fs/promises";
6897
- import { basename, extname, isAbsolute, join as join6 } from "path";
6953
+ import { basename, extname, isAbsolute, join as join5 } from "path";
6898
6954
  import { z as z6 } from "zod";
6899
6955
  var IMAGE_MIME_BY_EXT = {
6900
6956
  ".png": "image/png",
@@ -6913,7 +6969,7 @@ function buildUploadAttachmentTool(connection, config) {
6913
6969
  },
6914
6970
  async ({ path: path4, title }) => {
6915
6971
  try {
6916
- const filePath = isAbsolute(path4) ? path4 : join6(config.workspaceDir, path4);
6972
+ const filePath = isAbsolute(path4) ? path4 : join5(config.workspaceDir, path4);
6917
6973
  const mimeType = IMAGE_MIME_BY_EXT[extname(filePath).toLowerCase()];
6918
6974
  if (!mimeType) {
6919
6975
  return textResult(
@@ -8048,7 +8104,6 @@ async function handleExitPlanMode(host, input) {
8048
8104
  return { behavior: "allow", updatedInput: input };
8049
8105
  }
8050
8106
  try {
8051
- await host.syncPlanFile();
8052
8107
  const taskProps = await host.connection.getTaskProperties();
8053
8108
  const missingProps = collectMissingProps(taskProps);
8054
8109
  if (host.isParentTask) {
@@ -8318,7 +8373,7 @@ function sessionLineageKey(taskId, agentMode, runnerMode) {
8318
8373
  }
8319
8374
  function sessionFileExists(sessionUuid, cwd) {
8320
8375
  try {
8321
- return existsSync(sessionTranscriptPath(cwd, sessionUuid));
8376
+ return existsSync2(sessionTranscriptPath(cwd, sessionUuid));
8322
8377
  } catch {
8323
8378
  return false;
8324
8379
  }
@@ -8327,10 +8382,18 @@ function hasExistingSessionFile(taskId, cwd, lineage) {
8327
8382
  const key = sessionLineageKey(taskId, lineage.agentMode, lineage.runnerMode);
8328
8383
  return sessionFileExists(taskIdToSessionUuid(key), cwd);
8329
8384
  }
8385
+ function resolveSessionStart(lineageKey, cwd) {
8386
+ const sessionUuid = taskIdToSessionUuid(lineageKey);
8387
+ if (sessionFileExists(sessionUuid, cwd)) {
8388
+ repairTornSessionFile(sessionTranscriptPath(cwd, sessionUuid));
8389
+ return { resume: sessionUuid };
8390
+ }
8391
+ return { sessionId: sessionUuid };
8392
+ }
8330
8393
  function repairTornSessionFile(path4) {
8331
8394
  try {
8332
- if (!existsSync(path4)) return false;
8333
- const content = readFileSync2(path4, "utf8");
8395
+ if (!existsSync2(path4)) return false;
8396
+ const content = readFileSync(path4, "utf8");
8334
8397
  if (content.length === 0) return false;
8335
8398
  let keepEnd = content.length;
8336
8399
  if (!content.endsWith("\n")) {
@@ -8474,14 +8537,21 @@ async function buildFollowUpPrompt(host, context, followUpContent) {
8474
8537
 
8475
8538
  The team says:
8476
8539
  ${followUpText}` : followUpText;
8540
+ const isPty = host.harnessKind === "pty";
8477
8541
  if (isPmMode) {
8478
- const prompt = buildMultimodalPrompt(textPrompt, context);
8542
+ const prompt = buildMultimodalPrompt(textPrompt, context, isPty);
8479
8543
  if (followUpImages.length > 0 && Array.isArray(prompt)) {
8480
8544
  prompt.push(...followUpImages);
8481
8545
  }
8482
8546
  return prompt;
8483
8547
  }
8484
8548
  if (followUpImages.length > 0) {
8549
+ if (isPty) {
8550
+ const refs = followUpImages.map(
8551
+ () => `[Image attachment \u2014 use list_task_files / get_attachment to view]`
8552
+ );
8553
+ return [textPrompt, ...refs].join("\n");
8554
+ }
8485
8555
  return [{ type: "text", text: textPrompt }, ...followUpImages];
8486
8556
  }
8487
8557
  return textPrompt;
@@ -8522,17 +8592,11 @@ async function runSdkQuery(host, context, followUpContent, promptDeliveryOverrid
8522
8592
  if (host.isStopped()) return;
8523
8593
  const mode = host.agentMode;
8524
8594
  const isDiscoveryLike = mode === "discovery" || mode === "help";
8525
- const needsPlanSync = isDiscoveryLike || mode === "auto" && !host.hasExitedPlanMode;
8526
- if (needsPlanSync) {
8527
- host.snapshotPlanFiles();
8528
- }
8529
- const sessionUuid = taskIdToSessionUuid(
8530
- sessionLineageKey(context.taskId, mode, host.config.mode)
8595
+ const sessionStart = resolveSessionStart(
8596
+ sessionLineageKey(context.taskId, mode, host.config.mode),
8597
+ host.config.workspaceDir
8531
8598
  );
8532
- const hasExistingSession = sessionFileExists(sessionUuid, host.config.workspaceDir);
8533
- if (hasExistingSession) {
8534
- repairTornSessionFile(sessionTranscriptPath(host.config.workspaceDir, sessionUuid));
8535
- }
8599
+ const hasExistingSession = !!sessionStart.resume;
8536
8600
  const promptDelivery = promptDeliveryOverride ?? resolvePromptDelivery({
8537
8601
  harnessKind: host.harnessKind,
8538
8602
  runnerMode: host.config.mode,
@@ -8544,19 +8608,17 @@ async function runSdkQuery(host, context, followUpContent, promptDeliveryOverrid
8544
8608
  const options = {
8545
8609
  ...buildQueryOptions(host, context),
8546
8610
  promptDelivery,
8547
- ...hasExistingSession ? {} : { sessionId: sessionUuid }
8611
+ ...sessionStart.sessionId ? { sessionId: sessionStart.sessionId } : {}
8548
8612
  };
8549
- const resume = hasExistingSession ? sessionUuid : void 0;
8613
+ const resume = sessionStart.resume;
8550
8614
  if (followUpContent) {
8551
8615
  await runFollowUpQuery(host, context, options, resume, followUpContent);
8552
- } else if (isDiscoveryLike && promptDelivery !== "prefill") {
8553
8616
  return;
8554
- } else {
8555
- await runInitialQuery(host, context, options, resume, promptDelivery);
8556
8617
  }
8557
- if (needsPlanSync) {
8558
- await host.syncPlanFile();
8618
+ if (isDiscoveryLike && promptDelivery !== "prefill") {
8619
+ return;
8559
8620
  }
8621
+ await runInitialQuery(host, context, options, resume, promptDelivery);
8560
8622
  }
8561
8623
  async function runFollowUpQuery(host, context, options, resume, followUpContent) {
8562
8624
  if (options.promptDelivery === "prefill") {
@@ -8625,7 +8687,7 @@ function latestUserMessageText(context) {
8625
8687
  }
8626
8688
  return null;
8627
8689
  }
8628
- function selectInitialPromptInput(promptDelivery, initialPrompt, context, baseAppendSystemPrompt) {
8690
+ function selectInitialPromptInput(promptDelivery, initialPrompt, context, baseAppendSystemPrompt, harnessKind) {
8629
8691
  if (promptDelivery === "prefill") {
8630
8692
  return {
8631
8693
  prompt: latestUserMessageText(context) ?? "",
@@ -8633,7 +8695,9 @@ function selectInitialPromptInput(promptDelivery, initialPrompt, context, baseAp
8633
8695
  };
8634
8696
  }
8635
8697
  return {
8636
- prompt: buildMultimodalPrompt(initialPrompt, context),
8698
+ // On the PTY harness image blocks would be pasted into the TUI as text —
8699
+ // the prompt body already links each image (get_attachment / downloadUrl).
8700
+ prompt: buildMultimodalPrompt(initialPrompt, context, harnessKind === "pty"),
8637
8701
  appendSystemPrompt: baseAppendSystemPrompt
8638
8702
  };
8639
8703
  }
@@ -8648,7 +8712,8 @@ async function runInitialQuery(host, context, options, resume, promptDelivery) {
8648
8712
  promptDelivery,
8649
8713
  initialPrompt,
8650
8714
  context,
8651
- options.appendSystemPrompt
8715
+ options.appendSystemPrompt,
8716
+ host.harnessKind
8652
8717
  );
8653
8718
  const queryOptions = { ...options, appendSystemPrompt };
8654
8719
  let agentQuery = host.harness.executeQuery({
@@ -8673,7 +8738,7 @@ async function buildRetryQuery(host, context, options, lastErrorWasImage) {
8673
8738
  const retryPrompt = buildMultimodalPrompt(
8674
8739
  await buildInitialPrompt(host.config.mode, context, host.isAuto, host.agentMode),
8675
8740
  context,
8676
- lastErrorWasImage
8741
+ lastErrorWasImage || host.harnessKind === "pty"
8677
8742
  );
8678
8743
  return host.harness.executeQuery({
8679
8744
  prompt: host.createInputStream(retryPrompt),
@@ -8701,7 +8766,8 @@ async function handleAuthError(context, host, options) {
8701
8766
  host.connection.storeSessionId("");
8702
8767
  const freshPrompt = buildMultimodalPrompt(
8703
8768
  await buildInitialPrompt(host.config.mode, context, host.isAuto, host.agentMode),
8704
- context
8769
+ context,
8770
+ host.harnessKind === "pty"
8705
8771
  );
8706
8772
  const freshQuery = host.harness.executeQuery({
8707
8773
  prompt: host.createInputStream(freshPrompt),
@@ -8715,7 +8781,8 @@ async function handleStaleSession(context, host, options) {
8715
8781
  host.connection.storeSessionId("");
8716
8782
  const freshPrompt = buildMultimodalPrompt(
8717
8783
  await buildInitialPrompt(host.config.mode, context, host.isAuto, host.agentMode),
8718
- context
8784
+ context,
8785
+ host.harnessKind === "pty"
8719
8786
  );
8720
8787
  const freshQuery = host.harness.executeQuery({
8721
8788
  prompt: host.createInputStream(freshPrompt),
@@ -8941,13 +9008,14 @@ function resolveHarnessKind() {
8941
9008
  function buildPtyBridge(connection) {
8942
9009
  return {
8943
9010
  sendOutput: (data, dims) => connection.sendPtyOutput(data, dims),
9011
+ sendChatEvent: (event) => connection.sendPtyChatEvent(event),
8944
9012
  sendEnded: () => connection.sendPtyEnded(),
8945
9013
  onInput: (handler) => connection.onPtyInput(handler),
8946
9014
  onResize: (handler) => connection.onPtyResize(handler)
8947
9015
  };
8948
9016
  }
8949
9017
  var QueryBridge = class {
8950
- constructor(connection, mode, runnerConfig, callbacks, workspaceDir) {
9018
+ constructor(connection, mode, runnerConfig, callbacks) {
8951
9019
  this.connection = connection;
8952
9020
  this.mode = mode;
8953
9021
  this.runnerConfig = runnerConfig;
@@ -8958,7 +9026,6 @@ var QueryBridge = class {
8958
9026
  harnessKind,
8959
9027
  harnessKind === "pty" ? buildPtyBridge(connection) : void 0
8960
9028
  );
8961
- this.planSync = new PlanSync(workspaceDir, connection);
8962
9029
  }
8963
9030
  connection;
8964
9031
  mode;
@@ -8968,7 +9035,6 @@ var QueryBridge = class {
8968
9035
  /** Which harness drives this bridge ("pty" task chat; "sdk" only under the
8969
9036
  * CONVEYOR_FORCE_SDK_CARDS maintainer override). */
8970
9037
  harnessKind;
8971
- planSync;
8972
9038
  sessionIds = /* @__PURE__ */ new Map();
8973
9039
  activeQuery = null;
8974
9040
  pendingToolOutputs = [];
@@ -9169,8 +9235,6 @@ var QueryBridge = class {
9169
9235
  if (bridge.onSoftStop) bridge.onSoftStop();
9170
9236
  },
9171
9237
  createInputStream: (prompt) => bridge.createInputStream(prompt),
9172
- snapshotPlanFiles: () => bridge.planSync.snapshotPlanFiles(),
9173
- syncPlanFile: () => bridge.planSync.syncPlanFile(),
9174
9238
  onModeTransition: bridge.onModeTransition
9175
9239
  };
9176
9240
  }
@@ -9186,9 +9250,9 @@ var QueryBridge = class {
9186
9250
  };
9187
9251
 
9188
9252
  // src/runner/session-runner-helpers.ts
9189
- import { readFileSync as readFileSync3 } from "fs";
9190
- import { dirname as dirname2, join as join7 } from "path";
9191
- import { fileURLToPath } from "url";
9253
+ import { readFileSync as readFileSync2 } from "fs";
9254
+ import { dirname as dirname2, join as join6 } from "path";
9255
+ import { fileURLToPath as fileURLToPath2 } from "url";
9192
9256
  function mapChatHistory(messages) {
9193
9257
  if (!messages) return [];
9194
9258
  return messages.map((m) => ({
@@ -9213,10 +9277,10 @@ function mapChatHistory(messages) {
9213
9277
  }
9214
9278
  function readAgentVersion() {
9215
9279
  try {
9216
- const here = dirname2(fileURLToPath(import.meta.url));
9280
+ const here = dirname2(fileURLToPath2(import.meta.url));
9217
9281
  for (const rel of ["../package.json", "../../package.json"]) {
9218
9282
  try {
9219
- const pkg = JSON.parse(readFileSync3(join7(here, rel), "utf-8"));
9283
+ const pkg = JSON.parse(readFileSync2(join6(here, rel), "utf-8"));
9220
9284
  if (pkg.version) return pkg.version;
9221
9285
  } catch {
9222
9286
  }
@@ -9302,7 +9366,7 @@ async function sampleKeyUsage(token) {
9302
9366
 
9303
9367
  // src/runner/port-discovery.ts
9304
9368
  import { readFile as readFile4 } from "fs/promises";
9305
- import { execFile } from "child_process";
9369
+ import { execFile as execFile3 } from "child_process";
9306
9370
  var PROC_TCP_LISTEN_STATE = "0A";
9307
9371
  function parseProcNetTcpListeners(content) {
9308
9372
  const ports = [];
@@ -9337,7 +9401,7 @@ async function readProcListeningPorts(procPaths = DEFAULT_PROC_PATHS) {
9337
9401
  }
9338
9402
  async function readNetstatListeningPorts() {
9339
9403
  const output = await new Promise((resolve) => {
9340
- execFile("netstat", ["-an", "-p", "tcp"], { timeout: 5e3 }, (err, stdout) => {
9404
+ execFile3("netstat", ["-an", "-p", "tcp"], { timeout: 5e3 }, (err, stdout) => {
9341
9405
  resolve(err ? null : stdout);
9342
9406
  });
9343
9407
  });
@@ -9488,10 +9552,12 @@ var PortDiscovery = class {
9488
9552
  };
9489
9553
 
9490
9554
  // src/runner/parent-pull-handler.ts
9491
- import { execSync as execSync4 } from "child_process";
9492
- function handlePullBranch(workDir, branch) {
9555
+ import { execFile as execFile4 } from "child_process";
9556
+ import { promisify as promisify3 } from "util";
9557
+ var execFileAsync3 = promisify3(execFile4);
9558
+ async function handlePullBranch(workDir, branch) {
9493
9559
  if (!branch) return;
9494
- const current = getCurrentBranch(workDir);
9560
+ const current = await getCurrentBranch(workDir);
9495
9561
  if (current !== branch) {
9496
9562
  process.stderr.write(
9497
9563
  `[conveyor-agent] pull_branch ignored \u2014 current branch ${current ?? "(detached)"} != ${branch}
@@ -9499,7 +9565,7 @@ function handlePullBranch(workDir, branch) {
9499
9565
  );
9500
9566
  return;
9501
9567
  }
9502
- if (hasUncommittedChanges(workDir)) {
9568
+ if (await hasUncommittedChanges(workDir)) {
9503
9569
  process.stderr.write(
9504
9570
  `[conveyor-agent] pull_branch ignored \u2014 uncommitted changes on ${branch}
9505
9571
  `
@@ -9507,16 +9573,15 @@ function handlePullBranch(workDir, branch) {
9507
9573
  return;
9508
9574
  }
9509
9575
  try {
9510
- execSync4(`git fetch origin ${branch}`, { cwd: workDir, stdio: "ignore", timeout: 6e4 });
9576
+ await execFileAsync3("git", ["fetch", "origin", branch], { cwd: workDir, timeout: 6e4 });
9511
9577
  } catch {
9512
9578
  process.stderr.write(`[conveyor-agent] pull_branch: fetch failed for ${branch}
9513
9579
  `);
9514
9580
  return;
9515
9581
  }
9516
9582
  try {
9517
- execSync4(`git pull --ff-only origin ${branch}`, {
9583
+ await execFileAsync3("git", ["pull", "--ff-only", "origin", branch], {
9518
9584
  cwd: workDir,
9519
- stdio: "ignore",
9520
9585
  timeout: 6e4
9521
9586
  });
9522
9587
  process.stderr.write(`[conveyor-agent] pull_branch: pulled origin/${branch}
@@ -9529,6 +9594,33 @@ function handlePullBranch(workDir, branch) {
9529
9594
  }
9530
9595
  }
9531
9596
 
9597
+ // src/runner/heavy-gate.ts
9598
+ import { readFileSync as readFileSync3 } from "fs";
9599
+ import path3 from "path";
9600
+ var GATE_KEYS = ["heavy", "test", "typecheck", "build"];
9601
+ function runDir() {
9602
+ return process.env.CONVEYOR_RUN_DIR ?? "/tmp/conveyor-run";
9603
+ }
9604
+ function pidAlive(pid) {
9605
+ try {
9606
+ process.kill(pid, 0);
9607
+ return true;
9608
+ } catch {
9609
+ return false;
9610
+ }
9611
+ }
9612
+ function isHeavyGateActive() {
9613
+ for (const key of GATE_KEYS) {
9614
+ try {
9615
+ const raw = readFileSync3(path3.join(runDir(), `${key}.pid`), "utf8").trim();
9616
+ const pid = Number.parseInt(raw, 10);
9617
+ if (Number.isInteger(pid) && pid > 0 && pidAlive(pid)) return true;
9618
+ } catch {
9619
+ }
9620
+ }
9621
+ return false;
9622
+ }
9623
+
9532
9624
  // src/runner/session-runner.ts
9533
9625
  function isPostToChatTool(name) {
9534
9626
  return typeof name === "string" && (name === "post_to_chat" || name.endsWith("__post_to_chat"));
@@ -9566,8 +9658,14 @@ var SessionRunner = class _SessionRunner {
9566
9658
  agentSpokeThisTurn = false;
9567
9659
  /** Guards overlapping runs of the periodic git flush. */
9568
9660
  periodicFlushInFlight = false;
9661
+ /** Consecutive flush ticks skipped because a heavy gate was running. */
9662
+ gateSkipCount = 0;
9663
+ /** Max consecutive gate-deferred ticks (~2 min each) before flushing anyway. */
9664
+ static MAX_GATE_SKIPS = 10;
9569
9665
  /** Runtime preview-port poller (v3 dynamic port discovery). */
9570
9666
  portDiscovery = null;
9667
+ /** Main event-loop lag measurement, shared with the heartbeat worker. */
9668
+ loopLag = new LoopLagMonitor();
9571
9669
  constructor(config, callbacks) {
9572
9670
  this.config = config;
9573
9671
  this.callbacks = callbacks;
@@ -9576,7 +9674,7 @@ var SessionRunner = class _SessionRunner {
9576
9674
  this.mode = new ModeController(initialMode, config.runnerMode, config.isAuto);
9577
9675
  const lifecycleConfig = { ...DEFAULT_LIFECYCLE_CONFIG, ...config.lifecycle };
9578
9676
  this.lifecycle = new Lifecycle(lifecycleConfig, {
9579
- onHeartbeat: () => this.connection.sendHeartbeat(),
9677
+ onHeartbeat: () => this.connection.sendHeartbeat(this.loopLag.takeMaxLagMs()),
9580
9678
  onIdleTimeout: () => {
9581
9679
  process.stderr.write("[conveyor-agent] Idle timeout reached, stopping agent\n");
9582
9680
  this.stopped = true;
@@ -9624,6 +9722,8 @@ var SessionRunner = class _SessionRunner {
9624
9722
  this.connection.sendEvent({ type: "connected", sessionId: this.sessionId });
9625
9723
  this.wireConnectionCallbacks();
9626
9724
  this.lifecycle.startHeartbeat();
9725
+ this.loopLag.start();
9726
+ this.connection.startHeartbeatWorker(this.loopLag.sharedBuffer);
9627
9727
  this.lifecycle.startTokenRefresh();
9628
9728
  this.lifecycle.startUsageSample();
9629
9729
  const { pendingMessages: serverMessages } = await this.connection.call("connectAgent", {
@@ -9686,10 +9786,10 @@ var SessionRunner = class _SessionRunner {
9686
9786
  }
9687
9787
  if (process.env.CONVEYOR_GIT_READY !== "1") {
9688
9788
  if (this.fullContext?.githubBranch) {
9689
- ensureOnTaskBranch(this.config.workspaceDir, this.fullContext.githubBranch);
9789
+ await ensureOnTaskBranch(this.config.workspaceDir, this.fullContext.githubBranch);
9690
9790
  }
9691
9791
  if (this.fullContext?.baseBranch) {
9692
- syncWithBaseBranch(this.config.workspaceDir, this.fullContext.baseBranch);
9792
+ await syncWithBaseBranch(this.config.workspaceDir, this.fullContext.baseBranch);
9693
9793
  }
9694
9794
  }
9695
9795
  if (!this.stopped) {
@@ -9709,7 +9809,7 @@ var SessionRunner = class _SessionRunner {
9709
9809
  );
9710
9810
  }
9711
9811
  } else {
9712
- const restored = restoreWipSnapshot(
9812
+ const restored = await restoreWipSnapshot(
9713
9813
  this.config.workspaceDir,
9714
9814
  this.fullContext.githubBranch
9715
9815
  );
@@ -10052,6 +10152,14 @@ var SessionRunner = class _SessionRunner {
10052
10152
  * clean tree. Guarded so two ticks can't overlap. Never throws. */
10053
10153
  async periodicGitFlush() {
10054
10154
  if (this.periodicFlushInFlight || this.stopped) return;
10155
+ if (isHeavyGateActive() && this.gateSkipCount < _SessionRunner.MAX_GATE_SKIPS) {
10156
+ this.gateSkipCount++;
10157
+ if (this.gateSkipCount === 1) {
10158
+ process.stderr.write("[conveyor-agent] Periodic git flush deferred \u2014 heavy gate running\n");
10159
+ }
10160
+ return;
10161
+ }
10162
+ this.gateSkipCount = 0;
10055
10163
  this.periodicFlushInFlight = true;
10056
10164
  try {
10057
10165
  const result = await flushAllPendingWork(this.config.workspaceDir, {
@@ -10150,6 +10258,7 @@ var SessionRunner = class _SessionRunner {
10150
10258
  void this.queryBridge?.dispose().catch(() => {
10151
10259
  });
10152
10260
  this.portDiscovery?.stop();
10261
+ this.loopLag.stop();
10153
10262
  this.lifecycle.destroy();
10154
10263
  this.connection.disconnect();
10155
10264
  if (this.inputResolver) {
@@ -10169,23 +10278,41 @@ var SessionRunner = class _SessionRunner {
10169
10278
  }
10170
10279
  // ── Auto-mode stuck detection & nudge ───────────────────────────────
10171
10280
  static MAX_STUCK_NUDGES = 3;
10172
- /** The prompt delivered into the live TUI when an auto agent looks stuck. It
10173
- * must offer BOTH escape routes so the agent never just goes idle. */
10281
+ /** The build-phase nudge (In Progress, no PR). Offers BOTH escape routes so
10282
+ * the agent never just goes idle. */
10174
10283
  static STUCK_PROMPT = "You are in Auto mode, your task is still In Progress, and there is no open pull request. Do ONE of these right now: (1) open a pull request with the create_pull_request tool, OR (2) call post_to_chat to explain exactly where you are stuck and what you need from a human. Do not finish or go idle silently.";
10284
+ /** The planning-phase nudge (still Planning, identification/plan unfinished).
10285
+ * Same two-escape-route shape, aimed at finishing discovery instead of a PR. */
10286
+ static STUCK_PROMPT_PLANNING = "You are in Auto mode and still in the Planning phase \u2014 identification and/or the plan are not finished. Do ONE of these right now: (1) finish identifying the task (set the story points) and save the plan with the update_task_plan tool, then call ExitPlanMode to begin building, OR (2) call post_to_chat to explain exactly where you are stuck and what you need from a human. Do not finish or go idle silently.";
10175
10287
  /**
10176
- * An auto task is "stuck" when its last turn ended (the call sites run
10177
- * post-turn, so the TUI is no longer actively working), it has no open PR,
10178
- * AND the agent did not communicate in chat this turn. If the agent posted a
10179
- * message (explained itself / asked for help) it is waiting on a human, not
10180
- * silently stuck, so we leave it attached instead of nudging.
10288
+ * Classify how an auto task is "stuck" after its turn ended (call sites run
10289
+ * post-turn, so the TUI is no longer working), or null if it isn't. Shared
10290
+ * guards: only auto, not rate-limited, under the nudge cap, and the agent
10291
+ * stayed silent this turn if it posted to chat (explained itself / asked for
10292
+ * help) it's waiting on a human, not silently stuck.
10293
+ *
10294
+ * - "pr": In Progress with no open PR (build finished without shipping).
10295
+ * - "planning": still Planning without (identified + saved plan). A fresh auto
10296
+ * agent that finishes its plan turn WITHOUT ExitPlanMode would otherwise idle
10297
+ * → clean-exit → its session Ends → the workspace is reaped "agent_gone"
10298
+ * before a plan ever landed. Nudging (and, on exhaustion, going dormant)
10299
+ * keeps the session Active so the pod is never prematurely slept.
10181
10300
  */
10301
+ autoStuckKind() {
10302
+ if (!this.mode.isAuto || this.stopped) return null;
10303
+ if (this.queryBridge?.wasRateLimited) return null;
10304
+ if (this.prNudgeCount > _SessionRunner.MAX_STUCK_NUDGES) return null;
10305
+ if (this.agentSpokeThisTurn) return null;
10306
+ if (!this.taskContext) return null;
10307
+ const { status, storyPointId, plan, githubPRUrl } = this.taskContext;
10308
+ if (status === "InProgress" && !githubPRUrl) return "pr";
10309
+ if (status === "Planning" && !(storyPointId !== null && !!plan?.trim())) {
10310
+ return "planning";
10311
+ }
10312
+ return null;
10313
+ }
10182
10314
  isAutoStuck() {
10183
- if (!this.mode.isAuto || this.stopped) return false;
10184
- if (this.queryBridge?.wasRateLimited) return false;
10185
- if (this.prNudgeCount > _SessionRunner.MAX_STUCK_NUDGES) return false;
10186
- if (this.agentSpokeThisTurn) return false;
10187
- if (!this.taskContext) return false;
10188
- return this.taskContext.status === "InProgress" && !this.taskContext.githubPRUrl;
10315
+ return this.autoStuckKind() !== null;
10189
10316
  }
10190
10317
  /**
10191
10318
  * Stop driving the agent and route the core loop into dormant idle —
@@ -10221,6 +10348,16 @@ var SessionRunner = class _SessionRunner {
10221
10348
  } catch {
10222
10349
  }
10223
10350
  }
10351
+ /** Prompt + chat-message label for a stuck-nudge of the given kind. */
10352
+ stuckNudgeContent(kind) {
10353
+ if (kind === "planning") {
10354
+ return {
10355
+ prompt: _SessionRunner.STUCK_PROMPT_PLANNING,
10356
+ situation: "still Planning with no identified plan"
10357
+ };
10358
+ }
10359
+ return { prompt: _SessionRunner.STUCK_PROMPT, situation: "still no PR" };
10360
+ }
10224
10361
  async maybeHandleStuckAuto() {
10225
10362
  await this.refreshTaskContext();
10226
10363
  if (!this.isAutoStuck()) return;
@@ -10228,17 +10365,18 @@ var SessionRunner = class _SessionRunner {
10228
10365
  this.prNudgeCount++;
10229
10366
  if (this.prNudgeCount > _SessionRunner.MAX_STUCK_NUDGES) {
10230
10367
  this.enterDormantForHumanTakeover(
10231
- `Auto-mode: I couldn't open a PR or get unstuck after ${_SessionRunner.MAX_STUCK_NUDGES} attempts. Staying connected \u2014 reply here or in the terminal and I'll keep working.`
10368
+ `Auto-mode: I couldn't finish or get unstuck after ${_SessionRunner.MAX_STUCK_NUDGES} attempts. Staying connected \u2014 reply here or in the terminal and I'll keep working.`
10232
10369
  );
10233
10370
  return;
10234
10371
  }
10372
+ const { prompt, situation } = this.stuckNudgeContent(this.autoStuckKind());
10235
10373
  const attempt = this.prNudgeCount;
10236
- const chatMsg = attempt === 1 ? "Auto-nudge: still In Progress with no PR \u2014 prompting the agent to finish or explain where it's stuck..." : `Auto-nudge (attempt ${attempt}/${_SessionRunner.MAX_STUCK_NUDGES}): still no PR \u2014 prompting the agent again...`;
10374
+ const chatMsg = attempt === 1 ? `Auto-nudge: ${situation} \u2014 prompting the agent to finish or explain where it's stuck...` : `Auto-nudge (attempt ${attempt}/${_SessionRunner.MAX_STUCK_NUDGES}): ${situation} \u2014 prompting the agent again...`;
10237
10375
  this.connection.postChatMessage(chatMsg);
10238
10376
  await this.setState("running");
10239
- await this.callbacks.onEvent({ type: "pr_nudge", prompt: _SessionRunner.STUCK_PROMPT });
10377
+ await this.callbacks.onEvent({ type: "pr_nudge", prompt });
10240
10378
  if (this.interrupted || this.stopped) return;
10241
- await this.executeQuery(_SessionRunner.STUCK_PROMPT);
10379
+ await this.executeQuery(prompt);
10242
10380
  if (this.interrupted || this.stopped) return;
10243
10381
  await this.refreshTaskContext();
10244
10382
  if (this.agentSpokeThisTurn && !this.taskContext?.githubPRUrl) {
@@ -10296,32 +10434,26 @@ var SessionRunner = class _SessionRunner {
10296
10434
  mode: this.config.runnerMode,
10297
10435
  isAuto: this.config.isAuto
10298
10436
  };
10299
- const bridge = new QueryBridge(
10300
- this.connection,
10301
- this.mode,
10302
- runnerConfig,
10303
- {
10304
- onStatusChange: (status) => {
10305
- if (status === "running" && this._state === "waiting_for_input") {
10306
- this._state = "running";
10307
- this.lifecycle.cancelIdleTimer();
10308
- }
10309
- return this.callbacks.onStatusChange(status);
10310
- },
10311
- onEvent: (event) => {
10312
- const evt = event;
10313
- if (evt.type === "tool_use" && isPostToChatTool(evt.tool)) {
10314
- this.agentSpokeThisTurn = true;
10315
- }
10316
- if (event.type === "completed") {
10317
- this.completedThisTurn = true;
10318
- void this.connection.sendHeartbeat();
10319
- }
10320
- return this.callbacks.onEvent(event);
10437
+ const bridge = new QueryBridge(this.connection, this.mode, runnerConfig, {
10438
+ onStatusChange: (status) => {
10439
+ if (status === "running" && this._state === "waiting_for_input") {
10440
+ this._state = "running";
10441
+ this.lifecycle.cancelIdleTimer();
10321
10442
  }
10443
+ return this.callbacks.onStatusChange(status);
10322
10444
  },
10323
- this.config.workspaceDir
10324
- );
10445
+ onEvent: (event) => {
10446
+ const evt = event;
10447
+ if (evt.type === "tool_use" && isPostToChatTool(evt.tool)) {
10448
+ this.agentSpokeThisTurn = true;
10449
+ }
10450
+ if (event.type === "completed") {
10451
+ this.completedThisTurn = true;
10452
+ void this.connection.sendHeartbeat();
10453
+ }
10454
+ return this.callbacks.onEvent(event);
10455
+ }
10456
+ });
10325
10457
  bridge.isParentTask = this.fullContext?.isParentTask ?? false;
10326
10458
  bridge.onSoftStop = () => {
10327
10459
  process.stderr.write("[conveyor-agent] Soft stop requested (discovery ExitPlanMode)\n");
@@ -10374,15 +10506,16 @@ var SessionRunner = class _SessionRunner {
10374
10506
  }
10375
10507
  });
10376
10508
  this.connection.onPullBranch(({ branch }) => {
10377
- handlePullBranch(this.config.workspaceDir, branch);
10509
+ void handlePullBranch(this.config.workspaceDir, branch);
10378
10510
  });
10379
10511
  this.connection.onFinalizeSnapshot(() => void this.finalizeSnapshotNow());
10380
10512
  }
10381
10513
  /** Eager finalize snapshot triggered by the reconciler's sleep signal
10382
- * (session:finalizeSnapshot). Runs pg_dump + a full capture and uploads it
10514
+ * (session:finalizeSnapshot). Runs a full workspace capture and uploads it
10383
10515
  * NOW so the sleep confirms on this snapshot instead of waiting for the
10384
- * ~2min periodic flush and so sidecar DB state (which ONLY finalizeForSleep
10385
- * captures via pg_dump) actually lands. Best-effort: on failure the
10516
+ * ~2min periodic flush. Sidecar DB state is intentionally NOT captured it
10517
+ * is no longer durable across sleep/wake (#2623); only the agent's
10518
+ * /workspace rides the snapshot. Best-effort: on failure the
10386
10519
  * periodic/shutdown path stays the fallback. No-op on non-v3 pods. */
10387
10520
  async finalizeSnapshotNow() {
10388
10521
  const uploadUrl = process.env.CONVEYOR_SNAPSHOT_UPLOAD_URL;
@@ -10403,9 +10536,7 @@ var SessionRunner = class _SessionRunner {
10403
10536
  }
10404
10537
  }
10405
10538
  });
10406
- process.stderr.write(
10407
- "[conveyor-agent] Finalize snapshot (pg_dump) uploaded on sleep signal\n"
10408
- );
10539
+ process.stderr.write("[conveyor-agent] Finalize snapshot uploaded on sleep signal\n");
10409
10540
  } catch {
10410
10541
  }
10411
10542
  }
@@ -10415,7 +10546,7 @@ var SessionRunner = class _SessionRunner {
10415
10546
  const res = await this.connection.call("refreshGithubToken", {
10416
10547
  sessionId: this.connection.sessionId
10417
10548
  });
10418
- updateRemoteToken(this.config.workspaceDir, res.token);
10549
+ await updateRemoteToken(this.config.workspaceDir, res.token);
10419
10550
  process.env.GITHUB_TOKEN = res.token;
10420
10551
  process.env.GH_TOKEN = res.token;
10421
10552
  process.stderr.write("[conveyor-agent] Proactively refreshed GitHub token\n");
@@ -10434,7 +10565,7 @@ var SessionRunner = class _SessionRunner {
10434
10565
  });
10435
10566
  if (ctx?.githubBranch && this.fullContext) {
10436
10567
  this.fullContext.githubBranch = ctx.githubBranch;
10437
- ensureOnTaskBranch(this.config.workspaceDir, ctx.githubBranch);
10568
+ await ensureOnTaskBranch(this.config.workspaceDir, ctx.githubBranch);
10438
10569
  }
10439
10570
  } catch {
10440
10571
  process.stderr.write(
@@ -10444,6 +10575,8 @@ var SessionRunner = class _SessionRunner {
10444
10575
  }
10445
10576
  async setState(status) {
10446
10577
  this._state = status;
10578
+ const loopStatus = status === "idle" ? "idle" : "active";
10579
+ this.loopLag.setStatus(loopStatus);
10447
10580
  await this.connection.emitStatus(status);
10448
10581
  await this.callbacks.onStatusChange(status);
10449
10582
  }
@@ -10452,6 +10585,7 @@ var SessionRunner = class _SessionRunner {
10452
10585
  `);
10453
10586
  this.connection.sendEvent({ type: "shutdown", reason: finalState });
10454
10587
  this.portDiscovery?.stop();
10588
+ this.loopLag.stop();
10455
10589
  this.lifecycle.destroy();
10456
10590
  try {
10457
10591
  await this.queryBridge?.dispose();
@@ -10501,12 +10635,12 @@ var SessionRunner = class _SessionRunner {
10501
10635
 
10502
10636
  // src/setup/config.ts
10503
10637
  import { readFile as readFile5 } from "fs/promises";
10504
- import { join as join8 } from "path";
10638
+ import { join as join7 } from "path";
10505
10639
  var DEVCONTAINER_PATH = ".devcontainer/conveyor/devcontainer.json";
10506
10640
  var DEVCONTAINER_PORT_DENY_LIST = /* @__PURE__ */ new Set([5432, 6379, 9200]);
10507
10641
  async function loadForwardPorts(workspaceDir) {
10508
10642
  try {
10509
- const raw = await readFile5(join8(workspaceDir, DEVCONTAINER_PATH), "utf-8");
10643
+ const raw = await readFile5(join7(workspaceDir, DEVCONTAINER_PATH), "utf-8");
10510
10644
  const parsed = JSON.parse(raw);
10511
10645
  const ports = (parsed.forwardPorts ?? []).filter(
10512
10646
  (p) => typeof p === "number" && !DEVCONTAINER_PORT_DENY_LIST.has(p)
@@ -10538,19 +10672,17 @@ function buildSessionPreviewPorts(result) {
10538
10672
  function loadConveyorConfig() {
10539
10673
  const envSetup = process.env.CONVEYOR_SETUP_COMMAND;
10540
10674
  const envStart = process.env.CONVEYOR_START_COMMAND;
10541
- const envPort = process.env.CONVEYOR_PREVIEW_PORT;
10542
10675
  if (envSetup || envStart) {
10543
10676
  return {
10544
10677
  setupCommand: envSetup,
10545
- startCommand: envStart,
10546
- previewPort: envPort ? Number(envPort) : void 0
10678
+ startCommand: envStart
10547
10679
  };
10548
10680
  }
10549
10681
  return null;
10550
10682
  }
10551
10683
 
10552
10684
  // src/setup/commands.ts
10553
- import { spawn, execSync as execSync5 } from "child_process";
10685
+ import { spawn, execSync } from "child_process";
10554
10686
  function runSetupCommand(cmd, cwd, onOutput) {
10555
10687
  return new Promise((resolve, reject) => {
10556
10688
  const child = spawn("sh", ["-c", cmd], {
@@ -10579,7 +10711,7 @@ function runSetupCommand(cmd, cwd, onOutput) {
10579
10711
  var AUTH_TOKEN_TIMEOUT_MS = 3e4;
10580
10712
  function runAuthTokenCommand(cmd, userEmail, cwd) {
10581
10713
  try {
10582
- const output = execSync5(`${cmd} ${JSON.stringify(userEmail)}`, {
10714
+ const output = execSync(`${cmd} ${JSON.stringify(userEmail)}`, {
10583
10715
  cwd,
10584
10716
  timeout: AUTH_TOKEN_TIMEOUT_MS,
10585
10717
  stdio: ["ignore", "pipe", "ignore"],
@@ -10609,10 +10741,10 @@ function runStartCommand(cmd, cwd, onOutput) {
10609
10741
  }
10610
10742
 
10611
10743
  // src/setup/codespace.ts
10612
- import { execSync as execSync6 } from "child_process";
10744
+ import { execSync as execSync2 } from "child_process";
10613
10745
  function unshallowRepo(workspaceDir) {
10614
10746
  try {
10615
- execSync6("git fetch --unshallow", {
10747
+ execSync2("git fetch --unshallow", {
10616
10748
  cwd: workspaceDir,
10617
10749
  timeout: 6e4,
10618
10750
  stdio: "ignore"
@@ -10622,10 +10754,10 @@ function unshallowRepo(workspaceDir) {
10622
10754
  }
10623
10755
 
10624
10756
  export {
10625
- DEFAULT_SONNET_MODEL,
10626
10757
  fetchBootstrap,
10627
10758
  applyBootstrapToEnv,
10628
10759
  AgentConnection,
10760
+ DEFAULT_SONNET_MODEL,
10629
10761
  DEFAULT_LIFECYCLE_CONFIG,
10630
10762
  Lifecycle,
10631
10763
  PtyHarness,
@@ -10637,7 +10769,7 @@ export {
10637
10769
  updateRemoteToken,
10638
10770
  flushPendingChanges,
10639
10771
  pushToOrigin,
10640
- PlanSync,
10772
+ resolveSessionStart,
10641
10773
  sampleKeyUsage,
10642
10774
  awaitGitReady,
10643
10775
  uploadSnapshotToGcs,
@@ -10655,4 +10787,4 @@ export {
10655
10787
  runStartCommand,
10656
10788
  unshallowRepo
10657
10789
  };
10658
- //# sourceMappingURL=chunk-YGLTLUGA.js.map
10790
+ //# sourceMappingURL=chunk-DEMRCBJN.js.map