@evident-ai/cli 3.2.0 → 3.2.1-dev.5f057c3

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,6 +212,7 @@ var api = {
212
212
 
213
213
  // src/lib/keychain.ts
214
214
  var SERVICE_NAME = "evident-cli";
215
+ var keytarWarned = false;
215
216
  async function getKeytar() {
216
217
  try {
217
218
  const keytar = await import("keytar");
@@ -219,7 +220,13 @@ async function getKeytar() {
219
220
  return null;
220
221
  }
221
222
  return keytar;
222
- } catch {
223
+ } 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
+ }
223
230
  return null;
224
231
  }
225
232
  }
@@ -368,8 +375,9 @@ async function deviceFlowLogin(options) {
368
375
  await waitForEnter("Press Enter to open the browser...");
369
376
  try {
370
377
  await open(verification_uri);
371
- } catch {
372
- console.log(chalk2.dim("Could not open browser. Please visit the URL manually."));
378
+ } catch (error2) {
379
+ const message = error2 instanceof Error ? error2.message : String(error2);
380
+ console.log(chalk2.dim(`Could not open browser (${message}). Please visit the URL manually.`));
373
381
  }
374
382
  }
375
383
  const spinner = ora("Waiting for authentication...").start();
@@ -909,14 +917,25 @@ function readClaudeCliCredentials() {
909
917
  { encoding: "utf-8", timeout: 2e3, stdio: ["pipe", "pipe", "ignore"] }
910
918
  );
911
919
  return parseClaudeCliCredentials(raw);
912
- } catch {
920
+ } catch (err) {
921
+ if (err.status !== 44) {
922
+ console.warn(
923
+ `readClaudeCliCredentials: security find-generic-password failed: ${err instanceof Error ? err.message : String(err)}`
924
+ );
925
+ }
913
926
  return null;
914
927
  }
915
928
  }
916
929
  try {
917
930
  const raw = readFileSync(join(homedir(), ".claude", ".credentials.json"), "utf-8");
918
931
  return parseClaudeCliCredentials(raw);
919
- } catch {
932
+ } catch (err) {
933
+ const code = err.code;
934
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
935
+ console.warn(
936
+ `readClaudeCliCredentials: reading .claude/.credentials.json failed: ${err instanceof Error ? err.message : String(err)}`
937
+ );
938
+ }
920
939
  return null;
921
940
  }
922
941
  }
@@ -1004,7 +1023,7 @@ async function claudeUsage() {
1004
1023
 
1005
1024
  // src/commands/run.ts
1006
1025
  import { homedir as homedir3 } from "os";
1007
- import { isAbsolute as isAbsolute2, join as join3, parse, resolve as resolvePath } from "path";
1026
+ import { isAbsolute as isAbsolute2, join as join4, parse, resolve as resolvePath } from "path";
1008
1027
  import chalk6 from "chalk";
1009
1028
 
1010
1029
  // ../../packages/types/src/agents/index.ts
@@ -1414,7 +1433,10 @@ function findOpenCodeProcesses() {
1414
1433
  }
1415
1434
  }
1416
1435
  }
1417
- } catch {
1436
+ } catch (err) {
1437
+ console.warn(
1438
+ `findOpenCodeProcesses: ps fallback failed: ${err instanceof Error ? err.message : String(err)}`
1439
+ );
1418
1440
  }
1419
1441
  }
1420
1442
  for (const pid of pids) {
@@ -1437,7 +1459,10 @@ function findOpenCodeProcesses() {
1437
1459
  }
1438
1460
  }
1439
1461
  }
1440
- } catch {
1462
+ } catch (err) {
1463
+ console.warn(
1464
+ `findOpenCodeProcesses: process detection failed: ${err instanceof Error ? err.message : String(err)}`
1465
+ );
1441
1466
  }
1442
1467
  return instances;
1443
1468
  }
@@ -1511,7 +1536,12 @@ function stopOpenCode(opencodeProcess) {
1511
1536
  } else {
1512
1537
  process.kill(-opencodeProcess.pid, "SIGTERM");
1513
1538
  }
1514
- } catch {
1539
+ } catch (err) {
1540
+ if (err.code !== "ESRCH") {
1541
+ console.warn(
1542
+ `stopOpenCode: kill failed: ${err instanceof Error ? err.message : String(err)}`
1543
+ );
1544
+ }
1515
1545
  }
1516
1546
  }
1517
1547
 
@@ -2076,7 +2106,9 @@ function messageRunState(messages, userMessageId) {
2076
2106
  }
2077
2107
  if (!reply) return "queued";
2078
2108
  if (isAssistantInFlight(reply)) return "running";
2079
- return errorOf(reply) != null ? "failed" : "done";
2109
+ if (errorOf(reply) != null) return "failed";
2110
+ if (isAmbiguousTerminalFinish(reply)) return "running";
2111
+ return "done";
2080
2112
  }
2081
2113
  function isPreamblePinnedRunning(messages, userMessageId) {
2082
2114
  if (messageRunState(messages, userMessageId) !== "running") return false;
@@ -2086,6 +2118,19 @@ function isPreamblePinnedRunning(messages, userMessageId) {
2086
2118
  function isB2AbandonmentConfirmed(params) {
2087
2119
  return params.pinnedForMs >= params.minPinnedMs && params.descendantOngoing === false;
2088
2120
  }
2121
+ function isAmbiguousTerminalFinish(m) {
2122
+ if (completedOf(m) == null) return false;
2123
+ if (errorOf(m) != null) return false;
2124
+ const finish = finishOf(m);
2125
+ return finish !== "tool-calls" && finish !== "stop";
2126
+ }
2127
+ function isAmbiguousFinishPinnedRunning(messages, userMessageId) {
2128
+ const reply = findLastAssistantReplyFor(messages, userMessageId);
2129
+ return isAmbiguousTerminalFinish(reply);
2130
+ }
2131
+ function isAmbiguousFinishResolved(params) {
2132
+ return params.sessionOngoing === false || params.pinnedForMs >= params.maxPinnedMs;
2133
+ }
2089
2134
  function messageError(messages, userMessageId) {
2090
2135
  const reply = findLastAssistantReplyFor(messages, userMessageId);
2091
2136
  const error2 = errorOf(reply);
@@ -2292,6 +2337,149 @@ function resolveSessionCleanupConfig(flags, env = process.env) {
2292
2337
  return { enabled, maxAgeMs, maxCount, intervalMs, warnings };
2293
2338
  }
2294
2339
 
2340
+ // src/lib/opencode/session-db-size.ts
2341
+ import { statSync as statSync2 } from "fs";
2342
+ import { join as join2 } from "path";
2343
+ var LARGE_DB_THRESHOLD_BYTES = 268435456;
2344
+ function statSessionDbBytes(homeDir) {
2345
+ const dbPath = join2(homeDir, ".local", "share", "opencode", "opencode.db");
2346
+ try {
2347
+ return statSync2(dbPath).size;
2348
+ } catch (err) {
2349
+ const isMissingFile = err instanceof Error && "code" in err && err.code === "ENOENT";
2350
+ if (!isMissingFile) {
2351
+ console.error(
2352
+ `[statSessionDbBytes] could not stat ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
2353
+ );
2354
+ }
2355
+ return null;
2356
+ }
2357
+ }
2358
+ function buildSessionStoreSizeWarning(input) {
2359
+ const { dbBytes, cleanupEnabled, reclaimSkipReason } = input;
2360
+ if (dbBytes === null || dbBytes <= LARGE_DB_THRESHOLD_BYTES) return null;
2361
+ const mib = Math.round(dbBytes / 1024 / 1024);
2362
+ if (!cleanupEnabled) {
2363
+ 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.`;
2364
+ }
2365
+ if (reclaimSkipReason === "sqlite-unavailable" || reclaimSkipReason === "insufficient-disk-space") {
2366
+ 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";
2367
+ 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.`;
2368
+ }
2369
+ return null;
2370
+ }
2371
+
2372
+ // src/lib/opencode/session-db-reclaim.ts
2373
+ import { statSync as statSync3, statfsSync } from "fs";
2374
+ import { dirname as dirname2 } from "path";
2375
+ function insufficientSpaceReason(dbPath, requiredBytes) {
2376
+ try {
2377
+ const fsStats = statfsSync(dirname2(dbPath));
2378
+ const availableBytes = fsStats.bavail * fsStats.bsize;
2379
+ if (availableBytes < requiredBytes) {
2380
+ return `only ${availableBytes} bytes free, need ${requiredBytes} for a second copy`;
2381
+ }
2382
+ return null;
2383
+ } catch (err) {
2384
+ return `could not check free space (${err instanceof Error ? err.message : String(err)}); refusing to guess`;
2385
+ }
2386
+ }
2387
+ function readLogicalBytes(db) {
2388
+ const pageCount = db.prepare("PRAGMA page_count").get().page_count;
2389
+ const pageSize = db.prepare("PRAGMA page_size").get().page_size;
2390
+ return pageCount * pageSize;
2391
+ }
2392
+ function readCheckpointResult(db) {
2393
+ const row = db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get();
2394
+ return { busy: row.busy !== 0, log: row.log, checkpointed: row.checkpointed };
2395
+ }
2396
+ async function probeReclaimAvailability(input) {
2397
+ const { dbPath, requiredBytes } = input;
2398
+ let sqlite;
2399
+ try {
2400
+ sqlite = await import("sqlite");
2401
+ } catch (err) {
2402
+ console.warn(
2403
+ `[probeReclaimAvailability] node:sqlite unavailable for ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
2404
+ );
2405
+ return "sqlite-unavailable";
2406
+ }
2407
+ let autoVacuum = null;
2408
+ try {
2409
+ const db = new sqlite.DatabaseSync(dbPath, { readOnly: true });
2410
+ try {
2411
+ autoVacuum = db.prepare("PRAGMA auto_vacuum").get().auto_vacuum;
2412
+ } finally {
2413
+ db.close();
2414
+ }
2415
+ } catch (err) {
2416
+ console.warn(
2417
+ `[probeReclaimAvailability] could not read auto_vacuum mode for ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
2418
+ );
2419
+ }
2420
+ if (autoVacuum !== 0) return null;
2421
+ return insufficientSpaceReason(dbPath, requiredBytes) !== null ? "insufficient-disk-space" : null;
2422
+ }
2423
+ async function reclaimSessionDbSpace(input) {
2424
+ const { dbPath, maxPages, allowFullVacuum = true } = input;
2425
+ let sqlite;
2426
+ try {
2427
+ sqlite = await import("sqlite");
2428
+ } catch (err) {
2429
+ console.warn(
2430
+ `[reclaimSessionDbSpace] node:sqlite unavailable (need Node >=22.5, >=22.13 unflagged) for ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
2431
+ );
2432
+ return { ok: false, skipped: "sqlite-unavailable" };
2433
+ }
2434
+ const { DatabaseSync } = sqlite;
2435
+ let db;
2436
+ try {
2437
+ db = new DatabaseSync(dbPath);
2438
+ const autoVacuum = db.prepare("PRAGMA auto_vacuum").get().auto_vacuum;
2439
+ if (autoVacuum === 0) {
2440
+ if (!allowFullVacuum) {
2441
+ console.warn(
2442
+ `[reclaimSessionDbSpace] skipping VACUUM conversion of ${dbPath}: a session turn is live`
2443
+ );
2444
+ return { ok: false, skipped: "full-vacuum-blocked" };
2445
+ }
2446
+ const fileBytesForGuard = statSync3(dbPath).size;
2447
+ const skipReason = insufficientSpaceReason(dbPath, fileBytesForGuard);
2448
+ if (skipReason !== null) {
2449
+ console.warn(
2450
+ `[reclaimSessionDbSpace] skipping VACUUM conversion of ${dbPath}: ${skipReason}`
2451
+ );
2452
+ return { ok: false, skipped: "insufficient-disk-space" };
2453
+ }
2454
+ const beforeBytes = readLogicalBytes(db);
2455
+ db.exec("PRAGMA auto_vacuum=INCREMENTAL");
2456
+ db.exec("VACUUM");
2457
+ const afterBytes = readLogicalBytes(db);
2458
+ const checkpoint = readCheckpointResult(db);
2459
+ return { ok: true, mode: "convert", beforeBytes, afterBytes, checkpoint };
2460
+ }
2461
+ if (autoVacuum === 2) {
2462
+ const beforeBytes = readLogicalBytes(db);
2463
+ const bound = Math.max(0, Math.trunc(maxPages));
2464
+ db.exec(`PRAGMA incremental_vacuum(${bound})`);
2465
+ const afterBytes = readLogicalBytes(db);
2466
+ const checkpoint = readCheckpointResult(db);
2467
+ return { ok: true, mode: "incremental", beforeBytes, afterBytes, checkpoint };
2468
+ }
2469
+ console.warn(
2470
+ `[reclaimSessionDbSpace] ${dbPath} has auto_vacuum=${autoVacuum} (neither NONE nor INCREMENTAL); nothing to reclaim`
2471
+ );
2472
+ return { ok: false, skipped: "auto-vacuum-not-applicable" };
2473
+ } catch (err) {
2474
+ console.error(
2475
+ `[reclaimSessionDbSpace] reclaim failed for ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
2476
+ );
2477
+ return { ok: false, skipped: "reclaim-error" };
2478
+ } finally {
2479
+ db?.close();
2480
+ }
2481
+ }
2482
+
2295
2483
  // src/lib/tunnel/connection.ts
2296
2484
  import WebSocket2 from "ws";
2297
2485
 
@@ -2727,7 +2915,7 @@ import { homedir as homedir2 } from "os";
2727
2915
  // src/lib/file-push.ts
2728
2916
  import { randomUUID } from "crypto";
2729
2917
  import { chmod, mkdir, open as open2, realpath, rename, unlink } from "fs/promises";
2730
- import { basename, dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2, sep } from "path";
2918
+ import { basename, dirname as dirname3, isAbsolute, join as join3, relative, resolve as resolve2, sep } from "path";
2731
2919
  var FILE_MODE = 384;
2732
2920
  var DIRECTORY_MODE = 448;
2733
2921
  async function writePushedFile(request) {
@@ -2758,9 +2946,9 @@ async function writePushedFile(request) {
2758
2946
  }
2759
2947
  try {
2760
2948
  const { existingAncestor, missingSegments } = await resolveNearestExistingAncestor(
2761
- dirname2(candidate)
2949
+ dirname3(candidate)
2762
2950
  );
2763
- const realTarget = join2(existingAncestor, ...missingSegments, basename(candidate));
2951
+ const realTarget = join3(existingAncestor, ...missingSegments, basename(candidate));
2764
2952
  const allowedDirectory = await findContainingAllowedDirectory(allowedDirectories, realTarget);
2765
2953
  if (allowedDirectory === null) {
2766
2954
  return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
@@ -2770,8 +2958,8 @@ async function writePushedFile(request) {
2770
2958
  }
2771
2959
  if (missingSegments.length > 0) {
2772
2960
  await createMissingDirectories(existingAncestor, missingSegments);
2773
- const realParent = await realpath(dirname2(realTarget));
2774
- if (realParent !== dirname2(realTarget) || !contains(allowedDirectory, realTarget)) {
2961
+ const realParent = await realpath(dirname3(realTarget));
2962
+ if (realParent !== dirname3(realTarget) || !contains(allowedDirectory, realTarget)) {
2775
2963
  return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
2776
2964
  path: realTarget,
2777
2965
  bytes,
@@ -2796,7 +2984,7 @@ function expandAndValidate(requestedPath, homeDir) {
2796
2984
  if (requestedPath.trim() === "" || requestedPath.includes("\0")) {
2797
2985
  return null;
2798
2986
  }
2799
- const expanded = requestedPath === "~" ? homeDir : requestedPath.startsWith("~/") ? join2(homeDir, requestedPath.slice(2)) : requestedPath;
2987
+ const expanded = requestedPath === "~" ? homeDir : requestedPath.startsWith("~/") ? join3(homeDir, requestedPath.slice(2)) : requestedPath;
2800
2988
  if (expanded.split(/[/\\]/).includes("..")) {
2801
2989
  return null;
2802
2990
  }
@@ -2814,7 +3002,7 @@ async function resolveNearestExistingAncestor(directory) {
2814
3002
  try {
2815
3003
  return { existingAncestor: await realpath(current), missingSegments };
2816
3004
  } catch (err) {
2817
- const parent = dirname2(current);
3005
+ const parent = dirname3(current);
2818
3006
  if (err.code !== "ENOENT" || parent === current) {
2819
3007
  throw err;
2820
3008
  }
@@ -2869,13 +3057,13 @@ function contains(realDirectory, realTarget) {
2869
3057
  async function createMissingDirectories(existingAncestor, missingSegments) {
2870
3058
  let current = existingAncestor;
2871
3059
  for (const segment of missingSegments) {
2872
- current = join2(current, segment);
3060
+ current = join3(current, segment);
2873
3061
  await mkdir(current, { recursive: true, mode: DIRECTORY_MODE });
2874
3062
  await chmod(current, DIRECTORY_MODE);
2875
3063
  }
2876
3064
  }
2877
3065
  async function writeAtomically(realTarget, content) {
2878
- const temporaryPath = join2(dirname2(realTarget), `.evident-push-${randomUUID()}.tmp`);
3066
+ const temporaryPath = join3(dirname3(realTarget), `.evident-push-${randomUUID()}.tmp`);
2879
3067
  let handle;
2880
3068
  try {
2881
3069
  handle = await open2(temporaryPath, "wx", FILE_MODE);
@@ -3148,6 +3336,7 @@ var DEFAULT_STUCK_QUEUED_MS = 6e4;
3148
3336
  var HEARTBEAT_MS = 6e4;
3149
3337
  var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
3150
3338
  var B2_ABANDONMENT_MIN_PINNED_MS = 3 * 6e4;
3339
+ var AMBIGUOUS_FINISH_MAX_PINNED_MS = 3 * 6e4;
3151
3340
  var B2_ABANDONMENT_RECHECK_MS = HEARTBEAT_MS;
3152
3341
  var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
3153
3342
  var MAX_SUPERSEDED_CONVERSATIONS = 256;
@@ -3982,6 +4171,9 @@ var ChannelDriver = class _ChannelDriver {
3982
4171
  return this.reattachRedrive(conv, sessionId, message, ocId);
3983
4172
  }
3984
4173
  if (ongoing === false) {
4174
+ if (state === "running" && isAmbiguousFinishPinnedRunning(messages, ocId ?? "")) {
4175
+ return this.settleRedrive(conv, sessionId, message, ocId, messages, "done");
4176
+ }
3985
4177
  this.clearRedriveUnresolved(message.id);
3986
4178
  void this.postSignal(conv.id, message.id, "redrive_redispatched");
3987
4179
  return "dispatch";
@@ -4593,7 +4785,9 @@ var ChannelDriver = class _ChannelDriver {
4593
4785
  deliveryDeadlineAnchored: false,
4594
4786
  b2PinnedSinceMs: 0,
4595
4787
  b2LastDescendantCheckMs: 0,
4596
- b2AbandonedSignalled: false
4788
+ b2AbandonedSignalled: false,
4789
+ ambiguousPinnedSinceMs: 0,
4790
+ ambiguousResolved: false
4597
4791
  });
4598
4792
  }
4599
4793
  /**
@@ -4672,7 +4866,9 @@ var ChannelDriver = class _ChannelDriver {
4672
4866
  deliveryDeadlineAnchored: false,
4673
4867
  b2PinnedSinceMs: 0,
4674
4868
  b2LastDescendantCheckMs: 0,
4675
- b2AbandonedSignalled: false
4869
+ b2AbandonedSignalled: false,
4870
+ ambiguousPinnedSinceMs: 0,
4871
+ ambiguousResolved: false
4676
4872
  });
4677
4873
  }
4678
4874
  /**
@@ -4940,6 +5136,49 @@ var ChannelDriver = class _ChannelDriver {
4940
5136
  }
4941
5137
  }
4942
5138
  }
5139
+ const ambiguousPinnedNow = activelyRunning && isAmbiguousFinishPinnedRunning(messages, inFlight.opencodeMessageId);
5140
+ if (!ambiguousPinnedNow) {
5141
+ if (snapshotReadable) {
5142
+ inFlight.ambiguousPinnedSinceMs = 0;
5143
+ inFlight.ambiguousResolved = false;
5144
+ }
5145
+ } else {
5146
+ if (inFlight.ambiguousResolved) {
5147
+ await this.settleMessageDone(sessionId, watcher, inFlight, messages);
5148
+ return;
5149
+ }
5150
+ if (inFlight.ambiguousPinnedSinceMs === 0) {
5151
+ inFlight.ambiguousPinnedSinceMs = this.now();
5152
+ const reply = findLastAssistantReplyFor(messages, inFlight.opencodeMessageId);
5153
+ const finish = reply?.info?.finish ?? reply?.finish;
5154
+ this.log({
5155
+ level: "warn",
5156
+ 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)`,
5157
+ conversation_id: conv.id,
5158
+ message_id: id
5159
+ });
5160
+ }
5161
+ const pinnedForMs = this.now() - inFlight.ambiguousPinnedSinceMs;
5162
+ const ongoing = await isSessionOngoing(this.port, sessionId);
5163
+ if (isAmbiguousFinishResolved({
5164
+ pinnedForMs,
5165
+ maxPinnedMs: AMBIGUOUS_FINISH_MAX_PINNED_MS,
5166
+ sessionOngoing: ongoing
5167
+ })) {
5168
+ inFlight.ambiguousResolved = true;
5169
+ this.log({
5170
+ level: "warn",
5171
+ 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`,
5172
+ conversation_id: conv.id,
5173
+ message_id: id
5174
+ });
5175
+ void this.postSignal(conv.id, id, "ambiguous_finish_resolved", {
5176
+ watched_for_ms: pinnedForMs
5177
+ });
5178
+ await this.settleMessageDone(sessionId, watcher, inFlight, messages);
5179
+ return;
5180
+ }
5181
+ }
4943
5182
  if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
4944
5183
  this.log({
4945
5184
  level: "warn",
@@ -5194,48 +5433,7 @@ var ChannelDriver = class _ChannelDriver {
5194
5433
  const ocId = row.opencode_message_id;
5195
5434
  const state = messageRunState(messages, ocId ?? "");
5196
5435
  if (state === "done") {
5197
- if (this.doneUndeliverable.has(row.id)) {
5198
- this.log({
5199
- level: "debug",
5200
- message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
5201
- conversation_id: row.conversation_id,
5202
- message_id: row.id
5203
- });
5204
- return;
5205
- }
5206
- this.log({
5207
- level: "info",
5208
- message: `Re-adopt: message ${row.id.slice(0, 8)} completed while unwatched \u2014 marking done`,
5209
- conversation_id: row.conversation_id,
5210
- message_id: row.id
5211
- });
5212
- try {
5213
- const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
5214
- const usage = messageUsage(messages, ocId ?? "");
5215
- await this.markDone(row.conversation_id, row.id, sessionId, ocId, title, usage);
5216
- } catch (err) {
5217
- if (err instanceof ChannelAuthError) throw err;
5218
- if (err instanceof ChannelTerminalError) {
5219
- this.doneUndeliverable.add(row.id);
5220
- this.log({
5221
- level: "warn",
5222
- 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}`,
5223
- conversation_id: row.conversation_id,
5224
- message_id: row.id
5225
- });
5226
- void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
5227
- return;
5228
- }
5229
- this.log({
5230
- level: "warn",
5231
- message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
5232
- conversation_id: row.conversation_id,
5233
- message_id: row.id
5234
- });
5235
- return;
5236
- }
5237
- this.dontRedispatch.delete(row.id);
5238
- void this.postSignal(row.conversation_id, row.id, "readopt_done");
5436
+ await this.deliverReadoptedDone(sessionId, row, messages, ocId);
5239
5437
  return;
5240
5438
  }
5241
5439
  const restartAborted = state === "failed" && sessionOngoing === false && isAbortedTerminalReply(messages, ocId ?? "");
@@ -5300,6 +5498,17 @@ var ChannelDriver = class _ChannelDriver {
5300
5498
  const ongoing = sessionOngoing;
5301
5499
  statusReadableOngoing = ongoing;
5302
5500
  if (ongoing === false) {
5501
+ if (isAmbiguousFinishPinnedRunning(messages, ocId ?? "")) {
5502
+ const finish = reply?.info?.finish ?? reply?.finish;
5503
+ this.log({
5504
+ level: "info",
5505
+ 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`,
5506
+ conversation_id: row.conversation_id,
5507
+ message_id: row.id
5508
+ });
5509
+ await this.deliverReadoptedDone(sessionId, row, messages, ocId);
5510
+ return;
5511
+ }
5303
5512
  this.log({
5304
5513
  level: "info",
5305
5514
  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)`,
@@ -5376,6 +5585,63 @@ var ChannelDriver = class _ChannelDriver {
5376
5585
  }
5377
5586
  await this.forceReadoptRun(sessionId, row);
5378
5587
  }
5588
+ /**
5589
+ * Deliver a `processing` row whose correlated reply already completed while
5590
+ * nobody was watching (ADR-0046) — the `readoptOne` `state === 'done'` body,
5591
+ * extracted (#1493 Task 2.4) so the ambiguous-finish guard above can call the
5592
+ * SAME delivery instead of duplicating it.
5593
+ *
5594
+ * EVEN IF the row was previously parked in `dontRedispatch` (a give-up stops
5595
+ * re-dispatch, not delivery — Bugbot #202). Guarded EXACTLY like the watcher's
5596
+ * `settleMessageDone`: auth re-throws; terminal → park in `doneUndeliverable` +
5597
+ * leave for cron; transient → log + leave for the next drain (the still-
5598
+ * `processing` row is re-read and retried). markDone is idempotent server-side
5599
+ * (status-gated), so a repeat can never double-post.
5600
+ */
5601
+ async deliverReadoptedDone(sessionId, row, messages, ocId) {
5602
+ if (this.doneUndeliverable.has(row.id)) {
5603
+ this.log({
5604
+ level: "debug",
5605
+ message: `Re-adopt: message ${row.id.slice(0, 8)} markDone is terminally undeliverable \u2014 left to the cron; skipping until it leaves processing`,
5606
+ conversation_id: row.conversation_id,
5607
+ message_id: row.id
5608
+ });
5609
+ return;
5610
+ }
5611
+ this.log({
5612
+ level: "info",
5613
+ message: `Re-adopt: message ${row.id.slice(0, 8)} completed while unwatched \u2014 marking done`,
5614
+ conversation_id: row.conversation_id,
5615
+ message_id: row.id
5616
+ });
5617
+ try {
5618
+ const title = await this.resolveSessionTitle(sessionId, row.conversation_id);
5619
+ const usage = messageUsage(messages, ocId ?? "");
5620
+ await this.markDone(row.conversation_id, row.id, sessionId, ocId, title, usage);
5621
+ } catch (err) {
5622
+ if (err instanceof ChannelAuthError) throw err;
5623
+ if (err instanceof ChannelTerminalError) {
5624
+ this.doneUndeliverable.add(row.id);
5625
+ this.log({
5626
+ level: "warn",
5627
+ 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}`,
5628
+ conversation_id: row.conversation_id,
5629
+ message_id: row.id
5630
+ });
5631
+ void this.postSignal(row.conversation_id, row.id, "readopt_undeliverable");
5632
+ return;
5633
+ }
5634
+ this.log({
5635
+ level: "warn",
5636
+ message: `Re-adopt: failed to mark message ${row.id.slice(0, 8)} done (will retry next drain): ${err instanceof Error ? err.message : String(err)}`,
5637
+ conversation_id: row.conversation_id,
5638
+ message_id: row.id
5639
+ });
5640
+ return;
5641
+ }
5642
+ this.dontRedispatch.delete(row.id);
5643
+ void this.postSignal(row.conversation_id, row.id, "readopt_done");
5644
+ }
5379
5645
  /**
5380
5646
  * Re-dispatch an orphaned (`unknown`/null-id) `processing` row (ADR-0046 §2).
5381
5647
  *
@@ -6005,17 +6271,25 @@ var ChannelDriver = class _ChannelDriver {
6005
6271
  * the aborted-in-flight production bug after a restart.
6006
6272
  * - `b2` — a COMPLETED reply pinned running only by `finish === "tool-calls"`
6007
6273
  * (the sub-agent preamble — #253's shape).
6008
- * - `other` — any other shape (defensive; a running row is normally b1 or b2).
6009
- * Reads `info.time.completed` / `info.finish` (tolerating the legacy top-level
6010
- * shape) directly rather than re-importing the module-private `completedOf`/
6011
- * `finishOf` — this is a display label only, not a correctness predicate.
6274
+ * - `ambiguous` — a COMPLETED, non-errored reply whose `finish` is neither
6275
+ * "tool-calls" nor "stop" (issue #1493, class 4 see
6276
+ * `isAmbiguousFinishPinnedRunning`/Task 2.4).
6277
+ * - `other` — any other shape (defensive; a running row is normally b1, b2 or
6278
+ * ambiguous).
6279
+ * Reads `info.time.completed` / `info.finish` / `info.error` (tolerating the
6280
+ * legacy top-level shape) directly rather than re-importing the module-private
6281
+ * `completedOf`/`finishOf`/`errorOf` — this is a display label only, not a
6282
+ * correctness predicate (that is `isAmbiguousFinishPinnedRunning`'s job).
6012
6283
  */
6013
6284
  replyCompletionShape(reply) {
6014
6285
  if (!reply) return "other";
6015
6286
  const completed = reply.info?.time?.completed ?? reply.time?.completed;
6016
6287
  if (completed == null) return "b1";
6017
6288
  const finish = reply.info?.finish ?? reply.finish;
6018
- return finish === "tool-calls" ? "b2" : "other";
6289
+ if (finish === "tool-calls") return "b2";
6290
+ const error2 = reply.info?.error ?? reply.error;
6291
+ if (finish !== "stop" && error2 == null) return "ambiguous";
6292
+ return "other";
6019
6293
  }
6020
6294
  /**
6021
6295
  * Attribute a surfaced interaction to the in-flight message it paused on (M-1).
@@ -6632,7 +6906,7 @@ function resolveFileSyncDirectories(raw, homeDir) {
6632
6906
  if (trimmed === "") {
6633
6907
  throw new Error("--enable-file-sync-to requires a directory path (got an empty value)");
6634
6908
  }
6635
- const expanded = trimmed === "~" ? homeDir : trimmed.startsWith("~/") ? join3(homeDir, trimmed.slice(2)) : trimmed;
6909
+ const expanded = trimmed === "~" ? homeDir : trimmed.startsWith("~/") ? join4(homeDir, trimmed.slice(2)) : trimmed;
6636
6910
  if (!isAbsolute2(expanded)) {
6637
6911
  throw new Error(`--enable-file-sync-to requires an absolute directory path; got "${entry}"`);
6638
6912
  }
@@ -6826,7 +7100,9 @@ async function handleAuthError(state, error2) {
6826
7100
  );
6827
7101
  const newAuthHeader = getAuthHeader(credentials2);
6828
7102
  return { success: true, newAuthHeader };
6829
- } catch {
7103
+ } catch (error3) {
7104
+ const message = error3 instanceof Error ? error3.message : String(error3);
7105
+ logActivity(state, { type: "error", error: `Re-authentication failed: ${message}` });
6830
7106
  return { success: false };
6831
7107
  }
6832
7108
  }
@@ -6931,6 +7207,10 @@ async function driveChannels(state, driver) {
6931
7207
  }
6932
7208
  }
6933
7209
  var SESSION_CLEANUP_FIRST_SWEEP_MS = 1e4;
7210
+ var SESSION_DB_RECLAIM_MAX_PAGES = 2e3;
7211
+ function sessionDbPath() {
7212
+ return join4(homedir3(), ".local", "share", "opencode", "opencode.db");
7213
+ }
6934
7214
  async function runSweep(state, driver, config) {
6935
7215
  const mode = `age=${config.maxAgeMs ?? "\u2014"} count=${config.maxCount ?? "\u2014"}`;
6936
7216
  try {
@@ -6973,6 +7253,25 @@ async function runSweep(state, driver, config) {
6973
7253
  type: "info",
6974
7254
  message: `Session cleanup: inspected ${sessions.length}, deleted ${deleted}${failedNote}${skippedNote} (${mode})`
6975
7255
  });
7256
+ const reclaimResult = await reclaimSessionDbSpace({
7257
+ dbPath: sessionDbPath(),
7258
+ maxPages: SESSION_DB_RECLAIM_MAX_PAGES,
7259
+ allowFullVacuum: protectedNow.size === 0
7260
+ });
7261
+ if (reclaimResult.ok) {
7262
+ const beforeMib = (reclaimResult.beforeBytes / 1024 / 1024).toFixed(1);
7263
+ const afterMib = (reclaimResult.afterBytes / 1024 / 1024).toFixed(1);
7264
+ const checkpointNote = reclaimResult.checkpoint.busy ? ` (on-disk file truncation deferred: checkpoint busy, ${reclaimResult.checkpoint.log} WAL frames pending)` : "";
7265
+ logActivity(state, {
7266
+ type: "info",
7267
+ message: `Session cleanup: reclaimed session-db space (${reclaimResult.mode}): ${beforeMib} MiB -> ${afterMib} MiB${checkpointNote}`
7268
+ });
7269
+ } else {
7270
+ logActivity(state, {
7271
+ type: "info",
7272
+ message: `Session cleanup: session-db space reclaim skipped (${reclaimResult.skipped})`
7273
+ });
7274
+ }
6976
7275
  } catch (error2) {
6977
7276
  const message = error2 instanceof Error ? error2.message : String(error2);
6978
7277
  logActivity(state, {
@@ -6993,6 +7292,22 @@ function scheduleSessionCleanup(state, driver, options) {
6993
7292
  for (const warning2 of config.warnings) {
6994
7293
  logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
6995
7294
  }
7295
+ const dbBytes = statSessionDbBytes(homedir3());
7296
+ void (async () => {
7297
+ const reclaimSkipReason = dbBytes !== null && config.enabled ? await probeReclaimAvailability({ dbPath: sessionDbPath(), requiredBytes: dbBytes }) : null;
7298
+ const sizeWarning = buildSessionStoreSizeWarning({
7299
+ dbBytes,
7300
+ cleanupEnabled: config.enabled,
7301
+ reclaimSkipReason
7302
+ });
7303
+ if (sizeWarning !== null) {
7304
+ logActivity(state, { type: "info", level: "warn", message: sizeWarning });
7305
+ }
7306
+ })().catch((err) => {
7307
+ console.error(
7308
+ `[scheduleSessionCleanup] size-warning preflight failed: ${err instanceof Error ? err.message : String(err)}`
7309
+ );
7310
+ });
6996
7311
  if (!config.enabled) return;
6997
7312
  logActivity(state, {
6998
7313
  type: "info",