@evident-ai/cli 3.1.1-dev.7ab010b → 3.1.1-dev.7b93f9f

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
@@ -11,8 +11,8 @@ import chalk2 from "chalk";
11
11
 
12
12
  // src/lib/config.ts
13
13
  import Conf from "conf";
14
- import { homedir } from "os";
15
- import { join } from "path";
14
+ import { chmodSync, existsSync, statSync } from "fs";
15
+ import { dirname } from "path";
16
16
  var PRODUCTION_API_URL = "https://api.production.evident.run/v1";
17
17
  var PRODUCTION_TUNNEL_URL = "wss://tunnel.production.evident.run";
18
18
  var defaults = {
@@ -47,8 +47,35 @@ var credentials = new Conf({
47
47
  projectName: "evident",
48
48
  projectSuffix: "",
49
49
  configName: "credentials",
50
- defaults: {}
50
+ defaults: {},
51
+ configFileMode: 384
51
52
  });
53
+ var CREDENTIALS_FILE_MODE = 384;
54
+ var CREDENTIALS_DIR_MODE = 448;
55
+ var permissionWarningEmitted = false;
56
+ function hardenCredentialsPermissions() {
57
+ if (process.platform === "win32") {
58
+ return;
59
+ }
60
+ const file = credentials.path;
61
+ for (const [path, mode] of [
62
+ [file, CREDENTIALS_FILE_MODE],
63
+ [dirname(file), CREDENTIALS_DIR_MODE]
64
+ ]) {
65
+ try {
66
+ if (existsSync(path) && (statSync(path).mode & 511) !== mode) {
67
+ chmodSync(path, mode);
68
+ }
69
+ } catch (err) {
70
+ if (!permissionWarningEmitted) {
71
+ permissionWarningEmitted = true;
72
+ console.error(
73
+ `[config] could not restrict permissions on ${path}; the credentials file may be readable by other users on this machine: ${err instanceof Error ? err.message : String(err)}`
74
+ );
75
+ }
76
+ }
77
+ }
78
+ }
52
79
  function getApiUrlConfig() {
53
80
  return getApiUrl();
54
81
  }
@@ -59,6 +86,7 @@ function credentialsKey() {
59
86
  return getApiUrl();
60
87
  }
61
88
  function getCredentials() {
89
+ hardenCredentialsPermissions();
62
90
  const byEndpoint = credentials.get("byEndpoint") ?? {};
63
91
  return byEndpoint[credentialsKey()] ?? {};
64
92
  }
@@ -70,14 +98,17 @@ function setCredentials(creds) {
70
98
  expiresAt: creds.expiresAt
71
99
  };
72
100
  credentials.set("byEndpoint", byEndpoint);
101
+ hardenCredentialsPermissions();
73
102
  }
74
103
  function clearCredentials() {
75
104
  const byEndpoint = credentials.get("byEndpoint") ?? {};
76
105
  delete byEndpoint[credentialsKey()];
77
106
  credentials.set("byEndpoint", byEndpoint);
107
+ hardenCredentialsPermissions();
78
108
  }
79
109
  function clearAllCredentials() {
80
110
  credentials.clear();
111
+ hardenCredentialsPermissions();
81
112
  }
82
113
  function getCliName() {
83
114
  const argv1 = process.argv[1] || "";
@@ -373,7 +404,8 @@ async function deviceFlowLogin(options) {
373
404
  }
374
405
  async function tokenLogin() {
375
406
  console.log("Token login mode.");
376
- console.log("Visit your Evident dashboard to generate a CLI token.");
407
+ console.log("Run `evident login` on a machine with a browser to get a token.");
408
+ console.log("Manage or revoke existing tokens under Settings \u2192 CLI tokens.");
377
409
  blank();
378
410
  process.stdout.write("Paste token: ");
379
411
  const token = await new Promise((resolve3) => {
@@ -467,8 +499,8 @@ async function whoami() {
467
499
  }
468
500
 
469
501
  // src/commands/run.ts
470
- import { homedir as homedir3 } from "os";
471
- import { isAbsolute as isAbsolute2, join as join3, parse, resolve as resolvePath } from "path";
502
+ import { homedir as homedir2 } from "os";
503
+ import { isAbsolute as isAbsolute2, join as join2, parse, resolve as resolvePath } from "path";
472
504
  import chalk6 from "chalk";
473
505
 
474
506
  // ../../packages/types/src/telemetry/index.ts
@@ -1500,6 +1532,9 @@ function isPreamblePinnedRunning(messages, userMessageId) {
1500
1532
  const reply = findLastAssistantReplyFor(messages, userMessageId);
1501
1533
  return completedOf(reply) != null && finishOf(reply) === "tool-calls";
1502
1534
  }
1535
+ function isB2AbandonmentConfirmed(params) {
1536
+ return params.pinnedForMs >= params.minPinnedMs && params.descendantOngoing === false;
1537
+ }
1503
1538
  function messageError(messages, userMessageId) {
1504
1539
  const reply = findLastAssistantReplyFor(messages, userMessageId);
1505
1540
  const error2 = errorOf(reply);
@@ -2042,12 +2077,12 @@ var RunnerConnection = class {
2042
2077
  };
2043
2078
 
2044
2079
  // src/lib/channels/driver.ts
2045
- import { homedir as homedir2 } from "os";
2080
+ import { homedir } from "os";
2046
2081
 
2047
2082
  // src/lib/file-push.ts
2048
2083
  import { randomUUID } from "crypto";
2049
2084
  import { chmod, mkdir, open as open2, realpath, rename, unlink } from "fs/promises";
2050
- import { basename, dirname, isAbsolute, join as join2, relative, resolve as resolve2, sep } from "path";
2085
+ import { basename, dirname as dirname2, isAbsolute, join, relative, resolve as resolve2, sep } from "path";
2051
2086
  var FILE_MODE = 384;
2052
2087
  var DIRECTORY_MODE = 448;
2053
2088
  async function writePushedFile(request) {
@@ -2078,9 +2113,9 @@ async function writePushedFile(request) {
2078
2113
  }
2079
2114
  try {
2080
2115
  const { existingAncestor, missingSegments } = await resolveNearestExistingAncestor(
2081
- dirname(candidate)
2116
+ dirname2(candidate)
2082
2117
  );
2083
- const realTarget = join2(existingAncestor, ...missingSegments, basename(candidate));
2118
+ const realTarget = join(existingAncestor, ...missingSegments, basename(candidate));
2084
2119
  const allowedDirectory = await findContainingAllowedDirectory(allowedDirectories, realTarget);
2085
2120
  if (allowedDirectory === null) {
2086
2121
  return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
@@ -2090,8 +2125,8 @@ async function writePushedFile(request) {
2090
2125
  }
2091
2126
  if (missingSegments.length > 0) {
2092
2127
  await createMissingDirectories(existingAncestor, missingSegments);
2093
- const realParent = await realpath(dirname(realTarget));
2094
- if (realParent !== dirname(realTarget) || !contains(allowedDirectory, realTarget)) {
2128
+ const realParent = await realpath(dirname2(realTarget));
2129
+ if (realParent !== dirname2(realTarget) || !contains(allowedDirectory, realTarget)) {
2095
2130
  return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
2096
2131
  path: realTarget,
2097
2132
  bytes,
@@ -2116,7 +2151,7 @@ function expandAndValidate(requestedPath, homeDir) {
2116
2151
  if (requestedPath.trim() === "" || requestedPath.includes("\0")) {
2117
2152
  return null;
2118
2153
  }
2119
- const expanded = requestedPath === "~" ? homeDir : requestedPath.startsWith("~/") ? join2(homeDir, requestedPath.slice(2)) : requestedPath;
2154
+ const expanded = requestedPath === "~" ? homeDir : requestedPath.startsWith("~/") ? join(homeDir, requestedPath.slice(2)) : requestedPath;
2120
2155
  if (expanded.split(/[/\\]/).includes("..")) {
2121
2156
  return null;
2122
2157
  }
@@ -2134,7 +2169,7 @@ async function resolveNearestExistingAncestor(directory) {
2134
2169
  try {
2135
2170
  return { existingAncestor: await realpath(current), missingSegments };
2136
2171
  } catch (err) {
2137
- const parent = dirname(current);
2172
+ const parent = dirname2(current);
2138
2173
  if (err.code !== "ENOENT" || parent === current) {
2139
2174
  throw err;
2140
2175
  }
@@ -2189,13 +2224,13 @@ function contains(realDirectory, realTarget) {
2189
2224
  async function createMissingDirectories(existingAncestor, missingSegments) {
2190
2225
  let current = existingAncestor;
2191
2226
  for (const segment of missingSegments) {
2192
- current = join2(current, segment);
2227
+ current = join(current, segment);
2193
2228
  await mkdir(current, { recursive: true, mode: DIRECTORY_MODE });
2194
2229
  await chmod(current, DIRECTORY_MODE);
2195
2230
  }
2196
2231
  }
2197
2232
  async function writeAtomically(realTarget, content) {
2198
- const temporaryPath = join2(dirname(realTarget), `.evident-push-${randomUUID()}.tmp`);
2233
+ const temporaryPath = join(dirname2(realTarget), `.evident-push-${randomUUID()}.tmp`);
2199
2234
  let handle;
2200
2235
  try {
2201
2236
  handle = await open2(temporaryPath, "wx", FILE_MODE);
@@ -2467,6 +2502,8 @@ var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
2467
2502
  var DEFAULT_STUCK_QUEUED_MS = 6e4;
2468
2503
  var HEARTBEAT_MS = 6e4;
2469
2504
  var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
2505
+ var B2_ABANDONMENT_MIN_PINNED_MS = 3 * 6e4;
2506
+ var B2_ABANDONMENT_RECHECK_MS = HEARTBEAT_MS;
2470
2507
  var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
2471
2508
  var MAX_SUPERSEDED_CONVERSATIONS = 256;
2472
2509
  var ChannelAuthError = class extends Error {
@@ -2708,7 +2745,7 @@ var ChannelDriver = class _ChannelDriver {
2708
2745
  this.stuckQueuedMs = config2.stuckQueuedMs ?? DEFAULT_STUCK_QUEUED_MS;
2709
2746
  this.now = config2.now ?? (() => Date.now());
2710
2747
  this.fileSyncDirectories = config2.fileSyncDirectories ?? [];
2711
- this.homeDir = config2.homeDir ?? homedir2();
2748
+ this.homeDir = config2.homeDir ?? homedir();
2712
2749
  }
2713
2750
  /** The IPv4-loopback base URL for the local `opencode serve`. */
2714
2751
  get opencodeBase() {
@@ -3285,12 +3322,17 @@ var ChannelDriver = class _ChannelDriver {
3285
3322
  stuckReported: false,
3286
3323
  lastAliveAt: 0,
3287
3324
  aliveInFlight: false,
3325
+ titleSynced: false,
3326
+ titleSyncInFlight: false,
3288
3327
  awaitingHumanLatched: false,
3289
3328
  pausedOnQuestion: false,
3290
3329
  pausedOnPermission: false,
3291
3330
  pausedClearConfirmed: false,
3292
3331
  pausedInFlight: false,
3293
- deliveryDeadlineAnchored: false
3332
+ deliveryDeadlineAnchored: false,
3333
+ b2PinnedSinceMs: 0,
3334
+ b2LastDescendantCheckMs: 0,
3335
+ b2AbandonedSignalled: false
3294
3336
  });
3295
3337
  }
3296
3338
  /**
@@ -3358,12 +3400,17 @@ var ChannelDriver = class _ChannelDriver {
3358
3400
  // with no extra `re_adopted` signal needed (folds old WI-6).
3359
3401
  lastAliveAt: 0,
3360
3402
  aliveInFlight: false,
3403
+ titleSynced: false,
3404
+ titleSyncInFlight: false,
3361
3405
  awaitingHumanLatched: false,
3362
3406
  pausedOnQuestion: false,
3363
3407
  pausedOnPermission: false,
3364
3408
  pausedClearConfirmed: false,
3365
3409
  pausedInFlight: false,
3366
- deliveryDeadlineAnchored: false
3410
+ deliveryDeadlineAnchored: false,
3411
+ b2PinnedSinceMs: 0,
3412
+ b2LastDescendantCheckMs: 0,
3413
+ b2AbandonedSignalled: false
3367
3414
  });
3368
3415
  }
3369
3416
  /**
@@ -3525,58 +3572,7 @@ var ChannelDriver = class _ChannelDriver {
3525
3572
  }
3526
3573
  }
3527
3574
  if (state === "done") {
3528
- this.anchorDeliveryDeadline(inFlight);
3529
- if (!inFlight.done) {
3530
- this.log({
3531
- level: "info",
3532
- message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
3533
- conversation_id: conv.id,
3534
- message_id: inFlight.evidentMessageId
3535
- });
3536
- const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
3537
- const usage = messageUsage(messages, inFlight.opencodeMessageId);
3538
- try {
3539
- await this.markDone(
3540
- conv.id,
3541
- inFlight.evidentMessageId,
3542
- sessionId,
3543
- inFlight.opencodeMessageId,
3544
- title,
3545
- usage
3546
- );
3547
- } catch (err) {
3548
- if (err instanceof ChannelAuthError) throw err;
3549
- if (err instanceof ChannelTerminalError) {
3550
- this.log({
3551
- level: "warn",
3552
- message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
3553
- conversation_id: conv.id,
3554
- message_id: inFlight.evidentMessageId
3555
- });
3556
- this.removeInFlight(watcher, inFlight.evidentMessageId);
3557
- return;
3558
- }
3559
- if (this.now() >= inFlight.deadline) {
3560
- this.log({
3561
- level: "warn",
3562
- message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done within the watch window \u2014 leaving for the cron safety net: ${err instanceof Error ? err.message : String(err)}`,
3563
- conversation_id: conv.id,
3564
- message_id: inFlight.evidentMessageId
3565
- });
3566
- this.removeInFlight(watcher, inFlight.evidentMessageId);
3567
- return;
3568
- }
3569
- this.log({
3570
- level: "warn",
3571
- message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
3572
- conversation_id: conv.id,
3573
- message_id: inFlight.evidentMessageId
3574
- });
3575
- return;
3576
- }
3577
- inFlight.done = true;
3578
- }
3579
- this.removeInFlight(watcher, inFlight.evidentMessageId);
3575
+ await this.settleMessageDone(sessionId, watcher, inFlight, messages);
3580
3576
  return;
3581
3577
  }
3582
3578
  if (state === "failed") {
@@ -3636,6 +3632,44 @@ var ChannelDriver = class _ChannelDriver {
3636
3632
  });
3637
3633
  }
3638
3634
  const activelyRunning = state === "running" && !awaitingHuman;
3635
+ const pinnedNow = activelyRunning && isPreamblePinnedRunning(messages, inFlight.opencodeMessageId);
3636
+ const snapshotReadable = messages != null && messages.length > 0;
3637
+ if (!pinnedNow) {
3638
+ if (snapshotReadable) {
3639
+ inFlight.b2PinnedSinceMs = 0;
3640
+ inFlight.b2LastDescendantCheckMs = 0;
3641
+ inFlight.b2AbandonedSignalled = false;
3642
+ }
3643
+ } else {
3644
+ if (inFlight.b2AbandonedSignalled) {
3645
+ await this.settleMessageDone(sessionId, watcher, inFlight, messages);
3646
+ return;
3647
+ }
3648
+ if (inFlight.b2PinnedSinceMs === 0) inFlight.b2PinnedSinceMs = this.now();
3649
+ const pinnedForMs = this.now() - inFlight.b2PinnedSinceMs;
3650
+ if (pinnedForMs >= B2_ABANDONMENT_MIN_PINNED_MS && this.now() - inFlight.b2LastDescendantCheckMs >= B2_ABANDONMENT_RECHECK_MS) {
3651
+ inFlight.b2LastDescendantCheckMs = this.now();
3652
+ const descendantOngoing = await this.isAnyDescendantSessionOngoing(sessionId);
3653
+ if (isB2AbandonmentConfirmed({
3654
+ pinnedForMs,
3655
+ minPinnedMs: B2_ABANDONMENT_MIN_PINNED_MS,
3656
+ descendantOngoing
3657
+ })) {
3658
+ inFlight.b2AbandonedSignalled = true;
3659
+ this.log({
3660
+ level: "warn",
3661
+ message: `Message ${id.slice(0, 8)} b2-pinned for ${Math.round(pinnedForMs / 1e3)}s with no ongoing descendant sub-agent session (status-map confirmed) \u2014 treating the delegated/tool turn as abandoned, resolving done`,
3662
+ conversation_id: conv.id,
3663
+ message_id: id
3664
+ });
3665
+ void this.postSignal(conv.id, id, "b2_abandoned_resolved", {
3666
+ watched_for_ms: pinnedForMs
3667
+ });
3668
+ await this.settleMessageDone(sessionId, watcher, inFlight, messages);
3669
+ return;
3670
+ }
3671
+ }
3672
+ }
3639
3673
  if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
3640
3674
  this.log({
3641
3675
  level: "warn",
@@ -3655,6 +3689,18 @@ var ChannelDriver = class _ChannelDriver {
3655
3689
  inFlight.aliveInFlight = false;
3656
3690
  if (ok) inFlight.lastAliveAt = this.now();
3657
3691
  });
3692
+ if (!inFlight.titleSynced && !inFlight.titleSyncInFlight) {
3693
+ inFlight.titleSyncInFlight = true;
3694
+ void this.resolveSessionTitle(sessionId, conv.id).then(async (title) => {
3695
+ if (!title) {
3696
+ inFlight.titleSyncInFlight = false;
3697
+ return;
3698
+ }
3699
+ const ok = await this.patchConversationTitle(conv.id, title);
3700
+ inFlight.titleSyncInFlight = false;
3701
+ if (ok) inFlight.titleSynced = true;
3702
+ });
3703
+ }
3658
3704
  }
3659
3705
  if (awaitingHuman) {
3660
3706
  if (!inFlight.awaitingHumanLatched) {
@@ -3692,6 +3738,70 @@ var ChannelDriver = class _ChannelDriver {
3692
3738
  this.removeInFlight(watcher, inFlight.evidentMessageId);
3693
3739
  }
3694
3740
  }
3741
+ /**
3742
+ * Settle a message whose run-state has resolved `'done'` — extracted verbatim
3743
+ * (pure refactor, no behavior change) from `serviceInFlightMessage`'s former
3744
+ * inline `state === 'done'` branch body, so a SECOND caller (the #721
3745
+ * b2-abandonment resolution) can reach the exact same completion behavior
3746
+ * (delivery-deadline anchoring, title resolution, usage extraction, and
3747
+ * `markDone`'s auth/terminal/transient-retry discipline) without duplicating it
3748
+ * and risking the two copies silently drifting apart.
3749
+ */
3750
+ async settleMessageDone(sessionId, watcher, inFlight, messages) {
3751
+ const conv = watcher.conv;
3752
+ this.anchorDeliveryDeadline(inFlight);
3753
+ if (!inFlight.done) {
3754
+ this.log({
3755
+ level: "info",
3756
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
3757
+ conversation_id: conv.id,
3758
+ message_id: inFlight.evidentMessageId
3759
+ });
3760
+ const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
3761
+ const usage = messageUsage(messages, inFlight.opencodeMessageId);
3762
+ try {
3763
+ await this.markDone(
3764
+ conv.id,
3765
+ inFlight.evidentMessageId,
3766
+ sessionId,
3767
+ inFlight.opencodeMessageId,
3768
+ title,
3769
+ usage
3770
+ );
3771
+ } catch (err) {
3772
+ if (err instanceof ChannelAuthError) throw err;
3773
+ if (err instanceof ChannelTerminalError) {
3774
+ this.log({
3775
+ level: "warn",
3776
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
3777
+ conversation_id: conv.id,
3778
+ message_id: inFlight.evidentMessageId
3779
+ });
3780
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
3781
+ return;
3782
+ }
3783
+ if (this.now() >= inFlight.deadline) {
3784
+ this.log({
3785
+ level: "warn",
3786
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done within the watch window \u2014 leaving for the cron safety net: ${err instanceof Error ? err.message : String(err)}`,
3787
+ conversation_id: conv.id,
3788
+ message_id: inFlight.evidentMessageId
3789
+ });
3790
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
3791
+ return;
3792
+ }
3793
+ this.log({
3794
+ level: "warn",
3795
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
3796
+ conversation_id: conv.id,
3797
+ message_id: inFlight.evidentMessageId
3798
+ });
3799
+ return;
3800
+ }
3801
+ inFlight.done = true;
3802
+ }
3803
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
3804
+ }
3695
3805
  // Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
3696
3806
  /**
3697
3807
  * Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
@@ -4271,6 +4381,47 @@ var ChannelDriver = class _ChannelDriver {
4271
4381
  }
4272
4382
  return false;
4273
4383
  }
4384
+ /**
4385
+ * Tri-state variant of the upward parentID membership walk (#721), used ONLY
4386
+ * by `isAnyDescendantSessionOngoing`. Walks the SAME cached
4387
+ * `resolveSessionParent` chain `sessionBelongsTo` uses above, but — unlike
4388
+ * `sessionBelongsTo`, which deliberately collapses "confirmed not a
4389
+ * descendant" and "the walk's fetch failed" into the same `false` (safe for
4390
+ * its OTHER callers: interaction attribution and the recovery-path
4391
+ * `isAnyDescendantSessionAlive`, both of which just retry next tick with no
4392
+ * safety consequence either way) — this variant keeps those two outcomes
4393
+ * SEPARATE, because `isAnyDescendantSessionOngoing`'s caller
4394
+ * (`isB2AbandonmentConfirmed`) must never treat "couldn't tell" as "confirmed
4395
+ * not ongoing".
4396
+ *
4397
+ * Return contract:
4398
+ * - `true` → the walk reached `rootSessionId` — `sessionId` IS a descendant.
4399
+ * - `false` → the walk reached a definitive, parent-less root session
4400
+ * WITHOUT ever matching `rootSessionId` — `sessionId` is
4401
+ * CONFIRMED NOT a descendant of it.
4402
+ * - `null` → INDETERMINATE: a `GET /session/:id` fetch failed partway
4403
+ * through the walk (`resolveSessionParent` returned `undefined`),
4404
+ * or the depth cap (32) was hit without a definitive answer (a
4405
+ * pathological/cyclic chain proves nothing either way). NEVER
4406
+ * treat this the same as `false` — see `sessionBelongsTo`'s own
4407
+ * doc comment above for why that collapse is safe THERE but not
4408
+ * here.
4409
+ *
4410
+ * `sessionBelongsTo` itself is UNCHANGED — this is an additive helper scoped
4411
+ * to the live-path descendant check, not a modification of shared code used
4412
+ * by interaction attribution or the recovery path.
4413
+ */
4414
+ async resolveSessionMembership(sessionId, rootSessionId) {
4415
+ let current = sessionId;
4416
+ for (let depth = 0; current && depth < 32; depth++) {
4417
+ if (current === rootSessionId) return true;
4418
+ const parent = await this.resolveSessionParent(current);
4419
+ if (parent === void 0) return null;
4420
+ if (parent === null) return false;
4421
+ current = parent;
4422
+ }
4423
+ return null;
4424
+ }
4274
4425
  /**
4275
4426
  * Resolve (and cache) a session's `parentID` via `GET /session/:id`. Returns
4276
4427
  * `null` for a root session (no parent) and `undefined` when opencode is
@@ -4355,6 +4506,54 @@ var ChannelDriver = class _ChannelDriver {
4355
4506
  }
4356
4507
  return null;
4357
4508
  }
4509
+ /**
4510
+ * Best-effort mid-turn title sync (#711 follow-up): PATCH a resolved OpenCode
4511
+ * session title onto the conversation via the PLAIN conversation-update
4512
+ * endpoint (`PATCH /runners/:agentId/conversations/:conversationId`) — NOT the
4513
+ * message-status endpoint `markProcessing`/`markDone` use. Deliberately a
4514
+ * separate, lighter call: it carries no `status`, so it cannot re-trigger the
4515
+ * `processing`/`done` transition side effects (Slack notices, activity-log
4516
+ * rows, delivery jobs) those PATCHes gate on `transitioned` — this call only
4517
+ * ever touches `conversations.title`. That route (`routes/conversations.ts`)
4518
+ * skips a title write matching the stored value, so a redundant call with the
4519
+ * same title is a real no-op — it does not bump `updated_at`, which the
4520
+ * conversation list sorts and paginates on. (Note this is a DIFFERENT guard
4521
+ * from `threads.ts`'s "non-empty AND changed" one, which only covers the
4522
+ * message-status PATCH; the non-empty half is enforced here instead, by
4523
+ * `resolveSessionTitle` never returning an empty/placeholder title.)
4524
+ *
4525
+ * Telemetry-only / never blocks the caller, mirroring `postSignal`: a failure
4526
+ * is logged and the title is simply retried on the next heartbeat tick (the
4527
+ * caller only latches `titleSynced` on `true`).
4528
+ */
4529
+ async patchConversationTitle(conversationId, title) {
4530
+ try {
4531
+ const res = await this.fetchImpl(
4532
+ `${this.apiUrl}/runners/${this.agentId}/conversations/${conversationId}`,
4533
+ {
4534
+ method: "PATCH",
4535
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
4536
+ body: JSON.stringify({ title })
4537
+ }
4538
+ );
4539
+ if (!res.ok) {
4540
+ this.log({
4541
+ level: "debug",
4542
+ message: `Mid-turn title sync PATCH for conversation ${conversationId.slice(0, 8)} returned HTTP ${res.status} (best-effort, will retry next heartbeat)`,
4543
+ conversation_id: conversationId
4544
+ });
4545
+ return false;
4546
+ }
4547
+ return true;
4548
+ } catch (err) {
4549
+ this.log({
4550
+ level: "debug",
4551
+ message: `Best-effort mid-turn title sync PATCH failed for conversation ${conversationId.slice(0, 8)} (will retry next heartbeat): ${err instanceof Error ? err.message : String(err)}`,
4552
+ conversation_id: conversationId
4553
+ });
4554
+ return false;
4555
+ }
4556
+ }
4358
4557
  /**
4359
4558
  * DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
4360
4559
  * (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
@@ -4413,6 +4612,84 @@ var ChannelDriver = class _ChannelDriver {
4413
4612
  }
4414
4613
  return false;
4415
4614
  }
4615
+ /**
4616
+ * LIVE-PATH descendant-liveness check (#721): is any descendant (`task`
4617
+ * sub-agent) session under `rootSessionId` currently ONGOING per OpenCode's own
4618
+ * in-memory status map (`isSessionOngoing` — `busy`/`retry`)?
4619
+ *
4620
+ * Deliberately NOT `isAnyDescendantSessionAlive` (the RECOVERY-path
4621
+ * cross-check above): that method judges liveness from the child's OWN
4622
+ * TRANSCRIPT (`isSessionActivelyGenerating`), which is the right (only) option
4623
+ * on the recovery path because a restart WIPES `SessionStatus`. On the LIVE
4624
+ * path the local opencode server IS running, so its in-memory status map is
4625
+ * live and authoritative — and per ADR-0047 §4a ("the child has its own entry
4626
+ * [in the map]"), a `task` descendant's OWN busy/retry entry reflects its
4627
+ * ENTIRE turn (including any tool call it is itself executing), not a
4628
+ * per-message transcript snapshot. This sidesteps the "child's own tool is
4629
+ * executing, between its step's completion and the next generation step"
4630
+ * transcript gap that a transcript-based check would need a second,
4631
+ * sustained-window bound to guard against — it is simply not derived from
4632
+ * message timestamps at all.
4633
+ *
4634
+ * Why not just check `isSessionOngoing(port, rootSessionId)` (the ROOT's own
4635
+ * status, as the recovery path does per §4a)? Because on the LIVE path the
4636
+ * root session can be shared: a SECOND, unrelated user message can land on the
4637
+ * SAME session (issue #721's own root cause) and keep the root `busy` for a
4638
+ * reason that has nothing to do with THIS message's delegation. A `task`
4639
+ * descendant session is spawned for exactly one delegated turn and never
4640
+ * reused, so its OWN status-map entry is unambiguous evidence about that one
4641
+ * delegation — which the root's status is not.
4642
+ *
4643
+ * Why membership is checked via `resolveSessionMembership`, NOT
4644
+ * `sessionBelongsTo`: `sessionBelongsTo` collapses a transient
4645
+ * `GET /session/:id` fetch failure into "not a descendant", which would
4646
+ * silently drop a genuinely-live candidate from consideration on the one
4647
+ * unlucky tick its membership-walk fetch hiccups (#721).
4648
+ * `resolveSessionMembership` keeps that failure mode as a distinct `null`
4649
+ * (indeterminate) so it is folded into THIS method's own `indeterminate` flag
4650
+ * instead.
4651
+ *
4652
+ * Return contract (note the DIFFERENT judge vs. `isAnyDescendantSessionAlive`):
4653
+ * - `true` → some descendant session is `busy`/`retry` (genuinely ongoing).
4654
+ * - `false` → enumeration succeeded, EVERY candidate's MEMBERSHIP was
4655
+ * confirmed either way (`resolveSessionMembership` never
4656
+ * returned `null`), and every CONFIRMED descendant's status read
4657
+ * succeeded and is not ongoing (includes "no descendant session
4658
+ * exists at all" — e.g. a plain, non-`task` tool call).
4659
+ * - `null` → INDETERMINATE: `listSessions` failed, OR at least one
4660
+ * candidate's MEMBERSHIP could not be confirmed
4661
+ * (`resolveSessionMembership` returned `null` — a fetch failure
4662
+ * or pathological chain partway through the parent walk), OR at
4663
+ * least one CONFIRMED descendant's `isSessionOngoing` read
4664
+ * failed — and no OTHER candidate was already confirmed `true`.
4665
+ * The caller MUST NOT treat `null` the same as `false` here
4666
+ * (unlike the recovery cross-check's contract) — see
4667
+ * `isB2AbandonmentConfirmed`.
4668
+ */
4669
+ async isAnyDescendantSessionOngoing(rootSessionId) {
4670
+ const sessions = await listSessions(this.port);
4671
+ if (!sessions) {
4672
+ this.log({
4673
+ level: "warn",
4674
+ message: `Could not enumerate sessions to check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating descendant liveness as indeterminate`
4675
+ });
4676
+ return null;
4677
+ }
4678
+ let indeterminate = false;
4679
+ for (const candidate of sessions) {
4680
+ if (!candidate?.id || candidate.id === rootSessionId) continue;
4681
+ const membership = await this.resolveSessionMembership(candidate.id, rootSessionId);
4682
+ if (membership === null) {
4683
+ indeterminate = true;
4684
+ continue;
4685
+ }
4686
+ if (membership === false) continue;
4687
+ const ongoing = await isSessionOngoing(this.port, candidate.id);
4688
+ if (ongoing === true) return true;
4689
+ if (ongoing === null) indeterminate = true;
4690
+ }
4691
+ return indeterminate ? null : false;
4692
+ }
4416
4693
  /**
4417
4694
  * Cheap decision-telemetry label for a running row's LAST correlated reply
4418
4695
  * (WI-2 Task 2.2): which running SHAPE it is, for the status-gated recovery log.
@@ -5010,12 +5287,14 @@ async function resolveAgentIdFromKey(authHeader) {
5010
5287
  return { error: `Failed to resolve runner from key: ${message}` };
5011
5288
  }
5012
5289
  }
5290
+ var BEST_EFFORT_NOTIFY_TIMEOUT_MS = 2e3;
5013
5291
  async function notifyAgentDisconnected(agentId, authHeader) {
5014
5292
  const apiUrl = getApiUrlConfig();
5015
5293
  try {
5016
5294
  const response = await fetch(`${apiUrl}/runners/${agentId}/disconnect`, {
5017
5295
  method: "POST",
5018
- headers: { Authorization: authHeader }
5296
+ headers: { Authorization: authHeader },
5297
+ signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
5019
5298
  });
5020
5299
  if (!response.ok) {
5021
5300
  const serverMessage = await readErrorMessage(response);
@@ -5026,7 +5305,35 @@ async function notifyAgentDisconnected(agentId, authHeader) {
5026
5305
  }
5027
5306
  return { ok: true };
5028
5307
  } catch (error2) {
5029
- return { ok: false, error: error2 instanceof Error ? error2.message : String(error2) };
5308
+ return { ok: false, error: describeBestEffortError(error2) };
5309
+ }
5310
+ }
5311
+ function describeBestEffortError(error2) {
5312
+ const name = error2?.name;
5313
+ if (name === "TimeoutError" || name === "AbortError") {
5314
+ return `timed out after ${BEST_EFFORT_NOTIFY_TIMEOUT_MS}ms`;
5315
+ }
5316
+ return error2 instanceof Error ? error2.message : String(error2);
5317
+ }
5318
+ async function reportMicrovmId(agentId, authHeader, microvmId) {
5319
+ try {
5320
+ const apiUrl = getApiUrlConfig();
5321
+ const response = await fetch(`${apiUrl}/runners/${agentId}/microvm`, {
5322
+ method: "POST",
5323
+ headers: { Authorization: authHeader, "Content-Type": "application/json" },
5324
+ body: JSON.stringify({ microvm_id: microvmId }),
5325
+ signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
5326
+ });
5327
+ if (!response.ok) {
5328
+ const serverMessage = await readErrorMessage(response);
5329
+ return {
5330
+ ok: false,
5331
+ error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
5332
+ };
5333
+ }
5334
+ return { ok: true };
5335
+ } catch (error2) {
5336
+ return { ok: false, error: describeBestEffortError(error2) };
5030
5337
  }
5031
5338
  }
5032
5339
  async function getAgentInfo(agentId, authHeader) {
@@ -5076,6 +5383,7 @@ var MAX_ACTIVITY_LOG_ENTRIES = 10;
5076
5383
  var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
5077
5384
  var CHANNEL_STUCK_QUEUED_MS = Number(process.env.EVIDENT_STUCK_QUEUED_MS) || void 0;
5078
5385
  var SHUTDOWN_DRAIN_TIMEOUT_MS = Number(process.env.EVIDENT_SHUTDOWN_DRAIN_MS) || 25e3;
5386
+ var TELEMETRY_SHUTDOWN_TIMEOUT_MS = 5e3;
5079
5387
  function resolveLogLevel(options) {
5080
5388
  const accepted = Object.keys(LOG_LEVELS);
5081
5389
  const validate = (value, source) => {
@@ -5106,7 +5414,7 @@ function resolveFileSyncDirectories(raw, homeDir) {
5106
5414
  if (trimmed === "") {
5107
5415
  throw new Error("--enable-file-sync-to requires a directory path (got an empty value)");
5108
5416
  }
5109
- const expanded = trimmed === "~" ? homeDir : trimmed.startsWith("~/") ? join3(homeDir, trimmed.slice(2)) : trimmed;
5417
+ const expanded = trimmed === "~" ? homeDir : trimmed.startsWith("~/") ? join2(homeDir, trimmed.slice(2)) : trimmed;
5110
5418
  if (!isAbsolute2(expanded)) {
5111
5419
  throw new Error(`--enable-file-sync-to requires an absolute directory path; got "${entry}"`);
5112
5420
  }
@@ -5402,7 +5710,18 @@ async function notifyOffline(state) {
5402
5710
  if (state.interactive) displayStatus(state);
5403
5711
  }
5404
5712
  }
5713
+ async function timeShutdownPhase(state, durations, name, run2) {
5714
+ const startedAt = Date.now();
5715
+ try {
5716
+ return await run2();
5717
+ } finally {
5718
+ const elapsedMs = Date.now() - startedAt;
5719
+ durations[name] = elapsedMs;
5720
+ log2(state, `Shutdown phase ${name}: ${elapsedMs}ms`);
5721
+ }
5722
+ }
5405
5723
  async function cleanup(state, opts = {}) {
5724
+ const durations = {};
5406
5725
  state.running = false;
5407
5726
  for (const timer of state.sessionCleanupTimers) {
5408
5727
  clearInterval(timer);
@@ -5416,7 +5735,13 @@ async function cleanup(state, opts = {}) {
5416
5735
  logActivity(state, { type: "info", message: "Draining in-flight work before shutdown..." });
5417
5736
  displayStatus(state);
5418
5737
  }
5419
- const settled = await state.channelDriver.waitForInFlight(SHUTDOWN_DRAIN_TIMEOUT_MS);
5738
+ const driver = state.channelDriver;
5739
+ const settled = await timeShutdownPhase(
5740
+ state,
5741
+ durations,
5742
+ "drain",
5743
+ () => driver.waitForInFlight(SHUTDOWN_DRAIN_TIMEOUT_MS)
5744
+ );
5420
5745
  if (!settled) {
5421
5746
  logActivity(state, {
5422
5747
  type: "info",
@@ -5425,13 +5750,15 @@ async function cleanup(state, opts = {}) {
5425
5750
  if (state.interactive) displayStatus(state);
5426
5751
  }
5427
5752
  }
5428
- await notifyOffline(state);
5753
+ await timeShutdownPhase(state, durations, "offline_notify", () => notifyOffline(state));
5429
5754
  if (state.connection) {
5430
- state.connection.close();
5755
+ const connection = state.connection;
5756
+ await timeShutdownPhase(state, durations, "tunnel_close", () => connection.close());
5431
5757
  state.connection = null;
5432
5758
  }
5433
5759
  if (state.opencodeProcess) {
5434
- stopOpenCode(state.opencodeProcess);
5760
+ const opencodeProcess = state.opencodeProcess;
5761
+ await timeShutdownPhase(state, durations, "opencode_stop", () => stopOpenCode(opencodeProcess));
5435
5762
  if (state.interactive) {
5436
5763
  logActivity(state, { type: "info", message: "Stopped OpenCode process" });
5437
5764
  displayStatus(state);
@@ -5440,6 +5767,7 @@ async function cleanup(state, opts = {}) {
5440
5767
  }
5441
5768
  state.opencodeProcess = null;
5442
5769
  }
5770
+ return durations;
5443
5771
  }
5444
5772
  async function run(options) {
5445
5773
  const interactive = isInteractive(options.json);
@@ -5447,7 +5775,7 @@ async function run(options) {
5447
5775
  let fileSyncDirectories;
5448
5776
  try {
5449
5777
  logLevel = resolveLogLevel(options);
5450
- fileSyncDirectories = resolveFileSyncDirectories(options.enableFileSyncTo, homedir3());
5778
+ fileSyncDirectories = resolveFileSyncDirectories(options.enableFileSyncTo, homedir2());
5451
5779
  } catch (error2) {
5452
5780
  const message = error2 instanceof Error ? error2.message : String(error2);
5453
5781
  if (options.json) {
@@ -5510,14 +5838,38 @@ async function run(options) {
5510
5838
  const handleSignal = async () => {
5511
5839
  if (state.shuttingDown) return;
5512
5840
  state.shuttingDown = true;
5841
+ const shutdownStartedAt = Date.now();
5513
5842
  if (state.interactive) {
5514
5843
  logActivity(state, { type: "info", message: "Shutting down..." });
5515
5844
  displayStatus(state);
5516
5845
  } else {
5517
5846
  log2(state, "Shutting down...");
5518
5847
  }
5519
- await cleanup(state, { graceful: true });
5520
- await shutdownTelemetry();
5848
+ const durations = await cleanup(state, { graceful: true });
5849
+ const telemetryBudgetMs = Number(process.env.EVIDENT_TELEMETRY_SHUTDOWN_MS) || TELEMETRY_SHUTDOWN_TIMEOUT_MS;
5850
+ await timeShutdownPhase(state, durations, "telemetry_flush", async () => {
5851
+ let timer;
5852
+ const flushed = shutdownTelemetry().then(
5853
+ () => true,
5854
+ (error2) => {
5855
+ log2(
5856
+ state,
5857
+ `Telemetry flush failed during shutdown: ${error2 instanceof Error ? error2.message : String(error2)}`,
5858
+ "warn"
5859
+ );
5860
+ return true;
5861
+ }
5862
+ );
5863
+ const timedOut = new Promise((resolve3) => {
5864
+ timer = setTimeout(() => resolve3(false), telemetryBudgetMs);
5865
+ });
5866
+ if (!await Promise.race([flushed, timedOut])) {
5867
+ log2(state, `Telemetry flush exceeded ${telemetryBudgetMs}ms \u2014 exiting anyway`, "warn");
5868
+ }
5869
+ clearTimeout(timer);
5870
+ });
5871
+ const breakdown = Object.entries(durations).map(([phase, ms]) => `${phase}=${ms}ms`).join(" ");
5872
+ log2(state, `Shutdown complete in ${Date.now() - shutdownStartedAt}ms (${breakdown})`);
5521
5873
  process.exit(0);
5522
5874
  };
5523
5875
  process.on("SIGINT", handleSignal);
@@ -5631,6 +5983,21 @@ async function run(options) {
5631
5983
  }
5632
5984
  spinner?.succeed(`Runner: ${validation.agent.name || state.agentId}`);
5633
5985
  state.agentName = validation.agent.name;
5986
+ const microvmId = process.env.MICROVM_ID?.trim();
5987
+ if (microvmId) {
5988
+ const reported = await reportMicrovmId(state.agentId, state.authHeader, microvmId);
5989
+ if (reported.ok) {
5990
+ log2(state, "Reported MicroVM identity so this runner can be resumed rather than restarted");
5991
+ } else {
5992
+ const message = `Could not report MicroVM identity (future wakes will cold-start): ${reported.error}`;
5993
+ log2(state, message, "warn");
5994
+ if (state.interactive && !state.json) {
5995
+ logActivity(state, { type: "info", level: "warn", message });
5996
+ }
5997
+ }
5998
+ } else {
5999
+ log2(state, "Not running in a MicroVM (MICROVM_ID unset) \u2014 nothing to report", "debug");
6000
+ }
5634
6001
  const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
5635
6002
  try {
5636
6003
  const oc = await ensureOpenCodeRunning({
@@ -5682,7 +6049,7 @@ async function run(options) {
5682
6049
  // #559: the `--enable-file-sync-to` allow-list. Empty ⇒ pending files are
5683
6050
  // REJECTED with `file_sync_disabled` on the ack, not silently ignored.
5684
6051
  fileSyncDirectories,
5685
- homeDir: homedir3(),
6052
+ homeDir: homedir2(),
5686
6053
  log: (entry) => (
5687
6054
  // Thread the driver's real level straight through so `debug`/`warn`
5688
6055
  // survive the sink filter (they no longer collapse to info). `type`