@rallycry/conveyor-agent 11.0.2 → 11.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,32 @@ import {
2
2
  mapChatHistory,
3
3
  readAgentVersion
4
4
  } from "./chunk-XORJ6SII.js";
5
+ import {
6
+ MAX_BETWEEN_TURN_BUFFER,
7
+ MAX_DIAGNOSTIC_OUTPUT,
8
+ buildExitErrors,
9
+ buildPromptBytes,
10
+ buildSpawnArgs,
11
+ cleanTerminalOutput,
12
+ inheritedEnv,
13
+ killPtyWithEscalation,
14
+ needsRawReadyGate,
15
+ parseUserQuestions,
16
+ renderPromptContentText,
17
+ resolveClaudeBinary,
18
+ resolvePlanDialogTiming,
19
+ resolvePtySpawn,
20
+ resolveRawTuiProbeTiming,
21
+ resolveSubmitNudgeTiming,
22
+ resolveSubmitRedeliveryMaxAttempts,
23
+ resolveSubmitSettleMs,
24
+ sawTerminalSetup,
25
+ sentinelEchoed,
26
+ sessionTempBase,
27
+ spawnOptionsFingerprint,
28
+ transcriptSize,
29
+ turnOptionsFrom
30
+ } from "./chunk-3F4ZZKCA.js";
5
31
  import {
6
32
  AgentConnection,
7
33
  CodespacePortVisibility,
@@ -30,11 +56,21 @@ import {
30
56
  statWorkspacePath,
31
57
  updateRemoteToken,
32
58
  verifyGitCredential
33
- } from "./chunk-N4WSUTGV.js";
59
+ } from "./chunk-SLUBOIYE.js";
34
60
  import {
35
61
  registerBootMilestoneSocketFallback,
36
62
  reportBootMilestone
37
63
  } from "./chunk-GL2DIQEQ.js";
64
+ import {
65
+ describeTokenFile,
66
+ ghHostsExternallyOwned,
67
+ githubTokenFilePath,
68
+ sleep
69
+ } from "./chunk-W4LZ7R6Z.js";
70
+ import {
71
+ isHeavyGateActive,
72
+ listGateExitSentinels
73
+ } from "./chunk-7P6QZHXZ.js";
38
74
  import {
39
75
  LoopLagMonitor,
40
76
  loopStatusForRunnerStatus
@@ -45,38 +81,6 @@ import {
45
81
  import {
46
82
  workbenchEnabled
47
83
  } from "./chunk-KMB3BU4S.js";
48
- import {
49
- MAX_BETWEEN_TURN_BUFFER,
50
- MAX_DIAGNOSTIC_OUTPUT,
51
- buildExitErrors,
52
- buildPromptBytes,
53
- buildSpawnArgs,
54
- cleanTerminalOutput,
55
- inheritedEnv,
56
- killPtyWithEscalation,
57
- needsRawReadyGate,
58
- parseUserQuestions,
59
- renderPromptContentText,
60
- resolveClaudeBinary,
61
- resolvePlanDialogTiming,
62
- resolvePtySpawn,
63
- resolveRawTuiProbeTiming,
64
- resolveSubmitNudgeTiming,
65
- resolveSubmitRedeliveryMaxAttempts,
66
- resolveSubmitSettleMs,
67
- sawTerminalSetup,
68
- sentinelEchoed,
69
- sessionTempBase,
70
- spawnOptionsFingerprint,
71
- transcriptSize,
72
- turnOptionsFrom
73
- } from "./chunk-3F4ZZKCA.js";
74
- import {
75
- describeTokenFile,
76
- ghHostsExternallyOwned,
77
- githubTokenFilePath,
78
- sleep
79
- } from "./chunk-W4LZ7R6Z.js";
80
84
 
81
85
  // ../shared/dist/chunk-OKJPFFQI.js
82
86
  var CARD_DESCRIPTION_MAX = 255;
@@ -3134,8 +3138,8 @@ function mapTranscriptRecord(raw) {
3134
3138
  // src/harness/pty/jsonl-tailer.ts
3135
3139
  var POLL_INTERVAL_MS = 25;
3136
3140
  var JsonlTailer = class {
3137
- constructor(path2, onEvent, onRawRecord, mapRecord = mapTranscriptRecord) {
3138
- this.path = path2;
3141
+ constructor(path, onEvent, onRawRecord, mapRecord = mapTranscriptRecord) {
3142
+ this.path = path;
3139
3143
  this.onEvent = onEvent;
3140
3144
  this.onRawRecord = onRawRecord;
3141
3145
  this.mapRecord = mapRecord;
@@ -3991,11 +3995,13 @@ function conveyorCredentialsMarkerPath() {
3991
3995
  function tokenFingerprint(accessToken) {
3992
3996
  return createHash("sha256").update(accessToken).digest("hex");
3993
3997
  }
3994
- function buildCredentialsMarker(accessToken, now, previousHash) {
3998
+ function buildCredentialsMarker(accessToken, now, previousHash, refreshToken, codingAgentKeyId) {
3995
3999
  const accessTokenSha256 = tokenFingerprint(accessToken);
3996
4000
  return JSON.stringify({
3997
4001
  accessTokenSha256,
3998
4002
  ...previousHash && previousHash !== accessTokenSha256 ? { previousAccessTokenSha256: previousHash } : {},
4003
+ ...refreshToken ? { refreshTokenSha256: tokenFingerprint(refreshToken) } : {},
4004
+ ...codingAgentKeyId ? { codingAgentKeyId } : {},
3999
4005
  writtenAt: now
4000
4006
  });
4001
4007
  }
@@ -4012,16 +4018,59 @@ function parseCredentialsMarker(raw) {
4012
4018
  return [];
4013
4019
  }
4014
4020
  }
4021
+ function parseCredentialsRefreshMarker(raw) {
4022
+ if (!raw || raw.trim() === "") return null;
4023
+ try {
4024
+ const parsed = JSON.parse(raw);
4025
+ if (typeof parsed !== "object" || parsed === null) return null;
4026
+ const hash = parsed.refreshTokenSha256;
4027
+ return typeof hash === "string" && hash.length > 0 ? hash : null;
4028
+ } catch {
4029
+ return null;
4030
+ }
4031
+ }
4032
+ function parseCredentialsMarkerKeyId(raw) {
4033
+ if (!raw || raw.trim() === "") return null;
4034
+ try {
4035
+ const parsed = JSON.parse(raw);
4036
+ if (typeof parsed !== "object" || parsed === null) return null;
4037
+ const keyId = parsed.codingAgentKeyId;
4038
+ return typeof keyId === "string" && keyId.length > 0 ? keyId : null;
4039
+ } catch {
4040
+ return null;
4041
+ }
4042
+ }
4043
+ function credentialMarkerMatches(raw, accessToken, refreshToken, expectedKeyId) {
4044
+ if (expectedKeyId && parseCredentialsMarkerKeyId(raw) !== expectedKeyId) return false;
4045
+ if (accessToken && parseCredentialsMarker(raw).includes(tokenFingerprint(accessToken)))
4046
+ return true;
4047
+ if (!refreshToken) return false;
4048
+ return parseCredentialsRefreshMarker(raw) === tokenFingerprint(refreshToken);
4049
+ }
4050
+ function parseCredentialsIdentity(credentialsRaw, markerRaw, expectedKeyId) {
4051
+ if (!credentialsRaw) return null;
4052
+ try {
4053
+ const parsed = JSON.parse(credentialsRaw);
4054
+ if (typeof parsed !== "object" || parsed === null) return null;
4055
+ const oauth = parsed.claudeAiOauth;
4056
+ if (typeof oauth !== "object" || oauth === null) return null;
4057
+ const record = oauth;
4058
+ const accessToken = typeof record.accessToken === "string" ? record.accessToken : null;
4059
+ const refreshToken = typeof record.refreshToken === "string" ? record.refreshToken : null;
4060
+ return {
4061
+ accessToken,
4062
+ hasRefreshToken: Boolean(refreshToken),
4063
+ isConveyorOwned: credentialMarkerMatches(markerRaw, accessToken, refreshToken, expectedKeyId)
4064
+ };
4065
+ } catch {
4066
+ return null;
4067
+ }
4068
+ }
4015
4069
 
4016
- // src/harness/pty/credentials.ts
4070
+ // src/harness/pty/credentials-plan.ts
4017
4071
  var SYNTH_TOKEN_TTL_MS = 365 * 24 * 60 * 60 * 1e3;
4018
4072
  var REFRESH_SKEW_MS = 30 * 24 * 60 * 60 * 1e3;
4019
- function claudeCredentialsPath() {
4020
- return join5(claudeConfigHome(), ".credentials.json");
4021
- }
4022
- function isConveyorCloudEnv(env = process.env) {
4023
- return Boolean(env.CLAUDESPACE_NAME || env.CODESPACE_NAME || env.CODESPACES);
4024
- }
4073
+ var LEGACY_SCOPES = ["user:inference", "user:profile"];
4025
4074
  function parseClaudeOauthEnv(blob) {
4026
4075
  if (!blob) return null;
4027
4076
  try {
@@ -4029,7 +4078,7 @@ function parseClaudeOauthEnv(blob) {
4029
4078
  if (typeof parsed !== "object" || parsed === null) return null;
4030
4079
  const record = parsed;
4031
4080
  if (typeof record.access !== "string" || record.access === "") return null;
4032
- const scopes = Array.isArray(record.scopes) ? record.scopes.filter((s) => typeof s === "string" && s.length > 0) : [];
4081
+ const scopes = Array.isArray(record.scopes) ? record.scopes.filter((scope) => typeof scope === "string" && scope !== "") : [];
4033
4082
  return {
4034
4083
  access: record.access,
4035
4084
  refresh: typeof record.refresh === "string" && record.refresh ? record.refresh : void 0,
@@ -4059,19 +4108,13 @@ function parseClaudeAiOauth(raw) {
4059
4108
  return null;
4060
4109
  }
4061
4110
  }
4062
- var LEGACY_SCOPES = ["user:inference", "user:profile"];
4063
4111
  function buildCredentialsFile(material, now) {
4064
4112
  const refresh = material.refresh;
4065
4113
  return JSON.stringify({
4066
4114
  claudeAiOauth: {
4067
4115
  accessToken: material.access,
4068
4116
  ...refresh ? { refreshToken: refresh } : {},
4069
- // With a refresh token the CLI can recover from a real expiry, so report
4070
- // the true one. Without, claim a far-future expiry (see SYNTH_TOKEN_TTL_MS)
4071
- // because a refresh attempt would be unrecoverable.
4072
4117
  expiresAt: refresh && material.expires ? material.expires : now + SYNTH_TOKEN_TTL_MS,
4073
- // The refresh token has its own expiry; the CLI records it, so mirror it
4074
- // when the grant told us rather than leaving the field off.
4075
4118
  ...material.refreshExpires ? { refreshTokenExpiresAt: material.refreshExpires } : {},
4076
4119
  scopes: material.scopes?.length ? material.scopes : LEGACY_SCOPES,
4077
4120
  ...material.rateLimitTier ? { rateLimitTier: material.rateLimitTier } : {},
@@ -4083,31 +4126,74 @@ function buildSynthesizedCredentials(token, now) {
4083
4126
  return buildCredentialsFile({ access: token }, now);
4084
4127
  }
4085
4128
  function isConveyorOwnedCredentials(existing, markerHashes) {
4086
- const hasRefresh = typeof existing.refreshToken === "string" && existing.refreshToken.length > 0;
4129
+ const hasRefresh = typeof existing.refreshToken === "string" && existing.refreshToken !== "";
4087
4130
  if (!hasRefresh) return true;
4088
- if (markerHashes.length === 0) return false;
4089
4131
  if (typeof existing.accessToken !== "string") return false;
4090
4132
  return markerHashes.includes(tokenFingerprint(existing.accessToken));
4091
4133
  }
4134
+ function hasStableRefreshOwnership(existing, markerRaw) {
4135
+ return typeof existing.refreshToken === "string" && parseCredentialsRefreshMarker(markerRaw) === tokenFingerprint(existing.refreshToken);
4136
+ }
4137
+ function isCurrentCredential(existing, material, now) {
4138
+ if (material.refresh) return existing.accessToken === material.access;
4139
+ return existing.accessToken === material.access && typeof existing.expiresAt === "number" && existing.expiresAt > now + REFRESH_SKEW_MS;
4140
+ }
4141
+ function needsMarkerUpgrade(input, material) {
4142
+ if (!input.codingAgentKeyId) return false;
4143
+ if (parseCredentialsMarkerKeyId(input.markerRaw ?? null) !== input.codingAgentKeyId) return true;
4144
+ return Boolean(
4145
+ material.refresh && parseCredentialsRefreshMarker(input.markerRaw ?? null) !== tokenFingerprint(material.refresh)
4146
+ );
4147
+ }
4148
+ function ownsExistingCredential(existing, markerRaw, markerHashes) {
4149
+ if (isConveyorOwnedCredentials(existing, markerHashes)) return true;
4150
+ const accessToken = typeof existing.accessToken === "string" ? existing.accessToken : null;
4151
+ const refreshToken = typeof existing.refreshToken === "string" ? existing.refreshToken : null;
4152
+ return credentialMarkerMatches(markerRaw, accessToken, refreshToken);
4153
+ }
4154
+ function cliRefreshedSameKey(input, material, existing, markerRaw) {
4155
+ if (!material.refresh || !input.codingAgentKeyId) return false;
4156
+ if (parseCredentialsMarkerKeyId(markerRaw) !== input.codingAgentKeyId) return false;
4157
+ return hasStableRefreshOwnership(existing, markerRaw) && existing.accessToken !== material.access;
4158
+ }
4092
4159
  function planCredentialsWrite(input) {
4093
4160
  if (!input.isCloud) return { action: "skip", reason: "not-cloud" };
4094
4161
  const material = parseClaudeOauthEnv(input.oauthBlob) ?? (input.token ? { access: input.token } : null);
4095
4162
  if (!material) return { action: "skip", reason: "no-token" };
4096
- const markerHashes = parseCredentialsMarker(input.markerRaw ?? null);
4163
+ const markerRaw = input.markerRaw ?? null;
4164
+ const markerHashes = parseCredentialsMarker(markerRaw);
4097
4165
  const contents = buildCredentialsFile(material, input.now);
4098
- const marker = buildCredentialsMarker(material.access, input.now, markerHashes[0] ?? null);
4166
+ const marker = buildCredentialsMarker(
4167
+ material.access,
4168
+ input.now,
4169
+ markerHashes[0] ?? null,
4170
+ material.refresh,
4171
+ input.codingAgentKeyId
4172
+ );
4099
4173
  const existing = parseClaudeAiOauth(input.existingRaw);
4100
4174
  if (!existing) return { action: "write", contents, marker };
4101
- if (!isConveyorOwnedCredentials(existing, markerHashes)) {
4175
+ if (!ownsExistingCredential(existing, markerRaw, markerHashes)) {
4102
4176
  return { action: "skip", reason: "foreign-credentials" };
4103
4177
  }
4104
- const fresh = material.refresh ? existing.accessToken === material.access : existing.accessToken === material.access && typeof existing.expiresAt === "number" && existing.expiresAt > input.now + REFRESH_SKEW_MS;
4105
- if (fresh) return { action: "skip", reason: "current" };
4106
- return { action: "write", contents, marker };
4178
+ if (cliRefreshedSameKey(input, material, existing, markerRaw)) {
4179
+ return { action: "skip", reason: "current" };
4180
+ }
4181
+ if (!isCurrentCredential(existing, material, input.now)) {
4182
+ return { action: "write", contents, marker };
4183
+ }
4184
+ return needsMarkerUpgrade(input, material) ? { action: "mark", marker } : { action: "skip", reason: "current" };
4107
4185
  }
4108
- async function readRaw(path2) {
4186
+
4187
+ // src/harness/pty/credentials.ts
4188
+ function claudeCredentialsPath() {
4189
+ return join5(claudeConfigHome(), ".credentials.json");
4190
+ }
4191
+ function isConveyorCloudEnv(env = process.env) {
4192
+ return Boolean(env.CLAUDESPACE_NAME || env.CODESPACE_NAME || env.CODESPACES);
4193
+ }
4194
+ async function readRaw(path) {
4109
4195
  try {
4110
- return await readFile(path2, "utf8");
4196
+ return await readFile(path, "utf8");
4111
4197
  } catch {
4112
4198
  return null;
4113
4199
  }
@@ -4128,13 +4214,12 @@ async function resolveTuiAuthReadiness(env = process.env, readIdentity = readCre
4128
4214
  });
4129
4215
  return { ready: status === "ready", status };
4130
4216
  }
4131
- async function readCredentialsIdentity() {
4132
- const parsed = parseClaudeAiOauth(await readRaw(claudeCredentialsPath()));
4133
- if (!parsed) return null;
4134
- return {
4135
- accessToken: typeof parsed.accessToken === "string" ? parsed.accessToken : null,
4136
- hasRefreshToken: typeof parsed.refreshToken === "string" && parsed.refreshToken.length > 0
4137
- };
4217
+ async function readCredentialsIdentity(env = process.env) {
4218
+ return parseCredentialsIdentity(
4219
+ await readRaw(claudeCredentialsPath()),
4220
+ await readRaw(conveyorCredentialsMarkerPath()),
4221
+ env.CONVEYOR_CODING_AGENT_KEY_ID
4222
+ );
4138
4223
  }
4139
4224
  var READ_BACK_DELAYS_MS = [250, 500, 1e3, 2e3];
4140
4225
  var defaultSleep = (ms) => new Promise((resolve) => {
@@ -4149,10 +4234,10 @@ async function writeWithReadBackRetry(io, contents, delaysMs = READ_BACK_DELAYS_
4149
4234
  await sleep2(delaysMs[attempt]);
4150
4235
  }
4151
4236
  }
4152
- function fsWriteIo(path2, mode) {
4237
+ function fsWriteIo(path, mode) {
4153
4238
  return {
4154
- write: (contents) => writeFile4(path2, contents, mode === void 0 ? "utf8" : { encoding: "utf8", mode }),
4155
- read: () => readRaw(path2)
4239
+ write: (contents) => writeFile4(path, contents, mode === void 0 ? "utf8" : { encoding: "utf8", mode }),
4240
+ read: () => readRaw(path)
4156
4241
  };
4157
4242
  }
4158
4243
  async function ensureClaudeCredentials(env = process.env) {
@@ -4164,27 +4249,29 @@ async function ensureClaudeCredentials(env = process.env) {
4164
4249
  await sanitizeApprovedApiKeys(accessToken);
4165
4250
  }
4166
4251
  try {
4167
- const path2 = claudeCredentialsPath();
4252
+ const path = claudeCredentialsPath();
4168
4253
  const markerPath = conveyorCredentialsMarkerPath();
4169
4254
  const plan = planCredentialsWrite({
4170
4255
  isCloud,
4171
4256
  token,
4172
4257
  oauthBlob: env.CONVEYOR_CLAUDE_OAUTH,
4173
- existingRaw: await readRaw(path2),
4258
+ existingRaw: await readRaw(path),
4174
4259
  markerRaw: await readRaw(markerPath),
4260
+ codingAgentKeyId: env.CONVEYOR_CODING_AGENT_KEY_ID,
4175
4261
  now: Date.now()
4176
4262
  });
4177
4263
  if (plan.action === "skip") return;
4178
4264
  await mkdir2(claudeConfigHome(), { recursive: true });
4179
4265
  await writeWithReadBackRetry(fsWriteIo(markerPath, 384), plan.marker);
4180
- const verified = await writeWithReadBackRetry(fsWriteIo(path2, 384), plan.contents);
4266
+ if (plan.action === "mark") return;
4267
+ const verified = await writeWithReadBackRetry(fsWriteIo(path, 384), plan.contents);
4181
4268
  if (!verified) {
4182
4269
  process.stderr.write(
4183
- `[conveyor-agent] claude credentials read-back still stale after retries at ${path2} \u2014 TUI may land on the login picker
4270
+ `[conveyor-agent] claude credentials read-back still stale after retries at ${path} \u2014 TUI may land on the login picker
4184
4271
  `
4185
4272
  );
4186
4273
  }
4187
- await chmod2(path2, 384).catch(() => {
4274
+ await chmod2(path, 384).catch(() => {
4188
4275
  });
4189
4276
  } catch (err) {
4190
4277
  const message = err instanceof Error ? err.message : String(err);
@@ -4213,12 +4300,12 @@ function planApprovedApiKeyCleanup(existingRaw, oauthToken) {
4213
4300
  }
4214
4301
  async function sanitizeApprovedApiKeys(oauthToken) {
4215
4302
  try {
4216
- const path2 = claudeJsonPath();
4217
- const cleaned = planApprovedApiKeyCleanup(await readRaw(path2), oauthToken);
4303
+ const path = claudeJsonPath();
4304
+ const cleaned = planApprovedApiKeyCleanup(await readRaw(path), oauthToken);
4218
4305
  if (cleaned === null) return;
4219
- const verified = await writeWithReadBackRetry(fsWriteIo(path2), cleaned);
4306
+ const verified = await writeWithReadBackRetry(fsWriteIo(path), cleaned);
4220
4307
  process.stderr.write(
4221
- verified ? "[conveyor-agent] removed poisoned customApiKeyResponses.approved entry from .claude.json\n" : `[conveyor-agent] approved-key sanitize read-back still stale after retries at ${path2} \u2014 CLI may still see the poisoned entry
4308
+ verified ? "[conveyor-agent] removed poisoned customApiKeyResponses.approved entry from .claude.json\n" : `[conveyor-agent] approved-key sanitize read-back still stale after retries at ${path} \u2014 CLI may still see the poisoned entry
4222
4309
  `
4223
4310
  );
4224
4311
  } catch (err) {
@@ -4355,8 +4442,8 @@ async function persistOauthIdentityMarker(configIdentity, markerIdentity) {
4355
4442
  async function ensureClaudeOnboarding(env = process.env, trustCwd) {
4356
4443
  try {
4357
4444
  if (!isConveyorCloudEnv(env)) return;
4358
- const path2 = claudeJsonPath();
4359
- const existingRaw = await readRaw(path2);
4445
+ const path = claudeJsonPath();
4446
+ const existingRaw = await readRaw(path);
4360
4447
  const markerIdentity = parseOauthIdentity(await readRaw(conveyorOauthMarkerPath()));
4361
4448
  await persistOauthIdentityMarker(
4362
4449
  extractOauthIdentity(parseClaudeJson(existingRaw)),
@@ -4364,16 +4451,16 @@ async function ensureClaudeOnboarding(env = process.env, trustCwd) {
4364
4451
  );
4365
4452
  const contents = planClaudeJsonSeed(existingRaw, trustCwd, markerIdentity);
4366
4453
  if (contents === null) return;
4367
- const verified = await writeWithReadBackRetry(fsWriteIo(path2), contents);
4454
+ const verified = await writeWithReadBackRetry(fsWriteIo(path), contents);
4368
4455
  if (verified) {
4369
4456
  process.stderr.write(
4370
4457
  `[conveyor-agent] claude onboarding seeded${trustCwd ? ` (trust: ${trustCwd})` : ""}
4371
4458
  `
4372
4459
  );
4373
4460
  } else {
4374
- const verify = await readRaw(path2);
4461
+ const verify = await readRaw(path);
4375
4462
  process.stderr.write(
4376
- `[conveyor-agent] claude onboarding seed read-back MISMATCH at ${path2} after retries: wrote ${contents.length}B, read ${verify?.length ?? 0}B \u2014 CLI may see stale config and park at a startup dialog
4463
+ `[conveyor-agent] claude onboarding seed read-back MISMATCH at ${path} after retries: wrote ${contents.length}B, read ${verify?.length ?? 0}B \u2014 CLI may see stale config and park at a startup dialog
4377
4464
  `
4378
4465
  );
4379
4466
  }
@@ -4386,13 +4473,13 @@ async function ensureClaudeOnboarding(env = process.env, trustCwd) {
4386
4473
  async function removeConveyorCredentials(env = process.env) {
4387
4474
  try {
4388
4475
  if (!isConveyorCloudEnv(env)) return;
4389
- const path2 = claudeCredentialsPath();
4476
+ const path = claudeCredentialsPath();
4390
4477
  const markerPath = conveyorCredentialsMarkerPath();
4391
- const existing = parseClaudeAiOauth(await readRaw(path2));
4478
+ const existing = parseClaudeAiOauth(await readRaw(path));
4392
4479
  if (existing && !isConveyorOwnedCredentials(existing, parseCredentialsMarker(await readRaw(markerPath)))) {
4393
4480
  return;
4394
4481
  }
4395
- await rm(path2, { force: true });
4482
+ await rm(path, { force: true });
4396
4483
  await rm(markerPath, { force: true });
4397
4484
  } catch (err) {
4398
4485
  const message = err instanceof Error ? err.message : String(err);
@@ -5901,10 +5988,10 @@ var TuiUnavailableError = class extends Error {
5901
5988
  }
5902
5989
  tui;
5903
5990
  };
5904
- function isExecutable(path2) {
5991
+ function isExecutable(path) {
5905
5992
  try {
5906
- if (!statSync(path2).isFile()) return false;
5907
- accessSync(path2, constants.X_OK);
5993
+ if (!statSync(path).isFile()) return false;
5994
+ accessSync(path, constants.X_OK);
5908
5995
  return true;
5909
5996
  } catch {
5910
5997
  return false;
@@ -5957,21 +6044,21 @@ function shouldSeed(existingEntry, seed) {
5957
6044
  if (typeof entry.expires !== "number") return true;
5958
6045
  return entry.expires < seed.expires;
5959
6046
  }
5960
- async function readJsonFile(path2) {
6047
+ async function readJsonFile(path) {
5961
6048
  try {
5962
- return JSON.parse(await fs.readFile(path2, "utf8"));
6049
+ return JSON.parse(await fs.readFile(path, "utf8"));
5963
6050
  } catch {
5964
6051
  return {};
5965
6052
  }
5966
6053
  }
5967
- async function writeJsonFile(path2, value) {
5968
- await fs.mkdir(dirname2(path2), { recursive: true });
5969
- await fs.writeFile(path2, `${JSON.stringify(value, null, 2)}
6054
+ async function writeJsonFile(path, value) {
6055
+ await fs.mkdir(dirname2(path), { recursive: true });
6056
+ await fs.writeFile(path, `${JSON.stringify(value, null, 2)}
5970
6057
  `, { mode: 384 });
5971
6058
  }
5972
6059
  async function ensureAuthEntry(env, seed) {
5973
- const path2 = opencodeAuthPath(env);
5974
- const store = await readJsonFile(path2);
6060
+ const path = opencodeAuthPath(env);
6061
+ const store = await readJsonFile(path);
5975
6062
  if (!shouldSeed(store.openai, seed)) {
5976
6063
  logger.info("opencode oauth store is fresher than the seed; leaving it alone");
5977
6064
  return;
@@ -5982,12 +6069,12 @@ async function ensureAuthEntry(env, seed) {
5982
6069
  refresh: seed.refresh,
5983
6070
  expires: seed.expires
5984
6071
  };
5985
- await writeJsonFile(path2, store);
6072
+ await writeJsonFile(path, store);
5986
6073
  logger.info("seeded opencode oauth store entry");
5987
6074
  }
5988
6075
  async function ensurePluginConfig(env) {
5989
- const path2 = opencodeConfigPath(env);
5990
- const config = await readJsonFile(path2);
6076
+ const path = opencodeConfigPath(env);
6077
+ const config = await readJsonFile(path);
5991
6078
  const plugins = Array.isArray(config.plugin) ? config.plugin : [];
5992
6079
  const isOurs = (p) => typeof p === "string" && (p === PLUGIN_PACKAGE || p.startsWith(`${PLUGIN_PACKAGE}@`));
5993
6080
  const hasExactPin = plugins.includes(OPENCODE_CODEX_PLUGIN);
@@ -5995,7 +6082,7 @@ async function ensurePluginConfig(env) {
5995
6082
  if (hasExactPin && !hasStalePin) return;
5996
6083
  const kept = plugins.filter((p) => !isOurs(p));
5997
6084
  config.plugin = [...kept, OPENCODE_CODEX_PLUGIN];
5998
- await writeJsonFile(path2, config);
6085
+ await writeJsonFile(path, config);
5999
6086
  logger.info("ensured opencode codex-auth plugin in config");
6000
6087
  }
6001
6088
  async function seedOpenCodeOauth(env) {
@@ -6217,9 +6304,9 @@ var OpenCodeHeadlessHarness = class {
6217
6304
  */
6218
6305
  async writeSystemPrompt(text) {
6219
6306
  if (!text || text.trim() === "") return null;
6220
- const path2 = join10(this.tempDir, "conveyor-instructions.md");
6221
- await writeFile6(path2, text, "utf8");
6222
- return path2;
6307
+ const path = join10(this.tempDir, "conveyor-instructions.md");
6308
+ await writeFile6(path, text, "utf8");
6309
+ return path;
6223
6310
  }
6224
6311
  /**
6225
6312
  * Parse one NDJSON line and push whatever it maps to. Returns assistant text so
@@ -6682,7 +6769,7 @@ function wrapBridgeWithDirectStream(inner, reporter, options = {}) {
6682
6769
 
6683
6770
  // src/execution/query-executor.ts
6684
6771
  import { createHash as createHash2 } from "crypto";
6685
- import { existsSync, readFileSync as readFileSync3, truncateSync } from "fs";
6772
+ import { existsSync, readFileSync as readFileSync2, truncateSync } from "fs";
6686
6773
 
6687
6774
  // src/execution/chat-instructions.ts
6688
6775
  function buildChatInstructions(context, scenario, newMessages) {
@@ -10460,9 +10547,9 @@ Paste this into the PR description to show it inline:
10460
10547
  ${snippet}`;
10461
10548
  }
10462
10549
  function buildUploadAttachmentTool(connection, config) {
10463
- return defineContractTool(uploadAttachmentContract, async ({ path: path2, title, tags }) => {
10550
+ return defineContractTool(uploadAttachmentContract, async ({ path, title, tags }) => {
10464
10551
  try {
10465
- const filePath = isAbsolute(path2) ? path2 : join12(config.workspaceDir, path2);
10552
+ const filePath = isAbsolute(path) ? path : join12(config.workspaceDir, path);
10466
10553
  const mimeType = inferMimeType(filePath);
10467
10554
  const info = await statWorkspacePath(filePath);
10468
10555
  if (!info.isFile) {
@@ -12646,33 +12733,6 @@ function collectMissingProps(taskProps) {
12646
12733
  return missing;
12647
12734
  }
12648
12735
 
12649
- // src/runner/heavy-gate.ts
12650
- import { readFileSync as readFileSync2 } from "fs";
12651
- import path from "path";
12652
- var GATE_KEYS = ["heavy", "test", "typecheck", "build"];
12653
- function runDir() {
12654
- return process.env.CONVEYOR_RUN_DIR ?? "/tmp/conveyor-run";
12655
- }
12656
- function pidAlive(pid) {
12657
- try {
12658
- process.kill(pid, 0);
12659
- return true;
12660
- } catch {
12661
- return false;
12662
- }
12663
- }
12664
- function isHeavyGateActive() {
12665
- for (const key of GATE_KEYS) {
12666
- try {
12667
- const raw = readFileSync2(path.join(runDir(), `${key}.pid`), "utf8").trim();
12668
- const pid = Number.parseInt(raw, 10);
12669
- if (Number.isInteger(pid) && pid > 0 && pidAlive(pid)) return true;
12670
- } catch {
12671
- }
12672
- }
12673
- return false;
12674
- }
12675
-
12676
12736
  // src/execution/tool-loop-tracker.ts
12677
12737
  var REPEAT_INTERRUPT_THRESHOLD = 4;
12678
12738
  var REPEAT_INTERRUPT_INTERVAL = 4;
@@ -13150,7 +13210,9 @@ function taskIdToSessionUuid(lineageKey) {
13150
13210
  return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-8${hash.slice(13, 16)}-a${hash.slice(17, 20)}-${hash.slice(20, 32)}`;
13151
13211
  }
13152
13212
  function sessionLineageKey(taskId, agentMode, runnerMode) {
13153
- return agentMode === "review" || runnerMode === "code-review" ? `${taskId}:review` : taskId;
13213
+ if (agentMode === "review" || runnerMode === "code-review") return `${taskId}:review`;
13214
+ if (runnerMode === "plan") return `${taskId}:plan`;
13215
+ return taskId;
13154
13216
  }
13155
13217
  function sessionFileExists(sessionUuid, cwd) {
13156
13218
  try {
@@ -13171,10 +13233,10 @@ function resolveSessionStart(lineageKey, cwd) {
13171
13233
  }
13172
13234
  return { sessionId: sessionUuid };
13173
13235
  }
13174
- function repairTornSessionFile(path2) {
13236
+ function repairTornSessionFile(path) {
13175
13237
  try {
13176
- if (!existsSync(path2)) return false;
13177
- const content = readFileSync3(path2, "utf8");
13238
+ if (!existsSync(path)) return false;
13239
+ const content = readFileSync2(path, "utf8");
13178
13240
  if (content.length === 0) return false;
13179
13241
  let keepEnd = content.length;
13180
13242
  if (!content.endsWith("\n")) {
@@ -13193,9 +13255,9 @@ function repairTornSessionFile(path2) {
13193
13255
  keepEnd = prevNewline + 1;
13194
13256
  }
13195
13257
  if (keepEnd === content.length) return false;
13196
- truncateSync(path2, Buffer.byteLength(content.slice(0, keepEnd), "utf8"));
13258
+ truncateSync(path, Buffer.byteLength(content.slice(0, keepEnd), "utf8"));
13197
13259
  logger5.warn("Repaired torn transcript before resume", {
13198
- path: path2,
13260
+ path,
13199
13261
  trimmedBytes: content.length - keepEnd
13200
13262
  });
13201
13263
  return true;
@@ -13417,7 +13479,7 @@ async function handleTurnSilence(host, state, waitMs, silenceTimeoutMs, probeMou
13417
13479
  host,
13418
13480
  `[conveyor-agent] Turn produced no events for ${minutes}m with no heavy gate running \u2014 aborting the wedged turn
13419
13481
  `,
13420
- `Turn silent for ${minutes}m \u2014 aborting it so the agent can go idle (the conversation resumes on the next message)`
13482
+ `Turn silent for ${minutes}m \u2014 aborting it so the agent can go idle. A first turn retries automatically; otherwise the conversation resumes on the next message.`
13421
13483
  );
13422
13484
  return "keep_waiting";
13423
13485
  }
@@ -14299,6 +14361,7 @@ var FIRST_SEND_MS = 5e3;
14299
14361
  var RESEND_INTERVAL_MS = 4e3;
14300
14362
  var MAX_SENDS = 5;
14301
14363
  var RENDER_SETTLE_MS = 900;
14364
+ var EMPTY_MCP_CONFIG = JSON.stringify({ mcpServers: {} });
14302
14365
  function buildProbeEnv(env = process.env) {
14303
14366
  const clean = {};
14304
14367
  for (const [key, value] of Object.entries(env)) {
@@ -14354,7 +14417,7 @@ var UsageProbeRun = class {
14354
14417
  this.settleTimer = setTimeout(() => this.finish(this.buf), this.timing.settleMs);
14355
14418
  }
14356
14419
  finishBestEffort() {
14357
- this.finish(panelRendering(this.buf) ? this.buf : "");
14420
+ this.finish(panelRendering(this.buf) ? this.buf : cleanTerminalOutput(this.buf, 500));
14358
14421
  }
14359
14422
  finish(out) {
14360
14423
  if (this.settled) return;
@@ -14366,6 +14429,12 @@ var UsageProbeRun = class {
14366
14429
  this.resolve(out);
14367
14430
  }
14368
14431
  };
14432
+ function buildUsageProbeArgs() {
14433
+ return ["--mcp-config", EMPTY_MCP_CONFIG, "--strict-mcp-config"];
14434
+ }
14435
+ function resolveUsageProbeCwd(explicitCwd, env = process.env) {
14436
+ return explicitCwd ?? env.CONVEYOR_WORKSPACE ?? process.cwd();
14437
+ }
14369
14438
  async function runUsageProbe(deps = {}) {
14370
14439
  let spawn2 = deps.spawn;
14371
14440
  if (!spawn2) {
@@ -14376,7 +14445,8 @@ async function runUsageProbe(deps = {}) {
14376
14445
  }
14377
14446
  }
14378
14447
  const binary = deps.binary ?? resolveClaudeBinary();
14379
- const cwd = deps.cwd ?? process.cwd();
14448
+ const baseEnv = deps.env ?? process.env;
14449
+ const cwd = resolveUsageProbeCwd(deps.cwd, baseEnv);
14380
14450
  const timeoutMs = deps.timeoutMs ?? PROBE_TIMEOUT_MS;
14381
14451
  const timing = {
14382
14452
  firstSendMs: deps.firstSendMs ?? FIRST_SEND_MS,
@@ -14386,12 +14456,12 @@ async function runUsageProbe(deps = {}) {
14386
14456
  return new Promise((resolve) => {
14387
14457
  let child;
14388
14458
  try {
14389
- child = spawn2(binary, [], {
14459
+ child = spawn2(binary, buildUsageProbeArgs(), {
14390
14460
  name: "xterm-256color",
14391
14461
  cols: 120,
14392
14462
  rows: 45,
14393
14463
  cwd,
14394
- env: buildProbeEnv(deps.env)
14464
+ env: buildProbeEnv(baseEnv)
14395
14465
  });
14396
14466
  } catch {
14397
14467
  resolve("");
@@ -14406,10 +14476,10 @@ var logger7 = createServiceLogger("usage-sampler");
14406
14476
  var NO_SAMPLES = { samples: [], unmeasurable: null };
14407
14477
  function isAttributable(identity, sessionToken) {
14408
14478
  if (!identity) return { ok: true };
14409
- if (identity.hasRefreshToken) {
14479
+ if (identity.hasRefreshToken && !identity.isConveyorOwned) {
14410
14480
  return { ok: false, reason: "manual-login-credentials" };
14411
14481
  }
14412
- if (sessionToken && identity.accessToken && identity.accessToken !== sessionToken) {
14482
+ if (!(identity.hasRefreshToken && identity.isConveyorOwned) && sessionToken && identity.accessToken && identity.accessToken !== sessionToken) {
14413
14483
  return { ok: false, reason: "credentials-token-mismatch" };
14414
14484
  }
14415
14485
  return { ok: true };
@@ -14522,6 +14592,9 @@ async function handlePullBranch(workDir, branch) {
14522
14592
  }
14523
14593
  }
14524
14594
 
14595
+ // src/runner/session-runner.ts
14596
+ import { rmSync } from "fs";
14597
+
14525
14598
  // src/runner/background-work.ts
14526
14599
  var BACKGROUND_WORK_MAX_MS = 45 * 60 * 1e3;
14527
14600
  var BACKGROUND_DEFAULT_BY_TOOL = {
@@ -14632,8 +14705,8 @@ function findLiveChild(sources) {
14632
14705
 
14633
14706
  // src/runner/session-runner.ts
14634
14707
  var AUTO_RUN_MODES = /* @__PURE__ */ new Set(["building", "auto", "review", "discovery", "chat"]);
14635
- var AUTONOMOUS_RUNNER_MODES = /* @__PURE__ */ new Set(["pack", "pm", "code-review"]);
14636
14708
  var CHILD_DEFER_RECHECK_MS = 60 * 1e3;
14709
+ var ORPHAN_WAKE_GRACE_MS = 3 * 60 * 1e3;
14637
14710
  var SessionRunner = class _SessionRunner {
14638
14711
  connection;
14639
14712
  mode;
@@ -14703,9 +14776,11 @@ var SessionRunner = class _SessionRunner {
14703
14776
  onHeartbeat: () => {
14704
14777
  const loopStatus = this.refreshLoopStatus();
14705
14778
  this.connection.sendHeartbeat(this.loopLag.takeMaxLagMs(), loopStatus);
14779
+ this.maybeWakeForOrphanedBackgroundWork();
14706
14780
  },
14707
14781
  onIdleTimeout: () => {
14708
14782
  if (this.deferShutdownForLiveChild("idle")) return;
14783
+ if (this.deferShutdownForBackgroundWork()) return;
14709
14784
  process.stderr.write("[conveyor-agent] Idle timeout reached, stopping agent\n");
14710
14785
  this.stopped = true;
14711
14786
  this.queryBridge?.stop();
@@ -14786,6 +14861,133 @@ var SessionRunner = class _SessionRunner {
14786
14861
  }
14787
14862
  return true;
14788
14863
  }
14864
+ /** Epoch ms of the first idle-shutdown deferral for background work in the
14865
+ * current idle episode; null when nothing has deferred. Reset when a turn
14866
+ * runs, so each idle episode gets its own bounded hold. */
14867
+ backgroundDeferStartedAt = null;
14868
+ /**
14869
+ * Suppress an idle shutdown while backgrounded work (`run_in_background`
14870
+ * launches tracked by `BackgroundWorkTracker`) or a live gate process
14871
+ * (`isHeavyGateActive` — singleton pidfiles plus the `conveyor-gate`
14872
+ * contract dir) is still running. Without this the 30-minute idle timeout
14873
+ * stopped the runner mid-gate: `shutdown()` cleared the tracker, heartbeats
14874
+ * stopped, the activity clock expired, and the reconciler slept the pod and
14875
+ * reverted the card to Open.
14876
+ *
14877
+ * Bounded: deferrals past the first one cap at `BACKGROUND_WORK_MAX_MS`, so
14878
+ * a wedged gate process cannot pin a pod forever. Only the IDLE timeout is
14879
+ * deferred — post-`completed` dormancy keeps today's behavior, and a parked
14880
+ * prefill (`waiting_for_input`) contributes nothing here, so those cards
14881
+ * still sleep on the ordinary window.
14882
+ */
14883
+ deferShutdownForBackgroundWork() {
14884
+ let holding = null;
14885
+ try {
14886
+ if (this.backgroundWork.hasPending()) holding = "background work outstanding";
14887
+ else if (isHeavyGateActive()) holding = "a gate process is running";
14888
+ } catch (err) {
14889
+ process.stderr.write(`[conveyor-agent] Background-work probe failed, not deferring: ${err}
14890
+ `);
14891
+ return false;
14892
+ }
14893
+ if (!holding) {
14894
+ this.backgroundDeferStartedAt = null;
14895
+ return false;
14896
+ }
14897
+ const now = Date.now();
14898
+ this.backgroundDeferStartedAt ??= now;
14899
+ if (now - this.backgroundDeferStartedAt >= BACKGROUND_WORK_MAX_MS) {
14900
+ process.stderr.write(
14901
+ `[conveyor-agent] Idle-shutdown deferral cap (${BACKGROUND_WORK_MAX_MS}ms) reached while ${holding} \u2014 proceeding with shutdown
14902
+ `
14903
+ );
14904
+ return false;
14905
+ }
14906
+ const recheckMs = Math.min(this.lifecycle.config.idleTimeoutMs, CHILD_DEFER_RECHECK_MS);
14907
+ process.stderr.write(`[conveyor-agent] Idle timeout deferred: ${holding}
14908
+ `);
14909
+ this.lifecycle.startIdleTimer(recheckMs);
14910
+ return true;
14911
+ }
14912
+ /** When orphan evidence (tracked work pending, no live gate process) began
14913
+ * holding continuously; null while the evidence is absent. */
14914
+ orphanGraceStartedAt = null;
14915
+ /** One-shot latch: at most one watchdog wake per idle episode. Reset when a
14916
+ * turn runs. */
14917
+ backgroundWakeFiredThisIdle = false;
14918
+ /** When the current idle episode began — an exit sentinel older than this
14919
+ * appeared while a turn could still have consumed it, so it never wakes. */
14920
+ idleEpisodeStartedAt = 0;
14921
+ /**
14922
+ * Wake a parked agent whose background work finished — or died — without a
14923
+ * consumed completion callback. Completion normally arrives as the CLI's
14924
+ * `<task-notification>` transcript record; when that record is lost (SDK
14925
+ * harness, torn transcript, a gate killed out from under the CLI) the
14926
+ * tracker entry silently expires and the agent waits forever.
14927
+ *
14928
+ * This is NOT the removed auto-mode stuck-nudge: it fires only on positive
14929
+ * evidence of orphaned background work — a tracked launch with no live gate
14930
+ * process continuously past `ORPHAN_WAKE_GRACE_MS`, or an unconsumed
14931
+ * `gates/<label>.exit` sentinel that appeared this idle episode — never on
14932
+ * "the agent seems stuck". A `completed` agent is never woken (the
14933
+ * completion guard holds), and a live pid always suppresses the wake.
14934
+ */
14935
+ maybeWakeForOrphanedBackgroundWork() {
14936
+ try {
14937
+ if (this._state !== "idle" || this.stopped || this.completedThisTurn) {
14938
+ this.orphanGraceStartedAt = null;
14939
+ return;
14940
+ }
14941
+ if (this.backgroundWakeFiredThisIdle) return;
14942
+ if (!this.inputResolver) return;
14943
+ if (isHeavyGateActive()) {
14944
+ this.orphanGraceStartedAt = null;
14945
+ return;
14946
+ }
14947
+ const evidence = this.findOrphanEvidence(Date.now());
14948
+ if (!evidence) return;
14949
+ process.stderr.write(
14950
+ `[conveyor-agent] Background-work watchdog: waking agent \u2014 ${evidence.description}
14951
+ `
14952
+ );
14953
+ this.backgroundWakeFiredThisIdle = true;
14954
+ this.orphanGraceStartedAt = null;
14955
+ if (evidence.sentinel) rmSync(evidence.sentinel.path, { force: true });
14956
+ this.backgroundWork.clear();
14957
+ const resolver = this.inputResolver;
14958
+ this.inputResolver = null;
14959
+ resolver({
14960
+ content: `A background job you started appears to have finished or died without delivering its completion notification (${evidence.description}). Read the job's output \u2014 its log file, or the corresponding file under $CONVEYOR_RUN_DIR/gates/ \u2014 to determine the real result, then continue your plan. Do not assume the job succeeded; verify its output first, and rerun it if it was killed.`,
14961
+ userId: "system",
14962
+ source: "background_work_check"
14963
+ });
14964
+ } catch (err) {
14965
+ process.stderr.write(`[conveyor-agent] Background-work watchdog probe failed: ${err}
14966
+ `);
14967
+ }
14968
+ }
14969
+ /** Orphan evidence that has held past the grace, or null. Maintains the
14970
+ * grace clock for the tracked-work condition as a side effect. */
14971
+ findOrphanEvidence(now) {
14972
+ const sentinel = listGateExitSentinels().find(
14973
+ (s) => s.mtimeMs >= this.idleEpisodeStartedAt && now - s.mtimeMs >= ORPHAN_WAKE_GRACE_MS
14974
+ );
14975
+ if (sentinel) {
14976
+ return {
14977
+ sentinel,
14978
+ description: `gate '${sentinel.label}' finished (exit ${sentinel.code ?? "unknown"}) with its completion never consumed`
14979
+ };
14980
+ }
14981
+ if (!this.backgroundWork.hasPending()) {
14982
+ this.orphanGraceStartedAt = null;
14983
+ return null;
14984
+ }
14985
+ this.orphanGraceStartedAt ??= now;
14986
+ if (now - this.orphanGraceStartedAt < ORPHAN_WAKE_GRACE_MS) return null;
14987
+ return {
14988
+ description: `${this.backgroundWork.pendingCount()} tracked background launch(es) with no live gate process for ${Math.round(ORPHAN_WAKE_GRACE_MS / 6e4)}+ minutes`
14989
+ };
14990
+ }
14789
14991
  get sessionId() {
14790
14992
  return this.connection.sessionId;
14791
14993
  }
@@ -15113,26 +15315,48 @@ var SessionRunner = class _SessionRunner {
15113
15315
  return true;
15114
15316
  }
15115
15317
  /**
15116
- * The mid-turn wedge watchdog aborts a turn that produced no events for 30
15117
- * minutes. Its documented recovery — "the next message respawns with
15118
- * `--resume`" — assumes a human or a follow-up is coming, which is exactly
15119
- * what an autonomous card does NOT have: after the abort the runner goes
15120
- * idle and the card parks until a pack watchdog or a human notices.
15318
+ * The mid-turn watchdog aborts a turn that produced no events for 30 minutes
15319
+ * (or one whose config-home mount died). Its documented recovery — "the next
15320
+ * message respawns with `--resume`" — assumes a human or a follow-up is
15321
+ * coming. A KICKOFF turn has neither by construction: it is the turn that
15322
+ * starts the conversation, so there is no next message, and the runner goes
15323
+ * idle with the card parked until a human notices.
15324
+ *
15325
+ * Observed live 2026-08-28 (card cmtd78iql): a spawned builder child's
15326
+ * kickoff turn produced nothing for 30 minutes, was aborted, and the child
15327
+ * then heartbeated idle for another 43 minutes until a human typed
15328
+ * "@[agent:builder] continue".
15329
+ *
15330
+ * So any submit-delivery INITIAL query killed by the watchdog is re-run once
15331
+ * against the (durable, on-disk) resumed session. Bounded to a single retry:
15332
+ * a second wedge is a real failure that should surface as a parked card
15333
+ * rather than loop the pod.
15334
+ *
15335
+ * Nothing recovers a card from there automatically, and that is the honest
15336
+ * state of it. The server's session sweep does NOT catch this shape: its
15337
+ * predicate is `agentRunningAt` still null, and a turn that started and then
15338
+ * wedged has already stamped it. The sweep covers a builder that never took a
15339
+ * turn at all; this retry covers one whose first turn stalled. Twice-wedged
15340
+ * needs a human, and a card left that way keeps its Builder tab and its
15341
+ * heartbeat, so it is visible rather than silently gone.
15342
+ *
15343
+ * Skipped when a pending message already exists, because the core loop
15344
+ * delivers that instead, which is the path the watchdog's original recovery
15345
+ * assumed.
15121
15346
  *
15122
- * So for an autonomous card whose INITIAL query was killed as wedged, re-run
15123
- * that initial query once against the (durable, on-disk) resumed session.
15124
- * Bounded to a single retry: a second wedge is a real failure that should
15125
- * surface as a parked card rather than loop the pod. Skipped when a pending
15126
- * message already exists — the core loop delivers that instead, which is the
15127
- * path the watchdog's original recovery assumed.
15347
+ * A mount-death abort retries too, and must: the repair for a dead config
15348
+ * home is the respawn that follows the abort, so a kickoff with nothing
15349
+ * behind it would otherwise never get one.
15350
+ *
15351
+ * NOT for follow-up turns there "the conversation resumes on the next
15352
+ * message" is exactly right.
15128
15353
  */
15129
15354
  async requeueWedgedInitialQuery(delivery) {
15130
15355
  if (!this.queryBridge?.lastTurnWedgeAborted) return;
15131
15356
  if (this.stopped || this.interrupted || this.completedThisTurn) return;
15132
15357
  if (this.pendingMessages.length > 0) return;
15133
- if (!this.mode.isAuto && !AUTONOMOUS_RUNNER_MODES.has(this.config.runnerMode ?? "")) return;
15134
15358
  process.stderr.write(
15135
- "[conveyor-agent] Initial query was aborted as wedged with nothing queued behind it \u2014 re-running it once (an autonomous card has no follow-up message coming)\n"
15359
+ "[conveyor-agent] Initial query was aborted as wedged with nothing queued behind it \u2014 re-running it once (a kickoff turn has no follow-up message coming)\n"
15136
15360
  );
15137
15361
  this.connection.sendEvent({
15138
15362
  type: "error",
@@ -15373,7 +15597,10 @@ var SessionRunner = class _SessionRunner {
15373
15597
  async sampleAndReportKeyUsage() {
15374
15598
  if (this.stopped) return;
15375
15599
  const codingAgentKeyId = process.env.CONVEYOR_CODING_AGENT_KEY_ID;
15376
- const { samples, unmeasurable } = await sampleKeyUsage(process.env.CLAUDE_CODE_OAUTH_TOKEN);
15600
+ const { samples, unmeasurable } = await sampleKeyUsage(
15601
+ process.env.CLAUDE_CODE_OAUTH_TOKEN,
15602
+ () => runUsageProbe({ cwd: this.config.workspaceDir })
15603
+ );
15377
15604
  if (unmeasurable) {
15378
15605
  this.connection.sendEvent(buildUnmeasurableEvent(unmeasurable.reason, codingAgentKeyId));
15379
15606
  return;
@@ -15685,7 +15912,7 @@ ${outcome.failures.join("\n")}
15685
15912
  */
15686
15913
  resolveLoopStatus() {
15687
15914
  if (loopStatusForRunnerStatus(this._state) === "active") return "active";
15688
- return this.backgroundWork.hasPending() ? "waiting" : "idle";
15915
+ return this.backgroundWork.hasPending() || isHeavyGateActive() ? "waiting" : "idle";
15689
15916
  }
15690
15917
  /** Mirror the resolved status into the loop-lag buffer so the starvation-proof
15691
15918
  * heartbeat worker reports it too. Returns what it wrote. */
@@ -15701,6 +15928,11 @@ ${outcome.failures.join("\n")}
15701
15928
  this.backgroundWork.noteToolUse(event.tool, event.input);
15702
15929
  }
15703
15930
  async setState(status) {
15931
+ if (status === "running") {
15932
+ this.backgroundDeferStartedAt = null;
15933
+ this.backgroundWakeFiredThisIdle = false;
15934
+ }
15935
+ if (status === "idle" && this._state !== "idle") this.idleEpisodeStartedAt = Date.now();
15704
15936
  this._state = status;
15705
15937
  this.refreshLoopStatus();
15706
15938
  await this.connection.emitStatus(status);
@@ -15730,6 +15962,15 @@ ${outcome.failures.join("\n")}
15730
15962
  get finalState() {
15731
15963
  return this._finalState;
15732
15964
  }
15965
+ /** Does a transcript already exist for this session's lineage? */
15966
+ resolvesToTranscriptResume() {
15967
+ const taskId = this.fullContext?.taskId;
15968
+ if (!taskId) return false;
15969
+ return hasExistingSessionFile(taskId, this.config.workspaceDir, {
15970
+ agentMode: this.mode.effectiveMode,
15971
+ runnerMode: this.config.runnerMode
15972
+ });
15973
+ }
15733
15974
  buildTaskContextSnapshot() {
15734
15975
  return {
15735
15976
  isParentTask: this.fullContext?.isParentTask ?? false,
@@ -15746,6 +15987,12 @@ ${outcome.failures.join("\n")}
15746
15987
  mode: this.mode.effectiveMode,
15747
15988
  runnerMode: this.config.runnerMode ?? "task",
15748
15989
  sessionId: this.sessionId,
15990
+ // Whether this run will RESUME a transcript, read from disk under this
15991
+ // session's own lineage. `hasExistingSession` below answers a different
15992
+ // question — does the server hold an sdkSessionId — and the two disagree
15993
+ // whenever a role's lineage is fresh. Resume is filesystem-driven, so
15994
+ // this is the field to read when a session's context looks wrong.
15995
+ resumesTranscript: this.resolvesToTranscriptResume(),
15749
15996
  ...this.buildTaskContextSnapshot(),
15750
15997
  model: this.taskContext?.model,
15751
15998
  isAuto: this.config.isAuto ?? false,