@rallycry/conveyor-agent 10.2.2 → 10.2.5

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
@@ -451,8 +458,12 @@ var AgentConnection = class _AgentConnection {
451
458
  void this.refreshTaskTokenFromBootstrap().catch(() => {
452
459
  });
453
460
  });
454
- this.socket.io.on("reconnect", () => {
455
- 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();
456
467
  void this.reconnectToSession();
457
468
  });
458
469
  this.socket.io.on("reconnect_attempt", () => {
@@ -462,6 +473,7 @@ var AgentConnection = class _AgentConnection {
462
473
  disconnect() {
463
474
  this.stopProactiveTokenRefresh();
464
475
  this.clearSocketRecycle();
476
+ this.stopHeartbeatWorker();
465
477
  void this.flushEvents();
466
478
  if (this.socket) {
467
479
  this.socket.io.reconnection(false);
@@ -668,6 +680,19 @@ var AgentConnection = class _AgentConnection {
668
680
  }).catch(() => {
669
681
  });
670
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
+ }
671
696
  /**
672
697
  * Report that the interactive CLI process for this session has died and no
673
698
  * respawn is imminent (fire-and-forget). The server clears the scrollback
@@ -777,7 +802,7 @@ var AgentConnection = class _AgentConnection {
777
802
  if (this.recentMessages.length > 3) this.recentMessages.shift();
778
803
  return { duplicate: false };
779
804
  }
780
- sendHeartbeat() {
805
+ sendHeartbeat(loopLagMs) {
781
806
  if (!this.socket) return;
782
807
  const statusMap = {
783
808
  running: "active",
@@ -790,10 +815,67 @@ var AgentConnection = class _AgentConnection {
790
815
  void this.call("heartbeat", {
791
816
  sessionId: this.config.sessionId,
792
817
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
793
- status: heartbeatStatus
818
+ status: heartbeatStatus,
819
+ ...loopLagMs !== void 0 && loopLagMs > 0 ? { loopLagMs: Math.round(loopLagMs) } : {}
794
820
  }).catch(() => {
795
821
  });
796
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
+ }
797
879
  emitModeChanged(agentMode) {
798
880
  this.sendEvent({ type: "mode_changed", agentMode });
799
881
  }
@@ -927,6 +1009,7 @@ ${q.question}${q.options.length ? "\n" + q.options.map((o) => `- ${o.label}: ${o
927
1009
  auth.taskToken = result.config.taskToken;
928
1010
  }
929
1011
  }
1012
+ this.heartbeatWorker?.postMessage({ taskToken: result.config.taskToken });
930
1013
  }
931
1014
  const refreshedClaude = Boolean(result.config.envVars?.CLAUDE_CODE_OAUTH_TOKEN);
932
1015
  return { refreshedClaude, refreshedTaskToken };
@@ -959,6 +1042,7 @@ ${q.question}${q.options.length ? "\n" + q.options.map((o) => `- ${o.label}: ${o
959
1042
  auth.taskToken = bundle.sessionJwt;
960
1043
  }
961
1044
  }
1045
+ this.heartbeatWorker?.postMessage({ taskToken: bundle.sessionJwt });
962
1046
  }
963
1047
  const refreshedClaude = Boolean(bundle.envVars?.CLAUDE_CODE_OAUTH_TOKEN);
964
1048
  return { refreshedClaude, refreshedTaskToken };
@@ -1040,14 +1124,25 @@ function createServiceLogger(service) {
1040
1124
  }
1041
1125
 
1042
1126
  // src/runner/git-utils.ts
1043
- import { execSync, exec } from "child_process";
1127
+ import { execFile } from "child_process";
1044
1128
  import { realpathSync } from "fs";
1045
1129
  import { promisify } from "util";
1046
- var execAsync = promisify(exec);
1047
- 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) {
1048
1143
  if (!baseBranch) return true;
1049
1144
  try {
1050
- execSync(`git fetch origin ${baseBranch}`, { cwd, stdio: "ignore", timeout: 6e4 });
1145
+ await git(cwd, ["fetch", "origin", baseBranch]);
1051
1146
  } catch {
1052
1147
  process.stderr.write(
1053
1148
  `[conveyor-agent] Warning: git fetch origin ${baseBranch} failed, continuing with current base
@@ -1056,14 +1151,14 @@ function syncWithBaseBranch(cwd, baseBranch) {
1056
1151
  return false;
1057
1152
  }
1058
1153
  try {
1059
- execSync(`git merge origin/${baseBranch} --no-edit`, { cwd, stdio: "ignore", timeout: 3e4 });
1154
+ await git(cwd, ["merge", `origin/${baseBranch}`, "--no-edit"], 3e4);
1060
1155
  } catch {
1061
1156
  process.stderr.write(
1062
1157
  `[conveyor-agent] Warning: merge origin/${baseBranch} failed, aborting merge and continuing
1063
1158
  `
1064
1159
  );
1065
1160
  try {
1066
- execSync("git merge --abort", { cwd, stdio: "ignore" });
1161
+ await git(cwd, ["merge", "--abort"], 3e4);
1067
1162
  } catch {
1068
1163
  }
1069
1164
  return false;
@@ -1072,23 +1167,19 @@ function syncWithBaseBranch(cwd, baseBranch) {
1072
1167
  `);
1073
1168
  return true;
1074
1169
  }
1075
- function ensureOnTaskBranch(cwd, taskBranch) {
1170
+ async function ensureOnTaskBranch(cwd, taskBranch) {
1076
1171
  if (!taskBranch) return true;
1077
- const current = getCurrentBranch(cwd);
1172
+ const current = await getCurrentBranch(cwd);
1078
1173
  if (current === taskBranch) return true;
1079
1174
  try {
1080
- execSync(`git fetch origin ${taskBranch}`, { cwd, stdio: "ignore", timeout: 6e4 });
1175
+ await git(cwd, ["fetch", "origin", taskBranch]);
1081
1176
  } catch {
1082
1177
  process.stderr.write(`[conveyor-agent] Warning: git fetch origin ${taskBranch} failed
1083
1178
  `);
1084
1179
  return false;
1085
1180
  }
1086
1181
  try {
1087
- execSync(`git checkout -B ${taskBranch} origin/${taskBranch}`, {
1088
- cwd,
1089
- stdio: "ignore",
1090
- timeout: 3e4
1091
- });
1182
+ await git(cwd, ["checkout", "-B", taskBranch, `origin/${taskBranch}`], 3e4);
1092
1183
  } catch {
1093
1184
  process.stderr.write(`[conveyor-agent] Warning: git checkout ${taskBranch} failed
1094
1185
  `);
@@ -1098,74 +1189,62 @@ function ensureOnTaskBranch(cwd, taskBranch) {
1098
1189
  `);
1099
1190
  return true;
1100
1191
  }
1101
- function hasUncommittedChanges(cwd) {
1102
- const status = execSync("git status --porcelain", {
1103
- cwd,
1104
- stdio: ["ignore", "pipe", "ignore"]
1105
- }).toString().trim();
1192
+ async function hasUncommittedChanges(cwd) {
1193
+ const status = await git(cwd, ["status", "--porcelain"], GIT_SLOW_TIMEOUT_MS);
1106
1194
  return status.length > 0;
1107
1195
  }
1108
- function getCurrentBranch(cwd) {
1196
+ async function getCurrentBranch(cwd) {
1109
1197
  try {
1110
- const branch = execSync("git branch --show-current", {
1111
- cwd,
1112
- stdio: ["ignore", "pipe", "ignore"]
1113
- }).toString().trim();
1198
+ const branch = await git(cwd, ["branch", "--show-current"]);
1114
1199
  return branch || null;
1115
1200
  } catch {
1116
1201
  return null;
1117
1202
  }
1118
1203
  }
1119
- function hasUnpushedCommits(cwd) {
1204
+ async function hasUnpushedCommits(cwd) {
1120
1205
  try {
1121
- const currentBranch = getCurrentBranch(cwd);
1206
+ const currentBranch = await getCurrentBranch(cwd);
1122
1207
  if (!currentBranch) return false;
1123
1208
  try {
1124
- execSync(`git rev-parse origin/${currentBranch}`, {
1125
- cwd,
1126
- stdio: ["ignore", "pipe", "ignore"]
1127
- });
1209
+ await git(cwd, ["rev-parse", `origin/${currentBranch}`]);
1128
1210
  } catch {
1129
1211
  try {
1130
- execSync("git rev-parse HEAD", { cwd, stdio: ["ignore", "pipe", "ignore"] });
1212
+ await git(cwd, ["rev-parse", "HEAD"]);
1131
1213
  return true;
1132
1214
  } catch {
1133
1215
  return false;
1134
1216
  }
1135
1217
  }
1136
- const ahead = execSync(`git rev-list --count HEAD --not origin/${currentBranch}`, {
1137
- cwd,
1138
- stdio: ["ignore", "pipe", "ignore"]
1139
- }).toString().trim();
1218
+ const ahead = await git(cwd, [
1219
+ "rev-list",
1220
+ "--count",
1221
+ "HEAD",
1222
+ "--not",
1223
+ `origin/${currentBranch}`
1224
+ ]);
1140
1225
  return parseInt(ahead, 10) > 0;
1141
1226
  } catch {
1142
1227
  return false;
1143
1228
  }
1144
1229
  }
1145
- function stageAndCommit(cwd, message) {
1230
+ async function stageAndCommit(cwd, message) {
1146
1231
  try {
1147
- execSync("git add -A", { cwd, stdio: ["ignore", "pipe", "ignore"] });
1148
- if (!hasUncommittedChanges(cwd)) return null;
1149
- execSync(`git commit -m ${JSON.stringify(message)}`, {
1150
- cwd,
1151
- stdio: ["ignore", "pipe", "ignore"]
1152
- });
1153
- 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"]);
1154
1236
  } catch {
1155
1237
  return null;
1156
1238
  }
1157
1239
  }
1158
1240
  async function tryPush(cwd, branch, skipVerify = false) {
1159
- const noVerify = skipVerify ? "--no-verify " : "";
1241
+ const noVerify = skipVerify ? ["--no-verify"] : [];
1160
1242
  try {
1161
- await execAsync(`git push ${noVerify}origin ${branch}`, { cwd, timeout: 3e4 });
1243
+ await git(cwd, ["push", ...noVerify, "origin", branch], 3e4);
1162
1244
  return true;
1163
1245
  } catch {
1164
1246
  try {
1165
- await execAsync(`git push ${noVerify}--force-with-lease origin ${branch}`, {
1166
- cwd,
1167
- timeout: 3e4
1168
- });
1247
+ await git(cwd, ["push", ...noVerify, "--force-with-lease", "origin", branch], 3e4);
1169
1248
  return true;
1170
1249
  } catch {
1171
1250
  return false;
@@ -1174,7 +1253,7 @@ async function tryPush(cwd, branch, skipVerify = false) {
1174
1253
  }
1175
1254
  async function isAuthError(cwd) {
1176
1255
  try {
1177
- await execAsync("git push --dry-run", { cwd, timeout: 3e4 });
1256
+ await git(cwd, ["push", "--dry-run"], 3e4);
1178
1257
  return false;
1179
1258
  } catch (err) {
1180
1259
  if (err.killed) return true;
@@ -1184,16 +1263,18 @@ async function isAuthError(cwd) {
1184
1263
  return /authentication|authorization|403|401|token/i.test(msg);
1185
1264
  }
1186
1265
  }
1187
- function updateRemoteToken(cwd, token) {
1266
+ async function updateRemoteToken(cwd, token) {
1188
1267
  try {
1189
- 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"]);
1190
1269
  const match = url.match(/github\.com[/:]([^/]+\/[^/.]+)/);
1191
1270
  if (match) {
1192
1271
  const repo = match[1].replace(/\.git$/, "");
1193
- execSync(
1194
- `git remote set-url origin "https://x-access-token:${token}@github.com/${repo}.git"`,
1195
- { cwd, stdio: ["ignore", "pipe", "ignore"] }
1196
- );
1272
+ await git(cwd, [
1273
+ "remote",
1274
+ "set-url",
1275
+ "origin",
1276
+ `https://x-access-token:${token}@github.com/${repo}.git`
1277
+ ]);
1197
1278
  }
1198
1279
  } catch {
1199
1280
  }
@@ -1202,30 +1283,24 @@ function wipRefForBranch(branch) {
1202
1283
  return `conveyor-wip/${branch}`;
1203
1284
  }
1204
1285
  var wipRefPushed = /* @__PURE__ */ new Set();
1205
- function createWipSnapshot(cwd, message) {
1286
+ async function createWipSnapshot(cwd, message) {
1206
1287
  try {
1207
- execSync("git add -A", { cwd, stdio: ["ignore", "pipe", "ignore"] });
1208
- const sha = execSync(`git stash create ${JSON.stringify(message)}`, {
1209
- cwd,
1210
- stdio: ["ignore", "pipe", "ignore"]
1211
- }).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);
1212
1290
  return sha || null;
1213
1291
  } catch {
1214
1292
  return null;
1215
1293
  } finally {
1216
1294
  try {
1217
- execSync("git reset -q", { cwd, stdio: ["ignore", "pipe", "ignore"] });
1295
+ await git(cwd, ["reset", "-q"], GIT_SLOW_TIMEOUT_MS);
1218
1296
  } catch {
1219
1297
  }
1220
1298
  }
1221
1299
  }
1222
- function tryPushRefspec(cwd, refspec, force = false) {
1300
+ async function tryPushRefspec(cwd, refspec, force = false) {
1223
1301
  try {
1224
- execSync(`git push --no-verify ${force ? "--force " : ""}origin ${JSON.stringify(refspec)}`, {
1225
- cwd,
1226
- stdio: ["ignore", "pipe", "pipe"],
1227
- timeout: 3e4
1228
- });
1302
+ const forceArgs = force ? ["--force"] : [];
1303
+ await git(cwd, ["push", "--no-verify", ...forceArgs, "origin", refspec], 3e4);
1229
1304
  return true;
1230
1305
  } catch {
1231
1306
  return false;
@@ -1236,49 +1311,39 @@ async function refreshRemoteToken(cwd, refreshToken) {
1236
1311
  try {
1237
1312
  const token = await refreshToken();
1238
1313
  if (token) {
1239
- updateRemoteToken(cwd, token);
1314
+ await updateRemoteToken(cwd, token);
1240
1315
  process.env.GITHUB_TOKEN = token;
1241
1316
  process.env.GH_TOKEN = token;
1242
1317
  }
1243
1318
  } catch {
1244
1319
  }
1245
1320
  }
1246
- function restoreWipSnapshot(cwd, branch) {
1321
+ async function restoreWipSnapshot(cwd, branch) {
1247
1322
  if (!branch) return "none";
1248
1323
  const ref = wipRefForBranch(branch);
1249
1324
  try {
1250
- execSync(`git fetch origin +refs/heads/${ref}:refs/remotes/origin/${ref}`, {
1251
- cwd,
1252
- stdio: "ignore",
1253
- timeout: 6e4
1254
- });
1325
+ await git(cwd, ["fetch", "origin", `+refs/heads/${ref}:refs/remotes/origin/${ref}`]);
1255
1326
  } catch {
1256
1327
  return "none";
1257
1328
  }
1258
1329
  wipRefPushed.add(cwd);
1259
1330
  try {
1260
- const sha = execSync(`git rev-parse refs/remotes/origin/${ref}`, {
1261
- cwd,
1262
- stdio: ["ignore", "pipe", "ignore"]
1263
- }).toString().trim();
1264
- const parent = execSync(`git rev-parse "${sha}^"`, {
1265
- cwd,
1266
- stdio: ["ignore", "pipe", "ignore"]
1267
- }).toString().trim();
1268
- 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"]);
1269
1334
  if (parent !== head) {
1270
1335
  try {
1271
- execSync(`git stash apply ${sha}`, { cwd, stdio: ["ignore", "pipe", "pipe"] });
1336
+ await git(cwd, ["stash", "apply", sha], GIT_SLOW_TIMEOUT_MS);
1272
1337
  return "applied";
1273
1338
  } catch {
1274
1339
  try {
1275
- execSync("git reset --merge", { cwd, stdio: "ignore" });
1340
+ await git(cwd, ["reset", "--merge"], GIT_SLOW_TIMEOUT_MS);
1276
1341
  } catch {
1277
1342
  }
1278
1343
  return "stale";
1279
1344
  }
1280
1345
  }
1281
- execSync(`git stash apply ${sha}`, { cwd, stdio: ["ignore", "pipe", "pipe"] });
1346
+ await git(cwd, ["stash", "apply", sha], GIT_SLOW_TIMEOUT_MS);
1282
1347
  return "applied";
1283
1348
  } catch {
1284
1349
  return "failed";
@@ -1289,10 +1354,10 @@ async function flushPendingChanges(cwd, opts) {
1289
1354
  let pushed = false;
1290
1355
  let hadWork = false;
1291
1356
  try {
1292
- const branch = getCurrentBranch(cwd);
1357
+ const branch = await getCurrentBranch(cwd);
1293
1358
  if (!branch) return { committed, pushed, hadWork };
1294
- const dirty = hasUncommittedChanges(cwd);
1295
- const unpushed = hasUnpushedCommits(cwd);
1359
+ const dirty = await hasUncommittedChanges(cwd);
1360
+ const unpushed = await hasUnpushedCommits(cwd);
1296
1361
  if (!dirty && !unpushed) {
1297
1362
  await dropStaleWipRef(cwd, branch, opts?.refreshToken);
1298
1363
  return { committed, pushed, hadWork };
@@ -1304,9 +1369,9 @@ async function flushPendingChanges(cwd, opts) {
1304
1369
  }
1305
1370
  if (dirty) {
1306
1371
  const message = opts?.wipMessage ?? "WIP: conveyor-agent snapshot";
1307
- const sha = createWipSnapshot(cwd, message);
1372
+ const sha = await createWipSnapshot(cwd, message);
1308
1373
  if (sha) {
1309
- committed = tryPushRefspec(cwd, `${sha}:refs/heads/${wipRefForBranch(branch)}`, true);
1374
+ committed = await tryPushRefspec(cwd, `${sha}:refs/heads/${wipRefForBranch(branch)}`, true);
1310
1375
  if (committed) wipRefPushed.add(cwd);
1311
1376
  }
1312
1377
  }
@@ -1317,19 +1382,19 @@ async function flushPendingChanges(cwd, opts) {
1317
1382
  async function dropStaleWipRef(cwd, branch, refreshToken) {
1318
1383
  if (!wipRefPushed.has(cwd)) return;
1319
1384
  await refreshRemoteToken(cwd, refreshToken);
1320
- if (tryPushRefspec(cwd, `:refs/heads/${wipRefForBranch(branch)}`)) {
1385
+ if (await tryPushRefspec(cwd, `:refs/heads/${wipRefForBranch(branch)}`)) {
1321
1386
  wipRefPushed.delete(cwd);
1322
1387
  }
1323
1388
  }
1324
1389
  async function pushToOrigin(cwd, refreshToken, skipVerify = false) {
1325
1390
  try {
1326
- const currentBranch = getCurrentBranch(cwd);
1391
+ const currentBranch = await getCurrentBranch(cwd);
1327
1392
  if (!currentBranch) return false;
1328
1393
  if (refreshToken) {
1329
1394
  try {
1330
1395
  const token = await refreshToken();
1331
1396
  if (token) {
1332
- updateRemoteToken(cwd, token);
1397
+ await updateRemoteToken(cwd, token);
1333
1398
  process.env.GITHUB_TOKEN = token;
1334
1399
  process.env.GH_TOKEN = token;
1335
1400
  }
@@ -1340,7 +1405,7 @@ async function pushToOrigin(cwd, refreshToken, skipVerify = false) {
1340
1405
  if (refreshToken && await isAuthError(cwd)) {
1341
1406
  const token = await refreshToken();
1342
1407
  if (token) {
1343
- updateRemoteToken(cwd, token);
1408
+ await updateRemoteToken(cwd, token);
1344
1409
  process.env.GITHUB_TOKEN = token;
1345
1410
  process.env.GH_TOKEN = token;
1346
1411
  return await tryPush(cwd, currentBranch, skipVerify);
@@ -1354,12 +1419,9 @@ async function pushToOrigin(cwd, refreshToken, skipVerify = false) {
1354
1419
  function branchBackupRef(branch) {
1355
1420
  return `conveyor-wip/branches/${branch}`;
1356
1421
  }
1357
- function listWorktrees(cwd) {
1422
+ async function listWorktrees(cwd) {
1358
1423
  try {
1359
- const out = execSync("git worktree list --porcelain", {
1360
- cwd,
1361
- stdio: ["ignore", "pipe", "ignore"]
1362
- }).toString();
1424
+ const out = await git(cwd, ["worktree", "list", "--porcelain"]);
1363
1425
  const result = [];
1364
1426
  for (const entry of out.split("\n\n")) {
1365
1427
  const lines = entry.trim().split("\n");
@@ -1376,22 +1438,17 @@ function listWorktrees(cwd) {
1376
1438
  return [];
1377
1439
  }
1378
1440
  }
1379
- function listLocalBranches(cwd) {
1441
+ async function listLocalBranches(cwd) {
1380
1442
  try {
1381
- return execSync("git for-each-ref --format='%(refname:short)' refs/heads/", {
1382
- cwd,
1383
- stdio: ["ignore", "pipe", "ignore"]
1384
- }).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);
1385
1445
  } catch {
1386
1446
  return [];
1387
1447
  }
1388
1448
  }
1389
- function branchUnpushedCount(cwd, branch) {
1449
+ async function branchUnpushedCount(cwd, branch) {
1390
1450
  try {
1391
- const n = execSync(`git rev-list --count ${JSON.stringify(branch)} --not --remotes=origin`, {
1392
- cwd,
1393
- stdio: ["ignore", "pipe", "ignore"]
1394
- }).toString().trim();
1451
+ const n = await git(cwd, ["rev-list", "--count", branch, "--not", "--remotes=origin"]);
1395
1452
  return Number.parseInt(n, 10) || 0;
1396
1453
  } catch {
1397
1454
  return 0;
@@ -1408,9 +1465,9 @@ async function flushAllPendingWork(cwd, opts) {
1408
1465
  try {
1409
1466
  const primary = await flushPendingChanges(cwd, opts);
1410
1467
  await refreshRemoteToken(cwd, opts?.refreshToken);
1411
- const currentBranch = getCurrentBranch(cwd);
1412
- const worktreesSnapshotted = snapshotOtherWorktrees(cwd, opts?.wipMessage);
1413
- const branchesBackedUp = backupOtherBranches(cwd, currentBranch);
1468
+ const currentBranch = await getCurrentBranch(cwd);
1469
+ const worktreesSnapshotted = await snapshotOtherWorktrees(cwd, opts?.wipMessage);
1470
+ const branchesBackedUp = await backupOtherBranches(cwd, currentBranch);
1414
1471
  return {
1415
1472
  hadWork: primary.hadWork || worktreesSnapshotted > 0 || branchesBackedUp > 0,
1416
1473
  branchesBackedUp,
@@ -1420,14 +1477,14 @@ async function flushAllPendingWork(cwd, opts) {
1420
1477
  return { hadWork: false, branchesBackedUp: 0, worktreesSnapshotted: 0 };
1421
1478
  }
1422
1479
  }
1423
- function snapshotOtherWorktrees(cwd, wipMessage) {
1480
+ async function snapshotOtherWorktrees(cwd, wipMessage) {
1424
1481
  let count = 0;
1425
- for (const wt of listWorktrees(cwd)) {
1482
+ for (const wt of await listWorktrees(cwd)) {
1426
1483
  if (samePath(wt.path, cwd) || !wt.branch) continue;
1427
1484
  try {
1428
- if (!hasUncommittedChanges(wt.path)) continue;
1429
- const sha = createWipSnapshot(wt.path, wipMessage ?? "WIP: conveyor-agent snapshot");
1430
- if (sha && tryPushRefspec(wt.path, `${sha}:refs/heads/${wipRefForBranch(wt.branch)}`, true)) {
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)) {
1431
1488
  wipRefPushed.add(wt.path);
1432
1489
  count++;
1433
1490
  }
@@ -1436,13 +1493,17 @@ function snapshotOtherWorktrees(cwd, wipMessage) {
1436
1493
  }
1437
1494
  return count;
1438
1495
  }
1439
- function backupOtherBranches(cwd, currentBranch) {
1496
+ async function backupOtherBranches(cwd, currentBranch) {
1440
1497
  let count = 0;
1441
- for (const branch of listLocalBranches(cwd)) {
1498
+ for (const branch of await listLocalBranches(cwd)) {
1442
1499
  if (branch === currentBranch || branch.startsWith("conveyor-wip/")) continue;
1443
1500
  try {
1444
- if (branchUnpushedCount(cwd, branch) === 0) continue;
1445
- if (tryPushRefspec(cwd, `refs/heads/${branch}:refs/heads/${branchBackupRef(branch)}`, true)) {
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
+ )) {
1446
1507
  count++;
1447
1508
  }
1448
1509
  } catch {
@@ -1451,707 +1512,267 @@ function backupOtherBranches(cwd, currentBranch) {
1451
1512
  return count;
1452
1513
  }
1453
1514
 
1454
- // src/runner/plan-sync.ts
1455
- import { readdirSync, statSync, readFileSync } from "fs";
1456
- import { join as join2 } from "path";
1457
-
1458
- // src/harness/pty/settings.ts
1459
- import { mkdir, writeFile, chmod } from "fs/promises";
1460
- import { homedir } from "os";
1461
- import { join } from "path";
1462
- function claudeConfigHome() {
1463
- return process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
1515
+ // src/setup/git-ready.ts
1516
+ import { access, stat } from "fs/promises";
1517
+ var DEFAULT_FAILED_PATH = "/workspaces/.conveyor-git-failed";
1518
+ var DEFAULT_TIMEOUT_MS = 6e5;
1519
+ var DEFAULT_POLL_MS = 200;
1520
+ async function fileExists(path4) {
1521
+ try {
1522
+ await access(path4);
1523
+ const s = await stat(path4);
1524
+ return s.isFile();
1525
+ } catch {
1526
+ return false;
1527
+ }
1464
1528
  }
1465
- function projectSlug(cwd) {
1466
- return cwd.replace(/\//g, "-");
1529
+ function awaitGitReady(opts = {}) {
1530
+ const markerPath = opts.markerPath ?? process.env.CONVEYOR_GIT_READY_MARKER;
1531
+ if (!markerPath) {
1532
+ return Promise.resolve("not-gated");
1533
+ }
1534
+ const failedPath = opts.failedPath ?? process.env.CONVEYOR_GIT_FAILED_MARKER ?? DEFAULT_FAILED_PATH;
1535
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1536
+ const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
1537
+ opts.onLog?.(`waiting for workspace git (marker=${markerPath})`);
1538
+ return pollForMarkers(markerPath, failedPath, timeoutMs, pollMs, opts.onLog);
1467
1539
  }
1468
- function sessionTranscriptPath(cwd, sessionId) {
1469
- return join(claudeConfigHome(), "projects", projectSlug(cwd), `${sessionId}.jsonl`);
1540
+ async function pollForMarkers(markerPath, failedPath, timeoutMs, pollMs, onLog) {
1541
+ const deadline = Date.now() + timeoutMs;
1542
+ for (; ; ) {
1543
+ if (await fileExists(markerPath)) {
1544
+ onLog?.("workspace git ready");
1545
+ return "ready";
1546
+ }
1547
+ if (await fileExists(failedPath)) {
1548
+ onLog?.("workspace git preparation failed (marker present)");
1549
+ return "failed";
1550
+ }
1551
+ if (Date.now() >= deadline) {
1552
+ onLog?.(`workspace git not ready after ${timeoutMs}ms \u2014 giving up`);
1553
+ return "timeout";
1554
+ }
1555
+ await new Promise((resolve) => {
1556
+ setTimeout(resolve, pollMs);
1557
+ });
1558
+ }
1470
1559
  }
1471
- function configHomePlansDir() {
1472
- return join(claudeConfigHome(), "plans");
1560
+
1561
+ // src/runner/work-preservation/index.ts
1562
+ import { rm as rm3 } from "fs/promises";
1563
+
1564
+ // src/runner/work-preservation/restore-precedence.ts
1565
+ function selectRestoreSource(probe) {
1566
+ if (probe.gcsAvailable) return "gcs";
1567
+ if (probe.wipRestoreResult === "applied") return "wip";
1568
+ return "clean";
1473
1569
  }
1474
- var ALLOW_RULES = [
1475
- "Bash",
1476
- "BashOutput",
1477
- "Edit",
1478
- "Write",
1479
- "Read",
1480
- "Glob",
1481
- "Grep",
1482
- "WebFetch",
1483
- "WebSearch",
1484
- "NotebookEdit",
1485
- "Task",
1486
- "TodoWrite",
1487
- "ToolSearch",
1488
- "KillShell",
1489
- "SlashCommand",
1490
- "Skill",
1491
- "mcp__conveyor__*"
1492
- ];
1493
- var HOOK_HELPER_SOURCE = `"use strict";
1494
- const net = require("node:net");
1495
- const crypto = require("node:crypto");
1496
1570
 
1497
- const PRE_TOOL_USE_TIMEOUT_MS = 110000;
1498
- const ASK_USER_QUESTION_TIMEOUT_MS = 5000;
1571
+ // src/runner/work-preservation/snapshot-tar.ts
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";
1577
+ import { pipeline } from "stream/promises";
1578
+ import { promisify as promisify2 } from "util";
1579
+ import { createGzip } from "zlib";
1580
+ import * as tar from "tar";
1499
1581
 
1500
- let raw = "";
1501
- process.stdin.setEncoding("utf8");
1502
- process.stdin.on("data", (chunk) => {
1503
- raw += chunk;
1504
- });
1505
- process.stdin.on("end", () => {
1506
- let payload = {};
1582
+ // src/runner/work-preservation/snapshot-artifact.ts
1583
+ function planSnapshotFiles(git2) {
1584
+ const include = git2.trackedAndUntracked();
1585
+ const includeSet = new Set(include);
1586
+ const deletions = [...new Set(git2.deletedSinceHead())].filter((path4) => !includeSet.has(path4));
1587
+ return { include, deletions };
1588
+ }
1589
+
1590
+ // src/runner/work-preservation/snapshot-tar.ts
1591
+ var SNAPSHOT_MANIFEST_NAME = ".conveyor-snapshot-manifest.json";
1592
+ var LEGACY_PG_DUMP_FILENAME = ".conveyor-pgdump.sql";
1593
+ var STATUS_MAX_BUFFER = 64 * 1024 * 1024;
1594
+ function parseStatusPorcelainZ(raw) {
1595
+ const includes = [];
1596
+ const deletions = [];
1597
+ const tokens = raw.split("\0");
1598
+ for (let i = 0; i < tokens.length; i++) {
1599
+ const token = tokens[i];
1600
+ if (!token || token.length < 4) continue;
1601
+ const x = token[0];
1602
+ const y = token[1];
1603
+ const filePath = token.slice(3);
1604
+ if (x === "R" || x === "C") {
1605
+ const origPath = tokens[++i];
1606
+ if (x === "R" && origPath) deletions.push(origPath);
1607
+ }
1608
+ if (x === "!") continue;
1609
+ const missingFromWorktree = y === "D" || x === "D" && y === " ";
1610
+ if (missingFromWorktree) deletions.push(filePath);
1611
+ else includes.push(filePath);
1612
+ }
1613
+ return { includes, deletions };
1614
+ }
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());
1624
+ return {
1625
+ trackedAndUntracked: () => cached.includes,
1626
+ deletedSinceHead: () => cached.deletions
1627
+ };
1628
+ }
1629
+ async function gitHead(cwd) {
1507
1630
  try {
1508
- const parsed = JSON.parse(raw);
1509
- if (parsed && typeof parsed === "object") payload = parsed;
1631
+ const { stdout } = await execFileAsync2("git", ["rev-parse", "HEAD"], {
1632
+ cwd,
1633
+ timeout: GIT_TIMEOUT_MS2
1634
+ });
1635
+ return stdout.toString().trim() || null;
1510
1636
  } catch {
1511
- payload = {};
1512
- }
1513
- if (payload.hook_event_name === "PreToolUse") {
1514
- preToolUse(payload);
1515
- } else {
1516
- relayProgress(typeof payload.tool_name === "string" ? payload.tool_name : "");
1637
+ return null;
1517
1638
  }
1518
- });
1519
-
1520
- function relayProgress(toolName) {
1521
- const socketPath = process.env.CONVEYOR_HOOK_SOCKET;
1522
- let done = false;
1523
- const finish = () => {
1524
- if (done) return;
1525
- done = true;
1526
- process.stdout.write(JSON.stringify({ continue: true }));
1527
- process.exit(0);
1639
+ }
1640
+ async function buildSnapshotTar(cwd) {
1641
+ const staging = await mkdtemp(path.join(tmpdir(), "conveyor-snapshot-"));
1642
+ const cleanup = async () => {
1643
+ await rm(staging, { recursive: true, force: true }).catch(() => {
1644
+ });
1528
1645
  };
1529
- const fallback = setTimeout(finish, 250);
1530
- if (!socketPath) {
1531
- clearTimeout(fallback);
1532
- finish();
1533
- return;
1646
+ try {
1647
+ const plan = planSnapshotFiles(await realGitSurface(cwd));
1648
+ const head = await gitHead(cwd);
1649
+ if (!head) throw new Error("cannot snapshot a repo without a resolvable HEAD");
1650
+ const capturedAt = Date.now();
1651
+ const manifest = {
1652
+ head,
1653
+ deletions: plan.deletions,
1654
+ capturedAt
1655
+ };
1656
+ await writeFile(path.join(staging, SNAPSHOT_MANIFEST_NAME), JSON.stringify(manifest), "utf8");
1657
+ const stagedEntries = [SNAPSHOT_MANIFEST_NAME];
1658
+ const rawTarPath = path.join(staging, "snapshot.tar");
1659
+ await tar.create({ cwd: staging, file: rawTarPath, portable: true }, stagedEntries);
1660
+ if (plan.include.length > 0) {
1661
+ await tar.replace({ file: rawTarPath, cwd, portable: true }, plan.include);
1662
+ }
1663
+ const tarPath = path.join(staging, "snapshot.tar.gz");
1664
+ await pipeline(createReadStream(rawTarPath), createGzip(), createWriteStream(tarPath));
1665
+ await rm(rawTarPath, { force: true });
1666
+ const { size } = await stat2(tarPath);
1667
+ return {
1668
+ tarPath,
1669
+ sizeBytes: size,
1670
+ fileCount: plan.include.length,
1671
+ deletionCount: plan.deletions.length,
1672
+ capturedAt,
1673
+ cleanup
1674
+ };
1675
+ } catch (err) {
1676
+ await cleanup();
1677
+ throw err;
1534
1678
  }
1535
- const client = net.connect(socketPath, () => {
1536
- // PostToolUse does not supply tool duration, so elapsed_time_seconds is
1537
- // omitted rather than reported as a misleading 0 (the field is optional).
1538
- const line = JSON.stringify({ tool_name: toolName }) + "\\n";
1539
- client.write(line, () => {
1540
- client.end();
1541
- });
1542
- });
1543
- client.on("close", () => {
1544
- clearTimeout(fallback);
1545
- finish();
1546
- });
1547
- client.on("error", () => {
1548
- clearTimeout(fallback);
1549
- finish();
1550
- });
1551
1679
  }
1552
-
1553
- function preToolUse(payload) {
1554
- const socketPath = process.env.CONVEYOR_HOOK_SOCKET;
1555
- let done = false;
1556
- const respond = (decision, reason) => {
1557
- if (done) return;
1558
- done = true;
1559
- process.stdout.write(
1560
- JSON.stringify({
1561
- hookSpecificOutput: {
1562
- hookEventName: "PreToolUse",
1563
- permissionDecision: decision,
1564
- ...(reason ? { permissionDecisionReason: reason } : {}),
1565
- },
1566
- }),
1567
- );
1568
- process.exit(0);
1569
- };
1570
- // MCP tools are auto-allowed locally \u2014 no socket round trip, no fail-mode.
1571
- // Plan mode prompts for them despite permissions.allow (the CLI can't
1572
- // classify MCP tools as read-only), and the pod is the sandbox.
1573
- if (typeof payload.tool_name === "string" && payload.tool_name.startsWith("mcp__")) {
1574
- respond("allow");
1575
- return;
1576
- }
1577
- // AskUserQuestion is observe-only (status reporting) \u2014 fail OPEN so a
1578
- // missing/slow socket never denies the questionnaire. Everything else
1579
- // (ExitPlanMode) is a Conveyor gate \u2014 fail CLOSED.
1580
- const failOpen = payload.tool_name === "AskUserQuestion";
1581
- const respondUnavailable = () =>
1582
- failOpen
1583
- ? respond("allow")
1584
- : respond(
1585
- "deny",
1586
- "Conveyor validation is unavailable right now. Wait a moment and call the tool again.",
1587
- );
1588
- if (!socketPath) {
1589
- respondUnavailable();
1590
- return;
1591
- }
1592
- const id = crypto.randomBytes(8).toString("hex");
1593
- const timer = setTimeout(
1594
- respondUnavailable,
1595
- failOpen ? ASK_USER_QUESTION_TIMEOUT_MS : PRE_TOOL_USE_TIMEOUT_MS,
1596
- );
1597
- const client = net.connect(socketPath, () => {
1598
- client.write(
1599
- JSON.stringify({
1600
- kind: "pre_tool_use",
1601
- id,
1602
- tool_name: typeof payload.tool_name === "string" ? payload.tool_name : "",
1603
- tool_input:
1604
- payload.tool_input && typeof payload.tool_input === "object" ? payload.tool_input : {},
1605
- }) + "\\n",
1606
- );
1607
- });
1608
- let buffer = "";
1609
- client.on("data", (chunk) => {
1610
- buffer += chunk.toString("utf8");
1611
- let index = buffer.indexOf("\\n");
1612
- while (index >= 0) {
1613
- const line = buffer.slice(0, index);
1614
- buffer = buffer.slice(index + 1);
1615
- try {
1616
- const verdict = JSON.parse(line);
1617
- if (verdict && verdict.id === id) {
1618
- clearTimeout(timer);
1619
- client.end();
1620
- respond(verdict.decision === "allow" ? "allow" : "deny", verdict.reason);
1621
- return;
1622
- }
1623
- } catch {
1624
- /* keep scanning */
1625
- }
1626
- index = buffer.indexOf("\\n");
1627
- }
1628
- });
1629
- client.on("error", () => {
1630
- clearTimeout(timer);
1631
- respondUnavailable();
1632
- });
1633
- client.on("close", () => {
1634
- clearTimeout(timer);
1635
- respondUnavailable();
1636
- });
1680
+ function isWithinWorkspace(cwd, rel) {
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);
1637
1685
  }
1638
- `;
1639
- async function writeHookSettings(dir) {
1640
- const helperPath = join(dir, "hook-helper.cjs");
1641
- const settingsPath = join(dir, "settings.json");
1642
- await mkdir(dir, { recursive: true });
1643
- await writeFile(helperPath, HOOK_HELPER_SOURCE, "utf8");
1644
- await chmod(helperPath, 493);
1645
- const settings = {
1646
- // Pre-accept Claude Code's "Bypass Permissions mode" disclaimer. Build-capable
1647
- // spawns pass `--dangerously-skip-permissions`; on a real PTY the CLI otherwise
1648
- // parks on the interactive "Yes, I accept / No, exit" dialog whose default focus
1649
- // is "No, exit" — so it cannot be auto-dismissed by an Enter nudge and the run
1650
- // stalls until the auto-nudge watchdog shuts it down. The CLI reads this key
1651
- // from the `--settings` (flagSettings) layer via its bypass-mode gate, so
1652
- // setting it here suppresses the dialog deterministically on every spawn —
1653
- // independent of the `bypassPermissionsModeAccepted` seed in credentials.ts,
1654
- // which the CLI's one-time migration consumes and which is subject to
1655
- // persistence races on the codespace user-home.
1656
- skipDangerousModePermissionPrompt: true,
1657
- // Auto-approve tool calls so the interactive (PTY) agent never stops to
1658
- // prompt the viewer. This only suppresses per-tool approval prompts — it
1659
- // does NOT relax the planning gate: in discovery/plan mode the spawn passes
1660
- // `--permission-mode plan`, which keeps the agent read-only (no edits/commits
1661
- // until it exits plan mode) regardless of this allow-list. In build mode the
1662
- // spawn already bypasses prompts via `--dangerously-skip-permissions`.
1663
- // NOTE: a bare "*" rule is INVALID (the CLI rejects it and parks the TUI on
1664
- // a Settings Warning dialog at boot) — hence the explicit ALLOW_RULES list.
1665
- permissions: {
1666
- allow: ALLOW_RULES
1667
- },
1668
- hooks: {
1669
- PreToolUse: [
1670
- {
1671
- matcher: "ExitPlanMode",
1672
- hooks: [{ type: "command", command: `node ${JSON.stringify(helperPath)}`, timeout: 120 }]
1673
- },
1674
- {
1675
- // Observe-only: lets the session report waiting_for_input the moment
1676
- // the planning questionnaire renders. The helper fails open for this
1677
- // tool, so the questionnaire is never blocked by Conveyor.
1678
- matcher: "AskUserQuestion",
1679
- hooks: [{ type: "command", command: `node ${JSON.stringify(helperPath)}`, timeout: 30 }]
1680
- },
1681
- {
1682
- // Plan-permission spawns prompt for MCP tools even when they're in
1683
- // permissions.allow — the CLI can't classify them as read-only. A
1684
- // hook allow bypasses the permission engine in every mode. Loose by
1685
- // design: the pod is the sandbox, and planning agents must call
1686
- // update_task etc. The helper answers locally (no socket).
1687
- matcher: "mcp__.*",
1688
- hooks: [{ type: "command", command: `node ${JSON.stringify(helperPath)}`, timeout: 30 }]
1689
- }
1690
- ],
1691
- PostToolUse: [
1692
- {
1693
- matcher: "*",
1694
- hooks: [{ type: "command", command: `node ${JSON.stringify(helperPath)}` }]
1686
+ async function readSnapshotArchive(tarPath) {
1687
+ let manifestRaw = null;
1688
+ try {
1689
+ await tar.list({
1690
+ file: tarPath,
1691
+ onReadEntry: (entry) => {
1692
+ if (entry.path === SNAPSHOT_MANIFEST_NAME) {
1693
+ const chunks = [];
1694
+ entry.on("data", (chunk) => chunks.push(chunk));
1695
+ entry.on("end", () => {
1696
+ manifestRaw = Buffer.concat(chunks);
1697
+ });
1695
1698
  }
1696
- ]
1697
- }
1698
- };
1699
- await writeFile(settingsPath, JSON.stringify(settings, null, 2), "utf8");
1700
- return { settingsPath, helperPath };
1701
- }
1702
-
1703
- // src/runner/plan-sync.ts
1704
- var PlanSync = class {
1705
- planFileSnapshot = /* @__PURE__ */ new Map();
1706
- lockedPlanFile = null;
1707
- workspaceDir;
1708
- connection;
1709
- constructor(workspaceDir, connection) {
1710
- this.workspaceDir = workspaceDir;
1711
- this.connection = connection;
1712
- }
1713
- updateWorkspaceDir(workspaceDir) {
1714
- this.workspaceDir = workspaceDir;
1699
+ }
1700
+ });
1701
+ } catch {
1702
+ return null;
1715
1703
  }
1716
- getPlanDirs() {
1717
- return [join2(this.workspaceDir, ".claude", "plans"), configHomePlansDir()];
1704
+ if (!manifestRaw) return null;
1705
+ try {
1706
+ const parsed = JSON.parse(manifestRaw.toString("utf8"));
1707
+ if (typeof parsed.head !== "string" || !parsed.head) return null;
1708
+ return {
1709
+ head: parsed.head,
1710
+ deletions: Array.isArray(parsed.deletions) ? parsed.deletions : [],
1711
+ capturedAt: typeof parsed.capturedAt === "number" ? parsed.capturedAt : 0
1712
+ };
1713
+ } catch {
1714
+ return null;
1718
1715
  }
1719
- snapshotPlanFiles() {
1720
- this.planFileSnapshot.clear();
1721
- this.lockedPlanFile = null;
1722
- for (const plansDir of this.getPlanDirs()) {
1723
- try {
1724
- for (const file of readdirSync(plansDir).filter((f) => f.endsWith(".md"))) {
1725
- try {
1726
- const fullPath = join2(plansDir, file);
1727
- const stat6 = statSync(fullPath);
1728
- this.planFileSnapshot.set(fullPath, stat6.mtimeMs);
1729
- } catch {
1730
- continue;
1731
- }
1732
- }
1733
- } catch {
1734
- }
1735
- }
1716
+ }
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 };
1736
1723
  }
1737
- findNewestPlanFile() {
1738
- let newest = null;
1739
- for (const plansDir of this.getPlanDirs()) {
1740
- let files;
1741
- try {
1742
- files = readdirSync(plansDir).filter((f) => f.endsWith(".md"));
1743
- } catch {
1744
- continue;
1745
- }
1746
- for (const file of files) {
1747
- const fullPath = join2(plansDir, file);
1748
- try {
1749
- const stat6 = statSync(fullPath);
1750
- const prevMtime = this.planFileSnapshot.get(fullPath);
1751
- const isNew = prevMtime === void 0 || stat6.mtimeMs > prevMtime;
1752
- if (isNew && (!newest || stat6.mtimeMs > newest.mtime)) {
1753
- newest = { path: fullPath, mtime: stat6.mtimeMs };
1754
- }
1755
- } catch {
1756
- continue;
1757
- }
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;
1758
1731
  }
1732
+ filesExtracted++;
1733
+ return true;
1759
1734
  }
1760
- return newest;
1761
- }
1762
- async syncPlanFile() {
1763
- const isInitialDetection = this.lockedPlanFile === null;
1764
- const target = this.lockedPlanFile ?? this.findNewestPlanFile()?.path ?? null;
1765
- if (!target) return;
1766
- if (isInitialDetection) {
1767
- this.lockedPlanFile = target;
1768
- }
1769
- const fileName = target.split("/").pop() ?? "plan";
1770
- let content;
1771
- try {
1772
- content = readFileSync(target, "utf-8").trim();
1773
- } catch (err) {
1774
- const reason = err instanceof Error ? err.message : String(err);
1775
- this.connection.postChatMessage(
1776
- `Plan sync warning: could not read local plan file (${fileName}): ${reason}. The task plan was not updated.`
1777
- );
1778
- return;
1779
- }
1780
- if (!content) return;
1781
- const result = await this.connection.updateTaskFields({ plan: content });
1782
- if (!result.ok) {
1783
- this.connection.postChatMessage(
1784
- `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.`
1785
- );
1786
- return;
1787
- }
1788
- const verb = isInitialDetection ? "Detected local plan file" : "Synced local plan file";
1789
- const suffix = isInitialDetection ? " and synced it to the task plan." : " to the task plan.";
1790
- this.connection.postChatMessage(`${verb} (${fileName})${suffix}`);
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++;
1791
1741
  }
1792
- };
1742
+ return { status: "extracted", filesExtracted, deletionsApplied };
1743
+ }
1793
1744
 
1794
- // src/setup/git-ready.ts
1795
- import { access, stat } from "fs/promises";
1796
- var DEFAULT_FAILED_PATH = "/workspaces/.conveyor-git-failed";
1797
- var DEFAULT_TIMEOUT_MS = 6e5;
1798
- var DEFAULT_POLL_MS = 200;
1799
- async function fileExists(path4) {
1745
+ // src/runner/work-preservation/snapshot-transfer.ts
1746
+ import { randomUUID } from "crypto";
1747
+ import { createReadStream as createReadStream2, createWriteStream as createWriteStream2 } from "fs";
1748
+ import { rm as rm2 } from "fs/promises";
1749
+ import { tmpdir as tmpdir2 } from "os";
1750
+ import path2 from "path";
1751
+ import { Readable } from "stream";
1752
+ import { pipeline as pipeline2 } from "stream/promises";
1753
+ var SNAPSHOT_HTTP_TIMEOUT_MS = 6e4;
1754
+ var SNAPSHOT_CAPTURED_AT_HEADER = "x-snapshot-captured-at";
1755
+ async function putSnapshotToGcs(url, tarResult) {
1800
1756
  try {
1801
- await access(path4);
1802
- const s = await stat(path4);
1803
- return s.isFile();
1804
- } catch {
1805
- return false;
1806
- }
1807
- }
1808
- function awaitGitReady(opts = {}) {
1809
- const markerPath = opts.markerPath ?? process.env.CONVEYOR_GIT_READY_MARKER;
1810
- if (!markerPath) {
1811
- return Promise.resolve("not-gated");
1812
- }
1813
- const failedPath = opts.failedPath ?? process.env.CONVEYOR_GIT_FAILED_MARKER ?? DEFAULT_FAILED_PATH;
1814
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1815
- const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
1816
- opts.onLog?.(`waiting for workspace git (marker=${markerPath})`);
1817
- return pollForMarkers(markerPath, failedPath, timeoutMs, pollMs, opts.onLog);
1818
- }
1819
- async function pollForMarkers(markerPath, failedPath, timeoutMs, pollMs, onLog) {
1820
- const deadline = Date.now() + timeoutMs;
1821
- for (; ; ) {
1822
- if (await fileExists(markerPath)) {
1823
- onLog?.("workspace git ready");
1824
- return "ready";
1825
- }
1826
- if (await fileExists(failedPath)) {
1827
- onLog?.("workspace git preparation failed (marker present)");
1828
- return "failed";
1829
- }
1830
- if (Date.now() >= deadline) {
1831
- onLog?.(`workspace git not ready after ${timeoutMs}ms \u2014 giving up`);
1832
- return "timeout";
1833
- }
1834
- await new Promise((resolve) => {
1835
- setTimeout(resolve, pollMs);
1836
- });
1837
- }
1838
- }
1839
-
1840
- // src/runner/work-preservation/index.ts
1841
- import { rm as rm3 } from "fs/promises";
1842
-
1843
- // src/runner/work-preservation/pg-dump.ts
1844
- import { execSync as execSync2 } from "child_process";
1845
- import { randomUUID } from "crypto";
1846
- import { tmpdir } from "os";
1847
- import path from "path";
1848
- var PG_DUMP_FILENAME = ".conveyor-pgdump.sql";
1849
- function connectionDefaults(opts) {
1850
- return {
1851
- host: opts.host ?? "localhost",
1852
- port: opts.port ?? 5432,
1853
- db: opts.db ?? "conveyor"
1854
- };
1855
- }
1856
- function buildPgDumpCommand(opts) {
1857
- const { host, port, db } = connectionDefaults(opts);
1858
- return `pg_dump -h ${host} -p ${port} -U postgres ${db} -f ${JSON.stringify(
1859
- opts.outputPath
1860
- )} --no-owner --no-privileges`;
1861
- }
1862
- function buildPsqlRestoreCommand(opts) {
1863
- const { host, port, db } = connectionDefaults(opts);
1864
- return `psql -h ${host} -p ${port} -U postgres -d ${db} -f ${JSON.stringify(opts.dumpPath)}`;
1865
- }
1866
- function hasPostgresDep() {
1867
- return (process.env.CONVEYOR_DEPS ?? "").split(",").map((dep) => dep.trim()).includes("postgresql");
1868
- }
1869
- function execErrorDetail(err) {
1870
- const withStreams = err;
1871
- const stderr = withStreams.stderr instanceof Buffer ? withStreams.stderr.toString("utf8").trim() : typeof withStreams.stderr === "string" ? withStreams.stderr.trim() : "";
1872
- const message = withStreams.message ?? String(err);
1873
- return stderr ? `${message} \u2014 stderr: ${stderr}` : message;
1874
- }
1875
- function dumpPostgresIfPresent(exec2 = execSync2) {
1876
- if (!hasPostgresDep()) return Promise.resolve({ dumped: false });
1877
- const outputPath = path.join(tmpdir(), `conveyor-pgdump-${randomUUID()}.sql`);
1878
- try {
1879
- exec2(buildPgDumpCommand({ outputPath }), {
1880
- stdio: ["ignore", "pipe", "pipe"],
1881
- timeout: 6e4
1882
- });
1883
- return Promise.resolve({ dumped: true, path: outputPath });
1884
- } catch (err) {
1885
- process.stderr.write(`[conveyor-agent] pg_dump failed: ${execErrorDetail(err)}
1886
- `);
1887
- return Promise.resolve({ dumped: false });
1888
- }
1889
- }
1890
- function applyPgDumpIfConfigured(dumpPath, exec2 = execSync2) {
1891
- if (!hasPostgresDep()) return false;
1892
- try {
1893
- exec2(buildPsqlRestoreCommand({ dumpPath }), {
1894
- stdio: ["ignore", "pipe", "pipe"],
1895
- timeout: 12e4
1896
- });
1897
- process.stderr.write("[conveyor-agent] Restored postgres state from snapshot pg dump\n");
1898
- return true;
1899
- } catch (err) {
1900
- process.stderr.write(`[conveyor-agent] psql dump restore failed: ${execErrorDetail(err)}
1901
- `);
1902
- return false;
1903
- }
1904
- }
1905
-
1906
- // src/runner/work-preservation/restore-precedence.ts
1907
- function selectRestoreSource(probe) {
1908
- if (probe.gcsAvailable) return "gcs";
1909
- if (probe.wipRestoreResult === "applied") return "wip";
1910
- return "clean";
1911
- }
1912
-
1913
- // src/runner/work-preservation/snapshot-tar.ts
1914
- import { execSync as execSync3 } from "child_process";
1915
- import { createReadStream, createWriteStream, mkdtempSync } from "fs";
1916
- import { copyFile, mkdtemp, rm, stat as stat2, writeFile as writeFile2 } from "fs/promises";
1917
- import { tmpdir as tmpdir2 } from "os";
1918
- import path2 from "path";
1919
- import { pipeline } from "stream/promises";
1920
- import { createGzip } from "zlib";
1921
- import * as tar from "tar";
1922
-
1923
- // src/runner/work-preservation/snapshot-artifact.ts
1924
- function planSnapshotFiles(git) {
1925
- const include = git.trackedAndUntracked();
1926
- const includeSet = new Set(include);
1927
- const deletions = [...new Set(git.deletedSinceHead())].filter((path4) => !includeSet.has(path4));
1928
- return { include, deletions };
1929
- }
1930
-
1931
- // src/runner/work-preservation/snapshot-tar.ts
1932
- var SNAPSHOT_MANIFEST_NAME = ".conveyor-snapshot-manifest.json";
1933
- var STATUS_MAX_BUFFER = 64 * 1024 * 1024;
1934
- function parseStatusPorcelainZ(raw) {
1935
- const includes = [];
1936
- const deletions = [];
1937
- const tokens = raw.split("\0");
1938
- for (let i = 0; i < tokens.length; i++) {
1939
- const token = tokens[i];
1940
- if (!token || token.length < 4) continue;
1941
- const x = token[0];
1942
- const y = token[1];
1943
- const filePath = token.slice(3);
1944
- if (x === "R" || x === "C") {
1945
- const origPath = tokens[++i];
1946
- if (x === "R" && origPath) deletions.push(origPath);
1947
- }
1948
- if (x === "!") continue;
1949
- const missingFromWorktree = y === "D" || x === "D" && y === " ";
1950
- if (missingFromWorktree) deletions.push(filePath);
1951
- else includes.push(filePath);
1952
- }
1953
- return { includes, deletions };
1954
- }
1955
- function realGitSurface(cwd) {
1956
- let cached = null;
1957
- const load = () => {
1958
- if (!cached) {
1959
- const out = execSync3("git status --porcelain=v1 -z -uall", {
1960
- cwd,
1961
- stdio: ["ignore", "pipe", "ignore"],
1962
- maxBuffer: STATUS_MAX_BUFFER
1963
- }).toString("utf8");
1964
- cached = parseStatusPorcelainZ(out);
1965
- }
1966
- return cached;
1967
- };
1968
- return {
1969
- trackedAndUntracked: () => load().includes,
1970
- deletedSinceHead: () => load().deletions
1971
- };
1972
- }
1973
- function gitHead(cwd) {
1974
- try {
1975
- return execSync3("git rev-parse HEAD", { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || null;
1976
- } catch {
1977
- return null;
1978
- }
1979
- }
1980
- async function buildSnapshotTar(cwd, opts) {
1981
- const staging = await mkdtemp(path2.join(tmpdir2(), "conveyor-snapshot-"));
1982
- const cleanup = async () => {
1983
- await rm(staging, { recursive: true, force: true }).catch(() => {
1984
- });
1985
- };
1986
- try {
1987
- const plan = planSnapshotFiles(realGitSurface(cwd));
1988
- const head = gitHead(cwd);
1989
- if (!head) throw new Error("cannot snapshot a repo without a resolvable HEAD");
1990
- const capturedAt = Date.now();
1991
- const manifest = {
1992
- head,
1993
- deletions: plan.deletions,
1994
- capturedAt,
1995
- pgDump: Boolean(opts?.pgDumpPath)
1996
- };
1997
- await writeFile2(path2.join(staging, SNAPSHOT_MANIFEST_NAME), JSON.stringify(manifest), "utf8");
1998
- const stagedEntries = [SNAPSHOT_MANIFEST_NAME];
1999
- if (opts?.pgDumpPath) {
2000
- await copyFile(opts.pgDumpPath, path2.join(staging, PG_DUMP_FILENAME));
2001
- stagedEntries.push(PG_DUMP_FILENAME);
2002
- }
2003
- const rawTarPath = path2.join(staging, "snapshot.tar");
2004
- await tar.create({ cwd: staging, file: rawTarPath, portable: true }, stagedEntries);
2005
- if (plan.include.length > 0) {
2006
- await tar.replace({ file: rawTarPath, cwd, portable: true }, plan.include);
2007
- }
2008
- const tarPath = path2.join(staging, "snapshot.tar.gz");
2009
- await pipeline(createReadStream(rawTarPath), createGzip(), createWriteStream(tarPath));
2010
- await rm(rawTarPath, { force: true });
2011
- const { size } = await stat2(tarPath);
2012
- return {
2013
- tarPath,
2014
- sizeBytes: size,
2015
- fileCount: plan.include.length,
2016
- deletionCount: plan.deletions.length,
2017
- capturedAt,
2018
- cleanup
2019
- };
2020
- } catch (err) {
2021
- await cleanup();
2022
- throw err;
2023
- }
2024
- }
2025
- function isWithinWorkspace(cwd, rel) {
2026
- if (!rel || path2.isAbsolute(rel)) return false;
2027
- const root = path2.resolve(cwd);
2028
- const abs = path2.resolve(root, rel);
2029
- return abs !== root && abs.startsWith(root + path2.sep);
2030
- }
2031
- async function readSnapshotArchive(tarPath) {
2032
- let manifestRaw = null;
2033
- let pgDumpDir = null;
2034
- let pgDumpPath;
2035
- let pgDumpWritten = Promise.resolve();
2036
- const cleanup = async () => {
2037
- if (pgDumpDir) await rm(pgDumpDir, { recursive: true, force: true }).catch(() => {
2038
- });
2039
- };
2040
- try {
2041
- await tar.list({
2042
- file: tarPath,
2043
- onReadEntry: (entry) => {
2044
- if (entry.path === SNAPSHOT_MANIFEST_NAME) {
2045
- const chunks = [];
2046
- entry.on("data", (chunk) => chunks.push(chunk));
2047
- entry.on("end", () => {
2048
- manifestRaw = Buffer.concat(chunks);
2049
- });
2050
- } else if (entry.path === PG_DUMP_FILENAME) {
2051
- pgDumpDir = mkdtempSync(path2.join(tmpdir2(), "conveyor-pgdump-restore-"));
2052
- pgDumpPath = path2.join(pgDumpDir, "dump.sql");
2053
- const dest = createWriteStream(pgDumpPath);
2054
- pgDumpWritten = new Promise((resolve, reject) => {
2055
- dest.on("finish", () => resolve());
2056
- dest.on("error", reject);
2057
- });
2058
- entry.pipe(dest);
2059
- }
2060
- }
2061
- });
2062
- await pgDumpWritten;
2063
- } catch {
2064
- await cleanup();
2065
- return null;
2066
- }
2067
- if (!manifestRaw) {
2068
- await cleanup();
2069
- return null;
2070
- }
2071
- try {
2072
- const parsed = JSON.parse(manifestRaw.toString("utf8"));
2073
- if (typeof parsed.head !== "string" || !parsed.head) {
2074
- await cleanup();
2075
- return null;
2076
- }
2077
- return {
2078
- manifest: {
2079
- head: parsed.head,
2080
- deletions: Array.isArray(parsed.deletions) ? parsed.deletions : [],
2081
- capturedAt: typeof parsed.capturedAt === "number" ? parsed.capturedAt : 0,
2082
- pgDump: parsed.pgDump === true
2083
- },
2084
- ...pgDumpPath ? { pgDumpPath } : {},
2085
- cleanup
2086
- };
2087
- } catch {
2088
- await cleanup();
2089
- return null;
2090
- }
2091
- }
2092
- async function extractSnapshotTar(cwd, tarPath, opts) {
2093
- const read = await readSnapshotArchive(tarPath);
2094
- if (!read) return { status: "invalid" };
2095
- try {
2096
- const { manifest, pgDumpPath } = read;
2097
- const currentHead = gitHead(cwd);
2098
- if (manifest.head !== currentHead) {
2099
- return { status: "stale-head", snapshotHead: manifest.head, currentHead };
2100
- }
2101
- let filesExtracted = 0;
2102
- await tar.extract({
2103
- file: tarPath,
2104
- cwd,
2105
- filter: (entryPath) => {
2106
- if (entryPath === SNAPSHOT_MANIFEST_NAME || entryPath === PG_DUMP_FILENAME) return false;
2107
- filesExtracted++;
2108
- return true;
2109
- }
2110
- });
2111
- let deletionsApplied = 0;
2112
- for (const rel of manifest.deletions) {
2113
- if (typeof rel !== "string" || !isWithinWorkspace(cwd, rel)) continue;
2114
- await rm(path2.resolve(cwd, rel), { recursive: true, force: true });
2115
- deletionsApplied++;
2116
- }
2117
- const pgDumpApplied = manifest.pgDump && pgDumpPath ? applyPgDumpIfConfigured(pgDumpPath, opts?.pgExec) : false;
2118
- return { status: "extracted", filesExtracted, deletionsApplied, pgDumpApplied };
2119
- } finally {
2120
- await read.cleanup();
2121
- }
2122
- }
2123
-
2124
- // src/runner/work-preservation/snapshot-transfer.ts
2125
- import { randomUUID as randomUUID2 } from "crypto";
2126
- import { createReadStream as createReadStream2, createWriteStream as createWriteStream2 } from "fs";
2127
- import { rm as rm2 } from "fs/promises";
2128
- import { tmpdir as tmpdir3 } from "os";
2129
- import path3 from "path";
2130
- import { Readable } from "stream";
2131
- import { pipeline as pipeline2 } from "stream/promises";
2132
- var SNAPSHOT_HTTP_TIMEOUT_MS = 6e4;
2133
- var SNAPSHOT_CAPTURED_AT_HEADER = "x-snapshot-captured-at";
2134
- async function putSnapshotToGcs(url, tarResult) {
2135
- try {
2136
- const body = Readable.toWeb(createReadStream2(tarResult.tarPath));
2137
- const res = await fetch(url, {
2138
- method: "PUT",
2139
- headers: {
2140
- "Content-Type": "application/gzip",
2141
- "Content-Length": String(tarResult.sizeBytes),
2142
- [SNAPSHOT_CAPTURED_AT_HEADER]: String(tarResult.capturedAt)
2143
- },
2144
- body,
2145
- duplex: "half",
2146
- signal: AbortSignal.timeout(SNAPSHOT_HTTP_TIMEOUT_MS)
2147
- });
2148
- return res.ok;
1757
+ const body = Readable.toWeb(createReadStream2(tarResult.tarPath));
1758
+ const res = await fetch(url, {
1759
+ method: "PUT",
1760
+ headers: {
1761
+ "Content-Type": "application/gzip",
1762
+ "Content-Length": String(tarResult.sizeBytes),
1763
+ [SNAPSHOT_CAPTURED_AT_HEADER]: String(tarResult.capturedAt)
1764
+ },
1765
+ body,
1766
+ duplex: "half",
1767
+ signal: AbortSignal.timeout(SNAPSHOT_HTTP_TIMEOUT_MS)
1768
+ });
1769
+ return res.ok;
2149
1770
  } catch {
2150
1771
  return false;
2151
1772
  }
2152
1773
  }
2153
1774
  async function downloadSnapshotToTemp(url) {
2154
- const tmpTar = path3.join(tmpdir3(), `conveyor-snapshot-restore-${randomUUID2()}.tar.gz`);
1775
+ const tmpTar = path2.join(tmpdir2(), `conveyor-snapshot-restore-${randomUUID()}.tar.gz`);
2155
1776
  try {
2156
1777
  const res = await fetch(url, {
2157
1778
  method: "GET",
@@ -2178,13 +1799,12 @@ var EMPTY_GCS_RESULT = {
2178
1799
  uploaded: false,
2179
1800
  fileCount: 0,
2180
1801
  deletionCount: 0,
2181
- sizeBytes: 0,
2182
- pgDumped: false
1802
+ sizeBytes: 0
2183
1803
  };
2184
1804
  async function doUploadSnapshotToGcs(cwd, uploadUrl, opts) {
2185
1805
  let tarResult;
2186
1806
  try {
2187
- tarResult = await buildSnapshotTar(cwd, { pgDumpPath: opts?.pgDumpPath });
1807
+ tarResult = await buildSnapshotTar(cwd);
2188
1808
  } catch {
2189
1809
  return EMPTY_GCS_RESULT;
2190
1810
  }
@@ -2194,8 +1814,7 @@ async function doUploadSnapshotToGcs(cwd, uploadUrl, opts) {
2194
1814
  uploaded,
2195
1815
  fileCount: tarResult.fileCount,
2196
1816
  deletionCount: tarResult.deletionCount,
2197
- sizeBytes: tarResult.sizeBytes,
2198
- pgDumped: Boolean(opts?.pgDumpPath)
1817
+ sizeBytes: tarResult.sizeBytes
2199
1818
  };
2200
1819
  } finally {
2201
1820
  await tarResult.cleanup();
@@ -2217,7 +1836,6 @@ async function uploadSnapshotToGcs(cwd, uploadUrl, opts) {
2217
1836
  }
2218
1837
  async function captureSnapshot(ctx) {
2219
1838
  const gcs = await uploadSnapshotToGcs(ctx.cwd, ctx.snapshotUploadUrl, {
2220
- pgDumpPath: ctx.pgDumpPath,
2221
1839
  forceUpload: ctx.forceUpload
2222
1840
  });
2223
1841
  const wipResult = await flushPendingChanges(ctx.cwd, {
@@ -2229,8 +1847,7 @@ async function captureSnapshot(ctx) {
2229
1847
  deletionCount: gcs.deletionCount,
2230
1848
  sizeBytes: gcs.sizeBytes,
2231
1849
  gcsUploaded: gcs.uploaded,
2232
- wipPushed: wipResult.committed || wipResult.pushed,
2233
- pgDumped: gcs.pgDumped
1850
+ wipPushed: wipResult.committed || wipResult.pushed
2234
1851
  };
2235
1852
  }
2236
1853
  function startPeriodic(intervalMs, ctx) {
@@ -2255,21 +1872,13 @@ async function finalizeForSleep(ctx) {
2255
1872
  stop();
2256
1873
  if (inFlightCapture) await inFlightCapture.catch(() => {
2257
1874
  });
2258
- const dump = await dumpPostgresIfPresent();
2259
- try {
2260
- const artifact = await captureSnapshot({
2261
- ...ctx,
2262
- pgDumpPath: dump.path,
2263
- wipMessage: ctx.wipMessage ?? "WIP: WorkPreservation sleep finalize snapshot",
2264
- // Always stamp snapshotAt on sleep — even an empty workspace must confirm
2265
- // the finalize so teardown doesn't wait out the reconciler's cap.
2266
- forceUpload: true
2267
- });
2268
- return { ...artifact, pgDumped: dump.dumped };
2269
- } finally {
2270
- if (dump.path) await rm3(dump.path, { force: true }).catch(() => {
2271
- });
2272
- }
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
+ });
2273
1882
  }
2274
1883
  async function restoreOnBoot(bundle, cwd) {
2275
1884
  let gcsAvailable = false;
@@ -2291,7 +1900,7 @@ async function restoreOnBoot(bundle, cwd) {
2291
1900
  }
2292
1901
  }
2293
1902
  }
2294
- const wipRestoreResult = gcsAvailable ? "none" : restoreWipSnapshot(cwd, bundle.gitPlan.branch);
1903
+ const wipRestoreResult = gcsAvailable ? "none" : await restoreWipSnapshot(cwd, bundle.gitPlan.branch);
2295
1904
  const source = selectRestoreSource({ gcsAvailable, wipRestoreResult });
2296
1905
  return source === "gcs" ? { source, fileCount: gcsFileCount } : { source };
2297
1906
  }
@@ -2300,6 +1909,7 @@ async function restoreOnBoot(bundle, cwd) {
2300
1909
  import { z } from "zod";
2301
1910
  import { z as z2 } from "zod";
2302
1911
  var DEFAULT_SONNET_MODEL = "claude-sonnet-5";
1912
+ var TUI_KINDS = ["claude-code", "opencode"];
2303
1913
  var MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024;
2304
1914
  var EMBED_THRESHOLD_IMAGES = 5 * 1024 * 1024;
2305
1915
  var EMBED_THRESHOLD_TEXT = 2 * 1024 * 1024;
@@ -2308,7 +1918,9 @@ var AgentHeartbeatSchema = z.object({
2308
1918
  sessionId: z.string().optional(),
2309
1919
  timestamp: z.string(),
2310
1920
  status: z.enum(["active", "idle", "building"]),
2311
- currentAction: z.string().optional()
1921
+ currentAction: z.string().optional(),
1922
+ /** Sender-observed main event-loop lag (ms) — see AgentHeartbeat.loopLagMs. */
1923
+ loopLagMs: z.number().nonnegative().optional()
2312
1924
  });
2313
1925
  var CreatePRInputSchema = z.object({
2314
1926
  title: z.string().min(1),
@@ -2590,6 +2202,10 @@ var ListActivePtySessionsRequestSchema = z.object({
2590
2202
  var SendSoftStopRequestSchema = z.object({
2591
2203
  taskId: z.string()
2592
2204
  });
2205
+ var StopTaskSessionRequestSchema = z.object({
2206
+ taskId: z.string(),
2207
+ sessionId: z.string()
2208
+ });
2593
2209
  var FlushTaskQueueRequestSchema = z.object({
2594
2210
  taskId: z.string(),
2595
2211
  softStop: z.boolean().optional()
@@ -2669,6 +2285,29 @@ var PtyResizeRequestSchema = z.object({
2669
2285
  var PtyAttachRequestSchema = z.object({
2670
2286
  sessionId: z.string()
2671
2287
  });
2288
+ var PtyChatEventPayloadSchema = z.discriminatedUnion("kind", [
2289
+ z.object({
2290
+ kind: z.literal("init"),
2291
+ model: z.string().max(200),
2292
+ claudeSessionId: z.string().max(100).optional()
2293
+ }),
2294
+ z.object({ kind: z.literal("user_text"), text: z.string().max(16384) }),
2295
+ z.object({ kind: z.literal("assistant_text"), text: z.string().max(16384) }),
2296
+ z.object({
2297
+ kind: z.literal("tool_use"),
2298
+ name: z.string().max(200),
2299
+ // Compact preview: JSON.stringify(input) truncated agent-side.
2300
+ input: z.string().max(2e3)
2301
+ }),
2302
+ z.object({ kind: z.literal("turn_end") })
2303
+ ]);
2304
+ var PtyChatEventRequestSchema = z.object({
2305
+ sessionId: z.string(),
2306
+ event: PtyChatEventPayloadSchema
2307
+ });
2308
+ var PtyChatAttachRequestSchema = z.object({
2309
+ sessionId: z.string()
2310
+ });
2672
2311
  var CreatePRResponseSchema = z.object({
2673
2312
  prNumber: z.number().int().positive(),
2674
2313
  prUrl: z.string().url()
@@ -2806,9 +2445,21 @@ var ListMyLiveSessionsRequestSchema = z2.object({
2806
2445
  /** Admin-only: list another member's sessions instead of the caller's. */
2807
2446
  targetUserId: z2.string().optional()
2808
2447
  });
2448
+ var GetProjectAvailableTuisRequestSchema = z2.object({
2449
+ projectId: z2.string()
2450
+ });
2809
2451
  var StartAdhocSessionRequestSchema = z2.object({
2810
2452
  projectId: z2.string(),
2811
2453
  label: z2.string().max(200).optional(),
2454
+ /** Coding-agent key to launch under — validated pick-time (ownership + TUI availability) in the handler. */
2455
+ codingAgentKeyId: z2.string().optional(),
2456
+ /**
2457
+ * Session role. Constrained: other task-less modes fall through to the pm
2458
+ * runner in the pod entrypoint, and "review" would crash without a task.
2459
+ */
2460
+ mode: z2.enum(["adhoc", "pm"]).optional(),
2461
+ /** Base branch to check out (defaults to the project's dev branch). */
2462
+ branch: z2.string().max(300).optional(),
2812
2463
  requestingUserId: z2.string().optional()
2813
2464
  });
2814
2465
  var StopAdhocSessionRequestSchema = z2.object({
@@ -3332,8 +2983,8 @@ var ClaudeCodeHarness = class {
3332
2983
  };
3333
2984
 
3334
2985
  // src/harness/pty/session.ts
3335
- import { mkdtemp as mkdtemp2, mkdir as mkdir2, rm as rm4 } from "fs/promises";
3336
- import { tmpdir as tmpdir4 } from "os";
2986
+ import { mkdtemp as mkdtemp2, mkdir as mkdir3, rm as rm5 } from "fs/promises";
2987
+ import { tmpdir as tmpdir3 } from "os";
3337
2988
  import { join as join4, dirname } from "path";
3338
2989
 
3339
2990
  // src/harness/pty/event-queue.ts
@@ -3615,12 +3266,14 @@ function mapTranscriptRecord(raw) {
3615
3266
  // src/harness/pty/jsonl-tailer.ts
3616
3267
  var POLL_INTERVAL_MS = 25;
3617
3268
  var JsonlTailer = class {
3618
- constructor(path4, onEvent) {
3269
+ constructor(path4, onEvent, onRawRecord) {
3619
3270
  this.path = path4;
3620
3271
  this.onEvent = onEvent;
3272
+ this.onRawRecord = onRawRecord;
3621
3273
  }
3622
3274
  path;
3623
3275
  onEvent;
3276
+ onRawRecord;
3624
3277
  offset = 0;
3625
3278
  buffer = "";
3626
3279
  timer = null;
@@ -3692,79 +3345,349 @@ var JsonlTailer = class {
3692
3345
  } catch {
3693
3346
  return;
3694
3347
  }
3348
+ this.onRawRecord?.(parsed);
3695
3349
  const event = mapTranscriptRecord(parsed);
3696
3350
  if (event) this.onEvent(event);
3697
3351
  }
3698
3352
  };
3699
3353
 
3700
- // src/harness/pty/spawn-args.ts
3701
- function resolveClaudeBinary() {
3702
- return process.env.CONVEYOR_CLAUDE_BIN ?? "claude";
3354
+ // src/harness/pty/chat-record-mapper.ts
3355
+ var TEXT_MAX = 16e3;
3356
+ var TOOL_INPUT_MAX = 1900;
3357
+ function isRecord2(value) {
3358
+ return typeof value === "object" && value !== null;
3703
3359
  }
3704
- function buildSpawnArgs(input) {
3705
- const args = [];
3706
- if (input.resume) {
3707
- args.push("--resume", input.resume);
3708
- } else if (input.sessionId) {
3709
- args.push("--session-id", input.sessionId);
3710
- }
3711
- args.push("--model", input.model);
3712
- if (input.permissionMode === "bypassPermissions") {
3713
- args.push("--dangerously-skip-permissions");
3714
- } else {
3715
- args.push("--permission-mode", "plan");
3360
+ function isUnknownArray2(value) {
3361
+ return Array.isArray(value);
3362
+ }
3363
+ function stringField2(record, ...keys) {
3364
+ for (const key of keys) {
3365
+ const value = record[key];
3366
+ if (typeof value === "string") return value;
3716
3367
  }
3717
- args.push("--settings", input.settingsPath);
3718
- if (input.appendSystemPrompt) {
3719
- args.push("--append-system-prompt", input.appendSystemPrompt);
3368
+ return void 0;
3369
+ }
3370
+ function truncate(text, max) {
3371
+ return text.length > max ? `${text.slice(0, max)}\u2026` : text;
3372
+ }
3373
+ function isCommandWrapperText(text) {
3374
+ const trimmed = text.trimStart();
3375
+ return trimmed.startsWith("<command-name>") || trimmed.startsWith("<local-command-");
3376
+ }
3377
+ function mapSystem2(record) {
3378
+ if (record.subtype === "init") {
3379
+ const event = {
3380
+ kind: "init",
3381
+ model: stringField2(record, "model") ?? ""
3382
+ };
3383
+ const sessionId = stringField2(record, "session_id", "sessionId");
3384
+ if (sessionId !== void 0) event.claudeSessionId = sessionId;
3385
+ return [event];
3720
3386
  }
3721
- if (input.mcpConfigPath) {
3722
- args.push("--mcp-config", input.mcpConfigPath);
3723
- if (input.strictMcpConfig) {
3724
- args.push("--strict-mcp-config");
3387
+ if (record.subtype === "turn_duration") return [{ kind: "turn_end" }];
3388
+ return [];
3389
+ }
3390
+ function mapAssistant2(record) {
3391
+ const message = record.message;
3392
+ if (!isRecord2(message)) return [];
3393
+ const content = isUnknownArray2(message.content) ? message.content : [];
3394
+ const events = [];
3395
+ for (const raw of content) {
3396
+ if (!isRecord2(raw)) continue;
3397
+ if (raw.type === "text") {
3398
+ const text = stringField2(raw, "text");
3399
+ if (text && text.length > 0) {
3400
+ events.push({ kind: "assistant_text", text: truncate(text, TEXT_MAX) });
3401
+ }
3402
+ } else if (raw.type === "tool_use") {
3403
+ const name = stringField2(raw, "name");
3404
+ if (name) {
3405
+ const input = "input" in raw ? raw.input : void 0;
3406
+ events.push({
3407
+ kind: "tool_use",
3408
+ name: truncate(name, 200),
3409
+ input: JSON.stringify(input ?? {}).slice(0, TOOL_INPUT_MAX)
3410
+ });
3411
+ }
3725
3412
  }
3726
3413
  }
3727
- return args;
3414
+ return events;
3728
3415
  }
3729
- function spawnOptionsFingerprint(input) {
3730
- return JSON.stringify([
3731
- input.model,
3732
- input.permissionMode,
3733
- input.appendSystemPrompt ?? "",
3734
- input.cwd
3735
- ]);
3416
+ function mapUser(record) {
3417
+ const message = record.message;
3418
+ if (!isRecord2(message)) return [];
3419
+ const content = message.content;
3420
+ let text;
3421
+ if (typeof content === "string") {
3422
+ text = content;
3423
+ } else if (isUnknownArray2(content)) {
3424
+ const hasToolResult = content.some((b) => isRecord2(b) && b.type === "tool_result");
3425
+ if (hasToolResult) return [];
3426
+ text = content.filter((b) => isRecord2(b) && b.type === "text").map((b) => typeof b.text === "string" ? b.text : "").filter((t) => t.length > 0).join("\n");
3427
+ } else {
3428
+ return [];
3429
+ }
3430
+ const trimmed = text.trim();
3431
+ if (trimmed.length === 0 || isCommandWrapperText(trimmed)) return [];
3432
+ return [{ kind: "user_text", text: truncate(trimmed, TEXT_MAX) }];
3736
3433
  }
3737
- var ANSI_CSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[ -/]*[@-~]`, "g");
3738
- function cleanTerminalOutput(raw, maxChars = 1200) {
3739
- const noAnsi = raw.replace(ANSI_CSI, "");
3740
- let out = "";
3741
- for (const ch of noAnsi) {
3742
- const code = ch.charCodeAt(0);
3743
- if (ch === "\r" || ch === "\n") out += "\n";
3744
- else if (ch === " ") out += ch;
3745
- else if (code < 32 || code === 127) continue;
3746
- else out += ch;
3434
+ function mapChatRecords(raw) {
3435
+ if (!isRecord2(raw)) return [];
3436
+ if (raw.isSidechain === true || raw.isMeta === true) return [];
3437
+ switch (raw.type) {
3438
+ case "system":
3439
+ return mapSystem2(raw);
3440
+ case "assistant":
3441
+ return mapAssistant2(raw);
3442
+ case "user":
3443
+ return mapUser(raw);
3444
+ case "result":
3445
+ return [{ kind: "turn_end" }];
3446
+ default:
3447
+ return [];
3747
3448
  }
3748
- const lines = out.split("\n").map((line) => line.trimEnd()).filter((line) => line.trim().length > 0);
3749
- const text = lines.join("\n").trim();
3750
- return text.length > maxChars ? `\u2026${text.slice(-maxChars)}` : text;
3751
3449
  }
3752
- function isMissingBinaryFailure(tail) {
3753
- return /execvp\(\d+\) failed|no such file or directory|command not found/i.test(tail);
3450
+
3451
+ // src/harness/pty/settings.ts
3452
+ import { mkdir, writeFile as writeFile2, chmod } from "fs/promises";
3453
+ import { homedir } from "os";
3454
+ import { join } from "path";
3455
+ function claudeConfigHome() {
3456
+ return process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
3754
3457
  }
3755
- function buildExitErrors(exitCode, rawOutput, binary) {
3756
- const errors = [`claude exited (code ${exitCode}) without a result`];
3757
- const tail = cleanTerminalOutput(rawOutput);
3758
- if (isMissingBinaryFailure(tail)) {
3759
- errors.push(
3760
- `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.`
3458
+ function projectSlug(cwd) {
3459
+ return cwd.replace(/\//g, "-");
3460
+ }
3461
+ function sessionTranscriptPath(cwd, sessionId) {
3462
+ return join(claudeConfigHome(), "projects", projectSlug(cwd), `${sessionId}.jsonl`);
3463
+ }
3464
+ var ALLOW_RULES = [
3465
+ "Bash",
3466
+ "BashOutput",
3467
+ "Edit",
3468
+ "Write",
3469
+ "Read",
3470
+ "Glob",
3471
+ "Grep",
3472
+ "WebFetch",
3473
+ "WebSearch",
3474
+ "NotebookEdit",
3475
+ "Task",
3476
+ "TodoWrite",
3477
+ "ToolSearch",
3478
+ "KillShell",
3479
+ "SlashCommand",
3480
+ "Skill",
3481
+ "mcp__conveyor__*"
3482
+ ];
3483
+ var HOOK_HELPER_SOURCE = `"use strict";
3484
+ const net = require("node:net");
3485
+ const crypto = require("node:crypto");
3486
+
3487
+ const PRE_TOOL_USE_TIMEOUT_MS = 110000;
3488
+ const ASK_USER_QUESTION_TIMEOUT_MS = 5000;
3489
+
3490
+ let raw = "";
3491
+ process.stdin.setEncoding("utf8");
3492
+ process.stdin.on("data", (chunk) => {
3493
+ raw += chunk;
3494
+ });
3495
+ process.stdin.on("end", () => {
3496
+ let payload = {};
3497
+ try {
3498
+ const parsed = JSON.parse(raw);
3499
+ if (parsed && typeof parsed === "object") payload = parsed;
3500
+ } catch {
3501
+ payload = {};
3502
+ }
3503
+ if (payload.hook_event_name === "PreToolUse") {
3504
+ preToolUse(payload);
3505
+ } else {
3506
+ relayProgress(typeof payload.tool_name === "string" ? payload.tool_name : "");
3507
+ }
3508
+ });
3509
+
3510
+ function relayProgress(toolName) {
3511
+ const socketPath = process.env.CONVEYOR_HOOK_SOCKET;
3512
+ let done = false;
3513
+ const finish = () => {
3514
+ if (done) return;
3515
+ done = true;
3516
+ process.stdout.write(JSON.stringify({ continue: true }));
3517
+ process.exit(0);
3518
+ };
3519
+ const fallback = setTimeout(finish, 250);
3520
+ if (!socketPath) {
3521
+ clearTimeout(fallback);
3522
+ finish();
3523
+ return;
3524
+ }
3525
+ const client = net.connect(socketPath, () => {
3526
+ // PostToolUse does not supply tool duration, so elapsed_time_seconds is
3527
+ // omitted rather than reported as a misleading 0 (the field is optional).
3528
+ const line = JSON.stringify({ tool_name: toolName }) + "\\n";
3529
+ client.write(line, () => {
3530
+ client.end();
3531
+ });
3532
+ });
3533
+ client.on("close", () => {
3534
+ clearTimeout(fallback);
3535
+ finish();
3536
+ });
3537
+ client.on("error", () => {
3538
+ clearTimeout(fallback);
3539
+ finish();
3540
+ });
3541
+ }
3542
+
3543
+ function preToolUse(payload) {
3544
+ const socketPath = process.env.CONVEYOR_HOOK_SOCKET;
3545
+ let done = false;
3546
+ const respond = (decision, reason) => {
3547
+ if (done) return;
3548
+ done = true;
3549
+ process.stdout.write(
3550
+ JSON.stringify({
3551
+ hookSpecificOutput: {
3552
+ hookEventName: "PreToolUse",
3553
+ permissionDecision: decision,
3554
+ ...(reason ? { permissionDecisionReason: reason } : {}),
3555
+ },
3556
+ }),
3761
3557
  );
3558
+ process.exit(0);
3559
+ };
3560
+ // MCP tools are auto-allowed locally \u2014 no socket round trip, no fail-mode.
3561
+ // Plan mode prompts for them despite permissions.allow (the CLI can't
3562
+ // classify MCP tools as read-only), and the pod is the sandbox.
3563
+ if (typeof payload.tool_name === "string" && payload.tool_name.startsWith("mcp__")) {
3564
+ respond("allow");
3565
+ return;
3762
3566
  }
3763
- if (tail) {
3764
- errors.push(`Last terminal output before exit:
3765
- ${tail}`);
3567
+ // AskUserQuestion is observe-only (status reporting) \u2014 fail OPEN so a
3568
+ // missing/slow socket never denies the questionnaire. Everything else
3569
+ // (ExitPlanMode) is a Conveyor gate \u2014 fail CLOSED.
3570
+ const failOpen = payload.tool_name === "AskUserQuestion";
3571
+ const respondUnavailable = () =>
3572
+ failOpen
3573
+ ? respond("allow")
3574
+ : respond(
3575
+ "deny",
3576
+ "Conveyor validation is unavailable right now. Wait a moment and call the tool again.",
3577
+ );
3578
+ if (!socketPath) {
3579
+ respondUnavailable();
3580
+ return;
3766
3581
  }
3767
- return errors;
3582
+ const id = crypto.randomBytes(8).toString("hex");
3583
+ const timer = setTimeout(
3584
+ respondUnavailable,
3585
+ failOpen ? ASK_USER_QUESTION_TIMEOUT_MS : PRE_TOOL_USE_TIMEOUT_MS,
3586
+ );
3587
+ const client = net.connect(socketPath, () => {
3588
+ client.write(
3589
+ JSON.stringify({
3590
+ kind: "pre_tool_use",
3591
+ id,
3592
+ tool_name: typeof payload.tool_name === "string" ? payload.tool_name : "",
3593
+ tool_input:
3594
+ payload.tool_input && typeof payload.tool_input === "object" ? payload.tool_input : {},
3595
+ }) + "\\n",
3596
+ );
3597
+ });
3598
+ let buffer = "";
3599
+ client.on("data", (chunk) => {
3600
+ buffer += chunk.toString("utf8");
3601
+ let index = buffer.indexOf("\\n");
3602
+ while (index >= 0) {
3603
+ const line = buffer.slice(0, index);
3604
+ buffer = buffer.slice(index + 1);
3605
+ try {
3606
+ const verdict = JSON.parse(line);
3607
+ if (verdict && verdict.id === id) {
3608
+ clearTimeout(timer);
3609
+ client.end();
3610
+ respond(verdict.decision === "allow" ? "allow" : "deny", verdict.reason);
3611
+ return;
3612
+ }
3613
+ } catch {
3614
+ /* keep scanning */
3615
+ }
3616
+ index = buffer.indexOf("\\n");
3617
+ }
3618
+ });
3619
+ client.on("error", () => {
3620
+ clearTimeout(timer);
3621
+ respondUnavailable();
3622
+ });
3623
+ client.on("close", () => {
3624
+ clearTimeout(timer);
3625
+ respondUnavailable();
3626
+ });
3627
+ }
3628
+ `;
3629
+ async function writeHookSettings(dir) {
3630
+ const helperPath = join(dir, "hook-helper.cjs");
3631
+ const settingsPath = join(dir, "settings.json");
3632
+ await mkdir(dir, { recursive: true });
3633
+ await writeFile2(helperPath, HOOK_HELPER_SOURCE, "utf8");
3634
+ await chmod(helperPath, 493);
3635
+ const settings = {
3636
+ // Pre-accept Claude Code's "Bypass Permissions mode" disclaimer. Build-capable
3637
+ // spawns pass `--dangerously-skip-permissions`; on a real PTY the CLI otherwise
3638
+ // parks on the interactive "Yes, I accept / No, exit" dialog whose default focus
3639
+ // is "No, exit" — so it cannot be auto-dismissed by an Enter nudge and the run
3640
+ // stalls until the auto-nudge watchdog shuts it down. The CLI reads this key
3641
+ // from the `--settings` (flagSettings) layer via its bypass-mode gate, so
3642
+ // setting it here suppresses the dialog deterministically on every spawn —
3643
+ // independent of the `bypassPermissionsModeAccepted` seed in credentials.ts,
3644
+ // which the CLI's one-time migration consumes and which is subject to
3645
+ // persistence races on the codespace user-home.
3646
+ skipDangerousModePermissionPrompt: true,
3647
+ // Auto-approve tool calls so the interactive (PTY) agent never stops to
3648
+ // prompt the viewer. This only suppresses per-tool approval prompts — it
3649
+ // does NOT relax the planning gate: in discovery/plan mode the spawn passes
3650
+ // `--permission-mode plan`, which keeps the agent read-only (no edits/commits
3651
+ // until it exits plan mode) regardless of this allow-list. In build mode the
3652
+ // spawn already bypasses prompts via `--dangerously-skip-permissions`.
3653
+ // NOTE: a bare "*" rule is INVALID (the CLI rejects it and parks the TUI on
3654
+ // a Settings Warning dialog at boot) — hence the explicit ALLOW_RULES list.
3655
+ permissions: {
3656
+ allow: ALLOW_RULES
3657
+ },
3658
+ hooks: {
3659
+ PreToolUse: [
3660
+ {
3661
+ matcher: "ExitPlanMode",
3662
+ hooks: [{ type: "command", command: `node ${JSON.stringify(helperPath)}`, timeout: 120 }]
3663
+ },
3664
+ {
3665
+ // Observe-only: lets the session report waiting_for_input the moment
3666
+ // the planning questionnaire renders. The helper fails open for this
3667
+ // tool, so the questionnaire is never blocked by Conveyor.
3668
+ matcher: "AskUserQuestion",
3669
+ hooks: [{ type: "command", command: `node ${JSON.stringify(helperPath)}`, timeout: 30 }]
3670
+ },
3671
+ {
3672
+ // Plan-permission spawns prompt for MCP tools even when they're in
3673
+ // permissions.allow — the CLI can't classify them as read-only. A
3674
+ // hook allow bypasses the permission engine in every mode. Loose by
3675
+ // design: the pod is the sandbox, and planning agents must call
3676
+ // update_task etc. The helper answers locally (no socket).
3677
+ matcher: "mcp__.*",
3678
+ hooks: [{ type: "command", command: `node ${JSON.stringify(helperPath)}`, timeout: 30 }]
3679
+ }
3680
+ ],
3681
+ PostToolUse: [
3682
+ {
3683
+ matcher: "*",
3684
+ hooks: [{ type: "command", command: `node ${JSON.stringify(helperPath)}` }]
3685
+ }
3686
+ ]
3687
+ }
3688
+ };
3689
+ await writeFile2(settingsPath, JSON.stringify(settings, null, 2), "utf8");
3690
+ return { settingsPath, helperPath };
3768
3691
  }
3769
3692
 
3770
3693
  // src/harness/pty/output-coalescer.ts
@@ -3822,7 +3745,7 @@ var PtyOutputCoalescer = class {
3822
3745
  // src/harness/pty/tool-server.ts
3823
3746
  import { createServer as createServer2 } from "http";
3824
3747
  import { writeFile as writeFile3 } from "fs/promises";
3825
- import { join as join3 } from "path";
3748
+ import { join as join2 } from "path";
3826
3749
  import { randomBytes } from "crypto";
3827
3750
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3828
3751
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
@@ -3989,10 +3912,242 @@ async function startToolServers(mcpServers, tempDir) {
3989
3912
  servers.push(server);
3990
3913
  config[name] = { type: "http", url, headers: { Authorization: `Bearer ${token}` } };
3991
3914
  }
3992
- if (Object.keys(config).length === 0) return { servers, mcpConfigPath: null };
3993
- const mcpConfigPath = join3(tempDir, "mcp-config.json");
3994
- await writeFile3(mcpConfigPath, JSON.stringify({ mcpServers: config }, null, 2), "utf8");
3995
- return { servers, mcpConfigPath };
3915
+ if (Object.keys(config).length === 0) return { servers, mcpConfigPath: null };
3916
+ const mcpConfigPath = join2(tempDir, "mcp-config.json");
3917
+ await writeFile3(mcpConfigPath, JSON.stringify({ mcpServers: config }, null, 2), "utf8");
3918
+ return { servers, mcpConfigPath };
3919
+ }
3920
+
3921
+ // src/harness/pty/spawn-args.ts
3922
+ function buildSpawnArgs(input) {
3923
+ const args = [];
3924
+ if (input.resume) {
3925
+ args.push("--resume", input.resume);
3926
+ } else if (input.sessionId) {
3927
+ args.push("--session-id", input.sessionId);
3928
+ }
3929
+ args.push("--model", input.model);
3930
+ if (input.permissionMode === "bypassPermissions") {
3931
+ args.push("--dangerously-skip-permissions");
3932
+ } else {
3933
+ args.push("--permission-mode", "plan");
3934
+ }
3935
+ args.push("--settings", input.settingsPath);
3936
+ if (input.appendSystemPrompt) {
3937
+ args.push("--append-system-prompt", input.appendSystemPrompt);
3938
+ }
3939
+ if (input.mcpConfigPath) {
3940
+ args.push("--mcp-config", input.mcpConfigPath);
3941
+ if (input.strictMcpConfig) {
3942
+ args.push("--strict-mcp-config");
3943
+ }
3944
+ }
3945
+ return args;
3946
+ }
3947
+ function spawnOptionsFingerprint(input) {
3948
+ return JSON.stringify([
3949
+ input.model,
3950
+ input.permissionMode,
3951
+ input.appendSystemPrompt ?? "",
3952
+ input.cwd
3953
+ ]);
3954
+ }
3955
+ var ANSI_CSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[ -/]*[@-~]`, "g");
3956
+ function cleanTerminalOutput(raw, maxChars = 1200) {
3957
+ const noAnsi = raw.replace(ANSI_CSI, "");
3958
+ let out = "";
3959
+ for (const ch of noAnsi) {
3960
+ const code = ch.charCodeAt(0);
3961
+ if (ch === "\r" || ch === "\n") out += "\n";
3962
+ else if (ch === " ") out += ch;
3963
+ else if (code < 32 || code === 127) continue;
3964
+ else out += ch;
3965
+ }
3966
+ const lines = out.split("\n").map((line) => line.trimEnd()).filter((line) => line.trim().length > 0);
3967
+ const text = lines.join("\n").trim();
3968
+ return text.length > maxChars ? `\u2026${text.slice(-maxChars)}` : text;
3969
+ }
3970
+ function isMissingBinaryFailure(tail) {
3971
+ return /execvp\(\d+\) failed|no such file or directory|command not found/i.test(tail);
3972
+ }
3973
+ function buildExitErrors(exitCode, rawOutput, binary) {
3974
+ const errors = [`claude exited (code ${exitCode}) without a result`];
3975
+ const tail = cleanTerminalOutput(rawOutput);
3976
+ if (isMissingBinaryFailure(tail)) {
3977
+ errors.push(
3978
+ `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.`
3979
+ );
3980
+ }
3981
+ if (tail) {
3982
+ errors.push(`Last terminal output before exit:
3983
+ ${tail}`);
3984
+ }
3985
+ return errors;
3986
+ }
3987
+
3988
+ // src/harness/pty/credentials.ts
3989
+ import { chmod as chmod2, mkdir as mkdir2, readFile, rm as rm4, writeFile as writeFile4 } from "fs/promises";
3990
+ import { homedir as homedir2 } from "os";
3991
+ import { join as join3 } from "path";
3992
+ var SYNTH_TOKEN_TTL_MS = 365 * 24 * 60 * 60 * 1e3;
3993
+ var REFRESH_SKEW_MS = 30 * 24 * 60 * 60 * 1e3;
3994
+ function claudeCredentialsPath() {
3995
+ return join3(claudeConfigHome(), ".credentials.json");
3996
+ }
3997
+ function isConveyorCloudEnv(env = process.env) {
3998
+ return Boolean(env.CLAUDESPACE_NAME || env.CODESPACE_NAME || env.CODESPACES);
3999
+ }
4000
+ function parseClaudeAiOauth(raw) {
4001
+ if (!raw || raw.trim() === "") return null;
4002
+ try {
4003
+ const parsed = JSON.parse(raw);
4004
+ if (typeof parsed !== "object" || parsed === null) return null;
4005
+ const oauth = parsed.claudeAiOauth;
4006
+ if (typeof oauth !== "object" || oauth === null) return null;
4007
+ const record = oauth;
4008
+ return {
4009
+ accessToken: record.accessToken,
4010
+ refreshToken: record.refreshToken,
4011
+ expiresAt: record.expiresAt
4012
+ };
4013
+ } catch {
4014
+ return null;
4015
+ }
4016
+ }
4017
+ function buildSynthesizedCredentials(token, now) {
4018
+ return JSON.stringify({
4019
+ claudeAiOauth: {
4020
+ accessToken: token,
4021
+ expiresAt: now + SYNTH_TOKEN_TTL_MS,
4022
+ scopes: ["user:inference", "user:profile"],
4023
+ subscriptionType: "max"
4024
+ }
4025
+ });
4026
+ }
4027
+ function planCredentialsWrite(input) {
4028
+ if (!input.isCloud) return { action: "skip", reason: "not-cloud" };
4029
+ if (!input.token) return { action: "skip", reason: "no-token" };
4030
+ const contents = buildSynthesizedCredentials(input.token, input.now);
4031
+ const existing = parseClaudeAiOauth(input.existingRaw);
4032
+ if (!existing) return { action: "write", contents };
4033
+ if (typeof existing.refreshToken === "string" && existing.refreshToken.length > 0) {
4034
+ return { action: "skip", reason: "foreign-credentials" };
4035
+ }
4036
+ const fresh = existing.accessToken === input.token && typeof existing.expiresAt === "number" && existing.expiresAt > input.now + REFRESH_SKEW_MS;
4037
+ if (fresh) return { action: "skip", reason: "current" };
4038
+ return { action: "write", contents };
4039
+ }
4040
+ async function readRaw(path4) {
4041
+ try {
4042
+ return await readFile(path4, "utf8");
4043
+ } catch {
4044
+ return null;
4045
+ }
4046
+ }
4047
+ async function ensureClaudeCredentials(env = process.env) {
4048
+ try {
4049
+ const path4 = claudeCredentialsPath();
4050
+ const plan = planCredentialsWrite({
4051
+ isCloud: isConveyorCloudEnv(env),
4052
+ token: env.CLAUDE_CODE_OAUTH_TOKEN,
4053
+ existingRaw: await readRaw(path4),
4054
+ now: Date.now()
4055
+ });
4056
+ if (plan.action === "skip") return;
4057
+ await mkdir2(claudeConfigHome(), { recursive: true });
4058
+ await writeFile4(path4, plan.contents, { encoding: "utf8", mode: 384 });
4059
+ await chmod2(path4, 384).catch(() => {
4060
+ });
4061
+ } catch (err) {
4062
+ const message = err instanceof Error ? err.message : String(err);
4063
+ process.stderr.write(`[conveyor-agent] claude credentials sync failed: ${message}
4064
+ `);
4065
+ }
4066
+ }
4067
+ function claudeJsonPath() {
4068
+ const configDir = process.env.CLAUDE_CONFIG_DIR;
4069
+ return configDir ? join3(configDir, ".claude.json") : join3(homedir2(), ".claude.json");
4070
+ }
4071
+ function asRecord(value) {
4072
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
4073
+ }
4074
+ function parseClaudeJson(existingRaw) {
4075
+ if (!existingRaw || existingRaw.trim() === "") return {};
4076
+ try {
4077
+ return asRecord(JSON.parse(existingRaw));
4078
+ } catch {
4079
+ return {};
4080
+ }
4081
+ }
4082
+ function seedWorkspaceTrust(config, trustCwd) {
4083
+ const projects = asRecord(config.projects);
4084
+ const entry = asRecord(projects[trustCwd]);
4085
+ if (entry.hasTrustDialogAccepted === true) return false;
4086
+ entry.hasTrustDialogAccepted = true;
4087
+ projects[trustCwd] = entry;
4088
+ config.projects = projects;
4089
+ return true;
4090
+ }
4091
+ function planClaudeJsonSeed(existingRaw, trustCwd) {
4092
+ const config = parseClaudeJson(existingRaw);
4093
+ const isFresh = Object.keys(config).length === 0;
4094
+ let changed = false;
4095
+ if (config.hasCompletedOnboarding !== true) {
4096
+ config.hasCompletedOnboarding = true;
4097
+ changed = true;
4098
+ }
4099
+ if (config.bypassPermissionsModeAccepted !== true) {
4100
+ config.bypassPermissionsModeAccepted = true;
4101
+ changed = true;
4102
+ }
4103
+ if (isFresh && typeof config.theme !== "string") {
4104
+ config.theme = "dark";
4105
+ changed = true;
4106
+ }
4107
+ if (trustCwd && seedWorkspaceTrust(config, trustCwd)) {
4108
+ changed = true;
4109
+ }
4110
+ return changed ? JSON.stringify(config) : null;
4111
+ }
4112
+ async function ensureClaudeOnboarding(env = process.env, trustCwd) {
4113
+ try {
4114
+ if (!isConveyorCloudEnv(env)) return;
4115
+ const path4 = claudeJsonPath();
4116
+ const contents = planClaudeJsonSeed(await readRaw(path4), trustCwd);
4117
+ if (contents === null) return;
4118
+ await writeFile4(path4, contents, "utf8");
4119
+ const verify = await readRaw(path4);
4120
+ if (verify === contents) {
4121
+ process.stderr.write(
4122
+ `[conveyor-agent] claude onboarding seeded${trustCwd ? ` (trust: ${trustCwd})` : ""}
4123
+ `
4124
+ );
4125
+ } else {
4126
+ process.stderr.write(
4127
+ `[conveyor-agent] claude onboarding seed read-back MISMATCH at ${path4}: wrote ${contents.length}B, read ${verify?.length ?? 0}B \u2014 CLI may see stale config and park at a startup dialog
4128
+ `
4129
+ );
4130
+ }
4131
+ } catch (err) {
4132
+ const message = err instanceof Error ? err.message : String(err);
4133
+ process.stderr.write(`[conveyor-agent] claude onboarding seed failed: ${message}
4134
+ `);
4135
+ }
4136
+ }
4137
+ async function removeConveyorCredentials(env = process.env) {
4138
+ try {
4139
+ if (!isConveyorCloudEnv(env)) return;
4140
+ const path4 = claudeCredentialsPath();
4141
+ const existing = parseClaudeAiOauth(await readRaw(path4));
4142
+ if (existing && typeof existing.refreshToken === "string" && existing.refreshToken.length > 0) {
4143
+ return;
4144
+ }
4145
+ await rm4(path4, { force: true });
4146
+ } catch (err) {
4147
+ const message = err instanceof Error ? err.message : String(err);
4148
+ process.stderr.write(`[conveyor-agent] claude credentials removal failed: ${message}
4149
+ `);
4150
+ }
3996
4151
  }
3997
4152
 
3998
4153
  // src/harness/pty/pty-support.ts
@@ -4029,14 +4184,14 @@ function turnOptionsFrom(options) {
4029
4184
  abortController: options.abortController
4030
4185
  };
4031
4186
  }
4032
- function isRecord2(value) {
4187
+ function isRecord3(value) {
4033
4188
  return typeof value === "object" && value !== null;
4034
4189
  }
4035
4190
  function extractSpawn(mod) {
4036
- if (!isRecord2(mod)) return null;
4191
+ if (!isRecord3(mod)) return null;
4037
4192
  if (typeof mod.spawn === "function") return mod.spawn;
4038
4193
  const def = mod.default;
4039
- if (isRecord2(def) && typeof def.spawn === "function") return def.spawn;
4194
+ if (isRecord3(def) && typeof def.spawn === "function") return def.spawn;
4040
4195
  return null;
4041
4196
  }
4042
4197
  async function loadPtySpawn() {
@@ -4050,7 +4205,9 @@ function inheritedEnv(socketPath) {
4050
4205
  for (const [key, value] of Object.entries(process.env)) {
4051
4206
  if (typeof value === "string") env[key] = value;
4052
4207
  }
4053
- env.CONVEYOR_HOOK_SOCKET = socketPath;
4208
+ if (socketPath) {
4209
+ env.CONVEYOR_HOOK_SOCKET = socketPath;
4210
+ }
4054
4211
  env.MCP_TIMEOUT ??= "60000";
4055
4212
  env.MCP_TOOL_TIMEOUT ??= "180000";
4056
4213
  return env;
@@ -4058,6 +4215,16 @@ function inheritedEnv(socketPath) {
4058
4215
  function buildPromptBytes(text) {
4059
4216
  return `\x1B[200~${text}\x1B[201~`;
4060
4217
  }
4218
+ function renderPromptContentText(content) {
4219
+ return content.map((block) => {
4220
+ const b = block;
4221
+ if (b?.type === "text" && typeof b.text === "string") return b.text;
4222
+ if (b?.type === "image") {
4223
+ return `[Image attachment \u2014 use list_task_files / get_attachment to view]`;
4224
+ }
4225
+ return JSON.stringify(block);
4226
+ }).join("\n\n");
4227
+ }
4061
4228
  function sleep3(ms) {
4062
4229
  return new Promise((resolve) => {
4063
4230
  setTimeout(resolve, ms);
@@ -4074,9 +4241,9 @@ function parseUserQuestions(input) {
4074
4241
  if (!Array.isArray(input.questions)) return [];
4075
4242
  const questions = [];
4076
4243
  for (const entry of input.questions) {
4077
- if (!isRecord2(entry)) continue;
4244
+ if (!isRecord3(entry)) continue;
4078
4245
  if (typeof entry.question !== "string") continue;
4079
- const options = Array.isArray(entry.options) ? entry.options.filter(isRecord2).filter((o) => typeof o.label === "string").map((o) => ({
4246
+ const options = Array.isArray(entry.options) ? entry.options.filter(isRecord3).filter((o) => typeof o.label === "string").map((o) => ({
4080
4247
  label: o.label,
4081
4248
  description: typeof o.description === "string" ? o.description : ""
4082
4249
  })) : [];
@@ -4090,18 +4257,65 @@ function parseUserQuestions(input) {
4090
4257
  return questions;
4091
4258
  }
4092
4259
 
4260
+ // src/harness/pty/adapters/claude.ts
4261
+ var ClaudeTuiAdapter = class {
4262
+ id = "claude-code";
4263
+ capabilities = {
4264
+ resume: true,
4265
+ structuredEvents: true,
4266
+ prefill: true,
4267
+ passiveTurns: true
4268
+ };
4269
+ resolveBinary(env = process.env) {
4270
+ return env.CONVEYOR_CLAUDE_BIN ?? "claude";
4271
+ }
4272
+ buildSpawn(input) {
4273
+ const { options, resume } = input;
4274
+ const args = buildSpawnArgs({
4275
+ ...resume ? { resume } : {},
4276
+ ...options.sessionId ? { sessionId: options.sessionId } : {},
4277
+ model: options.model,
4278
+ permissionMode: options.permissionMode,
4279
+ settingsPath: input.settingsPath ?? "",
4280
+ ...options.appendSystemPrompt ? { appendSystemPrompt: options.appendSystemPrompt } : {},
4281
+ ...input.mcpConfigPath ? { mcpConfigPath: input.mcpConfigPath, strictMcpConfig: true } : {}
4282
+ });
4283
+ return {
4284
+ file: this.resolveBinary(),
4285
+ args,
4286
+ env: inheritedEnv(input.hookSocketPath)
4287
+ };
4288
+ }
4289
+ async prepareEnvironment(opts) {
4290
+ const env = opts.env ?? process.env;
4291
+ await ensureClaudeCredentials(env);
4292
+ await ensureClaudeOnboarding(env, opts.cwd);
4293
+ }
4294
+ spawnFingerprint(input) {
4295
+ return spawnOptionsFingerprint(input);
4296
+ }
4297
+ encodePromptBytes(text) {
4298
+ return buildPromptBytes(text);
4299
+ }
4300
+ buildExitErrors(exitCode, rawOutput) {
4301
+ return buildExitErrors(exitCode, rawOutput, this.resolveBinary());
4302
+ }
4303
+ };
4304
+
4093
4305
  // src/harness/pty/session.ts
4094
4306
  var PtySession = class {
4095
- constructor(prompt, options, resume, bridge) {
4307
+ constructor(prompt, options, resume, bridge, adapter = new ClaudeTuiAdapter()) {
4096
4308
  this.options = options;
4097
4309
  this.resume = resume;
4098
4310
  this.bridge = bridge;
4311
+ this.adapter = adapter;
4099
4312
  this.turnPrompt = prompt;
4100
4313
  this.turn = turnOptionsFrom(options);
4101
4314
  }
4102
4315
  options;
4103
4316
  resume;
4104
4317
  bridge;
4318
+ adapter;
4105
4319
  // The current turn's event stream. Null between turns (process parked) — the
4106
4320
  // process outlives any single turn, so the queue is per-turn, not per-process.
4107
4321
  activeQueue = null;
@@ -4187,7 +4401,7 @@ var PtySession = class {
4187
4401
  }
4188
4402
  /** Fingerprint of the spawn-time options a reused process cannot change. */
4189
4403
  get spawnFingerprint() {
4190
- return spawnOptionsFingerprint({
4404
+ return this.adapter.spawnFingerprint({
4191
4405
  model: this.options.model,
4192
4406
  permissionMode: this.options.permissionMode,
4193
4407
  ...this.options.appendSystemPrompt ? { appendSystemPrompt: this.options.appendSystemPrompt } : {},
@@ -4324,22 +4538,19 @@ var PtySession = class {
4324
4538
  return;
4325
4539
  }
4326
4540
  try {
4327
- this.tempDir = await mkdtemp2(join4(tmpdir4(), "conveyor-pty-"));
4328
- const socketPath = join4(this.tempDir, "hook.sock");
4329
- this.socket = new HookSocketServer(
4330
- socketPath,
4331
- (progress) => this.handleProgress(progress),
4332
- (request) => this.handlePreToolUse(request)
4333
- );
4334
- await this.socket.listen();
4335
- const { settingsPath } = await writeHookSettings(this.tempDir);
4336
- await this.setupToolServers();
4337
- const transcriptPath = sessionTranscriptPath(this.options.cwd, sessionId);
4338
- await mkdir2(dirname(transcriptPath), { recursive: true });
4339
- const startOffset = this.resume ? await transcriptSize(transcriptPath) : 0;
4340
- this.tailer = new JsonlTailer(transcriptPath, (event) => this.handleTranscriptEvent(event));
4341
- this.tailer.start(startOffset);
4342
- await this.spawn(settingsPath, socketPath);
4541
+ if (this.adapter.capabilities.structuredEvents) {
4542
+ const { settingsPath, socketPath } = await this.startStructuredEventSources(sessionId);
4543
+ await this.spawn(settingsPath, socketPath);
4544
+ } else {
4545
+ this.tempDir = await mkdtemp2(join4(tmpdir3(), "conveyor-pty-"));
4546
+ await this.spawn();
4547
+ this.pushEvent({
4548
+ type: "system",
4549
+ subtype: "init",
4550
+ session_id: sessionId,
4551
+ model: this.options.model
4552
+ });
4553
+ }
4343
4554
  if (signal) {
4344
4555
  this.abortHandler = () => {
4345
4556
  void this.teardown();
@@ -4360,6 +4571,38 @@ var PtySession = class {
4360
4571
  throw err;
4361
4572
  }
4362
4573
  }
4574
+ /**
4575
+ * Allocate the Claude-style structured-event sources: the PostToolUse hook
4576
+ * socket, the per-run settings file, the in-process tool servers, and the
4577
+ * transcript tailer. Only structured-events adapters call this — raw-terminal
4578
+ * adapters have no trusted event source and skip it entirely. Returns the
4579
+ * paths spawn() must wire into the child's argv/env.
4580
+ */
4581
+ async startStructuredEventSources(sessionId) {
4582
+ this.tempDir = await mkdtemp2(join4(tmpdir3(), "conveyor-pty-"));
4583
+ const socketPath = join4(this.tempDir, "hook.sock");
4584
+ this.socket = new HookSocketServer(
4585
+ socketPath,
4586
+ (progress) => this.handleProgress(progress),
4587
+ (request) => this.handlePreToolUse(request)
4588
+ );
4589
+ await this.socket.listen();
4590
+ const { settingsPath } = await writeHookSettings(this.tempDir);
4591
+ await this.setupToolServers();
4592
+ const transcriptPath = sessionTranscriptPath(this.options.cwd, sessionId);
4593
+ await mkdir3(dirname(transcriptPath), { recursive: true });
4594
+ const startOffset = this.resume ? await transcriptSize(transcriptPath) : 0;
4595
+ const sendChat = this.bridge?.sendChatEvent?.bind(this.bridge);
4596
+ this.tailer = new JsonlTailer(
4597
+ transcriptPath,
4598
+ (event) => this.handleTranscriptEvent(event),
4599
+ sendChat ? (raw) => {
4600
+ for (const chatEvent of mapChatRecords(raw)) sendChat(chatEvent);
4601
+ } : void 0
4602
+ );
4603
+ this.tailer.start(startOffset);
4604
+ return { settingsPath, socketPath };
4605
+ }
4363
4606
  writeStdin(text) {
4364
4607
  this.pty?.write(text);
4365
4608
  }
@@ -4425,7 +4668,7 @@ var PtySession = class {
4425
4668
  this.activeQueue?.close();
4426
4669
  this.activeQueue = null;
4427
4670
  if (this.tempDir) {
4428
- await rm4(this.tempDir, { recursive: true, force: true });
4671
+ await rm5(this.tempDir, { recursive: true, force: true });
4429
4672
  this.tempDir = "";
4430
4673
  }
4431
4674
  }
@@ -4444,25 +4687,23 @@ var PtySession = class {
4444
4687
  this.mcpConfigPath = mcpConfigPath;
4445
4688
  }
4446
4689
  async spawn(settingsPath, socketPath) {
4447
- const args = buildSpawnArgs({
4448
- resume: this.resume,
4449
- sessionId: this.options.sessionId,
4450
- model: this.options.model,
4451
- permissionMode: this.options.permissionMode,
4452
- settingsPath,
4453
- ...this.options.appendSystemPrompt ? { appendSystemPrompt: this.options.appendSystemPrompt } : {},
4690
+ const spec = this.adapter.buildSpawn({
4691
+ options: this.options,
4692
+ ...this.resume ? { resume: this.resume } : {},
4693
+ ...settingsPath ? { settingsPath } : {},
4694
+ ...socketPath ? { hookSocketPath: socketPath } : {},
4454
4695
  // Only this config: ignore the user's ~/.claude.json / project .mcp.json
4455
4696
  // so the agent's tool set is deterministic (and a stale personal conveyor
4456
4697
  // server doesn't load).
4457
- ...this.mcpConfigPath ? { mcpConfigPath: this.mcpConfigPath, strictMcpConfig: true } : {}
4698
+ ...this.mcpConfigPath ? { mcpConfigPath: this.mcpConfigPath } : {}
4458
4699
  });
4459
4700
  const spawn2 = await loadPtySpawn();
4460
- const pty = spawn2(resolveClaudeBinary(), args, {
4701
+ const pty = spawn2(spec.file, spec.args, {
4461
4702
  name: "xterm-color",
4462
4703
  cols: this.cols,
4463
4704
  rows: this.rows,
4464
4705
  cwd: this.options.cwd,
4465
- env: inheritedEnv(socketPath)
4706
+ env: spec.env
4466
4707
  });
4467
4708
  const bridge = this.bridge;
4468
4709
  if (bridge) {
@@ -4483,12 +4724,13 @@ var PtySession = class {
4483
4724
  this.pty = pty;
4484
4725
  }
4485
4726
  async deliverPrompt(text) {
4486
- this.writeStdin(buildPromptBytes(text));
4727
+ if (text === "" && !this.adapter.capabilities.prefill) return;
4728
+ this.writeStdin(this.adapter.encodePromptBytes(text));
4487
4729
  if (this.turn.promptDelivery === "prefill") return;
4488
4730
  await sleep3(SUBMIT_SETTLE_MS);
4489
4731
  if (this._toreDown) return;
4490
4732
  this.writeStdin("\r");
4491
- this.armSubmitNudge();
4733
+ if (this.adapter.capabilities.structuredEvents) this.armSubmitNudge();
4492
4734
  }
4493
4735
  async feedPrompt() {
4494
4736
  if (typeof this.turnPrompt === "string") {
@@ -4497,7 +4739,7 @@ var PtySession = class {
4497
4739
  }
4498
4740
  for await (const message of this.turnPrompt) {
4499
4741
  const content = message.message.content;
4500
- const text = typeof content === "string" ? content : JSON.stringify(content);
4742
+ const text = typeof content === "string" ? content : renderPromptContentText(content);
4501
4743
  await this.deliverPrompt(text);
4502
4744
  }
4503
4745
  }
@@ -4645,11 +4887,22 @@ var PtySession = class {
4645
4887
  this.tailer.close();
4646
4888
  await this.tailer.flush();
4647
4889
  }
4648
- if (!this.sawResult && this.activeQueue) {
4890
+ if (!this.adapter.capabilities.structuredEvents) {
4891
+ if (exitCode === 0) {
4892
+ this.pushEvent({ type: "result", subtype: "success", result: "", total_cost_usd: 0 });
4893
+ } else {
4894
+ this.pushEvent({
4895
+ type: "result",
4896
+ subtype: "error",
4897
+ errors: this.adapter.buildExitErrors(exitCode, this.recentOutput)
4898
+ });
4899
+ }
4900
+ this.endTurn(exitCode === 0);
4901
+ } else if (!this.sawResult && this.activeQueue) {
4649
4902
  this.activeQueue.push({
4650
4903
  type: "result",
4651
4904
  subtype: "error",
4652
- errors: buildExitErrors(exitCode, this.recentOutput, resolveClaudeBinary())
4905
+ errors: this.adapter.buildExitErrors(exitCode, this.recentOutput)
4653
4906
  });
4654
4907
  }
4655
4908
  for (const listener of this.exitListeners) listener(exitCode);
@@ -4658,191 +4911,29 @@ var PtySession = class {
4658
4911
  }
4659
4912
  };
4660
4913
 
4661
- // src/harness/pty/credentials.ts
4662
- import { chmod as chmod2, mkdir as mkdir3, readFile, rm as rm5, writeFile as writeFile4 } from "fs/promises";
4663
- import { homedir as homedir2 } from "os";
4664
- import { join as join5 } from "path";
4665
- var SYNTH_TOKEN_TTL_MS = 365 * 24 * 60 * 60 * 1e3;
4666
- var REFRESH_SKEW_MS = 30 * 24 * 60 * 60 * 1e3;
4667
- function claudeCredentialsPath() {
4668
- return join5(claudeConfigHome(), ".credentials.json");
4669
- }
4670
- function isConveyorCloudEnv(env = process.env) {
4671
- return Boolean(env.CLAUDESPACE_NAME || env.CODESPACE_NAME || env.CODESPACES);
4672
- }
4673
- function parseClaudeAiOauth(raw) {
4674
- if (!raw || raw.trim() === "") return null;
4675
- try {
4676
- const parsed = JSON.parse(raw);
4677
- if (typeof parsed !== "object" || parsed === null) return null;
4678
- const oauth = parsed.claudeAiOauth;
4679
- if (typeof oauth !== "object" || oauth === null) return null;
4680
- const record = oauth;
4681
- return {
4682
- accessToken: record.accessToken,
4683
- refreshToken: record.refreshToken,
4684
- expiresAt: record.expiresAt
4685
- };
4686
- } catch {
4687
- return null;
4688
- }
4689
- }
4690
- function buildSynthesizedCredentials(token, now) {
4691
- return JSON.stringify({
4692
- claudeAiOauth: {
4693
- accessToken: token,
4694
- expiresAt: now + SYNTH_TOKEN_TTL_MS,
4695
- scopes: ["user:inference", "user:profile"],
4696
- subscriptionType: "max"
4697
- }
4698
- });
4699
- }
4700
- function planCredentialsWrite(input) {
4701
- if (!input.isCloud) return { action: "skip", reason: "not-cloud" };
4702
- if (!input.token) return { action: "skip", reason: "no-token" };
4703
- const contents = buildSynthesizedCredentials(input.token, input.now);
4704
- const existing = parseClaudeAiOauth(input.existingRaw);
4705
- if (!existing) return { action: "write", contents };
4706
- if (typeof existing.refreshToken === "string" && existing.refreshToken.length > 0) {
4707
- return { action: "skip", reason: "foreign-credentials" };
4708
- }
4709
- const fresh = existing.accessToken === input.token && typeof existing.expiresAt === "number" && existing.expiresAt > input.now + REFRESH_SKEW_MS;
4710
- if (fresh) return { action: "skip", reason: "current" };
4711
- return { action: "write", contents };
4712
- }
4713
- async function readRaw(path4) {
4714
- try {
4715
- return await readFile(path4, "utf8");
4716
- } catch {
4717
- return null;
4718
- }
4719
- }
4720
- async function ensureClaudeCredentials(env = process.env) {
4721
- try {
4722
- const path4 = claudeCredentialsPath();
4723
- const plan = planCredentialsWrite({
4724
- isCloud: isConveyorCloudEnv(env),
4725
- token: env.CLAUDE_CODE_OAUTH_TOKEN,
4726
- existingRaw: await readRaw(path4),
4727
- now: Date.now()
4728
- });
4729
- if (plan.action === "skip") return;
4730
- await mkdir3(claudeConfigHome(), { recursive: true });
4731
- await writeFile4(path4, plan.contents, { encoding: "utf8", mode: 384 });
4732
- await chmod2(path4, 384).catch(() => {
4733
- });
4734
- } catch (err) {
4735
- const message = err instanceof Error ? err.message : String(err);
4736
- process.stderr.write(`[conveyor-agent] claude credentials sync failed: ${message}
4737
- `);
4738
- }
4739
- }
4740
- function claudeJsonPath() {
4741
- const configDir = process.env.CLAUDE_CONFIG_DIR;
4742
- return configDir ? join5(configDir, ".claude.json") : join5(homedir2(), ".claude.json");
4743
- }
4744
- function asRecord(value) {
4745
- return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
4746
- }
4747
- function parseClaudeJson(existingRaw) {
4748
- if (!existingRaw || existingRaw.trim() === "") return {};
4749
- try {
4750
- return asRecord(JSON.parse(existingRaw));
4751
- } catch {
4752
- return {};
4753
- }
4754
- }
4755
- function seedWorkspaceTrust(config, trustCwd) {
4756
- const projects = asRecord(config.projects);
4757
- const entry = asRecord(projects[trustCwd]);
4758
- if (entry.hasTrustDialogAccepted === true) return false;
4759
- entry.hasTrustDialogAccepted = true;
4760
- projects[trustCwd] = entry;
4761
- config.projects = projects;
4762
- return true;
4763
- }
4764
- function planClaudeJsonSeed(existingRaw, trustCwd) {
4765
- const config = parseClaudeJson(existingRaw);
4766
- const isFresh = Object.keys(config).length === 0;
4767
- let changed = false;
4768
- if (config.hasCompletedOnboarding !== true) {
4769
- config.hasCompletedOnboarding = true;
4770
- changed = true;
4771
- }
4772
- if (config.bypassPermissionsModeAccepted !== true) {
4773
- config.bypassPermissionsModeAccepted = true;
4774
- changed = true;
4775
- }
4776
- if (isFresh && typeof config.theme !== "string") {
4777
- config.theme = "dark";
4778
- changed = true;
4779
- }
4780
- if (trustCwd && seedWorkspaceTrust(config, trustCwd)) {
4781
- changed = true;
4782
- }
4783
- return changed ? JSON.stringify(config) : null;
4784
- }
4785
- async function ensureClaudeOnboarding(env = process.env, trustCwd) {
4786
- try {
4787
- if (!isConveyorCloudEnv(env)) return;
4788
- const path4 = claudeJsonPath();
4789
- const contents = planClaudeJsonSeed(await readRaw(path4), trustCwd);
4790
- if (contents === null) return;
4791
- await writeFile4(path4, contents, "utf8");
4792
- const verify = await readRaw(path4);
4793
- if (verify === contents) {
4794
- process.stderr.write(
4795
- `[conveyor-agent] claude onboarding seeded${trustCwd ? ` (trust: ${trustCwd})` : ""}
4796
- `
4797
- );
4798
- } else {
4799
- process.stderr.write(
4800
- `[conveyor-agent] claude onboarding seed read-back MISMATCH at ${path4}: wrote ${contents.length}B, read ${verify?.length ?? 0}B \u2014 CLI may see stale config and park at a startup dialog
4801
- `
4802
- );
4803
- }
4804
- } catch (err) {
4805
- const message = err instanceof Error ? err.message : String(err);
4806
- process.stderr.write(`[conveyor-agent] claude onboarding seed failed: ${message}
4807
- `);
4808
- }
4809
- }
4810
- async function removeConveyorCredentials(env = process.env) {
4811
- try {
4812
- if (!isConveyorCloudEnv(env)) return;
4813
- const path4 = claudeCredentialsPath();
4814
- const existing = parseClaudeAiOauth(await readRaw(path4));
4815
- if (existing && typeof existing.refreshToken === "string" && existing.refreshToken.length > 0) {
4816
- return;
4817
- }
4818
- await rm5(path4, { force: true });
4819
- } catch (err) {
4820
- const message = err instanceof Error ? err.message : String(err);
4821
- process.stderr.write(`[conveyor-agent] claude credentials removal failed: ${message}
4822
- `);
4823
- }
4824
- }
4825
-
4826
4914
  // src/harness/pty/index.ts
4827
4915
  var ENDED_GRACE_MS = 4e3;
4828
- function fingerprintOf(options) {
4829
- return spawnOptionsFingerprint({
4830
- model: options.model,
4831
- permissionMode: options.permissionMode,
4832
- ...options.appendSystemPrompt ? { appendSystemPrompt: options.appendSystemPrompt } : {},
4833
- cwd: options.cwd
4834
- });
4835
- }
4836
4916
  var PtyHarness = class {
4837
4917
  /**
4838
4918
  * `bridge` relays raw terminal I/O to/from the S2 server (and on to the S5
4839
4919
  * terminal). It is undefined for SDK-only callers and PTY runs that never
4840
4920
  * attach a relay; the session simply discards stdout in that case.
4841
4921
  */
4842
- constructor(bridge) {
4922
+ constructor(bridge, adapter = new ClaudeTuiAdapter()) {
4843
4923
  this.bridge = bridge;
4924
+ this.adapter = adapter;
4844
4925
  }
4845
4926
  bridge;
4927
+ adapter;
4928
+ /** Fingerprint of the spawn-time options a reused process cannot change. */
4929
+ fingerprintOf(options) {
4930
+ return this.adapter.spawnFingerprint({
4931
+ model: options.model,
4932
+ permissionMode: options.permissionMode,
4933
+ ...options.appendSystemPrompt ? { appendSystemPrompt: options.appendSystemPrompt } : {},
4934
+ cwd: options.cwd
4935
+ });
4936
+ }
4846
4937
  /** The session currently streaming events, if any — repaint target. */
4847
4938
  activeSession = null;
4848
4939
  /** A completed-but-alive session kept for the next turn (keep-alive). */
@@ -4861,7 +4952,7 @@ var PtyHarness = class {
4861
4952
  }
4862
4953
  async *executeQuery(opts) {
4863
4954
  const want = opts.resume ?? opts.options.resume;
4864
- const fingerprint = fingerprintOf(opts.options);
4955
+ const fingerprint = this.fingerprintOf(opts.options);
4865
4956
  let session;
4866
4957
  if (this.parked?.canReuse(want, fingerprint)) {
4867
4958
  session = this.parked;
@@ -4875,9 +4966,8 @@ var PtyHarness = class {
4875
4966
  this.cancelEndedTimer();
4876
4967
  await stale.teardown();
4877
4968
  }
4878
- session = new PtySession(opts.prompt, opts.options, want, this.bridge);
4879
- await ensureClaudeCredentials();
4880
- await ensureClaudeOnboarding(process.env, opts.options.cwd);
4969
+ session = new PtySession(opts.prompt, opts.options, want, this.bridge, this.adapter);
4970
+ await this.adapter.prepareEnvironment({ cwd: opts.options.cwd });
4881
4971
  session.onExit(() => this.handleSessionExit(session));
4882
4972
  await session.start();
4883
4973
  }
@@ -4995,7 +5085,7 @@ function createHarness(kind = "sdk", ptyBridge) {
4995
5085
 
4996
5086
  // src/execution/query-executor.ts
4997
5087
  import { createHash } from "crypto";
4998
- import { existsSync, readFileSync as readFileSync2, truncateSync } from "fs";
5088
+ import { existsSync as existsSync2, readFileSync, truncateSync } from "fs";
4999
5089
 
5000
5090
  // src/execution/pack-runner-prompt.ts
5001
5091
  function findLastAgentMessageIndex(history) {
@@ -5150,8 +5240,9 @@ function formatChatFile(file) {
5150
5240
  return [`[Attached: ${file.fileName} (${file.mimeType}${sizeStr})]: ${file.downloadUrl}`];
5151
5241
  }
5152
5242
  if (file.content && file.contentEncoding === "base64") {
5243
+ const link = file.downloadUrl ? ` or download: ${file.downloadUrl}` : "";
5153
5244
  return [
5154
- `[Attached image: ${file.fileName} (${file.mimeType}${sizeStr}) \u2014 use get_attachment("${file.fileId}") to view]`
5245
+ `[Attached image: ${file.fileName} (${file.mimeType}${sizeStr}) \u2014 use get_attachment("${file.fileId}") to view${link}]`
5155
5246
  ];
5156
5247
  }
5157
5248
  return [`[Attached: ${file.fileName} (${file.mimeType}${sizeStr})]`];
@@ -5163,8 +5254,9 @@ function formatTaskFile(file) {
5163
5254
  }
5164
5255
  if (file.content && file.contentEncoding === "base64") {
5165
5256
  const size = formatFileSize(file.fileSize);
5257
+ const link = file.downloadUrl ? ` or download: ${file.downloadUrl}` : "";
5166
5258
  return [
5167
- `- [Attached image: ${file.fileName} (${file.mimeType}${size ? `, ${size}` : ""}) \u2014 use get_attachment("${file.fileId}") to view]`
5259
+ `- [Attached image: ${file.fileName} (${file.mimeType}${size ? `, ${size}` : ""}) \u2014 use get_attachment("${file.fileId}") to view${link}]`
5168
5260
  ];
5169
5261
  }
5170
5262
  if (!file.content) {
@@ -5647,7 +5739,7 @@ function buildDiscoveryPrompt(context, runnerMode) {
5647
5739
  ## Mode: Discovery`,
5648
5740
  `You are in Discovery mode \u2014 helping plan and scope this task.`,
5649
5741
  `- You have read-only codebase access (can read files, run git commands, search code)`,
5650
- `- You can write plan files in .claude/plans/ only \u2014 no other file writes`,
5742
+ `- 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`,
5651
5743
  `- Do NOT attempt to edit, write, or modify source code files \u2014 these operations will be denied`,
5652
5744
  `- If you identify code changes needed, describe them in the plan instead of implementing them`,
5653
5745
  `- You can create and manage subtasks`,
@@ -5697,7 +5789,7 @@ function buildDiscoveryPrompt(context, runnerMode) {
5697
5789
  ``,
5698
5790
  `### Plan Verification Requirements`,
5699
5791
  `Every plan MUST include a **Testing / Verification** section so the build agent has a clear definition of "done". Enumerate:`,
5700
- `- The quality gates the build agent should run: \`bun run lint\`, \`bun run typecheck\`, \`bun run test\` (or scoped equivalents)`,
5792
+ `- 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).`,
5701
5793
  `- Any task-specific end-to-end checks (manual UI walk-through, API smoke test, migration dry-run, etc.)`,
5702
5794
  `You are NOT expected to run these gates yourself \u2014 discovery is read-only. Just describe them in the plan.`,
5703
5795
  ``,
@@ -5776,14 +5868,14 @@ function buildModePrompt(agentMode, context, runnerMode) {
5776
5868
  `- Goal: coordinate child task execution and ensure all children complete successfully`
5777
5869
  ] : [
5778
5870
  `- If this is a leaf task (no children): execute the plan directly`,
5779
- `- Goal: implement the plan, verify quality gates, open a PR when done`,
5871
+ `- Goal: implement the plan, run scoped verification, open a PR when done`,
5780
5872
  ``,
5781
5873
  `### Pre-PR Verification Checklist`,
5782
- `Before calling \`mcp__conveyor__create_pull_request\`, verify ALL of the following pass:`,
5783
- `1. \`bun run lint\` \u2014 no new lint errors`,
5784
- `2. \`bun run typecheck\` \u2014 no new type errors`,
5785
- `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)`,
5786
- `If a gate fails, fix it before opening the PR. Do NOT open PRs with known failing gates.`,
5874
+ `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:`,
5875
+ `1. \`bun run check\` \u2014 lint + typecheck (fast; run whenever you changed code)`,
5876
+ `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)`,
5877
+ `Docs/markdown/.claude-only changes need NO local gates \u2014 open the PR and let CI validate.`,
5878
+ `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.`,
5787
5879
  `For refactors: also run \`git diff ${context?.baseBranch ?? "dev"}..HEAD\` and confirm the public API surface (exports, function signatures) has no unintended breaking changes.`
5788
5880
  ]
5789
5881
  ];
@@ -5884,12 +5976,13 @@ function buildPmPreamble(context) {
5884
5976
  `
5885
5977
  Environment (ready, no setup required):`,
5886
5978
  `- Repository is cloned at your current working directory.`,
5979
+ `- The shell cwd resets between Bash calls \u2014 always use absolute paths (or \`git -C\`); never spend a tool call on a bare \`cd\`.`,
5887
5980
  `- You can read files and run git commands to understand the codebase before writing task plans.`,
5888
5981
  `- 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.`,
5889
5982
  `
5890
5983
  Workflow:`,
5891
- `- You can draft and iterate on plans in .claude/plans/*.md \u2014 these files are automatically synced to the task.`,
5892
- `- You can also use update_task_plan directly to save the plan to the task.`,
5984
+ `- You can draft and iterate on plans in .claude/plans/*.md, but files on disk are NOT synced to the task.`,
5985
+ `- Save the plan to the task with update_task_plan \u2014 this is the only way the plan is persisted.`,
5893
5986
  `- 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.`,
5894
5987
  `- A separate task agent will handle execution after the team reviews and approves your plan.`
5895
5988
  ];
@@ -5922,6 +6015,7 @@ Environment (ready, no setup required):`,
5922
6015
  `- Repository is cloned at your current working directory.`,
5923
6016
  `- You can read, write, and edit files directly.`,
5924
6017
  `- You can run shell commands including git, build tools, and test runners.`,
6018
+ `- 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\`.`,
5925
6019
  context.githubBranch ? `- You are working on branch: \`${context.githubBranch}\`` : "",
5926
6020
  `
5927
6021
  Safety rules:`,
@@ -5946,6 +6040,10 @@ Environment (fully ready \u2014 do NOT verify or set up):`,
5946
6040
  `- Branch \`${context.githubBranch}\` is already checked out.`,
5947
6041
  `- All dependencies are installed, database is migrated, and the dev server is running.`,
5948
6042
  `- Git is configured. Commit and push directly to this branch.`,
6043
+ `- 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\`.`,
6044
+ `- 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.`,
6045
+ `- The \`gh\` CLI may not be installed \u2014 use the mcp__conveyor__* tools for PR and task state.`,
6046
+ `- 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.`,
5949
6047
  `
5950
6048
  IMPORTANT \u2014 Skip all environment verification. Do NOT run any of the following:`,
5951
6049
  `- bun/npm install, pip install, or any dependency installation`,
@@ -6208,7 +6306,7 @@ CRITICAL: You are in Auto mode. Do NOT report status, ask for confirmation, or g
6208
6306
  `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.`,
6209
6307
  `Work on the git branch "${context.githubBranch}". Stay on this branch for the entire task. Do not checkout or create other branches.`,
6210
6308
  `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.`,
6211
- `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.`,
6309
+ `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.`,
6212
6310
  `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.`
6213
6311
  ];
6214
6312
  if (isAutoMode) {
@@ -6280,7 +6378,7 @@ New messages since your last run:`,
6280
6378
  ...newMessages.map((m) => `[${m.userName ?? "user"}]: ${m.content}`),
6281
6379
  `
6282
6380
  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.`,
6283
- `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.`,
6381
+ `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.`,
6284
6382
  `Implement your updates and open a PR when finished.`
6285
6383
  ];
6286
6384
  if (context.githubPRUrl) {
@@ -6723,11 +6821,11 @@ function buildCreatePullRequestTool(connection, config) {
6723
6821
  async ({ title, body, branch, baseBranch, commitMessage, skipVerify }) => {
6724
6822
  try {
6725
6823
  const cwd = config.workspaceDir;
6726
- if (hasUncommittedChanges(cwd)) {
6824
+ if (await hasUncommittedChanges(cwd)) {
6727
6825
  const message = commitMessage || `${title}
6728
6826
 
6729
6827
  Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>`;
6730
- const commitHash = stageAndCommit(cwd, message);
6828
+ const commitHash = await stageAndCommit(cwd, message);
6731
6829
  if (commitHash) {
6732
6830
  connection.sendEvent({
6733
6831
  type: "message",
@@ -6739,7 +6837,7 @@ Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>`;
6739
6837
  );
6740
6838
  }
6741
6839
  }
6742
- if (hasUnpushedCommits(cwd)) {
6840
+ if (await hasUnpushedCommits(cwd)) {
6743
6841
  const pushSuccess = await pushToOrigin(
6744
6842
  cwd,
6745
6843
  async () => {
@@ -6938,7 +7036,7 @@ function buildMutationTools(connection, config) {
6938
7036
 
6939
7037
  // src/tools/attachment-tools.ts
6940
7038
  import { readFile as readFile3, stat as stat5 } from "fs/promises";
6941
- import { basename, extname, isAbsolute, join as join6 } from "path";
7039
+ import { basename, extname, isAbsolute, join as join5 } from "path";
6942
7040
  import { z as z6 } from "zod";
6943
7041
  var IMAGE_MIME_BY_EXT = {
6944
7042
  ".png": "image/png",
@@ -6957,7 +7055,7 @@ function buildUploadAttachmentTool(connection, config) {
6957
7055
  },
6958
7056
  async ({ path: path4, title }) => {
6959
7057
  try {
6960
- const filePath = isAbsolute(path4) ? path4 : join6(config.workspaceDir, path4);
7058
+ const filePath = isAbsolute(path4) ? path4 : join5(config.workspaceDir, path4);
6961
7059
  const mimeType = IMAGE_MIME_BY_EXT[extname(filePath).toLowerCase()];
6962
7060
  if (!mimeType) {
6963
7061
  return textResult(
@@ -8092,7 +8190,6 @@ async function handleExitPlanMode(host, input) {
8092
8190
  return { behavior: "allow", updatedInput: input };
8093
8191
  }
8094
8192
  try {
8095
- await host.syncPlanFile();
8096
8193
  const taskProps = await host.connection.getTaskProperties();
8097
8194
  const missingProps = collectMissingProps(taskProps);
8098
8195
  if (host.isParentTask) {
@@ -8362,7 +8459,7 @@ function sessionLineageKey(taskId, agentMode, runnerMode) {
8362
8459
  }
8363
8460
  function sessionFileExists(sessionUuid, cwd) {
8364
8461
  try {
8365
- return existsSync(sessionTranscriptPath(cwd, sessionUuid));
8462
+ return existsSync2(sessionTranscriptPath(cwd, sessionUuid));
8366
8463
  } catch {
8367
8464
  return false;
8368
8465
  }
@@ -8381,8 +8478,8 @@ function resolveSessionStart(lineageKey, cwd) {
8381
8478
  }
8382
8479
  function repairTornSessionFile(path4) {
8383
8480
  try {
8384
- if (!existsSync(path4)) return false;
8385
- const content = readFileSync2(path4, "utf8");
8481
+ if (!existsSync2(path4)) return false;
8482
+ const content = readFileSync(path4, "utf8");
8386
8483
  if (content.length === 0) return false;
8387
8484
  let keepEnd = content.length;
8388
8485
  if (!content.endsWith("\n")) {
@@ -8526,14 +8623,21 @@ async function buildFollowUpPrompt(host, context, followUpContent) {
8526
8623
 
8527
8624
  The team says:
8528
8625
  ${followUpText}` : followUpText;
8626
+ const isPty = host.harnessKind === "pty";
8529
8627
  if (isPmMode) {
8530
- const prompt = buildMultimodalPrompt(textPrompt, context);
8628
+ const prompt = buildMultimodalPrompt(textPrompt, context, isPty);
8531
8629
  if (followUpImages.length > 0 && Array.isArray(prompt)) {
8532
8630
  prompt.push(...followUpImages);
8533
8631
  }
8534
8632
  return prompt;
8535
8633
  }
8536
8634
  if (followUpImages.length > 0) {
8635
+ if (isPty) {
8636
+ const refs = followUpImages.map(
8637
+ () => `[Image attachment \u2014 use list_task_files / get_attachment to view]`
8638
+ );
8639
+ return [textPrompt, ...refs].join("\n");
8640
+ }
8537
8641
  return [{ type: "text", text: textPrompt }, ...followUpImages];
8538
8642
  }
8539
8643
  return textPrompt;
@@ -8574,10 +8678,6 @@ async function runSdkQuery(host, context, followUpContent, promptDeliveryOverrid
8574
8678
  if (host.isStopped()) return;
8575
8679
  const mode = host.agentMode;
8576
8680
  const isDiscoveryLike = mode === "discovery" || mode === "help";
8577
- const needsPlanSync = isDiscoveryLike || mode === "auto" && !host.hasExitedPlanMode;
8578
- if (needsPlanSync) {
8579
- host.snapshotPlanFiles();
8580
- }
8581
8681
  const sessionStart = resolveSessionStart(
8582
8682
  sessionLineageKey(context.taskId, mode, host.config.mode),
8583
8683
  host.config.workspaceDir
@@ -8599,14 +8699,12 @@ async function runSdkQuery(host, context, followUpContent, promptDeliveryOverrid
8599
8699
  const resume = sessionStart.resume;
8600
8700
  if (followUpContent) {
8601
8701
  await runFollowUpQuery(host, context, options, resume, followUpContent);
8602
- } else if (isDiscoveryLike && promptDelivery !== "prefill") {
8603
8702
  return;
8604
- } else {
8605
- await runInitialQuery(host, context, options, resume, promptDelivery);
8606
8703
  }
8607
- if (needsPlanSync) {
8608
- await host.syncPlanFile();
8704
+ if (isDiscoveryLike && promptDelivery !== "prefill") {
8705
+ return;
8609
8706
  }
8707
+ await runInitialQuery(host, context, options, resume, promptDelivery);
8610
8708
  }
8611
8709
  async function runFollowUpQuery(host, context, options, resume, followUpContent) {
8612
8710
  if (options.promptDelivery === "prefill") {
@@ -8675,7 +8773,7 @@ function latestUserMessageText(context) {
8675
8773
  }
8676
8774
  return null;
8677
8775
  }
8678
- function selectInitialPromptInput(promptDelivery, initialPrompt, context, baseAppendSystemPrompt) {
8776
+ function selectInitialPromptInput(promptDelivery, initialPrompt, context, baseAppendSystemPrompt, harnessKind) {
8679
8777
  if (promptDelivery === "prefill") {
8680
8778
  return {
8681
8779
  prompt: latestUserMessageText(context) ?? "",
@@ -8683,7 +8781,9 @@ function selectInitialPromptInput(promptDelivery, initialPrompt, context, baseAp
8683
8781
  };
8684
8782
  }
8685
8783
  return {
8686
- prompt: buildMultimodalPrompt(initialPrompt, context),
8784
+ // On the PTY harness image blocks would be pasted into the TUI as text —
8785
+ // the prompt body already links each image (get_attachment / downloadUrl).
8786
+ prompt: buildMultimodalPrompt(initialPrompt, context, harnessKind === "pty"),
8687
8787
  appendSystemPrompt: baseAppendSystemPrompt
8688
8788
  };
8689
8789
  }
@@ -8698,7 +8798,8 @@ async function runInitialQuery(host, context, options, resume, promptDelivery) {
8698
8798
  promptDelivery,
8699
8799
  initialPrompt,
8700
8800
  context,
8701
- options.appendSystemPrompt
8801
+ options.appendSystemPrompt,
8802
+ host.harnessKind
8702
8803
  );
8703
8804
  const queryOptions = { ...options, appendSystemPrompt };
8704
8805
  let agentQuery = host.harness.executeQuery({
@@ -8723,7 +8824,7 @@ async function buildRetryQuery(host, context, options, lastErrorWasImage) {
8723
8824
  const retryPrompt = buildMultimodalPrompt(
8724
8825
  await buildInitialPrompt(host.config.mode, context, host.isAuto, host.agentMode),
8725
8826
  context,
8726
- lastErrorWasImage
8827
+ lastErrorWasImage || host.harnessKind === "pty"
8727
8828
  );
8728
8829
  return host.harness.executeQuery({
8729
8830
  prompt: host.createInputStream(retryPrompt),
@@ -8751,7 +8852,8 @@ async function handleAuthError(context, host, options) {
8751
8852
  host.connection.storeSessionId("");
8752
8853
  const freshPrompt = buildMultimodalPrompt(
8753
8854
  await buildInitialPrompt(host.config.mode, context, host.isAuto, host.agentMode),
8754
- context
8855
+ context,
8856
+ host.harnessKind === "pty"
8755
8857
  );
8756
8858
  const freshQuery = host.harness.executeQuery({
8757
8859
  prompt: host.createInputStream(freshPrompt),
@@ -8765,7 +8867,8 @@ async function handleStaleSession(context, host, options) {
8765
8867
  host.connection.storeSessionId("");
8766
8868
  const freshPrompt = buildMultimodalPrompt(
8767
8869
  await buildInitialPrompt(host.config.mode, context, host.isAuto, host.agentMode),
8768
- context
8870
+ context,
8871
+ host.harnessKind === "pty"
8769
8872
  );
8770
8873
  const freshQuery = host.harness.executeQuery({
8771
8874
  prompt: host.createInputStream(freshPrompt),
@@ -8991,13 +9094,14 @@ function resolveHarnessKind() {
8991
9094
  function buildPtyBridge(connection) {
8992
9095
  return {
8993
9096
  sendOutput: (data, dims) => connection.sendPtyOutput(data, dims),
9097
+ sendChatEvent: (event) => connection.sendPtyChatEvent(event),
8994
9098
  sendEnded: () => connection.sendPtyEnded(),
8995
9099
  onInput: (handler) => connection.onPtyInput(handler),
8996
9100
  onResize: (handler) => connection.onPtyResize(handler)
8997
9101
  };
8998
9102
  }
8999
9103
  var QueryBridge = class {
9000
- constructor(connection, mode, runnerConfig, callbacks, workspaceDir) {
9104
+ constructor(connection, mode, runnerConfig, callbacks) {
9001
9105
  this.connection = connection;
9002
9106
  this.mode = mode;
9003
9107
  this.runnerConfig = runnerConfig;
@@ -9008,7 +9112,6 @@ var QueryBridge = class {
9008
9112
  harnessKind,
9009
9113
  harnessKind === "pty" ? buildPtyBridge(connection) : void 0
9010
9114
  );
9011
- this.planSync = new PlanSync(workspaceDir, connection);
9012
9115
  }
9013
9116
  connection;
9014
9117
  mode;
@@ -9018,7 +9121,6 @@ var QueryBridge = class {
9018
9121
  /** Which harness drives this bridge ("pty" task chat; "sdk" only under the
9019
9122
  * CONVEYOR_FORCE_SDK_CARDS maintainer override). */
9020
9123
  harnessKind;
9021
- planSync;
9022
9124
  sessionIds = /* @__PURE__ */ new Map();
9023
9125
  activeQuery = null;
9024
9126
  pendingToolOutputs = [];
@@ -9219,8 +9321,6 @@ var QueryBridge = class {
9219
9321
  if (bridge.onSoftStop) bridge.onSoftStop();
9220
9322
  },
9221
9323
  createInputStream: (prompt) => bridge.createInputStream(prompt),
9222
- snapshotPlanFiles: () => bridge.planSync.snapshotPlanFiles(),
9223
- syncPlanFile: () => bridge.planSync.syncPlanFile(),
9224
9324
  onModeTransition: bridge.onModeTransition
9225
9325
  };
9226
9326
  }
@@ -9236,9 +9336,9 @@ var QueryBridge = class {
9236
9336
  };
9237
9337
 
9238
9338
  // src/runner/session-runner-helpers.ts
9239
- import { readFileSync as readFileSync3 } from "fs";
9240
- import { dirname as dirname2, join as join7 } from "path";
9241
- import { fileURLToPath } from "url";
9339
+ import { readFileSync as readFileSync2 } from "fs";
9340
+ import { dirname as dirname2, join as join6 } from "path";
9341
+ import { fileURLToPath as fileURLToPath2 } from "url";
9242
9342
  function mapChatHistory(messages) {
9243
9343
  if (!messages) return [];
9244
9344
  return messages.map((m) => ({
@@ -9263,10 +9363,10 @@ function mapChatHistory(messages) {
9263
9363
  }
9264
9364
  function readAgentVersion() {
9265
9365
  try {
9266
- const here = dirname2(fileURLToPath(import.meta.url));
9366
+ const here = dirname2(fileURLToPath2(import.meta.url));
9267
9367
  for (const rel of ["../package.json", "../../package.json"]) {
9268
9368
  try {
9269
- const pkg = JSON.parse(readFileSync3(join7(here, rel), "utf-8"));
9369
+ const pkg = JSON.parse(readFileSync2(join6(here, rel), "utf-8"));
9270
9370
  if (pkg.version) return pkg.version;
9271
9371
  } catch {
9272
9372
  }
@@ -9352,7 +9452,7 @@ async function sampleKeyUsage(token) {
9352
9452
 
9353
9453
  // src/runner/port-discovery.ts
9354
9454
  import { readFile as readFile4 } from "fs/promises";
9355
- import { execFile } from "child_process";
9455
+ import { execFile as execFile3 } from "child_process";
9356
9456
  var PROC_TCP_LISTEN_STATE = "0A";
9357
9457
  function parseProcNetTcpListeners(content) {
9358
9458
  const ports = [];
@@ -9387,7 +9487,7 @@ async function readProcListeningPorts(procPaths = DEFAULT_PROC_PATHS) {
9387
9487
  }
9388
9488
  async function readNetstatListeningPorts() {
9389
9489
  const output = await new Promise((resolve) => {
9390
- execFile("netstat", ["-an", "-p", "tcp"], { timeout: 5e3 }, (err, stdout) => {
9490
+ execFile3("netstat", ["-an", "-p", "tcp"], { timeout: 5e3 }, (err, stdout) => {
9391
9491
  resolve(err ? null : stdout);
9392
9492
  });
9393
9493
  });
@@ -9538,10 +9638,12 @@ var PortDiscovery = class {
9538
9638
  };
9539
9639
 
9540
9640
  // src/runner/parent-pull-handler.ts
9541
- import { execSync as execSync4 } from "child_process";
9542
- function handlePullBranch(workDir, branch) {
9641
+ import { execFile as execFile4 } from "child_process";
9642
+ import { promisify as promisify3 } from "util";
9643
+ var execFileAsync3 = promisify3(execFile4);
9644
+ async function handlePullBranch(workDir, branch) {
9543
9645
  if (!branch) return;
9544
- const current = getCurrentBranch(workDir);
9646
+ const current = await getCurrentBranch(workDir);
9545
9647
  if (current !== branch) {
9546
9648
  process.stderr.write(
9547
9649
  `[conveyor-agent] pull_branch ignored \u2014 current branch ${current ?? "(detached)"} != ${branch}
@@ -9549,7 +9651,7 @@ function handlePullBranch(workDir, branch) {
9549
9651
  );
9550
9652
  return;
9551
9653
  }
9552
- if (hasUncommittedChanges(workDir)) {
9654
+ if (await hasUncommittedChanges(workDir)) {
9553
9655
  process.stderr.write(
9554
9656
  `[conveyor-agent] pull_branch ignored \u2014 uncommitted changes on ${branch}
9555
9657
  `
@@ -9557,16 +9659,15 @@ function handlePullBranch(workDir, branch) {
9557
9659
  return;
9558
9660
  }
9559
9661
  try {
9560
- execSync4(`git fetch origin ${branch}`, { cwd: workDir, stdio: "ignore", timeout: 6e4 });
9662
+ await execFileAsync3("git", ["fetch", "origin", branch], { cwd: workDir, timeout: 6e4 });
9561
9663
  } catch {
9562
9664
  process.stderr.write(`[conveyor-agent] pull_branch: fetch failed for ${branch}
9563
9665
  `);
9564
9666
  return;
9565
9667
  }
9566
9668
  try {
9567
- execSync4(`git pull --ff-only origin ${branch}`, {
9669
+ await execFileAsync3("git", ["pull", "--ff-only", "origin", branch], {
9568
9670
  cwd: workDir,
9569
- stdio: "ignore",
9570
9671
  timeout: 6e4
9571
9672
  });
9572
9673
  process.stderr.write(`[conveyor-agent] pull_branch: pulled origin/${branch}
@@ -9579,6 +9680,33 @@ function handlePullBranch(workDir, branch) {
9579
9680
  }
9580
9681
  }
9581
9682
 
9683
+ // src/runner/heavy-gate.ts
9684
+ import { readFileSync as readFileSync3 } from "fs";
9685
+ import path3 from "path";
9686
+ var GATE_KEYS = ["heavy", "test", "typecheck", "build"];
9687
+ function runDir() {
9688
+ return process.env.CONVEYOR_RUN_DIR ?? "/tmp/conveyor-run";
9689
+ }
9690
+ function pidAlive(pid) {
9691
+ try {
9692
+ process.kill(pid, 0);
9693
+ return true;
9694
+ } catch {
9695
+ return false;
9696
+ }
9697
+ }
9698
+ function isHeavyGateActive() {
9699
+ for (const key of GATE_KEYS) {
9700
+ try {
9701
+ const raw = readFileSync3(path3.join(runDir(), `${key}.pid`), "utf8").trim();
9702
+ const pid = Number.parseInt(raw, 10);
9703
+ if (Number.isInteger(pid) && pid > 0 && pidAlive(pid)) return true;
9704
+ } catch {
9705
+ }
9706
+ }
9707
+ return false;
9708
+ }
9709
+
9582
9710
  // src/runner/session-runner.ts
9583
9711
  function isPostToChatTool(name) {
9584
9712
  return typeof name === "string" && (name === "post_to_chat" || name.endsWith("__post_to_chat"));
@@ -9616,8 +9744,14 @@ var SessionRunner = class _SessionRunner {
9616
9744
  agentSpokeThisTurn = false;
9617
9745
  /** Guards overlapping runs of the periodic git flush. */
9618
9746
  periodicFlushInFlight = false;
9747
+ /** Consecutive flush ticks skipped because a heavy gate was running. */
9748
+ gateSkipCount = 0;
9749
+ /** Max consecutive gate-deferred ticks (~2 min each) before flushing anyway. */
9750
+ static MAX_GATE_SKIPS = 10;
9619
9751
  /** Runtime preview-port poller (v3 dynamic port discovery). */
9620
9752
  portDiscovery = null;
9753
+ /** Main event-loop lag measurement, shared with the heartbeat worker. */
9754
+ loopLag = new LoopLagMonitor();
9621
9755
  constructor(config, callbacks) {
9622
9756
  this.config = config;
9623
9757
  this.callbacks = callbacks;
@@ -9626,7 +9760,7 @@ var SessionRunner = class _SessionRunner {
9626
9760
  this.mode = new ModeController(initialMode, config.runnerMode, config.isAuto);
9627
9761
  const lifecycleConfig = { ...DEFAULT_LIFECYCLE_CONFIG, ...config.lifecycle };
9628
9762
  this.lifecycle = new Lifecycle(lifecycleConfig, {
9629
- onHeartbeat: () => this.connection.sendHeartbeat(),
9763
+ onHeartbeat: () => this.connection.sendHeartbeat(this.loopLag.takeMaxLagMs()),
9630
9764
  onIdleTimeout: () => {
9631
9765
  process.stderr.write("[conveyor-agent] Idle timeout reached, stopping agent\n");
9632
9766
  this.stopped = true;
@@ -9674,6 +9808,8 @@ var SessionRunner = class _SessionRunner {
9674
9808
  this.connection.sendEvent({ type: "connected", sessionId: this.sessionId });
9675
9809
  this.wireConnectionCallbacks();
9676
9810
  this.lifecycle.startHeartbeat();
9811
+ this.loopLag.start();
9812
+ this.connection.startHeartbeatWorker(this.loopLag.sharedBuffer);
9677
9813
  this.lifecycle.startTokenRefresh();
9678
9814
  this.lifecycle.startUsageSample();
9679
9815
  const { pendingMessages: serverMessages } = await this.connection.call("connectAgent", {
@@ -9736,10 +9872,10 @@ var SessionRunner = class _SessionRunner {
9736
9872
  }
9737
9873
  if (process.env.CONVEYOR_GIT_READY !== "1") {
9738
9874
  if (this.fullContext?.githubBranch) {
9739
- ensureOnTaskBranch(this.config.workspaceDir, this.fullContext.githubBranch);
9875
+ await ensureOnTaskBranch(this.config.workspaceDir, this.fullContext.githubBranch);
9740
9876
  }
9741
9877
  if (this.fullContext?.baseBranch) {
9742
- syncWithBaseBranch(this.config.workspaceDir, this.fullContext.baseBranch);
9878
+ await syncWithBaseBranch(this.config.workspaceDir, this.fullContext.baseBranch);
9743
9879
  }
9744
9880
  }
9745
9881
  if (!this.stopped) {
@@ -9759,7 +9895,7 @@ var SessionRunner = class _SessionRunner {
9759
9895
  );
9760
9896
  }
9761
9897
  } else {
9762
- const restored = restoreWipSnapshot(
9898
+ const restored = await restoreWipSnapshot(
9763
9899
  this.config.workspaceDir,
9764
9900
  this.fullContext.githubBranch
9765
9901
  );
@@ -10102,6 +10238,14 @@ var SessionRunner = class _SessionRunner {
10102
10238
  * clean tree. Guarded so two ticks can't overlap. Never throws. */
10103
10239
  async periodicGitFlush() {
10104
10240
  if (this.periodicFlushInFlight || this.stopped) return;
10241
+ if (isHeavyGateActive() && this.gateSkipCount < _SessionRunner.MAX_GATE_SKIPS) {
10242
+ this.gateSkipCount++;
10243
+ if (this.gateSkipCount === 1) {
10244
+ process.stderr.write("[conveyor-agent] Periodic git flush deferred \u2014 heavy gate running\n");
10245
+ }
10246
+ return;
10247
+ }
10248
+ this.gateSkipCount = 0;
10105
10249
  this.periodicFlushInFlight = true;
10106
10250
  try {
10107
10251
  const result = await flushAllPendingWork(this.config.workspaceDir, {
@@ -10200,6 +10344,7 @@ var SessionRunner = class _SessionRunner {
10200
10344
  void this.queryBridge?.dispose().catch(() => {
10201
10345
  });
10202
10346
  this.portDiscovery?.stop();
10347
+ this.loopLag.stop();
10203
10348
  this.lifecycle.destroy();
10204
10349
  this.connection.disconnect();
10205
10350
  if (this.inputResolver) {
@@ -10219,23 +10364,41 @@ var SessionRunner = class _SessionRunner {
10219
10364
  }
10220
10365
  // ── Auto-mode stuck detection & nudge ───────────────────────────────
10221
10366
  static MAX_STUCK_NUDGES = 3;
10222
- /** The prompt delivered into the live TUI when an auto agent looks stuck. It
10223
- * must offer BOTH escape routes so the agent never just goes idle. */
10367
+ /** The build-phase nudge (In Progress, no PR). Offers BOTH escape routes so
10368
+ * the agent never just goes idle. */
10224
10369
  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.";
10370
+ /** The planning-phase nudge (still Planning, identification/plan unfinished).
10371
+ * Same two-escape-route shape, aimed at finishing discovery instead of a PR. */
10372
+ 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.";
10225
10373
  /**
10226
- * An auto task is "stuck" when its last turn ended (the call sites run
10227
- * post-turn, so the TUI is no longer actively working), it has no open PR,
10228
- * AND the agent did not communicate in chat this turn. If the agent posted a
10229
- * message (explained itself / asked for help) it is waiting on a human, not
10230
- * silently stuck, so we leave it attached instead of nudging.
10374
+ * Classify how an auto task is "stuck" after its turn ended (call sites run
10375
+ * post-turn, so the TUI is no longer working), or null if it isn't. Shared
10376
+ * guards: only auto, not rate-limited, under the nudge cap, and the agent
10377
+ * stayed silent this turn if it posted to chat (explained itself / asked for
10378
+ * help) it's waiting on a human, not silently stuck.
10379
+ *
10380
+ * - "pr": In Progress with no open PR (build finished without shipping).
10381
+ * - "planning": still Planning without (identified + saved plan). A fresh auto
10382
+ * agent that finishes its plan turn WITHOUT ExitPlanMode would otherwise idle
10383
+ * → clean-exit → its session Ends → the workspace is reaped "agent_gone"
10384
+ * before a plan ever landed. Nudging (and, on exhaustion, going dormant)
10385
+ * keeps the session Active so the pod is never prematurely slept.
10231
10386
  */
10387
+ autoStuckKind() {
10388
+ if (!this.mode.isAuto || this.stopped) return null;
10389
+ if (this.queryBridge?.wasRateLimited) return null;
10390
+ if (this.prNudgeCount > _SessionRunner.MAX_STUCK_NUDGES) return null;
10391
+ if (this.agentSpokeThisTurn) return null;
10392
+ if (!this.taskContext) return null;
10393
+ const { status, storyPointId, plan, githubPRUrl } = this.taskContext;
10394
+ if (status === "InProgress" && !githubPRUrl) return "pr";
10395
+ if (status === "Planning" && !(storyPointId !== null && !!plan?.trim())) {
10396
+ return "planning";
10397
+ }
10398
+ return null;
10399
+ }
10232
10400
  isAutoStuck() {
10233
- if (!this.mode.isAuto || this.stopped) return false;
10234
- if (this.queryBridge?.wasRateLimited) return false;
10235
- if (this.prNudgeCount > _SessionRunner.MAX_STUCK_NUDGES) return false;
10236
- if (this.agentSpokeThisTurn) return false;
10237
- if (!this.taskContext) return false;
10238
- return this.taskContext.status === "InProgress" && !this.taskContext.githubPRUrl;
10401
+ return this.autoStuckKind() !== null;
10239
10402
  }
10240
10403
  /**
10241
10404
  * Stop driving the agent and route the core loop into dormant idle —
@@ -10271,6 +10434,16 @@ var SessionRunner = class _SessionRunner {
10271
10434
  } catch {
10272
10435
  }
10273
10436
  }
10437
+ /** Prompt + chat-message label for a stuck-nudge of the given kind. */
10438
+ stuckNudgeContent(kind) {
10439
+ if (kind === "planning") {
10440
+ return {
10441
+ prompt: _SessionRunner.STUCK_PROMPT_PLANNING,
10442
+ situation: "still Planning with no identified plan"
10443
+ };
10444
+ }
10445
+ return { prompt: _SessionRunner.STUCK_PROMPT, situation: "still no PR" };
10446
+ }
10274
10447
  async maybeHandleStuckAuto() {
10275
10448
  await this.refreshTaskContext();
10276
10449
  if (!this.isAutoStuck()) return;
@@ -10278,17 +10451,18 @@ var SessionRunner = class _SessionRunner {
10278
10451
  this.prNudgeCount++;
10279
10452
  if (this.prNudgeCount > _SessionRunner.MAX_STUCK_NUDGES) {
10280
10453
  this.enterDormantForHumanTakeover(
10281
- `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.`
10454
+ `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.`
10282
10455
  );
10283
10456
  return;
10284
10457
  }
10458
+ const { prompt, situation } = this.stuckNudgeContent(this.autoStuckKind());
10285
10459
  const attempt = this.prNudgeCount;
10286
- 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...`;
10460
+ 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...`;
10287
10461
  this.connection.postChatMessage(chatMsg);
10288
10462
  await this.setState("running");
10289
- await this.callbacks.onEvent({ type: "pr_nudge", prompt: _SessionRunner.STUCK_PROMPT });
10463
+ await this.callbacks.onEvent({ type: "pr_nudge", prompt });
10290
10464
  if (this.interrupted || this.stopped) return;
10291
- await this.executeQuery(_SessionRunner.STUCK_PROMPT);
10465
+ await this.executeQuery(prompt);
10292
10466
  if (this.interrupted || this.stopped) return;
10293
10467
  await this.refreshTaskContext();
10294
10468
  if (this.agentSpokeThisTurn && !this.taskContext?.githubPRUrl) {
@@ -10346,32 +10520,26 @@ var SessionRunner = class _SessionRunner {
10346
10520
  mode: this.config.runnerMode,
10347
10521
  isAuto: this.config.isAuto
10348
10522
  };
10349
- const bridge = new QueryBridge(
10350
- this.connection,
10351
- this.mode,
10352
- runnerConfig,
10353
- {
10354
- onStatusChange: (status) => {
10355
- if (status === "running" && this._state === "waiting_for_input") {
10356
- this._state = "running";
10357
- this.lifecycle.cancelIdleTimer();
10358
- }
10359
- return this.callbacks.onStatusChange(status);
10360
- },
10361
- onEvent: (event) => {
10362
- const evt = event;
10363
- if (evt.type === "tool_use" && isPostToChatTool(evt.tool)) {
10364
- this.agentSpokeThisTurn = true;
10365
- }
10366
- if (event.type === "completed") {
10367
- this.completedThisTurn = true;
10368
- void this.connection.sendHeartbeat();
10369
- }
10370
- return this.callbacks.onEvent(event);
10523
+ const bridge = new QueryBridge(this.connection, this.mode, runnerConfig, {
10524
+ onStatusChange: (status) => {
10525
+ if (status === "running" && this._state === "waiting_for_input") {
10526
+ this._state = "running";
10527
+ this.lifecycle.cancelIdleTimer();
10371
10528
  }
10529
+ return this.callbacks.onStatusChange(status);
10372
10530
  },
10373
- this.config.workspaceDir
10374
- );
10531
+ onEvent: (event) => {
10532
+ const evt = event;
10533
+ if (evt.type === "tool_use" && isPostToChatTool(evt.tool)) {
10534
+ this.agentSpokeThisTurn = true;
10535
+ }
10536
+ if (event.type === "completed") {
10537
+ this.completedThisTurn = true;
10538
+ void this.connection.sendHeartbeat();
10539
+ }
10540
+ return this.callbacks.onEvent(event);
10541
+ }
10542
+ });
10375
10543
  bridge.isParentTask = this.fullContext?.isParentTask ?? false;
10376
10544
  bridge.onSoftStop = () => {
10377
10545
  process.stderr.write("[conveyor-agent] Soft stop requested (discovery ExitPlanMode)\n");
@@ -10424,15 +10592,16 @@ var SessionRunner = class _SessionRunner {
10424
10592
  }
10425
10593
  });
10426
10594
  this.connection.onPullBranch(({ branch }) => {
10427
- handlePullBranch(this.config.workspaceDir, branch);
10595
+ void handlePullBranch(this.config.workspaceDir, branch);
10428
10596
  });
10429
10597
  this.connection.onFinalizeSnapshot(() => void this.finalizeSnapshotNow());
10430
10598
  }
10431
10599
  /** Eager finalize snapshot triggered by the reconciler's sleep signal
10432
- * (session:finalizeSnapshot). Runs pg_dump + a full capture and uploads it
10600
+ * (session:finalizeSnapshot). Runs a full workspace capture and uploads it
10433
10601
  * NOW so the sleep confirms on this snapshot instead of waiting for the
10434
- * ~2min periodic flush and so sidecar DB state (which ONLY finalizeForSleep
10435
- * captures via pg_dump) actually lands. Best-effort: on failure the
10602
+ * ~2min periodic flush. Sidecar DB state is intentionally NOT captured it
10603
+ * is no longer durable across sleep/wake (#2623); only the agent's
10604
+ * /workspace rides the snapshot. Best-effort: on failure the
10436
10605
  * periodic/shutdown path stays the fallback. No-op on non-v3 pods. */
10437
10606
  async finalizeSnapshotNow() {
10438
10607
  const uploadUrl = process.env.CONVEYOR_SNAPSHOT_UPLOAD_URL;
@@ -10453,9 +10622,7 @@ var SessionRunner = class _SessionRunner {
10453
10622
  }
10454
10623
  }
10455
10624
  });
10456
- process.stderr.write(
10457
- "[conveyor-agent] Finalize snapshot (pg_dump) uploaded on sleep signal\n"
10458
- );
10625
+ process.stderr.write("[conveyor-agent] Finalize snapshot uploaded on sleep signal\n");
10459
10626
  } catch {
10460
10627
  }
10461
10628
  }
@@ -10465,7 +10632,7 @@ var SessionRunner = class _SessionRunner {
10465
10632
  const res = await this.connection.call("refreshGithubToken", {
10466
10633
  sessionId: this.connection.sessionId
10467
10634
  });
10468
- updateRemoteToken(this.config.workspaceDir, res.token);
10635
+ await updateRemoteToken(this.config.workspaceDir, res.token);
10469
10636
  process.env.GITHUB_TOKEN = res.token;
10470
10637
  process.env.GH_TOKEN = res.token;
10471
10638
  process.stderr.write("[conveyor-agent] Proactively refreshed GitHub token\n");
@@ -10484,7 +10651,7 @@ var SessionRunner = class _SessionRunner {
10484
10651
  });
10485
10652
  if (ctx?.githubBranch && this.fullContext) {
10486
10653
  this.fullContext.githubBranch = ctx.githubBranch;
10487
- ensureOnTaskBranch(this.config.workspaceDir, ctx.githubBranch);
10654
+ await ensureOnTaskBranch(this.config.workspaceDir, ctx.githubBranch);
10488
10655
  }
10489
10656
  } catch {
10490
10657
  process.stderr.write(
@@ -10494,6 +10661,8 @@ var SessionRunner = class _SessionRunner {
10494
10661
  }
10495
10662
  async setState(status) {
10496
10663
  this._state = status;
10664
+ const loopStatus = status === "idle" ? "idle" : "active";
10665
+ this.loopLag.setStatus(loopStatus);
10497
10666
  await this.connection.emitStatus(status);
10498
10667
  await this.callbacks.onStatusChange(status);
10499
10668
  }
@@ -10502,6 +10671,7 @@ var SessionRunner = class _SessionRunner {
10502
10671
  `);
10503
10672
  this.connection.sendEvent({ type: "shutdown", reason: finalState });
10504
10673
  this.portDiscovery?.stop();
10674
+ this.loopLag.stop();
10505
10675
  this.lifecycle.destroy();
10506
10676
  try {
10507
10677
  await this.queryBridge?.dispose();
@@ -10551,12 +10721,12 @@ var SessionRunner = class _SessionRunner {
10551
10721
 
10552
10722
  // src/setup/config.ts
10553
10723
  import { readFile as readFile5 } from "fs/promises";
10554
- import { join as join8 } from "path";
10724
+ import { join as join7 } from "path";
10555
10725
  var DEVCONTAINER_PATH = ".devcontainer/conveyor/devcontainer.json";
10556
10726
  var DEVCONTAINER_PORT_DENY_LIST = /* @__PURE__ */ new Set([5432, 6379, 9200]);
10557
10727
  async function loadForwardPorts(workspaceDir) {
10558
10728
  try {
10559
- const raw = await readFile5(join8(workspaceDir, DEVCONTAINER_PATH), "utf-8");
10729
+ const raw = await readFile5(join7(workspaceDir, DEVCONTAINER_PATH), "utf-8");
10560
10730
  const parsed = JSON.parse(raw);
10561
10731
  const ports = (parsed.forwardPorts ?? []).filter(
10562
10732
  (p) => typeof p === "number" && !DEVCONTAINER_PORT_DENY_LIST.has(p)
@@ -10588,19 +10758,17 @@ function buildSessionPreviewPorts(result) {
10588
10758
  function loadConveyorConfig() {
10589
10759
  const envSetup = process.env.CONVEYOR_SETUP_COMMAND;
10590
10760
  const envStart = process.env.CONVEYOR_START_COMMAND;
10591
- const envPort = process.env.CONVEYOR_PREVIEW_PORT;
10592
10761
  if (envSetup || envStart) {
10593
10762
  return {
10594
10763
  setupCommand: envSetup,
10595
- startCommand: envStart,
10596
- previewPort: envPort ? Number(envPort) : void 0
10764
+ startCommand: envStart
10597
10765
  };
10598
10766
  }
10599
10767
  return null;
10600
10768
  }
10601
10769
 
10602
10770
  // src/setup/commands.ts
10603
- import { spawn, execSync as execSync5 } from "child_process";
10771
+ import { spawn, execSync } from "child_process";
10604
10772
  function runSetupCommand(cmd, cwd, onOutput) {
10605
10773
  return new Promise((resolve, reject) => {
10606
10774
  const child = spawn("sh", ["-c", cmd], {
@@ -10629,7 +10797,7 @@ function runSetupCommand(cmd, cwd, onOutput) {
10629
10797
  var AUTH_TOKEN_TIMEOUT_MS = 3e4;
10630
10798
  function runAuthTokenCommand(cmd, userEmail, cwd) {
10631
10799
  try {
10632
- const output = execSync5(`${cmd} ${JSON.stringify(userEmail)}`, {
10800
+ const output = execSync(`${cmd} ${JSON.stringify(userEmail)}`, {
10633
10801
  cwd,
10634
10802
  timeout: AUTH_TOKEN_TIMEOUT_MS,
10635
10803
  stdio: ["ignore", "pipe", "ignore"],
@@ -10659,10 +10827,10 @@ function runStartCommand(cmd, cwd, onOutput) {
10659
10827
  }
10660
10828
 
10661
10829
  // src/setup/codespace.ts
10662
- import { execSync as execSync6 } from "child_process";
10830
+ import { execSync as execSync2 } from "child_process";
10663
10831
  function unshallowRepo(workspaceDir) {
10664
10832
  try {
10665
- execSync6("git fetch --unshallow", {
10833
+ execSync2("git fetch --unshallow", {
10666
10834
  cwd: workspaceDir,
10667
10835
  timeout: 6e4,
10668
10836
  stdio: "ignore"
@@ -10672,12 +10840,17 @@ function unshallowRepo(workspaceDir) {
10672
10840
  }
10673
10841
 
10674
10842
  export {
10675
- DEFAULT_SONNET_MODEL,
10676
10843
  fetchBootstrap,
10677
10844
  applyBootstrapToEnv,
10678
10845
  AgentConnection,
10846
+ DEFAULT_SONNET_MODEL,
10847
+ TUI_KINDS,
10679
10848
  DEFAULT_LIFECYCLE_CONFIG,
10680
10849
  Lifecycle,
10850
+ cleanTerminalOutput,
10851
+ inheritedEnv,
10852
+ buildPromptBytes,
10853
+ ClaudeTuiAdapter,
10681
10854
  PtyHarness,
10682
10855
  createServiceLogger,
10683
10856
  hasUncommittedChanges,
@@ -10688,7 +10861,6 @@ export {
10688
10861
  flushPendingChanges,
10689
10862
  pushToOrigin,
10690
10863
  resolveSessionStart,
10691
- PlanSync,
10692
10864
  sampleKeyUsage,
10693
10865
  awaitGitReady,
10694
10866
  uploadSnapshotToGcs,
@@ -10706,4 +10878,4 @@ export {
10706
10878
  runStartCommand,
10707
10879
  unshallowRepo
10708
10880
  };
10709
- //# sourceMappingURL=chunk-53ZLFIAI.js.map
10881
+ //# sourceMappingURL=chunk-CFELRV35.js.map