switchroom 0.21.18 → 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.
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.21.18", COMMIT_SHA = "ae063418", COMMIT_DATE = "2026-08-18T03:14:53Z";
2123
+ var VERSION = "0.21.19", COMMIT_SHA = "7e7061ba", COMMIT_DATE = "2026-08-18T15:36:04Z";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -21340,6 +21340,60 @@ function renderHindsightHooksOverrides(raw, tunables) {
21340
21340
  return JSON.stringify(root, null, 2) + `
21341
21341
  `;
21342
21342
  }
21343
+ function readHooksPrefetchAsyncTimeout(raw) {
21344
+ const absent = { present: false, async: false, timeout: null };
21345
+ let parsed;
21346
+ try {
21347
+ parsed = JSON.parse(raw);
21348
+ } catch {
21349
+ return absent;
21350
+ }
21351
+ const hooks = parsed?.hooks;
21352
+ if (hooks == null || typeof hooks !== "object")
21353
+ return absent;
21354
+ const matchers = hooks.Stop;
21355
+ if (!Array.isArray(matchers))
21356
+ return absent;
21357
+ for (const matcher of matchers) {
21358
+ const inner = matcher?.hooks;
21359
+ if (!Array.isArray(inner))
21360
+ continue;
21361
+ for (const hook of inner) {
21362
+ const h = hook;
21363
+ if (h == null || typeof h.command !== "string")
21364
+ continue;
21365
+ if (!h.command.includes(PREFETCH_HOOK_COMMAND_MARKER))
21366
+ continue;
21367
+ return {
21368
+ present: true,
21369
+ async: h.async === true,
21370
+ timeout: typeof h.timeout === "number" ? h.timeout : null
21371
+ };
21372
+ }
21373
+ }
21374
+ return absent;
21375
+ }
21376
+ function validatePrefetchAsyncTimeout(shape, prefetchRecallTimeoutSeconds) {
21377
+ const problems = [];
21378
+ if (!shape.present) {
21379
+ problems.push("hooks/hooks.json registers no Stop hook for prefetch.py (the async " + "recall-prefetch producer)");
21380
+ return problems;
21381
+ }
21382
+ if (!shape.async) {
21383
+ problems.push('the prefetch.py Stop hook is not marked `"async": true` \u2014 a synchronous ' + "prefetch blocks every turn's completion for up to its timeout");
21384
+ }
21385
+ if (shape.timeout === null || shape.timeout <= 0) {
21386
+ problems.push("the prefetch.py Stop hook has no positive `timeout` (async ceiling) \u2014 a " + "wedged producer would never be reaped");
21387
+ return problems;
21388
+ }
21389
+ if (shape.timeout > MAX_PREFETCH_ASYNC_TIMEOUT_SECONDS) {
21390
+ problems.push(`the prefetch.py async ceiling is ${shape.timeout}s, above the ` + `${MAX_PREFETCH_ASYNC_TIMEOUT_SECONDS}s maximum \u2014 a wedged producer holds a ` + `background slot for that long`);
21391
+ }
21392
+ if (prefetchRecallTimeoutSeconds >= shape.timeout) {
21393
+ problems.push(`memoryPrefetchTimeoutSeconds ${prefetchRecallTimeoutSeconds}s is >= the ` + `${shape.timeout}s async hook ceiling \u2014 the producer's own recall can outlive ` + `its hook and be SIGKILLed mid buffer-write, leaving a torn buffer`);
21394
+ }
21395
+ return problems;
21396
+ }
21343
21397
  function readHooksRecallTimeout(raw) {
21344
21398
  let parsed;
21345
21399
  try {
@@ -21368,7 +21422,7 @@ function readHooksRecallTimeout(raw) {
21368
21422
  }
21369
21423
  return null;
21370
21424
  }
21371
- var DEFAULT_RECALL_HOOK_TIMEOUT_SECONDS = 12, DEFAULT_RECALL_MAX_MEMORIES = 8, RECALL_DEADLINE_HEADROOM_SECONDS = 2, MIN_RECALL_HOOK_TIMEOUT_SECONDS, DEFAULT_RECALL_REQUEST_TIMEOUT_SECONDS = 12, MANAGED_HOOK_EVENT = "UserPromptSubmit", RECALL_HOOK_COMMAND_MARKER = "recall.py";
21425
+ var DEFAULT_RECALL_HOOK_TIMEOUT_SECONDS = 12, DEFAULT_RECALL_MAX_MEMORIES = 8, RECALL_DEADLINE_HEADROOM_SECONDS = 2, MIN_RECALL_HOOK_TIMEOUT_SECONDS, DEFAULT_RECALL_REQUEST_TIMEOUT_SECONDS = 12, MANAGED_HOOK_EVENT = "UserPromptSubmit", RECALL_HOOK_COMMAND_MARKER = "recall.py", PREFETCH_HOOK_COMMAND_MARKER = "prefetch.py", MAX_PREFETCH_ASYNC_TIMEOUT_SECONDS = 30;
21372
21426
  var init_hindsight_recall_tunables = __esm(() => {
21373
21427
  init_hindsight_recall_passthrough();
21374
21428
  MIN_RECALL_HOOK_TIMEOUT_SECONDS = RECALL_DEADLINE_HEADROOM_SECONDS + 1;
@@ -28559,6 +28613,25 @@ class DirectiveAdmin {
28559
28613
  });
28560
28614
  return `Deactivated directive '${target.name}' (id ${target.id}) in bank ` + `'${this.opts.bankId}', tagged ${args.tag}.`;
28561
28615
  }
28616
+ async deactivateAllActiveByName(args) {
28617
+ const all = await this.list();
28618
+ const named = all.filter((d) => d.name === args.name);
28619
+ if (named.length === 0) {
28620
+ const known = all.map((d) => d.name).sort().join(", ");
28621
+ throw new Error(`no directive named '${args.name}' in bank '${this.opts.bankId}' ` + `(the directive to deactivate). Known directives: ${known || "(none)"}`);
28622
+ }
28623
+ const active = named.filter((d) => d.is_active !== false);
28624
+ const alreadyInactive = named.filter((d) => d.is_active === false).map((d) => ({ id: d.id, name: d.name, priority: d.priority }));
28625
+ for (const d of active)
28626
+ this.refuseIfRulesBlock(d);
28627
+ const deactivated = [];
28628
+ for (const d of active) {
28629
+ if (!args.dryRun)
28630
+ await this.deactivateResolved(all, d);
28631
+ deactivated.push({ id: d.id, name: d.name, priority: d.priority });
28632
+ }
28633
+ return { deactivated, alreadyInactive, dryRun: args.dryRun ?? false };
28634
+ }
28562
28635
  async reactivate(args) {
28563
28636
  const all = await this.list();
28564
28637
  const target = this.resolve(all, args.name, "the directive to reactivate");
@@ -63701,6 +63774,49 @@ function detectHindsightRecallTunableDrift(name, agentConfig, agentDir, config)
63701
63774
  }
63702
63775
  return findings;
63703
63776
  }
63777
+ function detectPrefetchAsyncTimeoutDrift(name, agentConfig, agentDir, config) {
63778
+ if (!isHindsightEnabled(config))
63779
+ return [];
63780
+ const resolved = resolveAgentConfig(config.defaults, config.profiles, agentConfig);
63781
+ if (resolved.memory?.auto_recall === false)
63782
+ return [];
63783
+ const pluginDir = join66(agentDir, ".claude", "plugins", "hindsight-memory");
63784
+ if (!existsSync69(pluginDir))
63785
+ return [];
63786
+ const settingsPath = join66(pluginDir, "settings.json");
63787
+ if (!existsSync69(settingsPath))
63788
+ return [];
63789
+ let settings = null;
63790
+ try {
63791
+ settings = JSON.parse(readFileSync62(settingsPath, "utf-8"));
63792
+ } catch {
63793
+ return [];
63794
+ }
63795
+ if (settings?.memoryPrefetchEnabled !== true)
63796
+ return [];
63797
+ const hooksPath = join66(pluginDir, "hooks", "hooks.json");
63798
+ if (!existsSync69(hooksPath))
63799
+ return [];
63800
+ let shape;
63801
+ try {
63802
+ shape = readHooksPrefetchAsyncTimeout(readFileSync62(hooksPath, "utf-8"));
63803
+ } catch {
63804
+ return [];
63805
+ }
63806
+ const rawPrefetchTimeout = settings.memoryPrefetchTimeoutSeconds;
63807
+ const prefetchRecallTimeout = typeof rawPrefetchTimeout === "number" && Number.isFinite(rawPrefetchTimeout) && rawPrefetchTimeout > 0 ? rawPrefetchTimeout : 5;
63808
+ const problems = validatePrefetchAsyncTimeout(shape, prefetchRecallTimeout);
63809
+ if (problems.length === 0)
63810
+ return [];
63811
+ return [
63812
+ {
63813
+ surface: "memory-prefetch",
63814
+ agent: name,
63815
+ detail: `async recall-prefetch (memoryPrefetchEnabled) is ON but its async-timeout ` + `config is unsafe: ${problems.join("; ")}`,
63816
+ fix: "Re-stamp the plugin (`switchroom apply`) so hooks.json carries the " + '`prefetch.py` Stop hook with `"async": true` and a timeout above ' + "memoryPrefetchTimeoutSeconds, or set memoryPrefetchEnabled off until the " + "async ceiling is corrected \u2014 a wedged prefetch either blocks the turn or " + "is killed mid buffer-write."
63817
+ }
63818
+ ];
63819
+ }
63704
63820
  function writeDriftReport(agentDir, findings) {
63705
63821
  try {
63706
63822
  const report = {
@@ -63724,6 +63840,7 @@ function detectAgentDrift(name, agentConfigRaw, agentsDir, config, configPath, o
63724
63840
  }));
63725
63841
  findings.push(...detectSkillsDrift(name, agentDir));
63726
63842
  findings.push(...detectHindsightRecallTunableDrift(name, agentConfig, agentDir, config));
63843
+ findings.push(...detectPrefetchAsyncTimeoutDrift(name, agentConfig, agentDir, config));
63727
63844
  if (!opts.skipContainerProbes) {
63728
63845
  findings.push(...detectHookScriptDrift(name, {
63729
63846
  binDir: opts.binDir,
@@ -110495,6 +110612,37 @@ function registerMemoryDirectiveCommand(memory, program3) {
110495
110612
  process.exit(1);
110496
110613
  }
110497
110614
  }));
110615
+ directive.command("deactivate <agent> <name>").description("Deactivate every ACTIVE directive named <name> in <agent>'s bank by " + "flipping is_active=false \u2014 the M3 directive-placement campaign entry " + "point for retiring a guardrail's in-bank copy once it has been " + "relocated to the agent's always-loaded CLAUDE.md. Matches by NAME " + "(mirrors reconcile's 'every ACTIVE directive named <name>' selection); " + "if multiple active copies share the name, all are deactivated. " + "REVERSIBLE \u2014 only is_active is touched, never a delete or a " + "content/priority rewrite, so it can be reactivated. Idempotent: a " + "re-run over already-inactive copies is a clean no-op, not an error. " + "REFUSES any directive carrying the rules-block marker tag (that stays " + "active until an M3-flip removes the marker) \u2014 the whole call is " + "refused if even one active copy carries it, mutating nothing.").option("--dry-run", "Print what WOULD deactivate without mutating anything").option("--json", "Machine-readable output").action(withConfigError(async (agent, name, opts) => {
110616
+ const admin = resolveDirectiveAdmin(program3, agent);
110617
+ try {
110618
+ const result = await admin.deactivateAllActiveByName({
110619
+ name,
110620
+ ...opts.dryRun ? { dryRun: true } : {}
110621
+ });
110622
+ if (opts.json) {
110623
+ console.log(JSON.stringify({ ok: true, agent, name, ...result }));
110624
+ return;
110625
+ }
110626
+ const verb = result.dryRun ? "would deactivate" : "deactivated";
110627
+ if (result.deactivated.length === 0) {
110628
+ console.log(source_default.yellow(`\u2022 no-op: no ACTIVE directive named "${name}" in ${agent}'s bank ` + `(${result.alreadyInactive.length} already-inactive ` + `cop${result.alreadyInactive.length === 1 ? "y" : "ies"} left ` + `untouched). Nothing to do.`));
110629
+ return;
110630
+ }
110631
+ for (const d of result.deactivated) {
110632
+ console.log(source_default.green(` ${result.dryRun ? "[dry-run] " : "\u2713 "}${verb} "${d.name}" ` + `(id ${d.id}, prior priority ${d.priority ?? "unset"})`));
110633
+ }
110634
+ const n = result.deactivated.length;
110635
+ console.log(source_default.green(`${result.dryRun ? "[dry-run] " : "\u2713 "}${verb} ${n} directive` + `${n === 1 ? "" : "s"} named "${name}" in ${agent}'s bank` + (result.alreadyInactive.length > 0 ? ` (${result.alreadyInactive.length} already-inactive ` + `cop${result.alreadyInactive.length === 1 ? "y" : "ies"} skipped)` : "") + (result.dryRun ? " \u2014 nothing was mutated" : ". Reverse with reactivate.")));
110636
+ } catch (e) {
110637
+ const msg = e instanceof Error ? e.message : String(e);
110638
+ if (opts.json) {
110639
+ console.log(JSON.stringify({ ok: false, error: msg }));
110640
+ } else {
110641
+ console.error(source_default.red(`\u2717 ${msg}`));
110642
+ }
110643
+ process.exit(1);
110644
+ }
110645
+ }));
110498
110646
  directive.command("mark-rules-block <agent> <id>").description("Stamp the persisted rules-block marker tag on directive <id> in " + "<agent>'s bank \u2014 the SAME DirectiveAdmin.markRulesBlock write path " + "the batch triage executor uses. Once stamped, deactivate_directive " + "(and every other DirectiveAdmin deactivation path) refuses this " + "directive unconditionally until an M3-flip action removes the " + "marker. This is the real entry point the mental-model-curator " + "skill's interactive triage pass calls for every directive it " + "classifies rules-block, BEFORE presenting the card \u2014 that skill " + "has no other write path to this marker (no MCP tool exposes it), " + "so without this call the code-level refusal never actually " + "arms itself on the interactive path (PR #4760 review follow-up).").option("--json", "Machine-readable output").action(withConfigError(async (agent, id, opts) => {
110499
110647
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME;
110500
110648
  if (selfAgent && selfAgent !== agent) {
@@ -21598,7 +21598,7 @@ function allocateAgentUid(name) {
21598
21598
  }
21599
21599
 
21600
21600
  // src/build-info.ts
21601
- var VERSION = "0.21.18";
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.18",
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.18";
105880
- var COMMIT_SHA = "ae063418";
105881
- var COMMIT_DATE = "2026-08-18T03:14:53Z";
105882
- var LATEST_PR = 4772;
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
  }