switchroom 0.21.17 → 0.21.19

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.
Files changed (33) hide show
  1. package/dist/cli/switchroom.js +1884 -1512
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/skills/switchroom-release/SKILL.md +6 -1
  5. package/telegram-plugin/connection-drop.ts +83 -0
  6. package/telegram-plugin/dist/bridge/bridge.js +41 -2
  7. package/telegram-plugin/dist/gateway/gateway.js +48 -6
  8. package/telegram-plugin/dist/server.js +46 -3
  9. package/telegram-plugin/llm-error-present.ts +25 -0
  10. package/telegram-plugin/scripts/bun-test-ci.sh +6 -0
  11. package/telegram-plugin/session-tail.ts +38 -1
  12. package/telegram-plugin/tests/llm-error-present.test.ts +183 -0
  13. package/telegram-plugin/uat/flip/allowlist.test.ts +229 -0
  14. package/telegram-plugin/uat/flip/allowlist.ts +349 -0
  15. package/telegram-plugin/uat/flip/gate.test.ts +153 -0
  16. package/telegram-plugin/uat/flip/gate.ts +232 -0
  17. package/telegram-plugin/uat/flip/probe-scoring.test.ts +210 -0
  18. package/telegram-plugin/uat/flip/probe-scoring.ts +200 -0
  19. package/telegram-plugin/uat/flip/probe-suite.test.ts +95 -0
  20. package/telegram-plugin/uat/flip/probe-suite.ts +155 -0
  21. package/telegram-plugin/uat/flip/probes/kdogg.probes.json +36 -0
  22. package/telegram-plugin/uat/flip/probes/test-harness.probes.json +15 -0
  23. package/telegram-plugin/uat/flip/recall-log.test.ts +131 -0
  24. package/telegram-plugin/uat/flip/recall-log.ts +178 -0
  25. package/telegram-plugin/uat/flip/report.ts +95 -0
  26. package/telegram-plugin/uat/flip/tier1-equivalence.test.ts +470 -0
  27. package/telegram-plugin/uat/flip/tier1-equivalence.ts +697 -0
  28. package/telegram-plugin/uat/flip/tier2-probe-runner.ts +327 -0
  29. package/telegram-plugin/uat/runners/scorer.ts +1 -1
  30. package/vendor/hindsight-memory/scripts/prefetch.py +33 -5
  31. package/vendor/hindsight-memory/scripts/recall.py +131 -17
  32. package/vendor/hindsight-memory/scripts/tests/test_prefetch_pipeline.py +97 -6
  33. package/vendor/hindsight-memory/scripts/tests/test_recall_buffer_join.py +70 -0
@@ -21598,7 +21598,7 @@ function allocateAgentUid(name) {
21598
21598
  }
21599
21599
 
21600
21600
  // src/build-info.ts
21601
- var VERSION = "0.21.17";
21601
+ var VERSION = "0.21.19";
21602
21602
 
21603
21603
  // src/setup/hindsight-recall-passthrough.ts
21604
21604
  var HINDSIGHT_RECALL_TAG_WEIGHT_SEED = Object.freeze({ sidechain: 0.8 });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.21.17",
4
+ "version": "0.21.19",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -63,7 +63,12 @@ gh release create "vX.Y.Z" -R switchroom/switchroom \
63
63
  ```
64
64
 
65
65
  - **`--draft` is mandatory.** A published release with no assets immediately becomes `/releases/latest` and 404s every `curl | sh` install for the entire ~25-minute build window. `release.yml` will forcibly re-draft an incomplete published release within about a minute, but do not rely on the safety net — it exists for the case where this step was done wrong.
66
- - Creating the release object *before* the tag is what removes the race: `release.yml`'s `guard` job polls ~150s for it. **If you skip this step entirely, `guard` auto-creates the draft release from `CHANGELOG.md` (#4331)** — so a skipped `gh release create` is recoverable, not fatal, as long as the CHANGELOG section exists. Still create it here when you can: it is one fewer thing depending on the auto-heal, and it lets you author a title/notes that differ from the raw CHANGELOG section.
66
+ - **Creating the release object *before* the tag is effectively REQUIRED, not belt-and-suspenders.** `release.yml`'s `guard` job polls ~150s for the release to appear, and if it never does it auto-creates the draft from `CHANGELOG.md` (#4331). But that auto-create routinely lands **too late for the same `release` run's completeness check**: the draft is created at the tail of the poll window, the check re-reads the release list in the *same* job, still cannot see it, exits 4, and **fails the run** — `npm`/`publish`/`finalize` all `needs:` this job, so they SKIP and nothing ships. This is not hypothetical: on **v0.21.18** (`release` run `32095648269`, attempt 1) the tag was pushed with no draft, `guard` retried 10× over ~150s, logged `auto-created draft release v0.21.18 from CHANGELOG.md`, then still exited 4 (`auto-created v0.21.18 but the completeness check still cannot see it`) and the job failed at `03:33:23Z` — npm/publish/latest all SKIPPED. So do not read #4331 as "skipping this step is fine": create the draft here, on the pinned SHA, before the tag push. It also lets you author a title/notes that differ from the raw CHANGELOG section.
67
+ - **If the tag is already pushed without a draft and `guard` has failed, the fix-forward is a re-run, not a re-cut.** The auto-create has by then left a real draft object behind, so re-run the failed jobs — the guard's completeness check now sees the draft and passes:
68
+ ```bash
69
+ gh run rerun <release-run-id> --failed -R switchroom/switchroom
70
+ ```
71
+ On the second attempt `guard` passes → binaries attach → npm publishes → the GitHub Release un-drafts → `:vX.Y.Z` promotes to `:latest`. This is exactly how v0.21.18 recovered (attempt 2 published `03:37:03Z`). Do NOT cut a new patch version to route around a failed guard — the release for this tag already exists as a draft.
67
72
  - **Notes extraction gotcha (historical):** the naive `awk '/^## vX/,/^## v/' CHANGELOG` range collapses to a single line. Use a start-flag awk: `awk 'f{print} /^## vX\.Y\.Z/{print; f=1} f && /^## v/ && !/^## vX\.Y\.Z/{exit}'` — or extract the section to a temp file by line range.
68
73
  - **`gh release create` has been silently dropped in past runs.** Verify it exists and is a draft:
69
74
  ```bash
@@ -0,0 +1,83 @@
1
+ /**
2
+ * connection-drop.ts — the ONE canonical "was this a mid-stream connection /
3
+ * SSE drop?" wording matcher.
4
+ *
5
+ * THE PROBLEM this closes
6
+ * -----------------------
7
+ * Two independent classifiers historically disagreed about what a mid-stream
8
+ * "connection lost" / SSE-drop is, and they are consulted on different code
9
+ * paths:
10
+ *
11
+ * - Path A — `parseLlmError` (llm-error-present.ts) → `detectModelUnavailable`
12
+ * (model-unavailable.ts): a network-drop wording maps to
13
+ * `{kind:'transient', source:'network'}`.
14
+ * - Path B — the sub-agent transcript path → `detectErrorInTranscriptLine`
15
+ * (session-tail.ts) → `classifyClaudeError` (operator-events.ts): a
16
+ * connection-drop-worded line that is NOT the exact `server_error` /
17
+ * `api_error` type falls through to `unknown-5xx`/`unknown-4xx` (a generic
18
+ * terminal), because that path never consulted the network-drop wording
19
+ * list.
20
+ *
21
+ * So the SAME dropped-stream line was a "transient network" event on one path
22
+ * and a "generic terminal" event on the other. This module gives both paths a
23
+ * single predicate to consult so a connection-drop is identifiable as such on
24
+ * BOTH.
25
+ *
26
+ * SCOPE: this is a WORDING matcher only. It answers "does this text look like a
27
+ * transport connection drop?" — it deliberately does NOT decide the final
28
+ * error kind. Callers gate the raw predicate on their own classification (only
29
+ * flagging a drop on the transient/unknown/transport families, never on a
30
+ * positively-identified auth/quota/credit wall) so a wrapped auth/quota error
31
+ * whose OUTER text happens to say "fetch failed" is never mislabelled a drop.
32
+ *
33
+ * Pure module: no IPC, no bot, no FS. Trivially unit-testable.
34
+ */
35
+
36
+ /**
37
+ * Canonical connection-drop / SSE-drop wordings (lowercase substrings, matched
38
+ * case-insensitively). Provenance for each entry is in the PR description; the
39
+ * list is deliberately CONSERVATIVE — it covers transport-layer connection
40
+ * drops only and must NOT match auth / quota / overload / provider-credit
41
+ * wording (verified by the negative tests). Bare `stream` / `terminated` are
42
+ * intentionally EXCLUDED: `upstream`/`downstream` contain `stream`, and
43
+ * `terminated` appears in unrelated contexts — both would over-match and risk a
44
+ * false auto-resume in the later PRs that gate on this predicate.
45
+ *
46
+ * DNS-resolution failures (`enotfound`, `eai_again`, `getaddrinfo`, "request
47
+ * timed out") are also excluded: a name-resolution failure is a network fault
48
+ * but NOT a connection drop (no connection was ever established). Path A still
49
+ * classifies those as its broader `network` kind, unchanged — they simply do
50
+ * not set the connection-drop discriminator.
51
+ */
52
+ export const CONNECTION_DROP_SIGNALS: readonly string[] = [
53
+ // ── Node/undici transport error codes (worded + code forms) ──────────────
54
+ 'socket hang up', // model-unavailable.ts networkSignals; mcp-credential-failure.ts
55
+ 'econnreset', // model-unavailable.ts networkSignals; retry-api-call.ts; ubiquitous fixtures
56
+ 'econnrefused', // model-unavailable.ts networkSignals
57
+ 'etimedout', // model-unavailable.ts networkSignals; retry-api-call.ts
58
+ 'epipe', // broken pipe on a half-dead socket (tests: pending-card-expiry, boot-sweep-gate)
59
+ 'fetch failed', // model-unavailable.ts networkSignals; retry-api-call.ts
60
+ 'network error', // model-unavailable.ts networkSignals
61
+ // ── Worded connection-lost forms ─────────────────────────────────────────
62
+ 'connection refused', // model-unavailable.ts networkSignals
63
+ 'connection reset', // worded ECONNRESET (retry-api-call.ts comment)
64
+ 'connection closed', // model-unavailable.ts networkSignals; canonical "Connection closed mid-response"
65
+ 'connection lost', // plausible SSE-drop wording (precise phrase, no over-match risk)
66
+ // ── Mid-response / SSE-drop markers ──────────────────────────────────────
67
+ 'mid-response', // model-unavailable.ts networkSignals; "Connection closed mid-response"
68
+ 'premature close', // Node/undici stream "Premature close" — a response body that ended early
69
+ 'stream disconnected', // plausible SSE-drop wording (precise two-word phrase)
70
+ 'stream closed', // plausible SSE-drop wording (precise two-word phrase)
71
+ ]
72
+
73
+ /**
74
+ * True when `text` carries a canonical connection-drop / SSE-drop wording.
75
+ *
76
+ * WORDING ONLY — see the module header. Never throws; a non-string collapses to
77
+ * `false`.
78
+ */
79
+ export function isConnectionDropText(text: string): boolean {
80
+ if (typeof text !== 'string' || text.length === 0) return false
81
+ const lower = text.toLowerCase()
82
+ return CONNECTION_DROP_SIGNALS.some(s => lower.includes(s))
83
+ }
@@ -23205,6 +23205,31 @@ var OPERATOR_ACTIONABLE_KINDS = new Set([
23205
23205
  "proxy-misconfig"
23206
23206
  ]);
23207
23207
 
23208
+ // connection-drop.ts
23209
+ var CONNECTION_DROP_SIGNALS = [
23210
+ "socket hang up",
23211
+ "econnreset",
23212
+ "econnrefused",
23213
+ "etimedout",
23214
+ "epipe",
23215
+ "fetch failed",
23216
+ "network error",
23217
+ "connection refused",
23218
+ "connection reset",
23219
+ "connection closed",
23220
+ "connection lost",
23221
+ "mid-response",
23222
+ "premature close",
23223
+ "stream disconnected",
23224
+ "stream closed"
23225
+ ];
23226
+ function isConnectionDropText(text) {
23227
+ if (typeof text !== "string" || text.length === 0)
23228
+ return false;
23229
+ const lower = text.toLowerCase();
23230
+ return CONNECTION_DROP_SIGNALS.some((s) => lower.includes(s));
23231
+ }
23232
+
23208
23233
  // tool-label-sidecar.ts
23209
23234
  import { existsSync as existsSync2, readFileSync, statSync as statSync2 } from "node:fs";
23210
23235
  import { join as join2 } from "node:path";
@@ -23677,6 +23702,10 @@ function extractRetryState(obj) {
23677
23702
  maxRetries: typeof obj.maxRetries === "number" ? obj.maxRetries : null
23678
23703
  };
23679
23704
  }
23705
+ var CONNECTION_DROP_ELIGIBLE_KINDS = new Set(["transport-transient", "unknown-5xx", "unknown-4xx"]);
23706
+ function isConnectionDrop(kind, scanText) {
23707
+ return CONNECTION_DROP_ELIGIBLE_KINDS.has(kind) && isConnectionDropText(scanText);
23708
+ }
23680
23709
  function detectErrorInTranscriptLine(line) {
23681
23710
  if (!line || line.length > 2 * 1024 * 1024)
23682
23711
  return null;
@@ -23701,7 +23730,9 @@ ${errStr}`) ? "rate-limited" : "quota-exhausted" : classifyClaudeError({ type: e
23701
23730
  raw: obj,
23702
23731
  detail: text || errStr || "api error",
23703
23732
  transient: kind2 === "rate-limited",
23704
- terminal: true
23733
+ terminal: true,
23734
+ connectionDrop: isConnectionDrop(kind2, `${text}
23735
+ ${errStr}`)
23705
23736
  };
23706
23737
  }
23707
23738
  const isErrorLine = type === "api_error" || type === "error";
@@ -23714,7 +23745,15 @@ ${errStr}`) ? "rate-limited" : "quota-exhausted" : classifyClaudeError({ type: e
23714
23745
  const transient = kind === "rate-limited" || kind === "transport-transient";
23715
23746
  const retry = extractRetryState(obj);
23716
23747
  const terminal = !transient ? true : retry.retryAttempt != null && retry.maxRetries != null ? retry.retryAttempt >= retry.maxRetries : isErrorLine;
23717
- return { kind, raw, detail, transient, terminal };
23748
+ return {
23749
+ kind,
23750
+ raw,
23751
+ detail,
23752
+ transient,
23753
+ terminal,
23754
+ connectionDrop: isConnectionDrop(kind, `${detail}
23755
+ ${String(type ?? "")}`)
23756
+ };
23718
23757
  }
23719
23758
  function extractDetailMessage(obj) {
23720
23759
  if (!obj)
@@ -74529,6 +74529,31 @@ var OPERATOR_ACTIONABLE_KINDS = new Set([
74529
74529
  "proxy-misconfig"
74530
74530
  ]);
74531
74531
 
74532
+ // connection-drop.ts
74533
+ var CONNECTION_DROP_SIGNALS = [
74534
+ "socket hang up",
74535
+ "econnreset",
74536
+ "econnrefused",
74537
+ "etimedout",
74538
+ "epipe",
74539
+ "fetch failed",
74540
+ "network error",
74541
+ "connection refused",
74542
+ "connection reset",
74543
+ "connection closed",
74544
+ "connection lost",
74545
+ "mid-response",
74546
+ "premature close",
74547
+ "stream disconnected",
74548
+ "stream closed"
74549
+ ];
74550
+ function isConnectionDropText(text4) {
74551
+ if (typeof text4 !== "string" || text4.length === 0)
74552
+ return false;
74553
+ const lower = text4.toLowerCase();
74554
+ return CONNECTION_DROP_SIGNALS.some((s) => lower.includes(s));
74555
+ }
74556
+
74532
74557
  // session-tail.ts
74533
74558
  function sanitizeCwdToProjectName(cwd) {
74534
74559
  return cwd.replace(/[^a-zA-Z0-9]/g, "-");
@@ -74783,6 +74808,7 @@ function projectTranscriptLine(line) {
74783
74808
  }
74784
74809
  return [];
74785
74810
  }
74811
+ var CONNECTION_DROP_ELIGIBLE_KINDS = new Set(["transport-transient", "unknown-5xx", "unknown-4xx"]);
74786
74812
 
74787
74813
  // pty-tail.ts
74788
74814
  var import_headless = __toESM(require_xterm_headless(), 1);
@@ -78601,6 +78627,7 @@ function parseLlmError(raw, retryState) {
78601
78627
  terminal = true;
78602
78628
  }
78603
78629
  }
78630
+ const connectionDrop = (kind === "transient" || kind === "unknown") && isConnectionDropText(text4);
78604
78631
  return {
78605
78632
  kind,
78606
78633
  coreText: buildCoreText(kind, source),
@@ -78610,6 +78637,7 @@ function parseLlmError(raw, retryState) {
78610
78637
  ...requestId != null ? { requestId } : {},
78611
78638
  ...providerId != null ? { providerId } : {},
78612
78639
  source,
78640
+ connectionDrop,
78613
78641
  autoRetrying,
78614
78642
  terminal
78615
78643
  };
@@ -101454,6 +101482,10 @@ function extractRetryState(obj) {
101454
101482
  maxRetries: typeof obj.maxRetries === "number" ? obj.maxRetries : null
101455
101483
  };
101456
101484
  }
101485
+ var CONNECTION_DROP_ELIGIBLE_KINDS2 = new Set(["transport-transient", "unknown-5xx", "unknown-4xx"]);
101486
+ function isConnectionDrop(kind, scanText) {
101487
+ return CONNECTION_DROP_ELIGIBLE_KINDS2.has(kind) && isConnectionDropText(scanText);
101488
+ }
101457
101489
  function detectErrorInTranscriptLine(line) {
101458
101490
  if (!line || line.length > 2 * 1024 * 1024)
101459
101491
  return null;
@@ -101478,7 +101510,9 @@ ${errStr}`) ? "rate-limited" : "quota-exhausted" : classifyClaudeError({ type: e
101478
101510
  raw: obj,
101479
101511
  detail: text4 || errStr || "api error",
101480
101512
  transient: kind2 === "rate-limited",
101481
- terminal: true
101513
+ terminal: true,
101514
+ connectionDrop: isConnectionDrop(kind2, `${text4}
101515
+ ${errStr}`)
101482
101516
  };
101483
101517
  }
101484
101518
  const isErrorLine = type === "api_error" || type === "error";
@@ -101491,7 +101525,15 @@ ${errStr}`) ? "rate-limited" : "quota-exhausted" : classifyClaudeError({ type: e
101491
101525
  const transient = kind === "rate-limited" || kind === "transport-transient";
101492
101526
  const retry = extractRetryState(obj);
101493
101527
  const terminal = !transient ? true : retry.retryAttempt != null && retry.maxRetries != null ? retry.retryAttempt >= retry.maxRetries : isErrorLine;
101494
- return { kind, raw, detail, transient, terminal };
101528
+ return {
101529
+ kind,
101530
+ raw,
101531
+ detail,
101532
+ transient,
101533
+ terminal,
101534
+ connectionDrop: isConnectionDrop(kind, `${detail}
101535
+ ${String(type ?? "")}`)
101536
+ };
101495
101537
  }
101496
101538
  function extractDetailMessage(obj) {
101497
101539
  if (!obj)
@@ -105876,10 +105918,10 @@ function startOutboxSweep(deps) {
105876
105918
  }
105877
105919
 
105878
105920
  // ../src/build-info.ts
105879
- var VERSION2 = "0.21.17";
105880
- var COMMIT_SHA = "ac61272c";
105881
- var COMMIT_DATE = "2026-08-17T16:42:58Z";
105882
- var LATEST_PR = 4768;
105921
+ var VERSION2 = "0.21.19";
105922
+ var COMMIT_SHA = "7e7061ba";
105923
+ var COMMIT_DATE = "2026-08-18T15:36:04Z";
105924
+ var LATEST_PR = 4777;
105883
105925
  var COMMITS_AHEAD_OF_TAG = 0;
105884
105926
 
105885
105927
  // gateway/boot-version.ts
@@ -17278,6 +17278,34 @@ var init_operator_events = __esm(() => {
17278
17278
  ]);
17279
17279
  });
17280
17280
 
17281
+ // connection-drop.ts
17282
+ function isConnectionDropText(text) {
17283
+ if (typeof text !== "string" || text.length === 0)
17284
+ return false;
17285
+ const lower = text.toLowerCase();
17286
+ return CONNECTION_DROP_SIGNALS.some((s) => lower.includes(s));
17287
+ }
17288
+ var CONNECTION_DROP_SIGNALS;
17289
+ var init_connection_drop = __esm(() => {
17290
+ CONNECTION_DROP_SIGNALS = [
17291
+ "socket hang up",
17292
+ "econnreset",
17293
+ "econnrefused",
17294
+ "etimedout",
17295
+ "epipe",
17296
+ "fetch failed",
17297
+ "network error",
17298
+ "connection refused",
17299
+ "connection reset",
17300
+ "connection closed",
17301
+ "connection lost",
17302
+ "mid-response",
17303
+ "premature close",
17304
+ "stream disconnected",
17305
+ "stream closed"
17306
+ ];
17307
+ });
17308
+
17281
17309
  // tool-label-sidecar.ts
17282
17310
  import { existsSync as existsSync3, readFileSync as readFileSync2, statSync as statSync3 } from "node:fs";
17283
17311
  import { join as join3 } from "node:path";
@@ -17761,6 +17789,9 @@ function extractRetryState(obj) {
17761
17789
  maxRetries: typeof obj.maxRetries === "number" ? obj.maxRetries : null
17762
17790
  };
17763
17791
  }
17792
+ function isConnectionDrop(kind, scanText) {
17793
+ return CONNECTION_DROP_ELIGIBLE_KINDS.has(kind) && isConnectionDropText(scanText);
17794
+ }
17764
17795
  function detectErrorInTranscriptLine(line) {
17765
17796
  if (!line || line.length > 2 * 1024 * 1024)
17766
17797
  return null;
@@ -17785,7 +17816,9 @@ ${errStr}`) ? "rate-limited" : "quota-exhausted" : classifyClaudeError({ type: e
17785
17816
  raw: obj,
17786
17817
  detail: text || errStr || "api error",
17787
17818
  transient: kind2 === "rate-limited",
17788
- terminal: true
17819
+ terminal: true,
17820
+ connectionDrop: isConnectionDrop(kind2, `${text}
17821
+ ${errStr}`)
17789
17822
  };
17790
17823
  }
17791
17824
  const isErrorLine = type === "api_error" || type === "error";
@@ -17798,7 +17831,15 @@ ${errStr}`) ? "rate-limited" : "quota-exhausted" : classifyClaudeError({ type: e
17798
17831
  const transient = kind === "rate-limited" || kind === "transport-transient";
17799
17832
  const retry = extractRetryState(obj);
17800
17833
  const terminal = !transient ? true : retry.retryAttempt != null && retry.maxRetries != null ? retry.retryAttempt >= retry.maxRetries : isErrorLine;
17801
- return { kind, raw, detail, transient, terminal };
17834
+ return {
17835
+ kind,
17836
+ raw,
17837
+ detail,
17838
+ transient,
17839
+ terminal,
17840
+ connectionDrop: isConnectionDrop(kind, `${detail}
17841
+ ${String(type ?? "")}`)
17842
+ };
17802
17843
  }
17803
17844
  function extractDetailMessage(obj) {
17804
17845
  if (!obj)
@@ -18234,13 +18275,15 @@ function startSessionTail(config2) {
18234
18275
  }
18235
18276
  };
18236
18277
  }
18237
- var MAX_JSONL_LINE_BYTES, MAX_ERROR_TEXT_CHARS = 500;
18278
+ var MAX_JSONL_LINE_BYTES, MAX_ERROR_TEXT_CHARS = 500, CONNECTION_DROP_ELIGIBLE_KINDS;
18238
18279
  var init_session_tail = __esm(() => {
18239
18280
  init_operator_events();
18240
18281
  init_model_unavailable();
18282
+ init_connection_drop();
18241
18283
  init_tool_label_sidecar();
18242
18284
  init_model_label();
18243
18285
  MAX_JSONL_LINE_BYTES = 2 * 1024 * 1024;
18286
+ CONNECTION_DROP_ELIGIBLE_KINDS = new Set(["transport-transient", "unknown-5xx", "unknown-4xx"]);
18244
18287
  });
18245
18288
 
18246
18289
  // ../node_modules/.bun/@xterm+headless@6.0.0/node_modules/@xterm/headless/lib-headless/xterm-headless.js
@@ -41,6 +41,7 @@ import {
41
41
  type ProviderCreditEntry,
42
42
  } from './provider-credit.js'
43
43
  import { classifyClaudeError } from './operator-events.js'
44
+ import { isConnectionDropText } from './connection-drop.js'
44
45
  import { stripRawErrorBytes, extractRequestId } from './raw-error-scrub.js'
45
46
  import { fmtLocalClock, tzAbbrev } from './shared/local-time.js'
46
47
 
@@ -94,6 +95,18 @@ export interface ParsedLlmError {
94
95
  */
95
96
  providerId?: string
96
97
  source: LlmErrorSource
98
+ /**
99
+ * True when this error is a mid-stream connection / SSE drop (a transport
100
+ * connection loss), per the canonical {@link isConnectionDropText} matcher.
101
+ * Set consistently on BOTH classification paths (here and
102
+ * `detectErrorInTranscriptLine`) so a later PR can gate auto-resume on ONE
103
+ * reliable discriminator. Only ever true for the `transient`/`unknown`
104
+ * families — never for a positively-identified auth/quota/overload/credit
105
+ * wall (guarded below), so it can never trigger a wrong auto-resume of a
106
+ * genuinely terminal error. Classification only in this PR; no consumer acts
107
+ * on it yet.
108
+ */
109
+ connectionDrop: boolean
97
110
  /** True when the harness is still retrying this error internally (mid-retry). */
98
111
  autoRetrying: boolean
99
112
  /** True when the failure is final (NOT an in-flight retry). */
@@ -169,6 +182,17 @@ export function parseLlmError(
169
182
  }
170
183
  }
171
184
 
185
+ // Connection-drop discriminator. Gated to the transient/unknown families so a
186
+ // positively-classified auth/quota/overload/credit wall is NEVER flagged as a
187
+ // drop, even if its raw text coincidentally carries a drop wording (e.g. a
188
+ // LiteLLM-wrapped 401 whose outer text says "fetch failed"). The gate mirrors
189
+ // Path B's inclusion set (`transport-transient`/`unknown-*`), keeping the two
190
+ // classifiers in agreement. `source==='network'` is not required: a drop
191
+ // wording that `detectModelUnavailable` does not recognise (e.g. `EPIPE`)
192
+ // lands on `unknown` here, and must still be identifiable as a drop.
193
+ const connectionDrop =
194
+ (kind === 'transient' || kind === 'unknown') && isConnectionDropText(text)
195
+
172
196
  return {
173
197
  kind,
174
198
  coreText: buildCoreText(kind, source),
@@ -178,6 +202,7 @@ export function parseLlmError(
178
202
  ...(requestId != null ? { requestId } : {}),
179
203
  ...(providerId != null ? { providerId } : {}),
180
204
  source,
205
+ connectionDrop,
181
206
  autoRetrying,
182
207
  terminal,
183
208
  }
@@ -82,6 +82,12 @@ else
82
82
  uat/load-env.test.ts
83
83
  uat/uat-driver.test.ts
84
84
  uat/runners/
85
+ # M3 directive-flip UAT — deterministic (no-model, no-network) gate half.
86
+ # Same rationale as uat/runners/: vitest excludes all of uat/**, and these
87
+ # import `vitest` (bun runs them via its vitest-compat shim) — so they run
88
+ # on the BUN side. Directory match (trailing slash) never widens into
89
+ # uat/scenarios/. Fenced by scripts/check-test-runner-coverage.mjs.
90
+ uat/flip/
85
91
  )
86
92
  fi
87
93
 
@@ -42,6 +42,7 @@ function isMultiAgentEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
42
42
  }
43
43
  import { classifyClaudeError, type OperatorEventKind } from './operator-events.js'
44
44
  import { isLitellmProxyLocal429, isTransientUpstreamSignal } from './model-unavailable.js'
45
+ import { isConnectionDropText } from './connection-drop.js'
45
46
  import { createToolLabelSidecar, type ToolLabelSidecar, type SidecarOptions } from './tool-label-sidecar.js'
46
47
  import { isModelSentinel } from './model-label.js'
47
48
 
@@ -960,6 +961,25 @@ function extractRetryState(obj: Record<string, unknown>): {
960
961
  }
961
962
  }
962
963
 
964
+ /**
965
+ * The OperatorEventKinds on which a connection-drop wording may be flagged as a
966
+ * genuine transport drop. INCLUSION (not exclusion) by design: a positively
967
+ * classified auth/quota/credit/rate-limit/overload wall is NEVER in this set,
968
+ * so a wrapped terminal error whose outer text coincidentally carries a drop
969
+ * wording is never mislabelled a drop. `transport-transient` is the natural
970
+ * mid-stream-abort kind; `unknown-4xx`/`unknown-5xx` is where a drop-worded
971
+ * line lands when `classifyClaudeError` does not recognise its wording (the
972
+ * exact Path-B bug this closes). Mirrors Path A's `transient`/`unknown` gate in
973
+ * `parseLlmError`, keeping the two classifiers in agreement.
974
+ */
975
+ const CONNECTION_DROP_ELIGIBLE_KINDS: ReadonlySet<OperatorEventKind> =
976
+ new Set<OperatorEventKind>(['transport-transient', 'unknown-5xx', 'unknown-4xx'])
977
+
978
+ /** True when `kind` may carry the connection-drop flag AND the text is a drop wording. */
979
+ function isConnectionDrop(kind: OperatorEventKind, scanText: string): boolean {
980
+ return CONNECTION_DROP_ELIGIBLE_KINDS.has(kind) && isConnectionDropText(scanText)
981
+ }
982
+
963
983
  export function detectErrorInTranscriptLine(
964
984
  line: string,
965
985
  ): {
@@ -972,6 +992,15 @@ export function detectErrorInTranscriptLine(
972
992
  * error mid-retry is `transient:true, terminal:false`; the caller
973
993
  * suppresses it (no operator card until the failure is terminal). */
974
994
  terminal: boolean
995
+ /**
996
+ * True when this line is a mid-stream connection / SSE drop (a transport
997
+ * connection loss), per the canonical `isConnectionDropText` matcher, gated
998
+ * to the transport/unknown kinds. Set consistently with `parseLlmError`'s
999
+ * `ParsedLlmError.connectionDrop` so a later PR can gate auto-resume on ONE
1000
+ * reliable discriminator across both classification paths. Classification
1001
+ * only in this PR; no caller acts on it yet.
1002
+ */
1003
+ connectionDrop: boolean
975
1004
  } | null {
976
1005
  if (!line || line.length > 2 * 1024 * 1024) return null
977
1006
  let obj: Record<string, unknown>
@@ -1042,6 +1071,7 @@ export function detectErrorInTranscriptLine(
1042
1071
  detail: text || errStr || 'api error',
1043
1072
  transient: kind === 'rate-limited',
1044
1073
  terminal: true,
1074
+ connectionDrop: isConnectionDrop(kind, `${text}\n${errStr}`),
1045
1075
  }
1046
1076
  }
1047
1077
 
@@ -1089,7 +1119,14 @@ export function detectErrorInTranscriptLine(
1089
1119
  ? retry.retryAttempt >= retry.maxRetries
1090
1120
  : isErrorLine
1091
1121
 
1092
- return { kind, raw, detail, transient, terminal }
1122
+ return {
1123
+ kind,
1124
+ raw,
1125
+ detail,
1126
+ transient,
1127
+ terminal,
1128
+ connectionDrop: isConnectionDrop(kind, `${detail}\n${String(type ?? '')}`),
1129
+ }
1093
1130
  }
1094
1131
 
1095
1132
  function extractDetailMessage(obj: Record<string, unknown> | null): string | null {