@evident-ai/cli 3.2.1-dev.f48fa70 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -212,51 +212,80 @@ var api = {
212
212
 
213
213
  // src/lib/keychain.ts
214
214
  var SERVICE_NAME = "evident-cli";
215
- var keytarWarned = false;
216
- async function getKeytar() {
215
+ var PROBE_SERVICE_NAME = "evident-cli-probe";
216
+ var keychainWarned = false;
217
+ function warnUnavailable(err) {
218
+ if (!keychainWarned) {
219
+ keychainWarned = true;
220
+ console.warn(
221
+ `System keychain unavailable, falling back to file-based credential storage: ${err instanceof Error ? err.message : String(err)}`
222
+ );
223
+ }
224
+ }
225
+ var keychain;
226
+ async function probeKeychain() {
217
227
  try {
218
- const keytar = await import("keytar");
228
+ const keytar = await import("@napi-rs/keyring/keytar.js");
219
229
  if (typeof keytar.setPassword !== "function") {
220
230
  return null;
221
231
  }
232
+ await keytar.findCredentials(PROBE_SERVICE_NAME);
222
233
  return keytar;
223
234
  } catch (err) {
224
- if (!keytarWarned) {
225
- keytarWarned = true;
226
- console.warn(
227
- `System keychain unavailable, falling back to file-based credential storage: ${err instanceof Error ? err.message : String(err)}`
228
- );
229
- }
235
+ warnUnavailable(err);
230
236
  return null;
231
237
  }
232
238
  }
239
+ function resolveKeychain() {
240
+ if (!keychain) {
241
+ keychain = probeKeychain();
242
+ }
243
+ return keychain;
244
+ }
233
245
  function keychainAccount() {
234
246
  return getApiUrlConfig();
235
247
  }
248
+ function storeInFileFallback(credentials2) {
249
+ setCredentials({
250
+ token: credentials2.token,
251
+ user: credentials2.user,
252
+ expiresAt: credentials2.expiresAt
253
+ });
254
+ }
236
255
  async function storeToken(credentials2) {
237
- const keytar = await getKeytar();
256
+ const keytar = await resolveKeychain();
238
257
  if (keytar) {
239
- await keytar.setPassword(SERVICE_NAME, keychainAccount(), JSON.stringify(credentials2));
240
- } else {
241
- setCredentials({
242
- token: credentials2.token,
243
- user: credentials2.user,
244
- expiresAt: credentials2.expiresAt
245
- });
258
+ try {
259
+ await keytar.setPassword(SERVICE_NAME, keychainAccount(), JSON.stringify(credentials2));
260
+ return;
261
+ } catch (err) {
262
+ warnUnavailable(err);
263
+ }
246
264
  }
265
+ storeInFileFallback(credentials2);
247
266
  }
248
267
  async function getToken() {
249
- const keytar = await getKeytar();
268
+ const keytar = await resolveKeychain();
250
269
  if (keytar) {
251
270
  const account = keychainAccount();
252
- const stored = await keytar.getPassword(SERVICE_NAME, account);
253
- if (stored) {
254
- try {
255
- return JSON.parse(stored);
256
- } catch {
257
- await keytar.deletePassword(SERVICE_NAME, account);
258
- return null;
271
+ try {
272
+ const stored = await keytar.getPassword(SERVICE_NAME, account);
273
+ if (stored) {
274
+ try {
275
+ return JSON.parse(stored);
276
+ } catch {
277
+ try {
278
+ await keytar.deletePassword(SERVICE_NAME, account);
279
+ } catch (err) {
280
+ console.warn(
281
+ `Failed to clear invalid keychain entry for ${account}: ${err instanceof Error ? err.message : String(err)}`
282
+ );
283
+ }
284
+ return null;
285
+ }
259
286
  }
287
+ } catch (err) {
288
+ warnUnavailable(err);
260
289
  }
261
290
  }
262
291
  const creds = getCredentials();
@@ -273,7 +302,7 @@ function toError(err) {
273
302
  return err instanceof Error ? err : new Error(String(err));
274
303
  }
275
304
  async function deleteToken(options = {}) {
276
- const keytar = await getKeytar();
305
+ const keytar = await resolveKeychain();
277
306
  const failures = [];
278
307
  if (keytar) {
279
308
  if (options.all) {
@@ -286,14 +315,25 @@ async function deleteToken(options = {}) {
286
315
  await Promise.all(
287
316
  accounts.map(async (entry) => {
288
317
  try {
289
- await keytar.deletePassword(SERVICE_NAME, entry.account);
318
+ const deleted = await keytar.deletePassword(SERVICE_NAME, entry.account);
319
+ if (!deleted) {
320
+ failures.push({
321
+ type: "delete",
322
+ account: entry.account,
323
+ error: new Error("deletePassword resolved false")
324
+ });
325
+ }
290
326
  } catch (err) {
291
327
  failures.push({ type: "delete", account: entry.account, error: toError(err) });
292
328
  }
293
329
  })
294
330
  );
295
331
  } else {
296
- await keytar.deletePassword(SERVICE_NAME, keychainAccount());
332
+ try {
333
+ await keytar.deletePassword(SERVICE_NAME, keychainAccount());
334
+ } catch (err) {
335
+ warnUnavailable(err);
336
+ }
297
337
  }
298
338
  }
299
339
  if (options.all) {
@@ -1052,6 +1092,7 @@ var MAX_FILE_SYNC_DIRECTORIES = 16;
1052
1092
 
1053
1093
  // ../../packages/types/src/logging/index.ts
1054
1094
  var CORRELATION_ID_HEADER = "x-evident-correlation-id";
1095
+ var FORWARD_FAILURE_REASON_HEADER = "X-Evident-Failure-Reason";
1055
1096
  function log(level, event, fields) {
1056
1097
  const method = level === "debug" ? "log" : level;
1057
1098
  try {
@@ -2106,7 +2147,9 @@ function messageRunState(messages, userMessageId) {
2106
2147
  }
2107
2148
  if (!reply) return "queued";
2108
2149
  if (isAssistantInFlight(reply)) return "running";
2109
- return errorOf(reply) != null ? "failed" : "done";
2150
+ if (errorOf(reply) != null) return "failed";
2151
+ if (isAmbiguousTerminalFinish(reply)) return "running";
2152
+ return "done";
2110
2153
  }
2111
2154
  function isPreamblePinnedRunning(messages, userMessageId) {
2112
2155
  if (messageRunState(messages, userMessageId) !== "running") return false;
@@ -2116,6 +2159,19 @@ function isPreamblePinnedRunning(messages, userMessageId) {
2116
2159
  function isB2AbandonmentConfirmed(params) {
2117
2160
  return params.pinnedForMs >= params.minPinnedMs && params.descendantOngoing === false;
2118
2161
  }
2162
+ function isAmbiguousTerminalFinish(m) {
2163
+ if (completedOf(m) == null) return false;
2164
+ if (errorOf(m) != null) return false;
2165
+ const finish = finishOf(m);
2166
+ return finish !== "tool-calls" && finish !== "stop";
2167
+ }
2168
+ function isAmbiguousFinishPinnedRunning(messages, userMessageId) {
2169
+ const reply = findLastAssistantReplyFor(messages, userMessageId);
2170
+ return isAmbiguousTerminalFinish(reply);
2171
+ }
2172
+ function isAmbiguousFinishResolved(params) {
2173
+ return params.sessionOngoing === false || params.pinnedForMs >= params.maxPinnedMs;
2174
+ }
2119
2175
  function messageError(messages, userMessageId) {
2120
2176
  const reply = findLastAssistantReplyFor(messages, userMessageId);
2121
2177
  const error2 = errorOf(reply);
@@ -2341,10 +2397,128 @@ function statSessionDbBytes(homeDir) {
2341
2397
  }
2342
2398
  }
2343
2399
  function buildSessionStoreSizeWarning(input) {
2344
- const { dbBytes, cleanupEnabled } = input;
2345
- if (dbBytes === null || dbBytes <= LARGE_DB_THRESHOLD_BYTES || cleanupEnabled) return null;
2400
+ const { dbBytes, cleanupEnabled, reclaimSkipReason } = input;
2401
+ if (dbBytes === null || dbBytes <= LARGE_DB_THRESHOLD_BYTES) return null;
2346
2402
  const mib = Math.round(dbBytes / 1024 / 1024);
2347
- return `Session store is large: opencode.db is ${mib} MiB and automatic session cleanup is off. Enable it with --session-cleanup-max-age 24h (env EVIDENT_SESSION_CLEANUP_MAX_AGE) to stop it growing \u2014 a store much larger than this can exceed a hosted runner's session-history restore budget on the next start, losing this runner's session history.`;
2403
+ if (!cleanupEnabled) {
2404
+ return `Session store is large: opencode.db is ${mib} MiB and automatic session cleanup is off. Enable it with --session-cleanup-max-age 24h (env EVIDENT_SESSION_CLEANUP_MAX_AGE) to stop it growing \u2014 a store much larger than this can exceed a hosted runner's session-history restore budget on the next start, losing this runner's session history.`;
2405
+ }
2406
+ if (reclaimSkipReason === "sqlite-unavailable" || reclaimSkipReason === "insufficient-disk-space") {
2407
+ const reasonText = reclaimSkipReason === "sqlite-unavailable" ? "this Node runtime lacks node:sqlite (needs Node >=22.5)" : "there is not enough free disk space to compact it";
2408
+ return `Session store is large: opencode.db is ${mib} MiB. Automatic session cleanup is on, but space cannot currently be reclaimed because ${reasonText} \u2014 a store this large can exceed a hosted runner's session-history restore budget on the next start, losing this runner's session history.`;
2409
+ }
2410
+ return null;
2411
+ }
2412
+
2413
+ // src/lib/opencode/session-db-reclaim.ts
2414
+ import { statSync as statSync3, statfsSync } from "fs";
2415
+ import { dirname as dirname2 } from "path";
2416
+ function insufficientSpaceReason(dbPath, requiredBytes) {
2417
+ try {
2418
+ const fsStats = statfsSync(dirname2(dbPath));
2419
+ const availableBytes = fsStats.bavail * fsStats.bsize;
2420
+ if (availableBytes < requiredBytes) {
2421
+ return `only ${availableBytes} bytes free, need ${requiredBytes} for a second copy`;
2422
+ }
2423
+ return null;
2424
+ } catch (err) {
2425
+ return `could not check free space (${err instanceof Error ? err.message : String(err)}); refusing to guess`;
2426
+ }
2427
+ }
2428
+ function readLogicalBytes(db) {
2429
+ const pageCount = db.prepare("PRAGMA page_count").get().page_count;
2430
+ const pageSize = db.prepare("PRAGMA page_size").get().page_size;
2431
+ return pageCount * pageSize;
2432
+ }
2433
+ function readCheckpointResult(db) {
2434
+ const row = db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get();
2435
+ return { busy: row.busy !== 0, log: row.log, checkpointed: row.checkpointed };
2436
+ }
2437
+ async function probeReclaimAvailability(input) {
2438
+ const { dbPath, requiredBytes } = input;
2439
+ let sqlite;
2440
+ try {
2441
+ sqlite = await import("sqlite");
2442
+ } catch (err) {
2443
+ console.warn(
2444
+ `[probeReclaimAvailability] node:sqlite unavailable for ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
2445
+ );
2446
+ return "sqlite-unavailable";
2447
+ }
2448
+ let autoVacuum = null;
2449
+ try {
2450
+ const db = new sqlite.DatabaseSync(dbPath, { readOnly: true });
2451
+ try {
2452
+ autoVacuum = db.prepare("PRAGMA auto_vacuum").get().auto_vacuum;
2453
+ } finally {
2454
+ db.close();
2455
+ }
2456
+ } catch (err) {
2457
+ console.warn(
2458
+ `[probeReclaimAvailability] could not read auto_vacuum mode for ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
2459
+ );
2460
+ }
2461
+ if (autoVacuum !== 0) return null;
2462
+ return insufficientSpaceReason(dbPath, requiredBytes) !== null ? "insufficient-disk-space" : null;
2463
+ }
2464
+ async function reclaimSessionDbSpace(input) {
2465
+ const { dbPath, maxPages, allowFullVacuum = true } = input;
2466
+ let sqlite;
2467
+ try {
2468
+ sqlite = await import("sqlite");
2469
+ } catch (err) {
2470
+ console.warn(
2471
+ `[reclaimSessionDbSpace] node:sqlite unavailable (need Node >=22.5, >=22.13 unflagged) for ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
2472
+ );
2473
+ return { ok: false, skipped: "sqlite-unavailable" };
2474
+ }
2475
+ const { DatabaseSync } = sqlite;
2476
+ let db;
2477
+ try {
2478
+ db = new DatabaseSync(dbPath);
2479
+ const autoVacuum = db.prepare("PRAGMA auto_vacuum").get().auto_vacuum;
2480
+ if (autoVacuum === 0) {
2481
+ if (!allowFullVacuum) {
2482
+ console.warn(
2483
+ `[reclaimSessionDbSpace] skipping VACUUM conversion of ${dbPath}: a session turn is live`
2484
+ );
2485
+ return { ok: false, skipped: "full-vacuum-blocked" };
2486
+ }
2487
+ const fileBytesForGuard = statSync3(dbPath).size;
2488
+ const skipReason = insufficientSpaceReason(dbPath, fileBytesForGuard);
2489
+ if (skipReason !== null) {
2490
+ console.warn(
2491
+ `[reclaimSessionDbSpace] skipping VACUUM conversion of ${dbPath}: ${skipReason}`
2492
+ );
2493
+ return { ok: false, skipped: "insufficient-disk-space" };
2494
+ }
2495
+ const beforeBytes = readLogicalBytes(db);
2496
+ db.exec("PRAGMA auto_vacuum=INCREMENTAL");
2497
+ db.exec("VACUUM");
2498
+ const afterBytes = readLogicalBytes(db);
2499
+ const checkpoint = readCheckpointResult(db);
2500
+ return { ok: true, mode: "convert", beforeBytes, afterBytes, checkpoint };
2501
+ }
2502
+ if (autoVacuum === 2) {
2503
+ const beforeBytes = readLogicalBytes(db);
2504
+ const bound = Math.max(0, Math.trunc(maxPages));
2505
+ db.exec(`PRAGMA incremental_vacuum(${bound})`);
2506
+ const afterBytes = readLogicalBytes(db);
2507
+ const checkpoint = readCheckpointResult(db);
2508
+ return { ok: true, mode: "incremental", beforeBytes, afterBytes, checkpoint };
2509
+ }
2510
+ console.warn(
2511
+ `[reclaimSessionDbSpace] ${dbPath} has auto_vacuum=${autoVacuum} (neither NONE nor INCREMENTAL); nothing to reclaim`
2512
+ );
2513
+ return { ok: false, skipped: "auto-vacuum-not-applicable" };
2514
+ } catch (err) {
2515
+ console.error(
2516
+ `[reclaimSessionDbSpace] reclaim failed for ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
2517
+ );
2518
+ return { ok: false, skipped: "reclaim-error" };
2519
+ } finally {
2520
+ db?.close();
2521
+ }
2348
2522
  }
2349
2523
 
2350
2524
  // src/lib/tunnel/connection.ts
@@ -2512,6 +2686,17 @@ var StreamForwarder = class {
2512
2686
  };
2513
2687
 
2514
2688
  // src/lib/tunnel/connection.ts
2689
+ var FAILURE_REASON_HEADER_LC = FORWARD_FAILURE_REASON_HEADER.toLowerCase();
2690
+ var TunnelUpgradeRejectedError = class extends Error {
2691
+ constructor(message, reason) {
2692
+ super(message);
2693
+ this.reason = reason;
2694
+ }
2695
+ };
2696
+ function classifyUpgradeRejection(headers) {
2697
+ const value = headers[FAILURE_REASON_HEADER_LC];
2698
+ return value === "do_code_updated" ? "do_code_updated" : "unknown";
2699
+ }
2515
2700
  var MAX_RECONNECT_DELAY = 3e4;
2516
2701
  var BASE_RECONNECT_DELAY = 500;
2517
2702
  function getReconnectDelay(attempt) {
@@ -2556,6 +2741,7 @@ function connectTunnel(options) {
2556
2741
  onError,
2557
2742
  onResponse,
2558
2743
  onInfo,
2744
+ onWarning,
2559
2745
  onDrainPing
2560
2746
  } = options;
2561
2747
  const tunnelUrl = getTunnelUrlConfig();
@@ -2575,8 +2761,11 @@ function connectTunnel(options) {
2575
2761
  reject(new Error("Connection timeout"));
2576
2762
  }, 3e4);
2577
2763
  let upgradeRejection = null;
2764
+ let upgradeRejectionReason = null;
2578
2765
  ws.on("unexpected-response", (_req, res) => {
2579
2766
  clearTimeout(connectionTimeout);
2767
+ const reason = classifyUpgradeRejection(res.headers);
2768
+ upgradeRejectionReason = reason;
2580
2769
  const chunks = [];
2581
2770
  res.on("data", (chunk) => chunks.push(chunk));
2582
2771
  res.on("end", () => {
@@ -2590,8 +2779,14 @@ function connectTunnel(options) {
2590
2779
  }
2591
2780
  const statusLine = `HTTP ${res.statusCode}${res.statusMessage ? ` ${res.statusMessage}` : ""}`;
2592
2781
  upgradeRejection = detail ? `${statusLine}: ${detail}` : statusLine;
2593
- onError?.(`Tunnel refused by relay (${upgradeRejection})`);
2594
- reject(new Error(`Tunnel handshake rejected: ${upgradeRejection}`));
2782
+ if (reason === "do_code_updated") {
2783
+ onWarning?.("Relay redeployed \u2014 reconnecting");
2784
+ } else {
2785
+ onError?.(`Tunnel refused by relay (${upgradeRejection})`);
2786
+ }
2787
+ reject(
2788
+ new TunnelUpgradeRejectedError(`Tunnel handshake rejected: ${upgradeRejection}`, reason)
2789
+ );
2595
2790
  });
2596
2791
  });
2597
2792
  ws.on("open", () => {
@@ -2637,8 +2832,14 @@ function connectTunnel(options) {
2637
2832
  ws.on("error", (error2) => {
2638
2833
  clearTimeout(connectionTimeout);
2639
2834
  const detail = upgradeRejection ?? describeSocketError(error2, url);
2640
- onError?.(`Connection error: ${detail}`);
2641
- reject(upgradeRejection ? new Error(upgradeRejection) : new Error(detail));
2835
+ if (upgradeRejectionReason === "do_code_updated") {
2836
+ onWarning?.("Relay redeployed \u2014 reconnecting");
2837
+ } else {
2838
+ onError?.(`Connection error: ${detail}`);
2839
+ }
2840
+ reject(
2841
+ upgradeRejectionReason !== null ? new TunnelUpgradeRejectedError(detail, upgradeRejectionReason) : new Error(detail)
2842
+ );
2642
2843
  });
2643
2844
  ws.on("close", (code, reason) => {
2644
2845
  const reasonStr = reason.toString() || upgradeRejection || (code === 1006 ? "abnormal closure" : "No reason provided");
@@ -2714,7 +2915,8 @@ var RunnerConnection = class {
2714
2915
  onError: (error2) => events.onError?.(error2),
2715
2916
  onResponse: () => events.onResponse?.(),
2716
2917
  onDrainPing: () => events.onDrainPing?.(),
2717
- onInfo: (message) => events.onInfo?.(message)
2918
+ onInfo: (message) => events.onInfo?.(message),
2919
+ onWarning: (message) => events.onWarning?.(message)
2718
2920
  });
2719
2921
  return;
2720
2922
  } catch (error2) {
@@ -2725,7 +2927,12 @@ var RunnerConnection = class {
2725
2927
  }
2726
2928
  const delay = getReconnectDelay(this.reconnectAttempt);
2727
2929
  events.onReconnecting?.(this.reconnectAttempt);
2728
- events.onError?.(`Connection failed, retrying in ${Math.round(delay / 1e3)}s...`);
2930
+ const retryMessage = `Connection failed, retrying in ${Math.round(delay / 1e3)}s...`;
2931
+ if (error2 instanceof TunnelUpgradeRejectedError && error2.reason === "do_code_updated") {
2932
+ events.onWarning?.(retryMessage);
2933
+ } else {
2934
+ events.onError?.(retryMessage);
2935
+ }
2729
2936
  await this.sleep(delay);
2730
2937
  }
2731
2938
  }
@@ -2782,7 +2989,7 @@ import { homedir as homedir2 } from "os";
2782
2989
  // src/lib/file-push.ts
2783
2990
  import { randomUUID } from "crypto";
2784
2991
  import { chmod, mkdir, open as open2, realpath, rename, unlink } from "fs/promises";
2785
- import { basename, dirname as dirname2, isAbsolute, join as join3, relative, resolve as resolve2, sep } from "path";
2992
+ import { basename, dirname as dirname3, isAbsolute, join as join3, relative, resolve as resolve2, sep } from "path";
2786
2993
  var FILE_MODE = 384;
2787
2994
  var DIRECTORY_MODE = 448;
2788
2995
  async function writePushedFile(request) {
@@ -2813,7 +3020,7 @@ async function writePushedFile(request) {
2813
3020
  }
2814
3021
  try {
2815
3022
  const { existingAncestor, missingSegments } = await resolveNearestExistingAncestor(
2816
- dirname2(candidate)
3023
+ dirname3(candidate)
2817
3024
  );
2818
3025
  const realTarget = join3(existingAncestor, ...missingSegments, basename(candidate));
2819
3026
  const allowedDirectory = await findContainingAllowedDirectory(allowedDirectories, realTarget);
@@ -2825,8 +3032,8 @@ async function writePushedFile(request) {
2825
3032
  }
2826
3033
  if (missingSegments.length > 0) {
2827
3034
  await createMissingDirectories(existingAncestor, missingSegments);
2828
- const realParent = await realpath(dirname2(realTarget));
2829
- if (realParent !== dirname2(realTarget) || !contains(allowedDirectory, realTarget)) {
3035
+ const realParent = await realpath(dirname3(realTarget));
3036
+ if (realParent !== dirname3(realTarget) || !contains(allowedDirectory, realTarget)) {
2830
3037
  return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
2831
3038
  path: realTarget,
2832
3039
  bytes,
@@ -2869,7 +3076,7 @@ async function resolveNearestExistingAncestor(directory) {
2869
3076
  try {
2870
3077
  return { existingAncestor: await realpath(current), missingSegments };
2871
3078
  } catch (err) {
2872
- const parent = dirname2(current);
3079
+ const parent = dirname3(current);
2873
3080
  if (err.code !== "ENOENT" || parent === current) {
2874
3081
  throw err;
2875
3082
  }
@@ -2930,7 +3137,7 @@ async function createMissingDirectories(existingAncestor, missingSegments) {
2930
3137
  }
2931
3138
  }
2932
3139
  async function writeAtomically(realTarget, content) {
2933
- const temporaryPath = join3(dirname2(realTarget), `.evident-push-${randomUUID()}.tmp`);
3140
+ const temporaryPath = join3(dirname3(realTarget), `.evident-push-${randomUUID()}.tmp`);
2934
3141
  let handle;
2935
3142
  try {
2936
3143
  handle = await open2(temporaryPath, "wx", FILE_MODE);
@@ -3203,6 +3410,7 @@ var DEFAULT_STUCK_QUEUED_MS = 6e4;
3203
3410
  var HEARTBEAT_MS = 6e4;
3204
3411
  var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
3205
3412
  var B2_ABANDONMENT_MIN_PINNED_MS = 3 * 6e4;
3413
+ var AMBIGUOUS_FINISH_MAX_PINNED_MS = 3 * 6e4;
3206
3414
  var B2_ABANDONMENT_RECHECK_MS = HEARTBEAT_MS;
3207
3415
  var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
3208
3416
  var MAX_SUPERSEDED_CONVERSATIONS = 256;
@@ -4037,6 +4245,9 @@ var ChannelDriver = class _ChannelDriver {
4037
4245
  return this.reattachRedrive(conv, sessionId, message, ocId);
4038
4246
  }
4039
4247
  if (ongoing === false) {
4248
+ if (state === "running" && isAmbiguousFinishPinnedRunning(messages, ocId ?? "")) {
4249
+ return this.settleRedrive(conv, sessionId, message, ocId, messages, "done");
4250
+ }
4040
4251
  this.clearRedriveUnresolved(message.id);
4041
4252
  void this.postSignal(conv.id, message.id, "redrive_redispatched");
4042
4253
  return "dispatch";
@@ -4648,7 +4859,9 @@ var ChannelDriver = class _ChannelDriver {
4648
4859
  deliveryDeadlineAnchored: false,
4649
4860
  b2PinnedSinceMs: 0,
4650
4861
  b2LastDescendantCheckMs: 0,
4651
- b2AbandonedSignalled: false
4862
+ b2AbandonedSignalled: false,
4863
+ ambiguousPinnedSinceMs: 0,
4864
+ ambiguousResolved: false
4652
4865
  });
4653
4866
  }
4654
4867
  /**
@@ -4727,7 +4940,9 @@ var ChannelDriver = class _ChannelDriver {
4727
4940
  deliveryDeadlineAnchored: false,
4728
4941
  b2PinnedSinceMs: 0,
4729
4942
  b2LastDescendantCheckMs: 0,
4730
- b2AbandonedSignalled: false
4943
+ b2AbandonedSignalled: false,
4944
+ ambiguousPinnedSinceMs: 0,
4945
+ ambiguousResolved: false
4731
4946
  });
4732
4947
  }
4733
4948
  /**
@@ -4995,6 +5210,49 @@ var ChannelDriver = class _ChannelDriver {
4995
5210
  }
4996
5211
  }
4997
5212
  }
5213
+ const ambiguousPinnedNow = activelyRunning && isAmbiguousFinishPinnedRunning(messages, inFlight.opencodeMessageId);
5214
+ if (!ambiguousPinnedNow) {
5215
+ if (snapshotReadable) {
5216
+ inFlight.ambiguousPinnedSinceMs = 0;
5217
+ inFlight.ambiguousResolved = false;
5218
+ }
5219
+ } else {
5220
+ if (inFlight.ambiguousResolved) {
5221
+ await this.settleMessageDone(sessionId, watcher, inFlight, messages);
5222
+ return;
5223
+ }
5224
+ if (inFlight.ambiguousPinnedSinceMs === 0) {
5225
+ inFlight.ambiguousPinnedSinceMs = this.now();
5226
+ const reply = findLastAssistantReplyFor(messages, inFlight.opencodeMessageId);
5227
+ const finish = reply?.info?.finish ?? reply?.finish;
5228
+ this.log({
5229
+ level: "warn",
5230
+ message: `Message ${id.slice(0, 8)} pinned running by an unrecognised finish ("${finish ?? "(absent)"}") \u2014 corroborating against opencode's session status before settling (issue #1493)`,
5231
+ conversation_id: conv.id,
5232
+ message_id: id
5233
+ });
5234
+ }
5235
+ const pinnedForMs = this.now() - inFlight.ambiguousPinnedSinceMs;
5236
+ const ongoing = await isSessionOngoing(this.port, sessionId);
5237
+ if (isAmbiguousFinishResolved({
5238
+ pinnedForMs,
5239
+ maxPinnedMs: AMBIGUOUS_FINISH_MAX_PINNED_MS,
5240
+ sessionOngoing: ongoing
5241
+ })) {
5242
+ inFlight.ambiguousResolved = true;
5243
+ this.log({
5244
+ level: "warn",
5245
+ message: `Message ${id.slice(0, 8)} ambiguous-finish-pinned for ${Math.round(pinnedForMs / 1e3)}s \u2014 resolved (${ongoing === false ? "session confirmed not-ongoing" : "pin exceeded the no-hang cap"}) \u2014 settling done`,
5246
+ conversation_id: conv.id,
5247
+ message_id: id
5248
+ });
5249
+ void this.postSignal(conv.id, id, "ambiguous_finish_resolved", {
5250
+ watched_for_ms: pinnedForMs
5251
+ });
5252
+ await this.settleMessageDone(sessionId, watcher, inFlight, messages);
5253
+ return;
5254
+ }
5255
+ }
4998
5256
  if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
4999
5257
  this.log({
5000
5258
  level: "warn",
@@ -5249,48 +5507,7 @@ var ChannelDriver = class _ChannelDriver {
5249
5507
  const ocId = row.opencode_message_id;
5250
5508
  const state = messageRunState(messages, ocId ?? "");
5251
5509
  if (state === "done") {
5252
- if (this.doneUndeliverable.has(row.id)) {
5253
- this.log({
5254
- level: "debug",
5255
- message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
5256
- conversation_id: row.conversation_id,
5257
- message_id: row.id
5258
- });
5259
- return;
5260
- }
5261
- this.log({
5262
- level: "info",
5263
- message: `Re-adopt: message ${row.id.slice(0, 8)} completed while unwatched \u2014 marking done`,
5264
- conversation_id: row.conversation_id,
5265
- message_id: row.id
5266
- });
5267
- try {
5268
- const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
5269
- const usage = messageUsage(messages, ocId ?? "");
5270
- await this.markDone(row.conversation_id, row.id, sessionId, ocId, title, usage);
5271
- } catch (err) {
5272
- if (err instanceof ChannelAuthError) throw err;
5273
- if (err instanceof ChannelTerminalError) {
5274
- this.doneUndeliverable.add(row.id);
5275
- this.log({
5276
- level: "warn",
5277
- message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 parking until it leaves processing; leaving for the cron safety net: ${err.message}`,
5278
- conversation_id: row.conversation_id,
5279
- message_id: row.id
5280
- });
5281
- void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
5282
- return;
5283
- }
5284
- this.log({
5285
- level: "warn",
5286
- message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
5287
- conversation_id: row.conversation_id,
5288
- message_id: row.id
5289
- });
5290
- return;
5291
- }
5292
- this.dontRedispatch.delete(row.id);
5293
- void this.postSignal(row.conversation_id, row.id, "readopt_done");
5510
+ await this.deliverReadoptedDone(sessionId, row, messages, ocId);
5294
5511
  return;
5295
5512
  }
5296
5513
  const restartAborted = state === "failed" && sessionOngoing === false && isAbortedTerminalReply(messages, ocId ?? "");
@@ -5355,6 +5572,17 @@ var ChannelDriver = class _ChannelDriver {
5355
5572
  const ongoing = sessionOngoing;
5356
5573
  statusReadableOngoing = ongoing;
5357
5574
  if (ongoing === false) {
5575
+ if (isAmbiguousFinishPinnedRunning(messages, ocId ?? "")) {
5576
+ const finish = reply?.info?.finish ?? reply?.finish;
5577
+ this.log({
5578
+ level: "info",
5579
+ message: `Re-adopt: message ${row.id.slice(0, 8)} has an ambiguous finish ("${finish ?? "(absent)"}") but session ${sessionId.slice(0, 8)} is confirmed not-ongoing per GET /session/status \u2014 delivering the existing reply instead of re-dispatching`,
5580
+ conversation_id: row.conversation_id,
5581
+ message_id: row.id
5582
+ });
5583
+ await this.deliverReadoptedDone(sessionId, row, messages, ocId);
5584
+ return;
5585
+ }
5358
5586
  this.log({
5359
5587
  level: "info",
5360
5588
  message: `Re-adopt: message ${row.id.slice(0, 8)} running/${shape} but session ${sessionId.slice(0, 8)} is not-ongoing per GET /session/status (absent/idle) \u2014 re-dispatching from scratch (status-gated recovery)`,
@@ -5431,6 +5659,63 @@ var ChannelDriver = class _ChannelDriver {
5431
5659
  }
5432
5660
  await this.forceReadoptRun(sessionId, row);
5433
5661
  }
5662
+ /**
5663
+ * Deliver a `processing` row whose correlated reply already completed while
5664
+ * nobody was watching (ADR-0046) — the `readoptOne` `state === 'done'` body,
5665
+ * extracted (#1493 Task 2.4) so the ambiguous-finish guard above can call the
5666
+ * SAME delivery instead of duplicating it.
5667
+ *
5668
+ * EVEN IF the row was previously parked in `dontRedispatch` (a give-up stops
5669
+ * re-dispatch, not delivery — Bugbot #202). Guarded EXACTLY like the watcher's
5670
+ * `settleMessageDone`: auth re-throws; terminal → park in `doneUndeliverable` +
5671
+ * leave for cron; transient → log + leave for the next drain (the still-
5672
+ * `processing` row is re-read and retried). markDone is idempotent server-side
5673
+ * (status-gated), so a repeat can never double-post.
5674
+ */
5675
+ async deliverReadoptedDone(sessionId, row, messages, ocId) {
5676
+ if (this.doneUndeliverable.has(row.id)) {
5677
+ this.log({
5678
+ level: "debug",
5679
+ message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
5680
+ conversation_id: row.conversation_id,
5681
+ message_id: row.id
5682
+ });
5683
+ return;
5684
+ }
5685
+ this.log({
5686
+ level: "info",
5687
+ message: `Re-adopt: message ${row.id.slice(0, 8)} completed while unwatched \u2014 marking done`,
5688
+ conversation_id: row.conversation_id,
5689
+ message_id: row.id
5690
+ });
5691
+ try {
5692
+ const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
5693
+ const usage = messageUsage(messages, ocId ?? "");
5694
+ await this.markDone(row.conversation_id, row.id, sessionId, ocId, title, usage);
5695
+ } catch (err) {
5696
+ if (err instanceof ChannelAuthError) throw err;
5697
+ if (err instanceof ChannelTerminalError) {
5698
+ this.doneUndeliverable.add(row.id);
5699
+ this.log({
5700
+ level: "warn",
5701
+ message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 parking until it leaves processing; leaving for the cron safety net: ${err.message}`,
5702
+ conversation_id: row.conversation_id,
5703
+ message_id: row.id
5704
+ });
5705
+ void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
5706
+ return;
5707
+ }
5708
+ this.log({
5709
+ level: "warn",
5710
+ message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
5711
+ conversation_id: row.conversation_id,
5712
+ message_id: row.id
5713
+ });
5714
+ return;
5715
+ }
5716
+ this.dontRedispatch.delete(row.id);
5717
+ void this.postSignal(row.conversation_id, row.id, "readopt_done");
5718
+ }
5434
5719
  /**
5435
5720
  * Re-dispatch an orphaned (`unknown`/null-id) `processing` row (ADR-0046 §2).
5436
5721
  *
@@ -6060,17 +6345,25 @@ var ChannelDriver = class _ChannelDriver {
6060
6345
  * the aborted-in-flight production bug after a restart.
6061
6346
  * - `b2` — a COMPLETED reply pinned running only by `finish === "tool-calls"`
6062
6347
  * (the sub-agent preamble — #253's shape).
6063
- * - `other` — any other shape (defensive; a running row is normally b1 or b2).
6064
- * Reads `info.time.completed` / `info.finish` (tolerating the legacy top-level
6065
- * shape) directly rather than re-importing the module-private `completedOf`/
6066
- * `finishOf` — this is a display label only, not a correctness predicate.
6348
+ * - `ambiguous` — a COMPLETED, non-errored reply whose `finish` is neither
6349
+ * "tool-calls" nor "stop" (issue #1493, class 4 see
6350
+ * `isAmbiguousFinishPinnedRunning`/Task 2.4).
6351
+ * - `other` — any other shape (defensive; a running row is normally b1, b2 or
6352
+ * ambiguous).
6353
+ * Reads `info.time.completed` / `info.finish` / `info.error` (tolerating the
6354
+ * legacy top-level shape) directly rather than re-importing the module-private
6355
+ * `completedOf`/`finishOf`/`errorOf` — this is a display label only, not a
6356
+ * correctness predicate (that is `isAmbiguousFinishPinnedRunning`'s job).
6067
6357
  */
6068
6358
  replyCompletionShape(reply) {
6069
6359
  if (!reply) return "other";
6070
6360
  const completed = reply.info?.time?.completed ?? reply.time?.completed;
6071
6361
  if (completed == null) return "b1";
6072
6362
  const finish = reply.info?.finish ?? reply.finish;
6073
- return finish === "tool-calls" ? "b2" : "other";
6363
+ if (finish === "tool-calls") return "b2";
6364
+ const error2 = reply.info?.error ?? reply.error;
6365
+ if (finish !== "stop" && error2 == null) return "ambiguous";
6366
+ return "other";
6074
6367
  }
6075
6368
  /**
6076
6369
  * Attribute a surfaced interaction to the in-flight message it paused on (M-1).
@@ -6988,6 +7281,10 @@ async function driveChannels(state, driver) {
6988
7281
  }
6989
7282
  }
6990
7283
  var SESSION_CLEANUP_FIRST_SWEEP_MS = 1e4;
7284
+ var SESSION_DB_RECLAIM_MAX_PAGES = 2e3;
7285
+ function sessionDbPath() {
7286
+ return join4(homedir3(), ".local", "share", "opencode", "opencode.db");
7287
+ }
6991
7288
  async function runSweep(state, driver, config) {
6992
7289
  const mode = `age=${config.maxAgeMs ?? "\u2014"} count=${config.maxCount ?? "\u2014"}`;
6993
7290
  try {
@@ -7030,6 +7327,25 @@ async function runSweep(state, driver, config) {
7030
7327
  type: "info",
7031
7328
  message: `Session cleanup: inspected ${sessions.length}, deleted ${deleted}${failedNote}${skippedNote} (${mode})`
7032
7329
  });
7330
+ const reclaimResult = await reclaimSessionDbSpace({
7331
+ dbPath: sessionDbPath(),
7332
+ maxPages: SESSION_DB_RECLAIM_MAX_PAGES,
7333
+ allowFullVacuum: protectedNow.size === 0
7334
+ });
7335
+ if (reclaimResult.ok) {
7336
+ const beforeMib = (reclaimResult.beforeBytes / 1024 / 1024).toFixed(1);
7337
+ const afterMib = (reclaimResult.afterBytes / 1024 / 1024).toFixed(1);
7338
+ const checkpointNote = reclaimResult.checkpoint.busy ? ` (on-disk file truncation deferred: checkpoint busy, ${reclaimResult.checkpoint.log} WAL frames pending)` : "";
7339
+ logActivity(state, {
7340
+ type: "info",
7341
+ message: `Session cleanup: reclaimed session-db space (${reclaimResult.mode}): ${beforeMib} MiB -> ${afterMib} MiB${checkpointNote}`
7342
+ });
7343
+ } else {
7344
+ logActivity(state, {
7345
+ type: "info",
7346
+ message: `Session cleanup: session-db space reclaim skipped (${reclaimResult.skipped})`
7347
+ });
7348
+ }
7033
7349
  } catch (error2) {
7034
7350
  const message = error2 instanceof Error ? error2.message : String(error2);
7035
7351
  logActivity(state, {
@@ -7050,13 +7366,22 @@ function scheduleSessionCleanup(state, driver, options) {
7050
7366
  for (const warning2 of config.warnings) {
7051
7367
  logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
7052
7368
  }
7053
- const sizeWarning = buildSessionStoreSizeWarning({
7054
- dbBytes: statSessionDbBytes(homedir3()),
7055
- cleanupEnabled: config.enabled
7369
+ const dbBytes = statSessionDbBytes(homedir3());
7370
+ void (async () => {
7371
+ const reclaimSkipReason = dbBytes !== null && config.enabled ? await probeReclaimAvailability({ dbPath: sessionDbPath(), requiredBytes: dbBytes }) : null;
7372
+ const sizeWarning = buildSessionStoreSizeWarning({
7373
+ dbBytes,
7374
+ cleanupEnabled: config.enabled,
7375
+ reclaimSkipReason
7376
+ });
7377
+ if (sizeWarning !== null) {
7378
+ logActivity(state, { type: "info", level: "warn", message: sizeWarning });
7379
+ }
7380
+ })().catch((err) => {
7381
+ console.error(
7382
+ `[scheduleSessionCleanup] size-warning preflight failed: ${err instanceof Error ? err.message : String(err)}`
7383
+ );
7056
7384
  });
7057
- if (sizeWarning !== null) {
7058
- logActivity(state, { type: "info", level: "warn", message: sizeWarning });
7059
- }
7060
7385
  if (!config.enabled) return;
7061
7386
  logActivity(state, {
7062
7387
  type: "info",
@@ -7638,6 +7963,13 @@ async function run(options) {
7638
7963
  logActivity(state, { type: "error", error: error2 });
7639
7964
  if (state.interactive) displayStatus(state);
7640
7965
  },
7966
+ // `warn`, not `info`: `forwardRunnerActivity`'s FORWARDED_LEVELS floor is
7967
+ // {'warn','error'}, so an `info` entry would never leave the machine and
7968
+ // an operator couldn't correlate a reconnect storm with a relay deploy.
7969
+ onWarning: (message) => {
7970
+ logActivity(state, { type: "info", level: "warn", message });
7971
+ if (state.interactive) displayStatus(state);
7972
+ },
7641
7973
  // Web traffic is proxied transparently; note opencode is live and stamp
7642
7974
  // proxied activity so the idle loop treats interactive proxy use as work.
7643
7975
  // Fires per forwarded response head (incl. every SSE open) and excludes