@evident-ai/cli 3.4.1-dev.15755a4 → 3.4.1-dev.1856549

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -98,6 +98,10 @@ Options:
98
98
  healthy when the runner starts it itself (default: `180`). On expiry the runner
99
99
  warns and comes online anyway rather than failing. Env:
100
100
  `EVIDENT_OPENCODE_START_TIMEOUT` (seconds).
101
+ - `--litestream-config <path>` — Start `litestream replicate` for the OpenCode
102
+ session database with this config file. Use this for runner images that persist
103
+ the session database; the file is produced by `runner-synchroniser litestream-config`.
104
+ Omit it to disable replication.
101
105
  - `--claude-usage-reporting <mode>` — Whether to report the local Claude Code
102
106
  subscription's rate-limit usage to Evident, so it shows on the runner page:
103
107
  `auto` (default) reports it when a usable Claude Code login is found on this
package/dist/index.js CHANGED
@@ -722,6 +722,14 @@ function toReportedWindow(window) {
722
722
  if (!window) return null;
723
723
  return { utilization: window.utilization, resets_at: window.resetsAt };
724
724
  }
725
+ function toReportedOwner(snapshot) {
726
+ if (!snapshot.owner) return null;
727
+ return {
728
+ email: snapshot.owner.email,
729
+ organization_name: snapshot.owner.organizationName,
730
+ rate_limit_tier: snapshot.owner.rateLimitTier
731
+ };
732
+ }
725
733
  async function reportClaudeUsage(agentId, authHeader, snapshot) {
726
734
  try {
727
735
  const apiUrl = getApiUrlConfig();
@@ -730,7 +738,8 @@ async function reportClaudeUsage(agentId, authHeader, snapshot) {
730
738
  headers: { Authorization: authHeader, "Content-Type": "application/json" },
731
739
  body: JSON.stringify({
732
740
  five_hour: toReportedWindow(snapshot.fiveHour),
733
- seven_day: toReportedWindow(snapshot.sevenDay)
741
+ seven_day: toReportedWindow(snapshot.sevenDay),
742
+ owner: toReportedOwner(snapshot)
734
743
  }),
735
744
  signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
736
745
  });
@@ -996,7 +1005,10 @@ import { readFileSync } from "fs";
996
1005
  import { homedir } from "os";
997
1006
  import { join } from "path";
998
1007
  var CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
1008
+ var CLAUDE_PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
1009
+ var CLAUDE_PROFILE_TIMEOUT_MS = 2e3;
999
1010
  var KEYCHAIN_SERVICE = "Claude Code-credentials";
1011
+ var cachedOwner = null;
1000
1012
  var CLAUDE_CREDENTIALS_SEGMENTS = [".claude", ".credentials.json"];
1001
1013
  function parseClaudeCliCredentials(raw) {
1002
1014
  let parsed;
@@ -1070,6 +1082,47 @@ function toWindow(value) {
1070
1082
  }
1071
1083
  return { utilization: window.utilization, resetsAt };
1072
1084
  }
1085
+ function ownerLookupFailure(error2) {
1086
+ const name = error2?.name;
1087
+ return name === "TimeoutError" || name === "AbortError" ? "timed out" : "request failed";
1088
+ }
1089
+ async function getClaudeUsageOwner(accessToken) {
1090
+ if (cachedOwner?.accessToken === accessToken) {
1091
+ return { owner: cachedOwner.owner, ownerLookupError: null };
1092
+ }
1093
+ try {
1094
+ const response = await fetch(CLAUDE_PROFILE_URL, {
1095
+ headers: {
1096
+ Authorization: `Bearer ${accessToken}`,
1097
+ "Content-Type": "application/json",
1098
+ "anthropic-version": "2023-06-01"
1099
+ },
1100
+ signal: AbortSignal.timeout(CLAUDE_PROFILE_TIMEOUT_MS)
1101
+ });
1102
+ if (!response.ok) {
1103
+ return { owner: null, ownerLookupError: `HTTP ${response.status}` };
1104
+ }
1105
+ let body;
1106
+ try {
1107
+ body = await response.json();
1108
+ } catch (error2) {
1109
+ return { owner: null, ownerLookupError: "malformed response" };
1110
+ }
1111
+ const profile = body;
1112
+ if (typeof profile.account?.email !== "string" || !profile.account.email.trim()) {
1113
+ return { owner: null, ownerLookupError: "malformed response" };
1114
+ }
1115
+ const owner = {
1116
+ email: profile.account.email,
1117
+ organizationName: typeof profile.organization?.name === "string" && profile.organization.name.trim() ? profile.organization.name.trim() : null,
1118
+ rateLimitTier: typeof profile.organization?.rate_limit_tier === "string" && profile.organization.rate_limit_tier.trim() ? profile.organization.rate_limit_tier.trim() : null
1119
+ };
1120
+ cachedOwner = { accessToken, owner };
1121
+ return { owner, ownerLookupError: null };
1122
+ } catch (error2) {
1123
+ return { owner: null, ownerLookupError: ownerLookupFailure(error2) };
1124
+ }
1125
+ }
1073
1126
  async function getClaudeUsage() {
1074
1127
  const credentials2 = readClaudeCliCredentials();
1075
1128
  if (!credentials2) {
@@ -1089,15 +1142,19 @@ async function getClaudeUsage() {
1089
1142
  Authorization: `Bearer ${credentials2.accessToken}`,
1090
1143
  "Content-Type": "application/json",
1091
1144
  "anthropic-version": "2023-06-01"
1092
- }
1145
+ },
1146
+ signal: AbortSignal.timeout(CLAUDE_PROFILE_TIMEOUT_MS)
1093
1147
  });
1094
1148
  if (!res.ok) {
1095
1149
  throw new ClaudeUsageError(`Claude usage request failed: HTTP ${res.status}`, "request_failed");
1096
1150
  }
1097
1151
  const body = await res.json();
1152
+ const { owner, ownerLookupError } = await getClaudeUsageOwner(credentials2.accessToken);
1098
1153
  return {
1099
1154
  fiveHour: toWindow(body.five_hour),
1100
- sevenDay: toWindow(body.seven_day)
1155
+ sevenDay: toWindow(body.seven_day),
1156
+ owner,
1157
+ ownerLookupError
1101
1158
  };
1102
1159
  }
1103
1160
 
@@ -1467,7 +1524,7 @@ function drainSessionDbRecoveryReport({
1467
1524
  skippedLines++;
1468
1525
  return [];
1469
1526
  }
1470
- return [value];
1527
+ return [{ ...value, replication_suspended: value.replication_suspended ?? false }];
1471
1528
  } catch (error2) {
1472
1529
  skippedLines++;
1473
1530
  console.error(
@@ -1492,12 +1549,39 @@ function buildSessionDbRecoveryActivity(record) {
1492
1549
  const level = record.severity === "warning" ? "warn" : record.severity === "error" ? "error" : null;
1493
1550
  if (!level) return null;
1494
1551
  const noVerifiedPoint = record.verified_restore_point ? ` The verified restore point is ${record.verified_restore_point}.` : " No verified restore point is known.";
1552
+ const replication = record.replication_suspended ? " This start is not backing up its new session history; restart after fixing the cause." : "";
1553
+ const giveupMessage = (() => {
1554
+ switch (record.reason) {
1555
+ case "restore_deadline_exceeded":
1556
+ return "The restore did not finish before this startup deadline. Earlier sessions are unavailable for this start; collect the SESSION-DB-RESTORE-TRUNCATED boot log before retrying.";
1557
+ case "restore_tool_unusable":
1558
+ case "classification_unrecognised":
1559
+ return "The restore or classification tool produced an unsupported result. Inspect the named boot marker or log and repair the runner image or bundle before restarting.";
1560
+ case "synchroniser_config_unevaluable":
1561
+ case "synchroniser_config_incomplete":
1562
+ return "The installed hook and synchroniser bundle could not agree on configuration. Inspect the named boot marker or log and repair the runner image or bundle before restarting.";
1563
+ case "synchroniser_config_unresolved":
1564
+ return "The synchroniser configuration could not be resolved. Inspect the preceding boot error and repair the identified runner image, bundle, or local environment before restarting.";
1565
+ case "litestream_config_unavailable":
1566
+ return "The local Litestream configuration could not be generated or written. Inspect the preceding boot error and repair the runner image, bundle, local environment, or write permissions before restarting.";
1567
+ case "classification_fatal":
1568
+ return "Session history could not be restored because backup setup could not be established. Inspect the named boot marker or log and repair the identified backup configuration or permissions.";
1569
+ default:
1570
+ return null;
1571
+ }
1572
+ })();
1573
+ if (giveupMessage)
1574
+ return {
1575
+ level,
1576
+ metadata: withoutContractFields(record),
1577
+ message: `${giveupMessage}${replication}`
1578
+ };
1495
1579
  switch (record.outcome) {
1496
1580
  case "fresh_session_db":
1497
1581
  return {
1498
1582
  level,
1499
1583
  metadata: withoutContractFields(record),
1500
- message: `This runner started with a fresh session database. Earlier sessions are unavailable, and queued conversation or session references from before this start may no longer resolve.${noVerifiedPoint} Review the runner's backup configuration before relying on restored session history.`
1584
+ message: `This runner started with a fresh session database. Earlier sessions are unavailable, and queued conversation or session references from before this start may no longer resolve.${noVerifiedPoint} Review the runner's backup configuration before relying on restored session history.${replication}`
1501
1585
  };
1502
1586
  case "restore_retried":
1503
1587
  return {
@@ -1535,7 +1619,7 @@ function buildSessionDbRecoveryActivity(record) {
1535
1619
  return {
1536
1620
  level,
1537
1621
  metadata: withoutContractFields(record),
1538
- message: "This runner started with a fresh session database because it could not verify where its session history is backed up. Nothing in the backup was changed; check the runner backup configuration and permissions."
1622
+ message: `This runner started without restoring session history because it could not verify where its session history is backed up. Nothing in the backup was changed; check the runner backup configuration and permissions.${replication}`
1539
1623
  };
1540
1624
  case "session_db_boot_refused":
1541
1625
  return {
@@ -1575,7 +1659,7 @@ var STRING_OR_NULL_FIELDS = ["quarantine_destination", "verified_restore_point"]
1575
1659
  function isSessionDbRecoveryRecord(value) {
1576
1660
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1577
1661
  const record = value;
1578
- return record.v === 1 && record.event === "session_db_recovery" && typeof record.at === "string" && STAGES.has(record.stage) && OUTCOMES.has(record.outcome) && SEVERITIES.has(record.severity) && typeof record.reason === "string" && NUMBER_FIELDS.every((field) => record[field] === null || Number.isInteger(record[field])) && STRING_OR_NULL_FIELDS.every(
1662
+ return record.v === 1 && record.event === "session_db_recovery" && typeof record.at === "string" && STAGES.has(record.stage) && OUTCOMES.has(record.outcome) && SEVERITIES.has(record.severity) && typeof record.reason === "string" && (record.replication_suspended === void 0 || typeof record.replication_suspended === "boolean") && NUMBER_FIELDS.every((field) => record[field] === null || Number.isInteger(record[field])) && STRING_OR_NULL_FIELDS.every(
1579
1663
  (field) => record[field] === null || typeof record[field] === "string"
1580
1664
  );
1581
1665
  }
@@ -1624,6 +1708,62 @@ function buildOpenCodeVersionWarning(version2) {
1624
1708
 
1625
1709
  // src/lib/opencode/process.ts
1626
1710
  import { execSync, spawn } from "child_process";
1711
+
1712
+ // src/lib/process-stop.ts
1713
+ async function stopProcessAndWait(child, timeoutMs, sendTerm, sendKill) {
1714
+ if (!child.pid) {
1715
+ return { outcome: "not-running", code: child.exitCode, signal: child.signalCode };
1716
+ }
1717
+ if (child.exitCode !== null || child.signalCode !== null) {
1718
+ return { outcome: "exited", code: child.exitCode, signal: child.signalCode };
1719
+ }
1720
+ return new Promise((resolve3, reject) => {
1721
+ let forced = false;
1722
+ let settled = false;
1723
+ const timer = setTimeout(() => {
1724
+ forced = true;
1725
+ try {
1726
+ sendKill();
1727
+ } catch (error2) {
1728
+ if (error2.code === "ESRCH") {
1729
+ finish({ outcome: "exited", code: child.exitCode, signal: child.signalCode });
1730
+ } else {
1731
+ fail(error2);
1732
+ }
1733
+ }
1734
+ }, timeoutMs);
1735
+ const finish = (result) => {
1736
+ if (settled) return;
1737
+ settled = true;
1738
+ clearTimeout(timer);
1739
+ child.removeListener("exit", onExit);
1740
+ resolve3(result);
1741
+ };
1742
+ const fail = (error2) => {
1743
+ if (settled) return;
1744
+ settled = true;
1745
+ clearTimeout(timer);
1746
+ child.removeListener("exit", onExit);
1747
+ reject(error2);
1748
+ };
1749
+ const onExit = (code, signal) => {
1750
+ finish({ outcome: forced ? "killed" : "exited", code, signal });
1751
+ };
1752
+ child.once("exit", onExit);
1753
+ try {
1754
+ sendTerm();
1755
+ } catch (error2) {
1756
+ if (error2.code === "ESRCH") {
1757
+ finish({ outcome: "exited", code: child.exitCode, signal: child.signalCode });
1758
+ } else {
1759
+ fail(error2);
1760
+ }
1761
+ return;
1762
+ }
1763
+ });
1764
+ }
1765
+
1766
+ // src/lib/opencode/process.ts
1627
1767
  var OPENCODE_PORT_RANGE = [4096, 4097, 4098, 4099, 4100];
1628
1768
  function getProcessCwd(pid) {
1629
1769
  const platform = process.platform;
@@ -1795,23 +1935,20 @@ async function startOpenCode(port) {
1795
1935
  });
1796
1936
  return child;
1797
1937
  }
1798
- function stopOpenCode(opencodeProcess) {
1799
- if (!opencodeProcess || !opencodeProcess.pid) {
1800
- return;
1801
- }
1802
- try {
1938
+ function stopOpenCodeAndWait(opencodeProcess, timeoutMs) {
1939
+ const sendSignal = (signal) => {
1803
1940
  if (process.platform === "win32") {
1804
- opencodeProcess.kill("SIGTERM");
1941
+ opencodeProcess.kill(signal);
1805
1942
  } else {
1806
- process.kill(-opencodeProcess.pid, "SIGTERM");
1943
+ process.kill(-opencodeProcess.pid, signal);
1807
1944
  }
1808
- } catch (err) {
1809
- if (err.code !== "ESRCH") {
1810
- console.warn(
1811
- `stopOpenCode: kill failed: ${err instanceof Error ? err.message : String(err)}`
1812
- );
1813
- }
1814
- }
1945
+ };
1946
+ return stopProcessAndWait(
1947
+ opencodeProcess,
1948
+ timeoutMs,
1949
+ () => sendSignal("SIGTERM"),
1950
+ () => sendSignal("SIGKILL")
1951
+ );
1815
1952
  }
1816
1953
 
1817
1954
  // src/lib/opencode/install.ts
@@ -2098,6 +2235,7 @@ async function createOpenCodeSession(port, directory) {
2098
2235
  return data.id;
2099
2236
  }
2100
2237
  async function getModelAttachmentCapability(port, model) {
2238
+ const { model: baseModel } = splitModelVariant(model);
2101
2239
  try {
2102
2240
  const res = await timedFetch(`${opencodeBase(port)}/config/providers`);
2103
2241
  if (!res.ok) {
@@ -2114,9 +2252,9 @@ async function getModelAttachmentCapability(port, model) {
2114
2252
  );
2115
2253
  return null;
2116
2254
  }
2117
- const slash = model ? model.indexOf("/") : -1;
2118
- const providerId = slash > 0 ? model.slice(0, slash) : void 0;
2119
- let modelId = slash > 0 ? model.slice(slash + 1) : void 0;
2255
+ const slash = baseModel ? baseModel.indexOf("/") : -1;
2256
+ const providerId = slash > 0 ? baseModel.slice(0, slash) : void 0;
2257
+ let modelId = slash > 0 ? baseModel.slice(slash + 1) : void 0;
2120
2258
  const defaults2 = body?.default && typeof body.default === "object" ? body.default : void 0;
2121
2259
  let provider = providerId ? providers.find((p) => p?.id === providerId) : void 0;
2122
2260
  if (!provider && !providerId) {
@@ -2196,6 +2334,29 @@ async function buildFileParts(attachments, capable) {
2196
2334
  }
2197
2335
  return { parts, outcomes, capabilityUnknown };
2198
2336
  }
2337
+ function splitModelVariant(raw) {
2338
+ const value = raw?.trim();
2339
+ if (!value) return {};
2340
+ const hashIndex = value.indexOf("#");
2341
+ if (hashIndex === -1) return { model: value };
2342
+ const model = value.slice(0, hashIndex).trim() || void 0;
2343
+ const variant = value.slice(hashIndex + 1).trim() || void 0;
2344
+ return { model, variant };
2345
+ }
2346
+ function applyModelOptions(body, options) {
2347
+ if (options?.agent) body.agent = options.agent;
2348
+ const { model, variant } = splitModelVariant(options?.model);
2349
+ if (model) {
2350
+ const slashIndex = model.indexOf("/");
2351
+ if (slashIndex !== -1) {
2352
+ body.model = {
2353
+ providerID: model.substring(0, slashIndex),
2354
+ modelID: model.substring(slashIndex + 1)
2355
+ };
2356
+ }
2357
+ }
2358
+ if (variant) body.variant = variant;
2359
+ }
2199
2360
  function messageText(m) {
2200
2361
  if (!m || !Array.isArray(m.parts)) return "";
2201
2362
  return m.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
@@ -2220,18 +2381,7 @@ async function sendPromptAsync(port, sessionId, content, options, attachments) {
2220
2381
  const body = {
2221
2382
  parts
2222
2383
  };
2223
- if (options?.agent) {
2224
- body.agent = options.agent;
2225
- }
2226
- if (options?.model) {
2227
- const slashIndex = options.model.indexOf("/");
2228
- if (slashIndex !== -1) {
2229
- body.model = {
2230
- providerID: options.model.substring(0, slashIndex),
2231
- modelID: options.model.substring(slashIndex + 1)
2232
- };
2233
- }
2234
- }
2384
+ applyModelOptions(body, options);
2235
2385
  const res = await timedFetch(`${opencodeBase(port)}/session/${sessionId}/prompt_async`, {
2236
2386
  method: "POST",
2237
2387
  headers: { "Content-Type": "application/json" },
@@ -2239,7 +2389,10 @@ async function sendPromptAsync(port, sessionId, content, options, attachments) {
2239
2389
  });
2240
2390
  if (res.status < 200 || res.status >= 300) {
2241
2391
  const text = await res.text().catch(() => "");
2242
- throw new Error(`OpenCode prompt_async failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
2392
+ const { variant } = splitModelVariant(options?.model);
2393
+ throw new Error(
2394
+ `OpenCode prompt_async failed: HTTP ${res.status}${text ? `: ${text}` : ""}${variant ? ` (variant: ${variant})` : ""}`
2395
+ );
2243
2396
  }
2244
2397
  const READ_BACK_ATTEMPTS = 5;
2245
2398
  const READ_BACK_DELAY_MS = 150;
@@ -2394,7 +2547,7 @@ function isPreamblePinnedRunning(messages, userMessageId) {
2394
2547
  return completedOf(reply) != null && finishOf(reply) === "tool-calls";
2395
2548
  }
2396
2549
  function isB2AbandonmentConfirmed(params) {
2397
- return params.pinnedForMs >= params.minPinnedMs && params.descendantOngoing === false;
2550
+ return params.pinnedForMs >= params.minPinnedMs && params.descendantOngoing === false && params.rootOngoing === false;
2398
2551
  }
2399
2552
  function isAmbiguousTerminalFinish(m) {
2400
2553
  if (completedOf(m) == null) return false;
@@ -2407,7 +2560,7 @@ function isAmbiguousFinishPinnedRunning(messages, userMessageId) {
2407
2560
  return isAmbiguousTerminalFinish(reply);
2408
2561
  }
2409
2562
  function isAmbiguousFinishResolved(params) {
2410
- return params.sessionOngoing === false || params.pinnedForMs >= params.maxPinnedMs;
2563
+ return params.sessionOngoing === false || params.sessionOngoing !== true && params.pinnedForMs >= params.maxPinnedMs;
2411
2564
  }
2412
2565
  function messageError(messages, userMessageId) {
2413
2566
  const reply = findLastAssistantReplyFor(messages, userMessageId);
@@ -3189,6 +3342,22 @@ function writeTunnelReadyMarker(path, agentId) {
3189
3342
  }
3190
3343
  }
3191
3344
 
3345
+ // src/lib/replication.ts
3346
+ import { spawn as spawn2 } from "child_process";
3347
+ function startSessionDbReplication(configPath) {
3348
+ return spawn2("litestream", ["replicate", "-config", configPath], {
3349
+ stdio: "inherit"
3350
+ });
3351
+ }
3352
+ async function stopSessionDbReplication(child, timeoutMs) {
3353
+ return stopProcessAndWait(
3354
+ child,
3355
+ timeoutMs,
3356
+ () => child.kill("SIGTERM"),
3357
+ () => child.kill("SIGKILL")
3358
+ );
3359
+ }
3360
+
3192
3361
  // src/lib/openai-usage.ts
3193
3362
  import { readFileSync as readFileSync3 } from "fs";
3194
3363
  import { homedir as homedir2 } from "os";
@@ -5683,6 +5852,7 @@ var ChannelDriver = class _ChannelDriver {
5683
5852
  deliveryDeadlineAnchored: false,
5684
5853
  b2PinnedSinceMs: 0,
5685
5854
  b2LastDescendantCheckMs: 0,
5855
+ b2RootOngoingHeldLogged: false,
5686
5856
  b2AbandonedSignalled: false,
5687
5857
  ambiguousPinnedSinceMs: 0,
5688
5858
  ambiguousResolved: false
@@ -5777,6 +5947,7 @@ var ChannelDriver = class _ChannelDriver {
5777
5947
  deliveryDeadlineAnchored: false,
5778
5948
  b2PinnedSinceMs: 0,
5779
5949
  b2LastDescendantCheckMs: 0,
5950
+ b2RootOngoingHeldLogged: false,
5780
5951
  b2AbandonedSignalled: false,
5781
5952
  ambiguousPinnedSinceMs: 0,
5782
5953
  ambiguousResolved: false
@@ -6130,6 +6301,7 @@ var ChannelDriver = class _ChannelDriver {
6130
6301
  if (snapshotReadable) {
6131
6302
  inFlight.b2PinnedSinceMs = 0;
6132
6303
  inFlight.b2LastDescendantCheckMs = 0;
6304
+ inFlight.b2RootOngoingHeldLogged = false;
6133
6305
  inFlight.b2AbandonedSignalled = false;
6134
6306
  }
6135
6307
  } else {
@@ -6141,11 +6313,15 @@ var ChannelDriver = class _ChannelDriver {
6141
6313
  const pinnedForMs = this.now() - inFlight.b2PinnedSinceMs;
6142
6314
  if (pinnedForMs >= B2_ABANDONMENT_MIN_PINNED_MS && this.now() - inFlight.b2LastDescendantCheckMs >= B2_ABANDONMENT_RECHECK_MS) {
6143
6315
  inFlight.b2LastDescendantCheckMs = this.now();
6144
- const descendantOngoing = await this.isAnyDescendantSessionOngoing(sessionId);
6316
+ const [descendantOngoing, rootOngoing] = await Promise.all([
6317
+ this.isAnyDescendantSessionOngoing(sessionId),
6318
+ isSessionOngoing(this.port, sessionId)
6319
+ ]);
6145
6320
  if (isB2AbandonmentConfirmed({
6146
6321
  pinnedForMs,
6147
6322
  minPinnedMs: B2_ABANDONMENT_MIN_PINNED_MS,
6148
- descendantOngoing
6323
+ descendantOngoing,
6324
+ rootOngoing
6149
6325
  })) {
6150
6326
  inFlight.b2AbandonedSignalled = true;
6151
6327
  this.log({
@@ -6154,12 +6330,26 @@ var ChannelDriver = class _ChannelDriver {
6154
6330
  conversation_id: conv.id,
6155
6331
  message_id: id
6156
6332
  });
6333
+ const reply = findLastAssistantReplyFor(messages, inFlight.opencodeMessageId);
6157
6334
  void this.postSignal(conv.id, id, "b2_abandoned_resolved", {
6158
- watched_for_ms: pinnedForMs
6335
+ watched_for_ms: pinnedForMs,
6336
+ finish: reply?.info?.finish ?? reply?.finish,
6337
+ ...rootOngoing != null ? { root_ongoing: rootOngoing } : {},
6338
+ ...descendantOngoing != null ? { descendant_ongoing: descendantOngoing } : {},
6339
+ opencode_message_id: inFlight.opencodeMessageId
6159
6340
  });
6160
6341
  await this.settleMessageDone(sessionId, watcher, inFlight, messages);
6161
6342
  return;
6162
6343
  }
6344
+ if (rootOngoing === true && !inFlight.b2RootOngoingHeldLogged) {
6345
+ inFlight.b2RootOngoingHeldLogged = true;
6346
+ this.log({
6347
+ level: "warn",
6348
+ message: `Message ${id.slice(0, 8)} b2-pinned for ${Math.round(pinnedForMs / 1e3)}s with root_ongoing=${rootOngoing} and descendant_ongoing=${descendantOngoing} \u2014 holding until OpenCode confirms the root is idle`,
6349
+ conversation_id: conv.id,
6350
+ message_id: id
6351
+ });
6352
+ }
6163
6353
  }
6164
6354
  }
6165
6355
  const ambiguousPinnedNow = activelyRunning && isAmbiguousFinishPinnedRunning(messages, inFlight.opencodeMessageId);
@@ -7902,6 +8092,7 @@ var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_
7902
8092
  var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
7903
8093
  var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
7904
8094
  var TELEMETRY_SHUTDOWN_TIMEOUT_MS = 5e3;
8095
+ var CHILD_STOP_TIMEOUT_MS = 1e4;
7905
8096
  function resolveLogLevel(options) {
7906
8097
  const accepted = Object.keys(LOG_LEVELS);
7907
8098
  const validate = (value, source) => {
@@ -8507,7 +8698,17 @@ function scheduleClaudeUsageReporting(state, options) {
8507
8698
  setTimer: (timer) => {
8508
8699
  state.claudeUsageTimer = timer;
8509
8700
  },
8510
- fetchUsage: getClaudeUsage,
8701
+ fetchUsage: async () => {
8702
+ const usage = await getClaudeUsage();
8703
+ if (usage.ownerLookupError) {
8704
+ logActivity(state, {
8705
+ type: "info",
8706
+ level: "debug",
8707
+ message: `Claude usage owner lookup failed: ${usage.ownerLookupError}`
8708
+ });
8709
+ }
8710
+ return usage;
8711
+ },
8511
8712
  report: (usage) => reportClaudeUsage(state.agentId, state.authHeader, usage),
8512
8713
  isLocalCredentialProblem,
8513
8714
  forcedOnHint: "run `claude` to sign in",
@@ -8679,15 +8880,31 @@ async function cleanup(state, opts = {}) {
8679
8880
  }
8680
8881
  if (state.opencodeProcess) {
8681
8882
  const opencodeProcess = state.opencodeProcess;
8682
- await timeShutdownPhase(state, durations, "opencode_stop", () => stopOpenCode(opencodeProcess));
8883
+ const result = await timeShutdownPhase(
8884
+ state,
8885
+ durations,
8886
+ "opencode_stop",
8887
+ () => stopOpenCodeAndWait(opencodeProcess, CHILD_STOP_TIMEOUT_MS)
8888
+ );
8683
8889
  if (state.interactive) {
8684
- logActivity(state, { type: "info", message: "Stopped OpenCode process" });
8890
+ logActivity(state, { type: "info", message: `Stopped OpenCode process (${result.outcome})` });
8685
8891
  displayStatus(state);
8686
8892
  } else {
8687
- log2(state, "Stopped OpenCode process");
8893
+ log2(state, `Stopped OpenCode process (${result.outcome})`);
8688
8894
  }
8689
8895
  state.opencodeProcess = null;
8690
8896
  }
8897
+ if (state.litestreamProcess) {
8898
+ const litestreamProcess = state.litestreamProcess;
8899
+ const result = await timeShutdownPhase(
8900
+ state,
8901
+ durations,
8902
+ "litestream_stop",
8903
+ () => stopSessionDbReplication(litestreamProcess, CHILD_STOP_TIMEOUT_MS)
8904
+ );
8905
+ log2(state, `Stopped litestream replication (${result.outcome})`);
8906
+ state.litestreamProcess = null;
8907
+ }
8691
8908
  return durations;
8692
8909
  }
8693
8910
  async function run(options) {
@@ -8721,6 +8938,7 @@ async function run(options) {
8721
8938
  opencodeConnected: false,
8722
8939
  opencodeVersion: null,
8723
8940
  opencodeProcess: null,
8941
+ litestreamProcess: null,
8724
8942
  connection: null,
8725
8943
  channelDriver: null,
8726
8944
  running: true,
@@ -8985,6 +9203,40 @@ async function run(options) {
8985
9203
  ocSpinner?.fail(error2.message);
8986
9204
  throw error2;
8987
9205
  }
9206
+ if (options.litestreamConfig) {
9207
+ const litestreamProcess = startSessionDbReplication(options.litestreamConfig);
9208
+ state.litestreamProcess = litestreamProcess;
9209
+ let failureHandled = false;
9210
+ const failRunForReplication = (message) => {
9211
+ if (failureHandled || state.shuttingDown || !state.running) return;
9212
+ failureHandled = true;
9213
+ state.shuttingDown = true;
9214
+ logActivity(state, { type: "error", error: message });
9215
+ if (state.interactive) displayStatus(state);
9216
+ void (async () => {
9217
+ try {
9218
+ await cleanup(state);
9219
+ await shutdownTelemetry();
9220
+ } catch (error2) {
9221
+ console.error(
9222
+ `[run] replication failure cleanup failed: ${error2 instanceof Error ? error2.message : String(error2)}`
9223
+ );
9224
+ }
9225
+ process.exit(1);
9226
+ })();
9227
+ };
9228
+ litestreamProcess.on("exit", (code, signal) => {
9229
+ failRunForReplication(
9230
+ `Litestream replication stopped unexpectedly (code: ${code ?? "null"}, signal: ${signal ?? "none"})`
9231
+ );
9232
+ });
9233
+ litestreamProcess.on("error", (error2) => {
9234
+ failRunForReplication(
9235
+ `Litestream replication failed: ${error2 instanceof Error ? error2.message : String(error2)}`
9236
+ );
9237
+ });
9238
+ log2(state, `Started litestream replication with config ${options.litestreamConfig}`);
9239
+ }
8988
9240
  const tunnelSpinner = interactive && !state.json ? ora3("Connecting tunnel...").start() : null;
8989
9241
  const channelDriver = new ChannelDriver({
8990
9242
  agentId: state.agentId,
@@ -9245,6 +9497,9 @@ program.command("run").description("Connect to Evident and process messages").op
9245
9497
  ).option(
9246
9498
  "--tunnel-ready-file <path>",
9247
9499
  "Path to write once the tunnel is connected (opt-in; unused unless an operator/image sets it)"
9500
+ ).option(
9501
+ "--litestream-config <path>",
9502
+ "Replicate the OpenCode session database with `litestream replicate` using this config file (written by the runner image). Omit to disable replication."
9248
9503
  ).action(
9249
9504
  (options) => {
9250
9505
  run({
@@ -9276,7 +9531,8 @@ program.command("run").description("Connect to Evident and process messages").op
9276
9531
  // Raw values — expansion/validation is single-sourced in run.ts's
9277
9532
  // resolveFileSyncDirectories.
9278
9533
  enableFileSyncTo: options.enableFileSyncTo,
9279
- tunnelReadyFile: options.tunnelReadyFile
9534
+ tunnelReadyFile: options.tunnelReadyFile,
9535
+ litestreamConfig: options.litestreamConfig
9280
9536
  });
9281
9537
  }
9282
9538
  );