@evident-ai/cli 3.1.1-dev.94145fb → 3.1.1-dev.9513dc6

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
@@ -1532,6 +1532,9 @@ function isPreamblePinnedRunning(messages, userMessageId) {
1532
1532
  const reply = findLastAssistantReplyFor(messages, userMessageId);
1533
1533
  return completedOf(reply) != null && finishOf(reply) === "tool-calls";
1534
1534
  }
1535
+ function isB2AbandonmentConfirmed(params) {
1536
+ return params.pinnedForMs >= params.minPinnedMs && params.descendantOngoing === false;
1537
+ }
1535
1538
  function messageError(messages, userMessageId) {
1536
1539
  const reply = findLastAssistantReplyFor(messages, userMessageId);
1537
1540
  const error2 = errorOf(reply);
@@ -2073,6 +2076,18 @@ var RunnerConnection = class {
2073
2076
  }
2074
2077
  };
2075
2078
 
2079
+ // src/lib/tunnel/ready-marker.ts
2080
+ import { writeFileSync } from "fs";
2081
+ function writeTunnelReadyMarker(path, agentId) {
2082
+ try {
2083
+ writeFileSync(path, `${agentId}
2084
+ `);
2085
+ return { ok: true };
2086
+ } catch (error2) {
2087
+ return { ok: false, error: error2 instanceof Error ? error2.message : String(error2) };
2088
+ }
2089
+ }
2090
+
2076
2091
  // src/lib/channels/driver.ts
2077
2092
  import { homedir } from "os";
2078
2093
 
@@ -2499,6 +2514,8 @@ var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
2499
2514
  var DEFAULT_STUCK_QUEUED_MS = 6e4;
2500
2515
  var HEARTBEAT_MS = 6e4;
2501
2516
  var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
2517
+ var B2_ABANDONMENT_MIN_PINNED_MS = 3 * 6e4;
2518
+ var B2_ABANDONMENT_RECHECK_MS = HEARTBEAT_MS;
2502
2519
  var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
2503
2520
  var MAX_SUPERSEDED_CONVERSATIONS = 256;
2504
2521
  var ChannelAuthError = class extends Error {
@@ -3317,12 +3334,17 @@ var ChannelDriver = class _ChannelDriver {
3317
3334
  stuckReported: false,
3318
3335
  lastAliveAt: 0,
3319
3336
  aliveInFlight: false,
3337
+ titleSynced: false,
3338
+ titleSyncInFlight: false,
3320
3339
  awaitingHumanLatched: false,
3321
3340
  pausedOnQuestion: false,
3322
3341
  pausedOnPermission: false,
3323
3342
  pausedClearConfirmed: false,
3324
3343
  pausedInFlight: false,
3325
- deliveryDeadlineAnchored: false
3344
+ deliveryDeadlineAnchored: false,
3345
+ b2PinnedSinceMs: 0,
3346
+ b2LastDescendantCheckMs: 0,
3347
+ b2AbandonedSignalled: false
3326
3348
  });
3327
3349
  }
3328
3350
  /**
@@ -3390,12 +3412,17 @@ var ChannelDriver = class _ChannelDriver {
3390
3412
  // with no extra `re_adopted` signal needed (folds old WI-6).
3391
3413
  lastAliveAt: 0,
3392
3414
  aliveInFlight: false,
3415
+ titleSynced: false,
3416
+ titleSyncInFlight: false,
3393
3417
  awaitingHumanLatched: false,
3394
3418
  pausedOnQuestion: false,
3395
3419
  pausedOnPermission: false,
3396
3420
  pausedClearConfirmed: false,
3397
3421
  pausedInFlight: false,
3398
- deliveryDeadlineAnchored: false
3422
+ deliveryDeadlineAnchored: false,
3423
+ b2PinnedSinceMs: 0,
3424
+ b2LastDescendantCheckMs: 0,
3425
+ b2AbandonedSignalled: false
3399
3426
  });
3400
3427
  }
3401
3428
  /**
@@ -3557,58 +3584,7 @@ var ChannelDriver = class _ChannelDriver {
3557
3584
  }
3558
3585
  }
3559
3586
  if (state === "done") {
3560
- this.anchorDeliveryDeadline(inFlight);
3561
- if (!inFlight.done) {
3562
- this.log({
3563
- level: "info",
3564
- message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
3565
- conversation_id: conv.id,
3566
- message_id: inFlight.evidentMessageId
3567
- });
3568
- const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
3569
- const usage = messageUsage(messages, inFlight.opencodeMessageId);
3570
- try {
3571
- await this.markDone(
3572
- conv.id,
3573
- inFlight.evidentMessageId,
3574
- sessionId,
3575
- inFlight.opencodeMessageId,
3576
- title,
3577
- usage
3578
- );
3579
- } catch (err) {
3580
- if (err instanceof ChannelAuthError) throw err;
3581
- if (err instanceof ChannelTerminalError) {
3582
- this.log({
3583
- level: "warn",
3584
- message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
3585
- conversation_id: conv.id,
3586
- message_id: inFlight.evidentMessageId
3587
- });
3588
- this.removeInFlight(watcher, inFlight.evidentMessageId);
3589
- return;
3590
- }
3591
- if (this.now() >= inFlight.deadline) {
3592
- this.log({
3593
- level: "warn",
3594
- 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)}`,
3595
- conversation_id: conv.id,
3596
- message_id: inFlight.evidentMessageId
3597
- });
3598
- this.removeInFlight(watcher, inFlight.evidentMessageId);
3599
- return;
3600
- }
3601
- this.log({
3602
- level: "warn",
3603
- message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
3604
- conversation_id: conv.id,
3605
- message_id: inFlight.evidentMessageId
3606
- });
3607
- return;
3608
- }
3609
- inFlight.done = true;
3610
- }
3611
- this.removeInFlight(watcher, inFlight.evidentMessageId);
3587
+ await this.settleMessageDone(sessionId, watcher, inFlight, messages);
3612
3588
  return;
3613
3589
  }
3614
3590
  if (state === "failed") {
@@ -3668,6 +3644,44 @@ var ChannelDriver = class _ChannelDriver {
3668
3644
  });
3669
3645
  }
3670
3646
  const activelyRunning = state === "running" && !awaitingHuman;
3647
+ const pinnedNow = activelyRunning && isPreamblePinnedRunning(messages, inFlight.opencodeMessageId);
3648
+ const snapshotReadable = messages != null && messages.length > 0;
3649
+ if (!pinnedNow) {
3650
+ if (snapshotReadable) {
3651
+ inFlight.b2PinnedSinceMs = 0;
3652
+ inFlight.b2LastDescendantCheckMs = 0;
3653
+ inFlight.b2AbandonedSignalled = false;
3654
+ }
3655
+ } else {
3656
+ if (inFlight.b2AbandonedSignalled) {
3657
+ await this.settleMessageDone(sessionId, watcher, inFlight, messages);
3658
+ return;
3659
+ }
3660
+ if (inFlight.b2PinnedSinceMs === 0) inFlight.b2PinnedSinceMs = this.now();
3661
+ const pinnedForMs = this.now() - inFlight.b2PinnedSinceMs;
3662
+ if (pinnedForMs >= B2_ABANDONMENT_MIN_PINNED_MS && this.now() - inFlight.b2LastDescendantCheckMs >= B2_ABANDONMENT_RECHECK_MS) {
3663
+ inFlight.b2LastDescendantCheckMs = this.now();
3664
+ const descendantOngoing = await this.isAnyDescendantSessionOngoing(sessionId);
3665
+ if (isB2AbandonmentConfirmed({
3666
+ pinnedForMs,
3667
+ minPinnedMs: B2_ABANDONMENT_MIN_PINNED_MS,
3668
+ descendantOngoing
3669
+ })) {
3670
+ inFlight.b2AbandonedSignalled = true;
3671
+ this.log({
3672
+ level: "warn",
3673
+ 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`,
3674
+ conversation_id: conv.id,
3675
+ message_id: id
3676
+ });
3677
+ void this.postSignal(conv.id, id, "b2_abandoned_resolved", {
3678
+ watched_for_ms: pinnedForMs
3679
+ });
3680
+ await this.settleMessageDone(sessionId, watcher, inFlight, messages);
3681
+ return;
3682
+ }
3683
+ }
3684
+ }
3671
3685
  if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
3672
3686
  this.log({
3673
3687
  level: "warn",
@@ -3687,6 +3701,18 @@ var ChannelDriver = class _ChannelDriver {
3687
3701
  inFlight.aliveInFlight = false;
3688
3702
  if (ok) inFlight.lastAliveAt = this.now();
3689
3703
  });
3704
+ if (!inFlight.titleSynced && !inFlight.titleSyncInFlight) {
3705
+ inFlight.titleSyncInFlight = true;
3706
+ void this.resolveSessionTitle(sessionId, conv.id).then(async (title) => {
3707
+ if (!title) {
3708
+ inFlight.titleSyncInFlight = false;
3709
+ return;
3710
+ }
3711
+ const ok = await this.patchConversationTitle(conv.id, title);
3712
+ inFlight.titleSyncInFlight = false;
3713
+ if (ok) inFlight.titleSynced = true;
3714
+ });
3715
+ }
3690
3716
  }
3691
3717
  if (awaitingHuman) {
3692
3718
  if (!inFlight.awaitingHumanLatched) {
@@ -3724,6 +3750,70 @@ var ChannelDriver = class _ChannelDriver {
3724
3750
  this.removeInFlight(watcher, inFlight.evidentMessageId);
3725
3751
  }
3726
3752
  }
3753
+ /**
3754
+ * Settle a message whose run-state has resolved `'done'` — extracted verbatim
3755
+ * (pure refactor, no behavior change) from `serviceInFlightMessage`'s former
3756
+ * inline `state === 'done'` branch body, so a SECOND caller (the #721
3757
+ * b2-abandonment resolution) can reach the exact same completion behavior
3758
+ * (delivery-deadline anchoring, title resolution, usage extraction, and
3759
+ * `markDone`'s auth/terminal/transient-retry discipline) without duplicating it
3760
+ * and risking the two copies silently drifting apart.
3761
+ */
3762
+ async settleMessageDone(sessionId, watcher, inFlight, messages) {
3763
+ const conv = watcher.conv;
3764
+ this.anchorDeliveryDeadline(inFlight);
3765
+ if (!inFlight.done) {
3766
+ this.log({
3767
+ level: "info",
3768
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
3769
+ conversation_id: conv.id,
3770
+ message_id: inFlight.evidentMessageId
3771
+ });
3772
+ const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
3773
+ const usage = messageUsage(messages, inFlight.opencodeMessageId);
3774
+ try {
3775
+ await this.markDone(
3776
+ conv.id,
3777
+ inFlight.evidentMessageId,
3778
+ sessionId,
3779
+ inFlight.opencodeMessageId,
3780
+ title,
3781
+ usage
3782
+ );
3783
+ } catch (err) {
3784
+ if (err instanceof ChannelAuthError) throw err;
3785
+ if (err instanceof ChannelTerminalError) {
3786
+ this.log({
3787
+ level: "warn",
3788
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
3789
+ conversation_id: conv.id,
3790
+ message_id: inFlight.evidentMessageId
3791
+ });
3792
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
3793
+ return;
3794
+ }
3795
+ if (this.now() >= inFlight.deadline) {
3796
+ this.log({
3797
+ level: "warn",
3798
+ 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)}`,
3799
+ conversation_id: conv.id,
3800
+ message_id: inFlight.evidentMessageId
3801
+ });
3802
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
3803
+ return;
3804
+ }
3805
+ this.log({
3806
+ level: "warn",
3807
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
3808
+ conversation_id: conv.id,
3809
+ message_id: inFlight.evidentMessageId
3810
+ });
3811
+ return;
3812
+ }
3813
+ inFlight.done = true;
3814
+ }
3815
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
3816
+ }
3727
3817
  // Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
3728
3818
  /**
3729
3819
  * Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
@@ -4303,6 +4393,47 @@ var ChannelDriver = class _ChannelDriver {
4303
4393
  }
4304
4394
  return false;
4305
4395
  }
4396
+ /**
4397
+ * Tri-state variant of the upward parentID membership walk (#721), used ONLY
4398
+ * by `isAnyDescendantSessionOngoing`. Walks the SAME cached
4399
+ * `resolveSessionParent` chain `sessionBelongsTo` uses above, but — unlike
4400
+ * `sessionBelongsTo`, which deliberately collapses "confirmed not a
4401
+ * descendant" and "the walk's fetch failed" into the same `false` (safe for
4402
+ * its OTHER callers: interaction attribution and the recovery-path
4403
+ * `isAnyDescendantSessionAlive`, both of which just retry next tick with no
4404
+ * safety consequence either way) — this variant keeps those two outcomes
4405
+ * SEPARATE, because `isAnyDescendantSessionOngoing`'s caller
4406
+ * (`isB2AbandonmentConfirmed`) must never treat "couldn't tell" as "confirmed
4407
+ * not ongoing".
4408
+ *
4409
+ * Return contract:
4410
+ * - `true` → the walk reached `rootSessionId` — `sessionId` IS a descendant.
4411
+ * - `false` → the walk reached a definitive, parent-less root session
4412
+ * WITHOUT ever matching `rootSessionId` — `sessionId` is
4413
+ * CONFIRMED NOT a descendant of it.
4414
+ * - `null` → INDETERMINATE: a `GET /session/:id` fetch failed partway
4415
+ * through the walk (`resolveSessionParent` returned `undefined`),
4416
+ * or the depth cap (32) was hit without a definitive answer (a
4417
+ * pathological/cyclic chain proves nothing either way). NEVER
4418
+ * treat this the same as `false` — see `sessionBelongsTo`'s own
4419
+ * doc comment above for why that collapse is safe THERE but not
4420
+ * here.
4421
+ *
4422
+ * `sessionBelongsTo` itself is UNCHANGED — this is an additive helper scoped
4423
+ * to the live-path descendant check, not a modification of shared code used
4424
+ * by interaction attribution or the recovery path.
4425
+ */
4426
+ async resolveSessionMembership(sessionId, rootSessionId) {
4427
+ let current = sessionId;
4428
+ for (let depth = 0; current && depth < 32; depth++) {
4429
+ if (current === rootSessionId) return true;
4430
+ const parent = await this.resolveSessionParent(current);
4431
+ if (parent === void 0) return null;
4432
+ if (parent === null) return false;
4433
+ current = parent;
4434
+ }
4435
+ return null;
4436
+ }
4306
4437
  /**
4307
4438
  * Resolve (and cache) a session's `parentID` via `GET /session/:id`. Returns
4308
4439
  * `null` for a root session (no parent) and `undefined` when opencode is
@@ -4387,6 +4518,54 @@ var ChannelDriver = class _ChannelDriver {
4387
4518
  }
4388
4519
  return null;
4389
4520
  }
4521
+ /**
4522
+ * Best-effort mid-turn title sync (#711 follow-up): PATCH a resolved OpenCode
4523
+ * session title onto the conversation via the PLAIN conversation-update
4524
+ * endpoint (`PATCH /runners/:agentId/conversations/:conversationId`) — NOT the
4525
+ * message-status endpoint `markProcessing`/`markDone` use. Deliberately a
4526
+ * separate, lighter call: it carries no `status`, so it cannot re-trigger the
4527
+ * `processing`/`done` transition side effects (Slack notices, activity-log
4528
+ * rows, delivery jobs) those PATCHes gate on `transitioned` — this call only
4529
+ * ever touches `conversations.title`. That route (`routes/conversations.ts`)
4530
+ * skips a title write matching the stored value, so a redundant call with the
4531
+ * same title is a real no-op — it does not bump `updated_at`, which the
4532
+ * conversation list sorts and paginates on. (Note this is a DIFFERENT guard
4533
+ * from `threads.ts`'s "non-empty AND changed" one, which only covers the
4534
+ * message-status PATCH; the non-empty half is enforced here instead, by
4535
+ * `resolveSessionTitle` never returning an empty/placeholder title.)
4536
+ *
4537
+ * Telemetry-only / never blocks the caller, mirroring `postSignal`: a failure
4538
+ * is logged and the title is simply retried on the next heartbeat tick (the
4539
+ * caller only latches `titleSynced` on `true`).
4540
+ */
4541
+ async patchConversationTitle(conversationId, title) {
4542
+ try {
4543
+ const res = await this.fetchImpl(
4544
+ `${this.apiUrl}/runners/${this.agentId}/conversations/${conversationId}`,
4545
+ {
4546
+ method: "PATCH",
4547
+ headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
4548
+ body: JSON.stringify({ title })
4549
+ }
4550
+ );
4551
+ if (!res.ok) {
4552
+ this.log({
4553
+ level: "debug",
4554
+ message: `Mid-turn title sync PATCH for conversation ${conversationId.slice(0, 8)} returned HTTP ${res.status} (best-effort, will retry next heartbeat)`,
4555
+ conversation_id: conversationId
4556
+ });
4557
+ return false;
4558
+ }
4559
+ return true;
4560
+ } catch (err) {
4561
+ this.log({
4562
+ level: "debug",
4563
+ 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)}`,
4564
+ conversation_id: conversationId
4565
+ });
4566
+ return false;
4567
+ }
4568
+ }
4390
4569
  /**
4391
4570
  * DEFENSIVE cross-check for the restart-recovery path (WI-2): is any descendant
4392
4571
  * (`task` sub-agent) session under `rootSessionId` still genuinely doing work?
@@ -4445,6 +4624,84 @@ var ChannelDriver = class _ChannelDriver {
4445
4624
  }
4446
4625
  return false;
4447
4626
  }
4627
+ /**
4628
+ * LIVE-PATH descendant-liveness check (#721): is any descendant (`task`
4629
+ * sub-agent) session under `rootSessionId` currently ONGOING per OpenCode's own
4630
+ * in-memory status map (`isSessionOngoing` — `busy`/`retry`)?
4631
+ *
4632
+ * Deliberately NOT `isAnyDescendantSessionAlive` (the RECOVERY-path
4633
+ * cross-check above): that method judges liveness from the child's OWN
4634
+ * TRANSCRIPT (`isSessionActivelyGenerating`), which is the right (only) option
4635
+ * on the recovery path because a restart WIPES `SessionStatus`. On the LIVE
4636
+ * path the local opencode server IS running, so its in-memory status map is
4637
+ * live and authoritative — and per ADR-0047 §4a ("the child has its own entry
4638
+ * [in the map]"), a `task` descendant's OWN busy/retry entry reflects its
4639
+ * ENTIRE turn (including any tool call it is itself executing), not a
4640
+ * per-message transcript snapshot. This sidesteps the "child's own tool is
4641
+ * executing, between its step's completion and the next generation step"
4642
+ * transcript gap that a transcript-based check would need a second,
4643
+ * sustained-window bound to guard against — it is simply not derived from
4644
+ * message timestamps at all.
4645
+ *
4646
+ * Why not just check `isSessionOngoing(port, rootSessionId)` (the ROOT's own
4647
+ * status, as the recovery path does per §4a)? Because on the LIVE path the
4648
+ * root session can be shared: a SECOND, unrelated user message can land on the
4649
+ * SAME session (issue #721's own root cause) and keep the root `busy` for a
4650
+ * reason that has nothing to do with THIS message's delegation. A `task`
4651
+ * descendant session is spawned for exactly one delegated turn and never
4652
+ * reused, so its OWN status-map entry is unambiguous evidence about that one
4653
+ * delegation — which the root's status is not.
4654
+ *
4655
+ * Why membership is checked via `resolveSessionMembership`, NOT
4656
+ * `sessionBelongsTo`: `sessionBelongsTo` collapses a transient
4657
+ * `GET /session/:id` fetch failure into "not a descendant", which would
4658
+ * silently drop a genuinely-live candidate from consideration on the one
4659
+ * unlucky tick its membership-walk fetch hiccups (#721).
4660
+ * `resolveSessionMembership` keeps that failure mode as a distinct `null`
4661
+ * (indeterminate) so it is folded into THIS method's own `indeterminate` flag
4662
+ * instead.
4663
+ *
4664
+ * Return contract (note the DIFFERENT judge vs. `isAnyDescendantSessionAlive`):
4665
+ * - `true` → some descendant session is `busy`/`retry` (genuinely ongoing).
4666
+ * - `false` → enumeration succeeded, EVERY candidate's MEMBERSHIP was
4667
+ * confirmed either way (`resolveSessionMembership` never
4668
+ * returned `null`), and every CONFIRMED descendant's status read
4669
+ * succeeded and is not ongoing (includes "no descendant session
4670
+ * exists at all" — e.g. a plain, non-`task` tool call).
4671
+ * - `null` → INDETERMINATE: `listSessions` failed, OR at least one
4672
+ * candidate's MEMBERSHIP could not be confirmed
4673
+ * (`resolveSessionMembership` returned `null` — a fetch failure
4674
+ * or pathological chain partway through the parent walk), OR at
4675
+ * least one CONFIRMED descendant's `isSessionOngoing` read
4676
+ * failed — and no OTHER candidate was already confirmed `true`.
4677
+ * The caller MUST NOT treat `null` the same as `false` here
4678
+ * (unlike the recovery cross-check's contract) — see
4679
+ * `isB2AbandonmentConfirmed`.
4680
+ */
4681
+ async isAnyDescendantSessionOngoing(rootSessionId) {
4682
+ const sessions = await listSessions(this.port);
4683
+ if (!sessions) {
4684
+ this.log({
4685
+ level: "warn",
4686
+ message: `Could not enumerate sessions to check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating descendant liveness as indeterminate`
4687
+ });
4688
+ return null;
4689
+ }
4690
+ let indeterminate = false;
4691
+ for (const candidate of sessions) {
4692
+ if (!candidate?.id || candidate.id === rootSessionId) continue;
4693
+ const membership = await this.resolveSessionMembership(candidate.id, rootSessionId);
4694
+ if (membership === null) {
4695
+ indeterminate = true;
4696
+ continue;
4697
+ }
4698
+ if (membership === false) continue;
4699
+ const ongoing = await isSessionOngoing(this.port, candidate.id);
4700
+ if (ongoing === true) return true;
4701
+ if (ongoing === null) indeterminate = true;
4702
+ }
4703
+ return indeterminate ? null : false;
4704
+ }
4448
4705
  /**
4449
4706
  * Cheap decision-telemetry label for a running row's LAST correlated reply
4450
4707
  * (WI-2 Task 2.2): which running SHAPE it is, for the status-gated recovery log.
@@ -4905,7 +5162,7 @@ async function ensureOpenCodeRunning(ctx) {
4905
5162
  console.log(chalk5.yellow("Tip: Run with the correct port:"));
4906
5163
  console.log(
4907
5164
  chalk5.dim(
4908
- ` ${getCliName()} run --agent ${ctx.agentId} --port ${runningInstances[0].port}`
5165
+ ` ${getCliName()} run --runner ${ctx.agentId} --port ${runningInstances[0].port}`
4909
5166
  )
4910
5167
  );
4911
5168
  }
@@ -5035,21 +5292,21 @@ async function resolveAgentIdFromKey(authHeader) {
5035
5292
  return { agent_id: data.agent_id };
5036
5293
  }
5037
5294
  return {
5038
- error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --agent explicitly."
5295
+ error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --runner explicitly."
5039
5296
  };
5040
5297
  } catch (error2) {
5041
5298
  const message = error2 instanceof Error ? error2.message : "Unknown error";
5042
5299
  return { error: `Failed to resolve runner from key: ${message}` };
5043
5300
  }
5044
5301
  }
5045
- var OFFLINE_NOTIFY_TIMEOUT_MS = 2e3;
5302
+ var BEST_EFFORT_NOTIFY_TIMEOUT_MS = 2e3;
5046
5303
  async function notifyAgentDisconnected(agentId, authHeader) {
5047
5304
  const apiUrl = getApiUrlConfig();
5048
5305
  try {
5049
5306
  const response = await fetch(`${apiUrl}/runners/${agentId}/disconnect`, {
5050
5307
  method: "POST",
5051
5308
  headers: { Authorization: authHeader },
5052
- signal: AbortSignal.timeout(OFFLINE_NOTIFY_TIMEOUT_MS)
5309
+ signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
5053
5310
  });
5054
5311
  if (!response.ok) {
5055
5312
  const serverMessage = await readErrorMessage(response);
@@ -5060,11 +5317,35 @@ async function notifyAgentDisconnected(agentId, authHeader) {
5060
5317
  }
5061
5318
  return { ok: true };
5062
5319
  } catch (error2) {
5063
- const name = error2?.name;
5064
- if (name === "TimeoutError" || name === "AbortError") {
5065
- return { ok: false, error: `timed out after ${OFFLINE_NOTIFY_TIMEOUT_MS}ms` };
5320
+ return { ok: false, error: describeBestEffortError(error2) };
5321
+ }
5322
+ }
5323
+ function describeBestEffortError(error2) {
5324
+ const name = error2?.name;
5325
+ if (name === "TimeoutError" || name === "AbortError") {
5326
+ return `timed out after ${BEST_EFFORT_NOTIFY_TIMEOUT_MS}ms`;
5327
+ }
5328
+ return error2 instanceof Error ? error2.message : String(error2);
5329
+ }
5330
+ async function reportMicrovmId(agentId, authHeader, microvmId) {
5331
+ try {
5332
+ const apiUrl = getApiUrlConfig();
5333
+ const response = await fetch(`${apiUrl}/runners/${agentId}/microvm`, {
5334
+ method: "POST",
5335
+ headers: { Authorization: authHeader, "Content-Type": "application/json" },
5336
+ body: JSON.stringify({ microvm_id: microvmId }),
5337
+ signal: AbortSignal.timeout(BEST_EFFORT_NOTIFY_TIMEOUT_MS)
5338
+ });
5339
+ if (!response.ok) {
5340
+ const serverMessage = await readErrorMessage(response);
5341
+ return {
5342
+ ok: false,
5343
+ error: `HTTP ${response.status}${serverMessage ? `: ${serverMessage}` : ""}`
5344
+ };
5066
5345
  }
5067
- return { ok: false, error: error2 instanceof Error ? error2.message : String(error2) };
5346
+ return { ok: true };
5347
+ } catch (error2) {
5348
+ return { ok: false, error: describeBestEffortError(error2) };
5068
5349
  }
5069
5350
  }
5070
5351
  async function getAgentInfo(agentId, authHeader) {
@@ -5714,6 +5995,21 @@ async function run(options) {
5714
5995
  }
5715
5996
  spinner?.succeed(`Runner: ${validation.agent.name || state.agentId}`);
5716
5997
  state.agentName = validation.agent.name;
5998
+ const microvmId = process.env.MICROVM_ID?.trim();
5999
+ if (microvmId) {
6000
+ const reported = await reportMicrovmId(state.agentId, state.authHeader, microvmId);
6001
+ if (reported.ok) {
6002
+ log2(state, "Reported MicroVM identity so this runner can be resumed rather than restarted");
6003
+ } else {
6004
+ const message = `Could not report MicroVM identity (future wakes will cold-start): ${reported.error}`;
6005
+ log2(state, message, "warn");
6006
+ if (state.interactive && !state.json) {
6007
+ logActivity(state, { type: "info", level: "warn", message });
6008
+ }
6009
+ }
6010
+ } else {
6011
+ log2(state, "Not running in a MicroVM (MICROVM_ID unset) \u2014 nothing to report", "debug");
6012
+ }
5717
6013
  const ocSpinner = interactive && !state.json ? ora3("Checking OpenCode...").start() : null;
5718
6014
  try {
5719
6015
  const oc = await ensureOpenCodeRunning({
@@ -5792,6 +6088,18 @@ async function run(options) {
5792
6088
  type: "info",
5793
6089
  message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (runner: ${agentId})`
5794
6090
  });
6091
+ if (options.tunnelReadyFile) {
6092
+ const marker = writeTunnelReadyMarker(options.tunnelReadyFile, agentId);
6093
+ if (marker.ok) {
6094
+ log2(state, `Wrote tunnel readiness marker to ${options.tunnelReadyFile}`, "debug");
6095
+ } else {
6096
+ log2(
6097
+ state,
6098
+ `Failed to write tunnel readiness marker to ${options.tunnelReadyFile}: ${marker.error}`,
6099
+ "error"
6100
+ );
6101
+ }
6102
+ }
5795
6103
  emitAgentConnected(state.agentId, {
5796
6104
  port: state.port,
5797
6105
  cli_version: getCliVersion(),
@@ -5956,6 +6264,9 @@ program.command("run").description("Connect to Evident and process messages").op
5956
6264
  "Allow Evident to write files into this directory (repeatable). Omit to disable file sync entirely.",
5957
6265
  (value, previous) => previous.concat([value]),
5958
6266
  []
6267
+ ).option(
6268
+ "--tunnel-ready-file <path>",
6269
+ "Path to write once the tunnel is connected (set by the MicroVM hooks; unused on a developer machine)"
5959
6270
  ).action(
5960
6271
  (options) => {
5961
6272
  run({
@@ -5975,7 +6286,8 @@ program.command("run").description("Connect to Evident and process messages").op
5975
6286
  sessionCleanupInterval: options.sessionCleanupInterval,
5976
6287
  // Raw values — expansion/validation is single-sourced in run.ts's
5977
6288
  // resolveFileSyncDirectories.
5978
- enableFileSyncTo: options.enableFileSyncTo
6289
+ enableFileSyncTo: options.enableFileSyncTo,
6290
+ tunnelReadyFile: options.tunnelReadyFile
5979
6291
  });
5980
6292
  }
5981
6293
  );