@evident-ai/cli 3.1.1-dev.0d1732f → 3.1.1-dev.0d69ecc

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);
@@ -1545,6 +1548,42 @@ function messageError(messages, userMessageId) {
1545
1548
  }
1546
1549
  return "The agent run failed.";
1547
1550
  }
1551
+ function messageFailure(messages, userMessageId) {
1552
+ const reply = findLastAssistantReplyFor(messages, userMessageId);
1553
+ const error2 = errorOf(reply);
1554
+ if (error2 == null || typeof error2 !== "object") return null;
1555
+ const e = error2;
1556
+ const replyProviderId = reply?.info?.providerID ?? null;
1557
+ const replyModelId = reply?.info?.modelID ?? null;
1558
+ if (e.name === "ProviderAuthError") {
1559
+ const data = e.data;
1560
+ const providerId = typeof data?.providerID === "string" && data.providerID || replyProviderId;
1561
+ return { kind: "model_auth", providerId, modelId: replyModelId, reason: "missing" };
1562
+ }
1563
+ if (e.name === "APIError") {
1564
+ const data = e.data;
1565
+ const statusCode = data?.statusCode;
1566
+ if (statusCode === 401 || statusCode === 403) {
1567
+ return {
1568
+ kind: "model_auth",
1569
+ providerId: replyProviderId,
1570
+ modelId: replyModelId,
1571
+ reason: "rejected"
1572
+ };
1573
+ }
1574
+ }
1575
+ return null;
1576
+ }
1577
+ function applyZeroProviderFallback(classified, hasConfiguredProvider, replyProviderId, replyModelId = null) {
1578
+ if (classified != null) return classified;
1579
+ if (hasConfiguredProvider !== false) return null;
1580
+ return {
1581
+ kind: "model_auth",
1582
+ providerId: replyProviderId,
1583
+ modelId: replyModelId,
1584
+ reason: "missing"
1585
+ };
1586
+ }
1548
1587
  function hasRunningAssistantExcept(messages, exceptUserMessageId) {
1549
1588
  if (!messages || messages.length === 0) return false;
1550
1589
  return messages.some(
@@ -2073,6 +2112,18 @@ var RunnerConnection = class {
2073
2112
  }
2074
2113
  };
2075
2114
 
2115
+ // src/lib/tunnel/ready-marker.ts
2116
+ import { writeFileSync } from "fs";
2117
+ function writeTunnelReadyMarker(path, agentId) {
2118
+ try {
2119
+ writeFileSync(path, `${agentId}
2120
+ `);
2121
+ return { ok: true };
2122
+ } catch (error2) {
2123
+ return { ok: false, error: error2 instanceof Error ? error2.message : String(error2) };
2124
+ }
2125
+ }
2126
+
2076
2127
  // src/lib/channels/driver.ts
2077
2128
  import { homedir } from "os";
2078
2129
 
@@ -2499,6 +2550,8 @@ var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
2499
2550
  var DEFAULT_STUCK_QUEUED_MS = 6e4;
2500
2551
  var HEARTBEAT_MS = 6e4;
2501
2552
  var ABSOLUTE_MAX_PROCESSING_MS = 6 * 60 * 60 * 1e3;
2553
+ var B2_ABANDONMENT_MIN_PINNED_MS = 3 * 6e4;
2554
+ var B2_ABANDONMENT_RECHECK_MS = HEARTBEAT_MS;
2502
2555
  var POLL_MISS_GRACE_MS = HEARTBEAT_MS;
2503
2556
  var MAX_SUPERSEDED_CONVERSATIONS = 256;
2504
2557
  var ChannelAuthError = class extends Error {
@@ -3324,7 +3377,10 @@ var ChannelDriver = class _ChannelDriver {
3324
3377
  pausedOnPermission: false,
3325
3378
  pausedClearConfirmed: false,
3326
3379
  pausedInFlight: false,
3327
- deliveryDeadlineAnchored: false
3380
+ deliveryDeadlineAnchored: false,
3381
+ b2PinnedSinceMs: 0,
3382
+ b2LastDescendantCheckMs: 0,
3383
+ b2AbandonedSignalled: false
3328
3384
  });
3329
3385
  }
3330
3386
  /**
@@ -3399,7 +3455,10 @@ var ChannelDriver = class _ChannelDriver {
3399
3455
  pausedOnPermission: false,
3400
3456
  pausedClearConfirmed: false,
3401
3457
  pausedInFlight: false,
3402
- deliveryDeadlineAnchored: false
3458
+ deliveryDeadlineAnchored: false,
3459
+ b2PinnedSinceMs: 0,
3460
+ b2LastDescendantCheckMs: 0,
3461
+ b2AbandonedSignalled: false
3403
3462
  });
3404
3463
  }
3405
3464
  /**
@@ -3561,58 +3620,7 @@ var ChannelDriver = class _ChannelDriver {
3561
3620
  }
3562
3621
  }
3563
3622
  if (state === "done") {
3564
- this.anchorDeliveryDeadline(inFlight);
3565
- if (!inFlight.done) {
3566
- this.log({
3567
- level: "info",
3568
- message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
3569
- conversation_id: conv.id,
3570
- message_id: inFlight.evidentMessageId
3571
- });
3572
- const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
3573
- const usage = messageUsage(messages, inFlight.opencodeMessageId);
3574
- try {
3575
- await this.markDone(
3576
- conv.id,
3577
- inFlight.evidentMessageId,
3578
- sessionId,
3579
- inFlight.opencodeMessageId,
3580
- title,
3581
- usage
3582
- );
3583
- } catch (err) {
3584
- if (err instanceof ChannelAuthError) throw err;
3585
- if (err instanceof ChannelTerminalError) {
3586
- this.log({
3587
- level: "warn",
3588
- message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
3589
- conversation_id: conv.id,
3590
- message_id: inFlight.evidentMessageId
3591
- });
3592
- this.removeInFlight(watcher, inFlight.evidentMessageId);
3593
- return;
3594
- }
3595
- if (this.now() >= inFlight.deadline) {
3596
- this.log({
3597
- level: "warn",
3598
- 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)}`,
3599
- conversation_id: conv.id,
3600
- message_id: inFlight.evidentMessageId
3601
- });
3602
- this.removeInFlight(watcher, inFlight.evidentMessageId);
3603
- return;
3604
- }
3605
- this.log({
3606
- level: "warn",
3607
- message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
3608
- conversation_id: conv.id,
3609
- message_id: inFlight.evidentMessageId
3610
- });
3611
- return;
3612
- }
3613
- inFlight.done = true;
3614
- }
3615
- this.removeInFlight(watcher, inFlight.evidentMessageId);
3623
+ await this.settleMessageDone(sessionId, watcher, inFlight, messages);
3616
3624
  return;
3617
3625
  }
3618
3626
  if (state === "failed") {
@@ -3626,8 +3634,16 @@ var ChannelDriver = class _ChannelDriver {
3626
3634
  message_id: inFlight.evidentMessageId
3627
3635
  });
3628
3636
  const usage = messageUsage(messages, inFlight.opencodeMessageId);
3637
+ const failure = await this.classifyModelAuthFailure(messages, inFlight.opencodeMessageId);
3629
3638
  try {
3630
- await this.markFailed(conv.id, inFlight.evidentMessageId, sessionId, error2, usage);
3639
+ await this.markFailed(
3640
+ conv.id,
3641
+ inFlight.evidentMessageId,
3642
+ sessionId,
3643
+ error2,
3644
+ usage,
3645
+ failure
3646
+ );
3631
3647
  } catch (err) {
3632
3648
  if (err instanceof ChannelAuthError) throw err;
3633
3649
  if (err instanceof ChannelTerminalError) {
@@ -3672,6 +3688,44 @@ var ChannelDriver = class _ChannelDriver {
3672
3688
  });
3673
3689
  }
3674
3690
  const activelyRunning = state === "running" && !awaitingHuman;
3691
+ const pinnedNow = activelyRunning && isPreamblePinnedRunning(messages, inFlight.opencodeMessageId);
3692
+ const snapshotReadable = messages != null && messages.length > 0;
3693
+ if (!pinnedNow) {
3694
+ if (snapshotReadable) {
3695
+ inFlight.b2PinnedSinceMs = 0;
3696
+ inFlight.b2LastDescendantCheckMs = 0;
3697
+ inFlight.b2AbandonedSignalled = false;
3698
+ }
3699
+ } else {
3700
+ if (inFlight.b2AbandonedSignalled) {
3701
+ await this.settleMessageDone(sessionId, watcher, inFlight, messages);
3702
+ return;
3703
+ }
3704
+ if (inFlight.b2PinnedSinceMs === 0) inFlight.b2PinnedSinceMs = this.now();
3705
+ const pinnedForMs = this.now() - inFlight.b2PinnedSinceMs;
3706
+ if (pinnedForMs >= B2_ABANDONMENT_MIN_PINNED_MS && this.now() - inFlight.b2LastDescendantCheckMs >= B2_ABANDONMENT_RECHECK_MS) {
3707
+ inFlight.b2LastDescendantCheckMs = this.now();
3708
+ const descendantOngoing = await this.isAnyDescendantSessionOngoing(sessionId);
3709
+ if (isB2AbandonmentConfirmed({
3710
+ pinnedForMs,
3711
+ minPinnedMs: B2_ABANDONMENT_MIN_PINNED_MS,
3712
+ descendantOngoing
3713
+ })) {
3714
+ inFlight.b2AbandonedSignalled = true;
3715
+ this.log({
3716
+ level: "warn",
3717
+ 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`,
3718
+ conversation_id: conv.id,
3719
+ message_id: id
3720
+ });
3721
+ void this.postSignal(conv.id, id, "b2_abandoned_resolved", {
3722
+ watched_for_ms: pinnedForMs
3723
+ });
3724
+ await this.settleMessageDone(sessionId, watcher, inFlight, messages);
3725
+ return;
3726
+ }
3727
+ }
3728
+ }
3675
3729
  if (activelyRunning && this.now() - inFlight.processingAnchorMs >= ABSOLUTE_MAX_PROCESSING_MS) {
3676
3730
  this.log({
3677
3731
  level: "warn",
@@ -3740,6 +3794,70 @@ var ChannelDriver = class _ChannelDriver {
3740
3794
  this.removeInFlight(watcher, inFlight.evidentMessageId);
3741
3795
  }
3742
3796
  }
3797
+ /**
3798
+ * Settle a message whose run-state has resolved `'done'` — extracted verbatim
3799
+ * (pure refactor, no behavior change) from `serviceInFlightMessage`'s former
3800
+ * inline `state === 'done'` branch body, so a SECOND caller (the #721
3801
+ * b2-abandonment resolution) can reach the exact same completion behavior
3802
+ * (delivery-deadline anchoring, title resolution, usage extraction, and
3803
+ * `markDone`'s auth/terminal/transient-retry discipline) without duplicating it
3804
+ * and risking the two copies silently drifting apart.
3805
+ */
3806
+ async settleMessageDone(sessionId, watcher, inFlight, messages) {
3807
+ const conv = watcher.conv;
3808
+ this.anchorDeliveryDeadline(inFlight);
3809
+ if (!inFlight.done) {
3810
+ this.log({
3811
+ level: "info",
3812
+ message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
3813
+ conversation_id: conv.id,
3814
+ message_id: inFlight.evidentMessageId
3815
+ });
3816
+ const title = await this.resolveSessionTitle(sessionId, watcher.conv.id);
3817
+ const usage = messageUsage(messages, inFlight.opencodeMessageId);
3818
+ try {
3819
+ await this.markDone(
3820
+ conv.id,
3821
+ inFlight.evidentMessageId,
3822
+ sessionId,
3823
+ inFlight.opencodeMessageId,
3824
+ title,
3825
+ usage
3826
+ );
3827
+ } catch (err) {
3828
+ if (err instanceof ChannelAuthError) throw err;
3829
+ if (err instanceof ChannelTerminalError) {
3830
+ this.log({
3831
+ level: "warn",
3832
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
3833
+ conversation_id: conv.id,
3834
+ message_id: inFlight.evidentMessageId
3835
+ });
3836
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
3837
+ return;
3838
+ }
3839
+ if (this.now() >= inFlight.deadline) {
3840
+ this.log({
3841
+ level: "warn",
3842
+ 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)}`,
3843
+ conversation_id: conv.id,
3844
+ message_id: inFlight.evidentMessageId
3845
+ });
3846
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
3847
+ return;
3848
+ }
3849
+ this.log({
3850
+ level: "warn",
3851
+ message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
3852
+ conversation_id: conv.id,
3853
+ message_id: inFlight.evidentMessageId
3854
+ });
3855
+ return;
3856
+ }
3857
+ inFlight.done = true;
3858
+ }
3859
+ this.removeInFlight(watcher, inFlight.evidentMessageId);
3860
+ }
3743
3861
  // Restart recovery: re-adopt `processing` messages (ADR-0046, WI-3/4/5)
3744
3862
  /**
3745
3863
  * Re-adopt this agent's `processing` messages on drain (ADR-0046 Decision §1).
@@ -3906,6 +4024,7 @@ var ChannelDriver = class _ChannelDriver {
3906
4024
  if (state === "failed") {
3907
4025
  const error2 = messageError(messages, ocId ?? "") ?? void 0;
3908
4026
  const usage = messageUsage(messages, ocId ?? "");
4027
+ const failure = await this.classifyModelAuthFailure(messages, ocId ?? "");
3909
4028
  this.log({
3910
4029
  level: "error",
3911
4030
  message: `Re-adopt: message ${row.id.slice(0, 8)} errored while unwatched \u2014 marking failed: ${error2 ?? "(no error text)"}`,
@@ -3913,7 +4032,7 @@ var ChannelDriver = class _ChannelDriver {
3913
4032
  message_id: row.id
3914
4033
  });
3915
4034
  try {
3916
- await this.markFailed(row.conversation_id, row.id, sessionId, error2, usage);
4035
+ await this.markFailed(row.conversation_id, row.id, sessionId, error2, usage, failure);
3917
4036
  } catch (err) {
3918
4037
  if (err instanceof ChannelAuthError) throw err;
3919
4038
  if (err instanceof ChannelTerminalError) {
@@ -4319,6 +4438,47 @@ var ChannelDriver = class _ChannelDriver {
4319
4438
  }
4320
4439
  return false;
4321
4440
  }
4441
+ /**
4442
+ * Tri-state variant of the upward parentID membership walk (#721), used ONLY
4443
+ * by `isAnyDescendantSessionOngoing`. Walks the SAME cached
4444
+ * `resolveSessionParent` chain `sessionBelongsTo` uses above, but — unlike
4445
+ * `sessionBelongsTo`, which deliberately collapses "confirmed not a
4446
+ * descendant" and "the walk's fetch failed" into the same `false` (safe for
4447
+ * its OTHER callers: interaction attribution and the recovery-path
4448
+ * `isAnyDescendantSessionAlive`, both of which just retry next tick with no
4449
+ * safety consequence either way) — this variant keeps those two outcomes
4450
+ * SEPARATE, because `isAnyDescendantSessionOngoing`'s caller
4451
+ * (`isB2AbandonmentConfirmed`) must never treat "couldn't tell" as "confirmed
4452
+ * not ongoing".
4453
+ *
4454
+ * Return contract:
4455
+ * - `true` → the walk reached `rootSessionId` — `sessionId` IS a descendant.
4456
+ * - `false` → the walk reached a definitive, parent-less root session
4457
+ * WITHOUT ever matching `rootSessionId` — `sessionId` is
4458
+ * CONFIRMED NOT a descendant of it.
4459
+ * - `null` → INDETERMINATE: a `GET /session/:id` fetch failed partway
4460
+ * through the walk (`resolveSessionParent` returned `undefined`),
4461
+ * or the depth cap (32) was hit without a definitive answer (a
4462
+ * pathological/cyclic chain proves nothing either way). NEVER
4463
+ * treat this the same as `false` — see `sessionBelongsTo`'s own
4464
+ * doc comment above for why that collapse is safe THERE but not
4465
+ * here.
4466
+ *
4467
+ * `sessionBelongsTo` itself is UNCHANGED — this is an additive helper scoped
4468
+ * to the live-path descendant check, not a modification of shared code used
4469
+ * by interaction attribution or the recovery path.
4470
+ */
4471
+ async resolveSessionMembership(sessionId, rootSessionId) {
4472
+ let current = sessionId;
4473
+ for (let depth = 0; current && depth < 32; depth++) {
4474
+ if (current === rootSessionId) return true;
4475
+ const parent = await this.resolveSessionParent(current);
4476
+ if (parent === void 0) return null;
4477
+ if (parent === null) return false;
4478
+ current = parent;
4479
+ }
4480
+ return null;
4481
+ }
4322
4482
  /**
4323
4483
  * Resolve (and cache) a session's `parentID` via `GET /session/:id`. Returns
4324
4484
  * `null` for a root session (no parent) and `undefined` when opencode is
@@ -4509,6 +4669,84 @@ var ChannelDriver = class _ChannelDriver {
4509
4669
  }
4510
4670
  return false;
4511
4671
  }
4672
+ /**
4673
+ * LIVE-PATH descendant-liveness check (#721): is any descendant (`task`
4674
+ * sub-agent) session under `rootSessionId` currently ONGOING per OpenCode's own
4675
+ * in-memory status map (`isSessionOngoing` — `busy`/`retry`)?
4676
+ *
4677
+ * Deliberately NOT `isAnyDescendantSessionAlive` (the RECOVERY-path
4678
+ * cross-check above): that method judges liveness from the child's OWN
4679
+ * TRANSCRIPT (`isSessionActivelyGenerating`), which is the right (only) option
4680
+ * on the recovery path because a restart WIPES `SessionStatus`. On the LIVE
4681
+ * path the local opencode server IS running, so its in-memory status map is
4682
+ * live and authoritative — and per ADR-0047 §4a ("the child has its own entry
4683
+ * [in the map]"), a `task` descendant's OWN busy/retry entry reflects its
4684
+ * ENTIRE turn (including any tool call it is itself executing), not a
4685
+ * per-message transcript snapshot. This sidesteps the "child's own tool is
4686
+ * executing, between its step's completion and the next generation step"
4687
+ * transcript gap that a transcript-based check would need a second,
4688
+ * sustained-window bound to guard against — it is simply not derived from
4689
+ * message timestamps at all.
4690
+ *
4691
+ * Why not just check `isSessionOngoing(port, rootSessionId)` (the ROOT's own
4692
+ * status, as the recovery path does per §4a)? Because on the LIVE path the
4693
+ * root session can be shared: a SECOND, unrelated user message can land on the
4694
+ * SAME session (issue #721's own root cause) and keep the root `busy` for a
4695
+ * reason that has nothing to do with THIS message's delegation. A `task`
4696
+ * descendant session is spawned for exactly one delegated turn and never
4697
+ * reused, so its OWN status-map entry is unambiguous evidence about that one
4698
+ * delegation — which the root's status is not.
4699
+ *
4700
+ * Why membership is checked via `resolveSessionMembership`, NOT
4701
+ * `sessionBelongsTo`: `sessionBelongsTo` collapses a transient
4702
+ * `GET /session/:id` fetch failure into "not a descendant", which would
4703
+ * silently drop a genuinely-live candidate from consideration on the one
4704
+ * unlucky tick its membership-walk fetch hiccups (#721).
4705
+ * `resolveSessionMembership` keeps that failure mode as a distinct `null`
4706
+ * (indeterminate) so it is folded into THIS method's own `indeterminate` flag
4707
+ * instead.
4708
+ *
4709
+ * Return contract (note the DIFFERENT judge vs. `isAnyDescendantSessionAlive`):
4710
+ * - `true` → some descendant session is `busy`/`retry` (genuinely ongoing).
4711
+ * - `false` → enumeration succeeded, EVERY candidate's MEMBERSHIP was
4712
+ * confirmed either way (`resolveSessionMembership` never
4713
+ * returned `null`), and every CONFIRMED descendant's status read
4714
+ * succeeded and is not ongoing (includes "no descendant session
4715
+ * exists at all" — e.g. a plain, non-`task` tool call).
4716
+ * - `null` → INDETERMINATE: `listSessions` failed, OR at least one
4717
+ * candidate's MEMBERSHIP could not be confirmed
4718
+ * (`resolveSessionMembership` returned `null` — a fetch failure
4719
+ * or pathological chain partway through the parent walk), OR at
4720
+ * least one CONFIRMED descendant's `isSessionOngoing` read
4721
+ * failed — and no OTHER candidate was already confirmed `true`.
4722
+ * The caller MUST NOT treat `null` the same as `false` here
4723
+ * (unlike the recovery cross-check's contract) — see
4724
+ * `isB2AbandonmentConfirmed`.
4725
+ */
4726
+ async isAnyDescendantSessionOngoing(rootSessionId) {
4727
+ const sessions = await listSessions(this.port);
4728
+ if (!sessions) {
4729
+ this.log({
4730
+ level: "warn",
4731
+ message: `Could not enumerate sessions to check descendant liveness for root ${rootSessionId} (listSessions failed) \u2014 treating descendant liveness as indeterminate`
4732
+ });
4733
+ return null;
4734
+ }
4735
+ let indeterminate = false;
4736
+ for (const candidate of sessions) {
4737
+ if (!candidate?.id || candidate.id === rootSessionId) continue;
4738
+ const membership = await this.resolveSessionMembership(candidate.id, rootSessionId);
4739
+ if (membership === null) {
4740
+ indeterminate = true;
4741
+ continue;
4742
+ }
4743
+ if (membership === false) continue;
4744
+ const ongoing = await isSessionOngoing(this.port, candidate.id);
4745
+ if (ongoing === true) return true;
4746
+ if (ongoing === null) indeterminate = true;
4747
+ }
4748
+ return indeterminate ? null : false;
4749
+ }
4512
4750
  /**
4513
4751
  * Cheap decision-telemetry label for a running row's LAST correlated reply
4514
4752
  * (WI-2 Task 2.2): which running SHAPE it is, for the status-gated recovery log.
@@ -4772,7 +5010,7 @@ var ChannelDriver = class _ChannelDriver {
4772
5010
  * exists but is wedged, so the next attempt must get a fresh one
4773
5011
  * instead of reusing it — see WI-1's server-side null-clearing PATCH).
4774
5012
  */
4775
- async markFailed(conversationId, messageId, sessionId, error2, usage) {
5013
+ async markFailed(conversationId, messageId, sessionId, error2, usage, failure) {
4776
5014
  const body = { status: "failed" };
4777
5015
  if (sessionId === null) {
4778
5016
  body.opencode_session_id = null;
@@ -4781,6 +5019,12 @@ var ChannelDriver = class _ChannelDriver {
4781
5019
  }
4782
5020
  if (error2 !== void 0) body.error = error2;
4783
5021
  if (usage) Object.assign(body, usage);
5022
+ if (failure) {
5023
+ body.failure_kind = failure.kind;
5024
+ body.failure_provider_id = failure.providerId;
5025
+ body.failure_model_id = failure.modelId;
5026
+ body.failure_reason = failure.reason;
5027
+ }
4784
5028
  await this.callWithRetry(
4785
5029
  "marking message as failed",
4786
5030
  () => this.fetchImpl(
@@ -4793,6 +5037,29 @@ var ChannelDriver = class _ChannelDriver {
4793
5037
  )
4794
5038
  );
4795
5039
  }
5040
+ /**
5041
+ * Classify an errored turn as a model-auth failure (#736 P1-4), for `markFailed`.
5042
+ *
5043
+ * `messageFailure` alone (structured OpenCode error → `model_auth`) covers
5044
+ * most cases; when it returns `null` on this ALREADY-FAILED turn, fall back
5045
+ * to the P1-2b zero-provider check — one extra loopback call to
5046
+ * `hasAnyConfiguredProvider`, only reached when the structured classifier
5047
+ * couldn't place it. Fails open (never throws): a fallback probe failure
5048
+ * (`null`/indeterminate) leaves the classification `null`, which produces
5049
+ * today's byte-identical PATCH body via `markFailed`'s `if (failure)` guard.
5050
+ */
5051
+ async classifyModelAuthFailure(messages, userMessageId) {
5052
+ const classified = messageFailure(messages, userMessageId);
5053
+ if (classified != null) return classified;
5054
+ const reply = findLastAssistantReplyFor(messages, userMessageId);
5055
+ const hasProvider = await hasAnyConfiguredProvider(this.port);
5056
+ return applyZeroProviderFallback(
5057
+ classified,
5058
+ hasProvider,
5059
+ reply?.info?.providerID ?? null,
5060
+ reply?.info?.modelID ?? null
5061
+ );
5062
+ }
4796
5063
  /**
4797
5064
  * Best-effort, SINGLE-ATTEMPT runner→server telemetry ping
4798
5065
  * (queued-followup-redrive). `POST .../messages/:id/signal {signal, ...extra}`
@@ -4969,7 +5236,7 @@ async function ensureOpenCodeRunning(ctx) {
4969
5236
  console.log(chalk5.yellow("Tip: Run with the correct port:"));
4970
5237
  console.log(
4971
5238
  chalk5.dim(
4972
- ` ${getCliName()} run --agent ${ctx.agentId} --port ${runningInstances[0].port}`
5239
+ ` ${getCliName()} run --runner ${ctx.agentId} --port ${runningInstances[0].port}`
4973
5240
  )
4974
5241
  );
4975
5242
  }
@@ -5099,7 +5366,7 @@ async function resolveAgentIdFromKey(authHeader) {
5099
5366
  return { agent_id: data.agent_id };
5100
5367
  }
5101
5368
  return {
5102
- error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --agent explicitly."
5369
+ error: "Cannot resolve runner ID: auth type is not agent_key. Please provide --runner explicitly."
5103
5370
  };
5104
5371
  } catch (error2) {
5105
5372
  const message = error2 instanceof Error ? error2.message : "Unknown error";
@@ -5895,6 +6162,18 @@ async function run(options) {
5895
6162
  type: "info",
5896
6163
  message: `Tunnel ${isReconnect ? "reconnected" : "connected"} (runner: ${agentId})`
5897
6164
  });
6165
+ if (options.tunnelReadyFile) {
6166
+ const marker = writeTunnelReadyMarker(options.tunnelReadyFile, agentId);
6167
+ if (marker.ok) {
6168
+ log2(state, `Wrote tunnel readiness marker to ${options.tunnelReadyFile}`, "debug");
6169
+ } else {
6170
+ log2(
6171
+ state,
6172
+ `Failed to write tunnel readiness marker to ${options.tunnelReadyFile}: ${marker.error}`,
6173
+ "error"
6174
+ );
6175
+ }
6176
+ }
5898
6177
  emitAgentConnected(state.agentId, {
5899
6178
  port: state.port,
5900
6179
  cli_version: getCliVersion(),
@@ -6059,6 +6338,9 @@ program.command("run").description("Connect to Evident and process messages").op
6059
6338
  "Allow Evident to write files into this directory (repeatable). Omit to disable file sync entirely.",
6060
6339
  (value, previous) => previous.concat([value]),
6061
6340
  []
6341
+ ).option(
6342
+ "--tunnel-ready-file <path>",
6343
+ "Path to write once the tunnel is connected (set by the MicroVM hooks; unused on a developer machine)"
6062
6344
  ).action(
6063
6345
  (options) => {
6064
6346
  run({
@@ -6078,7 +6360,8 @@ program.command("run").description("Connect to Evident and process messages").op
6078
6360
  sessionCleanupInterval: options.sessionCleanupInterval,
6079
6361
  // Raw values — expansion/validation is single-sourced in run.ts's
6080
6362
  // resolveFileSyncDirectories.
6081
- enableFileSyncTo: options.enableFileSyncTo
6363
+ enableFileSyncTo: options.enableFileSyncTo,
6364
+ tunnelReadyFile: options.tunnelReadyFile
6082
6365
  });
6083
6366
  }
6084
6367
  );