switchroom 0.21.6 → 0.21.7

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.
@@ -86,8 +86,28 @@ expect {
86
86
  send "\r"
87
87
  exp_continue
88
88
  }
89
+ -re {Continue with Fable} {
90
+ # Fable-5 usage-credits consent modal:
91
+ # Fable 5 runs on usage credits, purchased separately from your plan.
92
+ # 1. Continue with Fable 5
93
+ # ❯ 2. Switch to Sonnet 5 and continue
94
+ # Enter to confirm · Esc to cancel
95
+ # MUST precede the generic "Enter to confirm" branch below: that
96
+ # footer matches this modal too, and the CLI focuses option 2 by
97
+ # default (`defaultFocusValue:"switch"`), so a bare Return silently
98
+ # declines Fable and downgrades the agent to Sonnet. Select option 1
99
+ # by digit instead. Kept in sync with the `fable-usage-credits-consent`
100
+ # rule in src/agents/autoaccept.ts.
101
+ sleep 0.5
102
+ send "1\r"
103
+ exp_continue
104
+ }
89
105
  -re {Enter.{1,30}confirm} {
90
- # Generic "Enter to confirm" prompt (dev channels, trust dialog)
106
+ # Generic "Enter to confirm" prompt (dev channels, trust dialog).
107
+ # NOTE: the TS poller additionally guards this catch-all against
108
+ # numbered selectors (NUMBERED_CHOICE_SIGNATURE). expect matches a
109
+ # STREAM in branch order, so here the equivalent protection is
110
+ # ordering: every option-selecting branch must sit above this one.
91
111
  sleep 0.8
92
112
  send "\r"
93
113
  exp_continue
@@ -20,6 +20,29 @@ import { execFileSync as execFileSync3 } from "child_process";
20
20
 
21
21
  // src/agents/autoaccept.ts
22
22
  import { execFileSync } from "node:child_process";
23
+ var NUMBERED_CHOICE_SIGNATURE = /(?=[\s\S]*^[^\S\n]*(?:\u276f[^\S\n]*)?1\.[^\S\n]+\S)(?=[\s\S]*^[^\S\n]*(?:\u276f[^\S\n]*)?2\.[^\S\n]+\S)/m;
24
+ var FABLE_CONSENT_SIGNATURE = /(?=[\s\S]*Continue with Fable)(?=[\s\S]*usage credits)/i;
25
+ function fableConsentNav(text) {
26
+ for (const line of text.split(`
27
+ `)) {
28
+ const m = line.match(/^\s*(\u276f)?\s*(\d+)\.\s+(.*\S)\s*$/);
29
+ if (!m)
30
+ continue;
31
+ if (/Continue with Fable/i.test(m[3]))
32
+ return [m[2], "Enter"];
33
+ }
34
+ return null;
35
+ }
36
+ function ruleMatches(rule, text) {
37
+ if (!rule.match.test(text))
38
+ return false;
39
+ if (rule.notMatch && rule.notMatch.test(text))
40
+ return false;
41
+ return true;
42
+ }
43
+ function resolveRuleKeys(rule, text) {
44
+ return rule.keysFor?.(text) ?? rule.keys;
45
+ }
23
46
  var PROMPTS = [
24
47
  {
25
48
  name: "dev-channels-loading",
@@ -57,9 +80,16 @@ var PROMPTS = [
57
80
  keys: ["Escape"],
58
81
  maxFires: 5
59
82
  },
83
+ {
84
+ name: "fable-usage-credits-consent",
85
+ match: FABLE_CONSENT_SIGNATURE,
86
+ keysFor: fableConsentNav,
87
+ keys: ["1", "Enter"]
88
+ },
60
89
  {
61
90
  name: "enter-to-confirm",
62
91
  match: /Enter.{1,30}confirm/,
92
+ notMatch: NUMBERED_CHOICE_SIGNATURE,
63
93
  keys: ["Enter"]
64
94
  }
65
95
  ];
@@ -117,12 +147,13 @@ async function runAutoaccept(opts) {
117
147
  for (const entry of rules) {
118
148
  if (entry.fired >= entry.cap)
119
149
  continue;
120
- if (entry.rule.match.test(text)) {
150
+ if (ruleMatches(entry.rule, text)) {
151
+ const keys = resolveRuleKeys(entry.rule, text);
121
152
  entry.fired++;
122
153
  matchedThisPoll = true;
123
154
  fired.push(entry.rule.name);
124
- console.error(`[autoaccept] ${opts.agentName}: fired ${entry.rule.name} (${entry.rule.keys.join("+")})`);
125
- sendKeys(opts.agentName, entry.rule.keys);
155
+ console.error(`[autoaccept] ${opts.agentName}: fired ${entry.rule.name} (${keys.join("+")})`);
156
+ sendKeys(opts.agentName, keys);
126
157
  }
127
158
  }
128
159
  }
@@ -141,6 +172,26 @@ async function runAutoaccept(opts) {
141
172
 
142
173
  // src/agents/autoaccept.ts
143
174
  import { execFileSync as execFileSync2 } from "node:child_process";
175
+ var NUMBERED_CHOICE_SIGNATURE2 = /(?=[\s\S]*^[^\S\n]*(?:\u276f[^\S\n]*)?1\.[^\S\n]+\S)(?=[\s\S]*^[^\S\n]*(?:\u276f[^\S\n]*)?2\.[^\S\n]+\S)/m;
176
+ var FABLE_CONSENT_SIGNATURE2 = /(?=[\s\S]*Continue with Fable)(?=[\s\S]*usage credits)/i;
177
+ function fableConsentNav2(text) {
178
+ for (const line of text.split(`
179
+ `)) {
180
+ const m = line.match(/^\s*(\u276f)?\s*(\d+)\.\s+(.*\S)\s*$/);
181
+ if (!m)
182
+ continue;
183
+ if (/Continue with Fable/i.test(m[3]))
184
+ return [m[2], "Enter"];
185
+ }
186
+ return null;
187
+ }
188
+ function ruleMatches2(rule, text) {
189
+ if (!rule.match.test(text))
190
+ return false;
191
+ if (rule.notMatch && rule.notMatch.test(text))
192
+ return false;
193
+ return true;
194
+ }
144
195
  var PROMPTS2 = [
145
196
  {
146
197
  name: "dev-channels-loading",
@@ -178,9 +229,16 @@ var PROMPTS2 = [
178
229
  keys: ["Escape"],
179
230
  maxFires: 5
180
231
  },
232
+ {
233
+ name: "fable-usage-credits-consent",
234
+ match: FABLE_CONSENT_SIGNATURE2,
235
+ keysFor: fableConsentNav2,
236
+ keys: ["1", "Enter"]
237
+ },
181
238
  {
182
239
  name: "enter-to-confirm",
183
240
  match: /Enter.{1,30}confirm/,
241
+ notMatch: NUMBERED_CHOICE_SIGNATURE2,
184
242
  keys: ["Enter"]
185
243
  }
186
244
  ];
@@ -613,6 +671,7 @@ var DEFAULT_STABILITY_THRESHOLD = 3;
613
671
  var DEFAULT_COOLDOWN_MS = 60000;
614
672
  var DEFAULT_CONFIRM_MODAL_POLLS = 3;
615
673
  var DEFAULT_MANIFEST_STALL_POLLS = 60;
674
+ var DEFAULT_FABLE_CONSENT_POLLS = 3;
616
675
  var DEFAULT_PERMISSION_PROMPT_POLLS = 3;
617
676
  var DEFAULT_PERMISSION_CARDLESS_POLLS = 24;
618
677
  var DEFAULT_PERMISSION_FLOOD_MAX_POLLS = 120;
@@ -643,6 +702,8 @@ async function runWedgeWatchdog(opts) {
643
702
  const parseReset = opts.parseReset ?? parseWeeklyReset;
644
703
  const confirmModalSignature = opts.confirmModalSignature === null ? null : opts.confirmModalSignature ?? CONFIRM_MODAL_SIGNATURE;
645
704
  const confirmModalPolls = opts.confirmModalPolls ?? envInt("SWITCHROOM_WEDGE_CONFIRM_POLLS", DEFAULT_CONFIRM_MODAL_POLLS);
705
+ const fableConsentSignature = opts.fableConsentSignature === null ? null : opts.fableConsentSignature ?? FABLE_CONSENT_SIGNATURE2;
706
+ const fableConsentPolls = opts.fableConsentPolls ?? envInt("SWITCHROOM_WEDGE_FABLE_POLLS", DEFAULT_FABLE_CONSENT_POLLS);
646
707
  const permissionPromptSignature = opts.permissionPromptSignature === null ? null : opts.permissionPromptSignature ?? PERMISSION_PROMPT_SIGNATURE;
647
708
  const permissionPromptPolls = opts.permissionPromptPolls ?? envInt("SWITCHROOM_WEDGE_PERMISSION_POLLS", DEFAULT_PERMISSION_PROMPT_POLLS);
648
709
  const permissionCardlessPolls = Math.max(permissionPromptPolls, opts.permissionCardlessPolls ?? envInt("SWITCHROOM_WEDGE_PERMISSION_CARDLESS_POLLS", DEFAULT_PERMISSION_CARDLESS_POLLS));
@@ -664,6 +725,7 @@ async function runWedgeWatchdog(opts) {
664
725
  let rateLimitFires = 0;
665
726
  let overageCreditSelections = 0;
666
727
  let confirmModalFires = 0;
728
+ let fableConsentFires = 0;
667
729
  let permissionPromptFires = 0;
668
730
  let permissionPromptDeferrals = 0;
669
731
  let permissionPromptFloodHolds = 0;
@@ -671,10 +733,12 @@ async function runWedgeWatchdog(opts) {
671
733
  let restartEscalations = 0;
672
734
  let polls = 0;
673
735
  let confirmModalPresent = 0;
736
+ let fableConsentPresent = 0;
674
737
  let permissionPromptPresent = 0;
675
738
  let manifestStallPresent = 0;
676
739
  let lastManifestKey = null;
677
740
  let confirmCooldownUntil = 0;
741
+ let fableCooldownUntil = 0;
678
742
  let permissionCooldownUntil = 0;
679
743
  let lastPermissionHoldLogAt = Number.NEGATIVE_INFINITY;
680
744
  while (polls < maxPolls) {
@@ -687,9 +751,10 @@ async function runWedgeWatchdog(opts) {
687
751
  text = "";
688
752
  }
689
753
  const isRateLimitMenu = !!text && rateLimitSignature !== null && rateLimitSignature.test(text);
690
- const isPermissionPrompt = !isRateLimitMenu && !!text && permissionPromptSignature !== null && permissionPromptSignature.test(text);
691
- const isConfirmModal = !isRateLimitMenu && !isPermissionPrompt && !!text && confirmModalSignature !== null && confirmModalSignature.test(text);
692
- const isBlockingModal = !isRateLimitMenu && !isPermissionPrompt && !isConfirmModal && !!text && signature.test(text) && !deferToPrompts.some((p) => p.match.test(text));
754
+ const isFableConsent = !isRateLimitMenu && !!text && fableConsentSignature !== null && fableConsentSignature.test(text);
755
+ const isPermissionPrompt = !isRateLimitMenu && !isFableConsent && !!text && permissionPromptSignature !== null && permissionPromptSignature.test(text);
756
+ const isConfirmModal = !isRateLimitMenu && !isFableConsent && !isPermissionPrompt && !!text && confirmModalSignature !== null && confirmModalSignature.test(text);
757
+ const isBlockingModal = !isRateLimitMenu && !isFableConsent && !isPermissionPrompt && !isConfirmModal && !!text && signature.test(text) && !deferToPrompts.some((p) => ruleMatches2(p, text));
693
758
  const manifestSignatureHit = !!text && manifestStallSignature !== null && manifestStallSignature.test(text);
694
759
  const manifestKey = manifestSignatureHit ? stabilityKey(text) : null;
695
760
  const stopHookPresent = !!text && STOP_HOOK_ERROR_SIGNATURE.test(text);
@@ -767,10 +832,30 @@ async function runWedgeWatchdog(opts) {
767
832
  stableCount = 0;
768
833
  lastKey = null;
769
834
  }
835
+ } else if (isFableConsent) {
836
+ fableConsentPresent++;
837
+ stableCount = 0;
838
+ lastKey = null;
839
+ permissionPromptPresent = 0;
840
+ confirmModalPresent = 0;
841
+ if (fableConsentPresent >= fableConsentPolls && now() >= fableCooldownUntil) {
842
+ const nav = fableConsentNav2(text) ?? ["1", "Enter"];
843
+ console.error(`[wedge-watchdog] ${opts.agentName}: Fable-5 usage-credits consent modal present ` + `${fableConsentPresent} polls ` + `(~${Math.round(fableConsentPresent * pollIntervalMs / 1000)}s) \u2014 ` + `selecting "Continue with Fable 5" (${nav.join(" ")}); the session is on fable ` + `because the operator configured it, and Enter/Esc here would silently decline it`);
844
+ try {
845
+ send(opts.agentName, nav);
846
+ } catch (err) {
847
+ console.error(`[wedge-watchdog] ${opts.agentName}: send threw: ${err.message}`);
848
+ }
849
+ fires++;
850
+ fableConsentFires++;
851
+ fableCooldownUntil = now() + cooldownMs;
852
+ fableConsentPresent = 0;
853
+ }
770
854
  } else if (isPermissionPrompt) {
771
855
  permissionPromptPresent++;
772
856
  stableCount = 0;
773
857
  lastKey = null;
858
+ fableConsentPresent = 0;
774
859
  if (permissionPromptPresent >= permissionPromptPolls && now() >= permissionCooldownUntil) {
775
860
  let status = null;
776
861
  if (queryPendingPermission2) {
@@ -824,6 +909,7 @@ async function runWedgeWatchdog(opts) {
824
909
  stableCount = 0;
825
910
  lastKey = null;
826
911
  permissionPromptPresent = 0;
912
+ fableConsentPresent = 0;
827
913
  if (confirmModalPresent >= confirmModalPolls && now() >= confirmCooldownUntil) {
828
914
  console.error(`[wedge-watchdog] ${opts.agentName}: dismissing stuck confirmation modal ` + `(Esc == "No, go back") after ${confirmModalPresent} polls present ` + `(~${Math.round(confirmModalPresent * pollIntervalMs / 1000)}s, flicker-immune) ` + `\u2014 no human to answer it`);
829
915
  try {
@@ -861,6 +947,7 @@ async function runWedgeWatchdog(opts) {
861
947
  lastKey = null;
862
948
  confirmModalPresent = 0;
863
949
  permissionPromptPresent = 0;
950
+ fableConsentPresent = 0;
864
951
  }
865
952
  await sleep(pollIntervalMs);
866
953
  }
@@ -869,6 +956,7 @@ async function runWedgeWatchdog(opts) {
869
956
  rateLimitFires,
870
957
  overageCreditSelections,
871
958
  confirmModalFires,
959
+ fableConsentFires,
872
960
  permissionPromptFires,
873
961
  permissionPromptDeferrals,
874
962
  permissionPromptFloodHolds,
@@ -5628,7 +5716,7 @@ async function main() {
5628
5716
  queryPendingPermission: permissionCardAware ? undefined : null,
5629
5717
  floodPressure: permissionFloodAware ? undefined : null
5630
5718
  });
5631
- console.error(`[autoaccept-poll] ${agentName}: wedge-watchdog returned reason=${res.reason} fires=${res.fires} rateLimitFires=${res.rateLimitFires} overageCreditSelections=${res.overageCreditSelections} confirmModalFires=${res.confirmModalFires} permissionPromptFires=${res.permissionPromptFires} permissionPromptDeferrals=${res.permissionPromptDeferrals} permissionPromptFloodHolds=${res.permissionPromptFloodHolds} permissionPromptCardlessHolds=${res.permissionPromptCardlessHolds} restartEscalations=${res.restartEscalations}`);
5719
+ console.error(`[autoaccept-poll] ${agentName}: wedge-watchdog returned reason=${res.reason} fires=${res.fires} rateLimitFires=${res.rateLimitFires} overageCreditSelections=${res.overageCreditSelections} confirmModalFires=${res.confirmModalFires} fableConsentFires=${res.fableConsentFires} permissionPromptFires=${res.permissionPromptFires} permissionPromptDeferrals=${res.permissionPromptDeferrals} permissionPromptFloodHolds=${res.permissionPromptFloodHolds} permissionPromptCardlessHolds=${res.permissionPromptCardlessHolds} restartEscalations=${res.restartEscalations}`);
5632
5720
  } catch (err) {
5633
5721
  console.error(`[autoaccept-poll] ${agentName}: wedge-watchdog unexpected throw: ${err.message}`);
5634
5722
  }
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.21.6", COMMIT_SHA = "ffc152ca";
2123
+ var VERSION = "0.21.7", COMMIT_SHA = "e4e50f84";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -31796,7 +31796,7 @@ ${baseAppend}` : TELEGRAM_FORMATTING_FLOOR_CARD;
31796
31796
  }
31797
31797
  }
31798
31798
  const settingsPath = join12(agentDir, ".claude", "settings.json");
31799
- if (existsSync18(settingsPath)) {
31799
+ if (existsSync18(settingsPath) && !options.skipNonCronWrites) {
31800
31800
  const before = readFileSync16(settingsPath, "utf-8");
31801
31801
  const settings = JSON.parse(before);
31802
31802
  settings.permissions = settings.permissions ?? {};
@@ -31955,8 +31955,10 @@ ${body}
31955
31955
  }
31956
31956
  }
31957
31957
  }
31958
- refreshTelegramBotTokenEnv(agentDir, resolvedBotToken, changes);
31959
- if (agentConfig.skills) {
31958
+ if (!options.skipNonCronWrites) {
31959
+ refreshTelegramBotTokenEnv(agentDir, resolvedBotToken, changes);
31960
+ }
31961
+ if (agentConfig.skills && !options.skipNonCronWrites) {
31960
31962
  syncGlobalSkills(agentDir, agentConfig.skills, switchroomConfig.switchroom.skills_dir);
31961
31963
  }
31962
31964
  if (!options.skipProfileTemplates) {
@@ -32027,14 +32029,16 @@ ${body}
32027
32029
  const after = JSON.stringify(mcpJson, null, 2) + `
32028
32030
  `;
32029
32031
  const before = existsSync18(mcpJsonPath) ? readFileSync16(mcpJsonPath, "utf-8") : "";
32030
- if (after !== before) {
32032
+ if (after !== before && !options.skipNonCronWrites) {
32031
32033
  atomicWriteFileSync2(mcpJsonPath, after, 384);
32032
32034
  changes.push(mcpJsonPath);
32033
32035
  }
32034
32036
  const cronMcp = maybeWriteCronMcp(agentDir, mcpServers, buildCronSessionContext(agentConfig).cronSessionEnabled);
32035
32037
  if (cronMcp)
32036
32038
  changes.push(cronMcp);
32037
- ensureMcpServersTrusted(agentDir, Object.keys(mcpServers));
32039
+ if (!options.skipNonCronWrites) {
32040
+ ensureMcpServersTrusted(agentDir, Object.keys(mcpServers));
32041
+ }
32038
32042
  }
32039
32043
  const reconcileWorkspaceDir = join12(agentDir, "workspace");
32040
32044
  if (!options.skipProfileTemplates) {
@@ -32093,7 +32097,7 @@ ${body}
32093
32097
  }
32094
32098
  const agentSoulPath = join12(agentDir, "SOUL.md");
32095
32099
  const workspaceSoulPath = join12(agentDir, "workspace", "SOUL.md");
32096
- if (existsSync18(workspaceSoulPath)) {
32100
+ if (existsSync18(workspaceSoulPath) && !options.skipNonCronWrites) {
32097
32101
  if (existsSync18(agentSoulPath)) {
32098
32102
  const stat = lstatSync4(agentSoulPath);
32099
32103
  if (stat.isSymbolicLink()) {
@@ -34980,7 +34984,7 @@ function getAgentLogs(name, follow, tail, timestamps) {
34980
34984
  });
34981
34985
  }
34982
34986
  function classifyChangeKind(path2) {
34983
- if (/\/telegram\/cron-(?:\d+|[0-9a-f]{12})\.sh$/.test(path2))
34987
+ if (/\/telegram\/cron-(?:\d+|[0-9a-f]{12})\.(?:sh|source)$/.test(path2))
34984
34988
  return "cron";
34985
34989
  if (path2.includes("/.claude-cron/"))
34986
34990
  return "cron";
@@ -124771,13 +124775,13 @@ function reconcileAgentCronOnly(agent) {
124771
124775
  return { ok: false, error: `agent "${agent}" not in switchroom.yaml` };
124772
124776
  }
124773
124777
  const agentsDir = resolveAgentsDir(config);
124774
- const result = reconcileAgent(agent, agentConfig, agentsDir, config.telegram, config, undefined, { skipProfileTemplates: true });
124778
+ const result = reconcileAgent(agent, agentConfig, agentsDir, config.telegram, config, undefined, { skipProfileTemplates: true, skipNonCronWrites: true });
124775
124779
  const changes = [...result.changes];
124776
124780
  const nonCron = changes.filter((p) => classifyChangeKind(p) !== "cron");
124777
124781
  if (nonCron.length > 0) {
124778
124782
  return {
124779
124783
  ok: false,
124780
- error: `non-cron changes surfaced during cron-only reconcile: ${nonCron.join(", ")}`
124784
+ error: `non-cron changes surfaced during cron-only reconcile ` + `(ungated writer \u2014 see ReconcileOptions.skipNonCronWrites): ${nonCron.join(", ")}`
124781
124785
  };
124782
124786
  }
124783
124787
  const r = applyCronChangesHot(agent, changes);
@@ -21565,7 +21565,7 @@ function allocateAgentUid(name) {
21565
21565
  }
21566
21566
 
21567
21567
  // src/build-info.ts
21568
- var VERSION = "0.21.6";
21568
+ var VERSION = "0.21.7";
21569
21569
 
21570
21570
  // src/setup/hindsight-recall-passthrough.ts
21571
21571
  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.6",
4
+ "version": "0.21.7",
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": {
@@ -28,7 +28,7 @@
28
28
  "test:vitest": "vitest run",
29
29
  "test:bun": "bun test telegram-plugin/tests/agent-state-dir-preload.test.ts telegram-plugin/tests/hindsight-bank-preload.test.ts telegram-plugin/tests/catch-all-forwarded-history.test.ts src/vault/grants.test.ts src/vault/grants-db.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-scope-persist.test.ts src/vault/broker/server-tokenless-scope.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/server-admin-only-keys.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/litellm/provision-apply-e2e.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/approval-origin.test.ts src/vault/approvals/self-approval-bypass.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/boot-briefing-builder.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/ipc-server-buzz-dedup.test.ts telegram-plugin/tests/ipc-server-query-pending-permission.test.ts telegram-plugin/tests/ipc-server-check-pre-approved.test.ts telegram-plugin/tests/rollout-narration-edit-socket.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/progress-fallback-cap.test.ts telegram-plugin/tests/progress-cap.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/worker-origin-gap-dispatch.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/status-query-telemetry.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/uat/uat-driver.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
30
30
  "test:watch": "vitest",
31
- "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-test-runner-coverage.mjs && node scripts/check-bun-module-mock-scope.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-bench-baseline-anonymised.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-auth-test-hermeticity.mjs && node scripts/check-agent-state-dir-hermeticity.mjs && node scripts/check-hindsight-bank-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs && node scripts/check-mcp-instructions-budget.mjs && node scripts/check-web-subscription-honest.mjs && node scripts/check-no-unpinned-npx-playwright.mjs && node scripts/check-gateway-line-ratchet.mjs && node scripts/check-retry-flood-hooks.mjs && node scripts/check-callback-ctx-wrapping.mjs && node scripts/check-ctx-send-wrapping.mjs && node scripts/check-status-pin-single-path.mjs && node scripts/check-litellm-config-guard.mjs && node scripts/check-release-asset-names.mjs && node scripts/check-foreign-db-readonly.mjs && node scripts/check-changelog-entry.mjs && node scripts/check-agent-attribution-trailers.mjs && node scripts/check-hindsight-write-redaction.mjs && bun scripts/check-secret-pattern-parity.ts && bun scripts/check-hostd-template-guard.ts",
31
+ "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-test-runner-coverage.mjs && node scripts/check-bun-module-mock-scope.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-bench-baseline-anonymised.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-auth-test-hermeticity.mjs && node scripts/check-agent-state-dir-hermeticity.mjs && node scripts/check-hindsight-bank-hermeticity.mjs && node scripts/check-parked-turn-start-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs && node scripts/check-mcp-instructions-budget.mjs && node scripts/check-web-subscription-honest.mjs && node scripts/check-no-unpinned-npx-playwright.mjs && node scripts/check-gateway-line-ratchet.mjs && node scripts/check-retry-flood-hooks.mjs && node scripts/check-callback-ctx-wrapping.mjs && node scripts/check-ctx-send-wrapping.mjs && node scripts/check-status-pin-single-path.mjs && node scripts/check-litellm-config-guard.mjs && node scripts/check-release-asset-names.mjs && node scripts/check-foreign-db-readonly.mjs && node scripts/check-changelog-entry.mjs && node scripts/check-agent-attribution-trailers.mjs && node scripts/check-hindsight-write-redaction.mjs && bun scripts/check-secret-pattern-parity.ts && bun scripts/check-hostd-template-guard.ts",
32
32
  "lint:tsc": "tsc --noEmit",
33
33
  "lint:hindsight-write-redaction": "node scripts/check-hindsight-write-redaction.mjs",
34
34
  "lint:secret-pattern-parity": "bun scripts/check-secret-pattern-parity.ts",
@@ -41,6 +41,7 @@
41
41
  "lint:auth-test-hermeticity": "node scripts/check-auth-test-hermeticity.mjs",
42
42
  "lint:agent-state-dir-hermeticity": "node scripts/check-agent-state-dir-hermeticity.mjs",
43
43
  "lint:hindsight-bank-hermeticity": "node scripts/check-hindsight-bank-hermeticity.mjs",
44
+ "lint:parked-turn-start-hermeticity": "node scripts/check-parked-turn-start-hermeticity.mjs",
44
45
  "lint:web-subscription-honest": "node scripts/check-web-subscription-honest.mjs",
45
46
  "lint:no-broadcast-delivery": "node scripts/check-no-broadcast-delivery.mjs",
46
47
  "lint:gateway-line-ratchet": "node scripts/check-gateway-line-ratchet.mjs",
@@ -3,12 +3,20 @@
3
3
  #
4
4
  # bun reads the bunfig.toml in its CWD only, so this file exists purely to load
5
5
  # the same shared-state hermeticity preloads as the repo-root bunfig.toml. Keep
6
- # the two in sync; `npm run lint:agent-state-dir-hermeticity` and
7
- # `npm run lint:hindsight-bank-hermeticity` fail if either bunfig stops loading
8
- # either guard. Rationale lives in tests/vitest-setup/agent-state-dir-guard.mjs
9
- # and tests/vitest-setup/hindsight-bank-guard.mjs.
6
+ # the two in sync; `npm run lint:agent-state-dir-hermeticity`,
7
+ # `npm run lint:hindsight-bank-hermeticity` and
8
+ # `npm run lint:parked-turn-start-hermeticity` fail if either bunfig stops
9
+ # loading any of the three guards. Rationale lives in
10
+ # tests/vitest-setup/agent-state-dir-guard.mjs,
11
+ # tests/vitest-setup/hindsight-bank-guard.mjs and
12
+ # tests/vitest-setup/parked-turn-start-guard.mjs.
13
+ #
14
+ # This is the bunfig CI's full plugin sweep reads, so the parked-turn-start
15
+ # guard's blast radius is exactly the ~657-file one-process run that #4611
16
+ # poisoned.
10
17
  [test]
11
18
  preload = [
12
19
  "../tests/vitest-setup/agent-state-dir-guard.mjs",
13
20
  "../tests/vitest-setup/hindsight-bank-guard.mjs",
21
+ "../tests/vitest-setup/parked-turn-start-guard.mjs",
14
22
  ]
@@ -104467,10 +104467,10 @@ function startOutboxSweep(deps) {
104467
104467
  }
104468
104468
 
104469
104469
  // ../src/build-info.ts
104470
- var VERSION2 = "0.21.6";
104471
- var COMMIT_SHA = "ffc152ca";
104472
- var COMMIT_DATE = "2026-08-11T03:22:29Z";
104473
- var LATEST_PR = 4605;
104470
+ var VERSION2 = "0.21.7";
104471
+ var COMMIT_SHA = "e4e50f84";
104472
+ var COMMIT_DATE = "2026-08-11T23:08:27Z";
104473
+ var LATEST_PR = 4613;
104474
104474
  var COMMITS_AHEAD_OF_TAG = 0;
104475
104475
 
104476
104476
  // gateway/boot-version.ts
@@ -23,7 +23,7 @@
23
23
  * parked store is emptied AND the parked message was handed back into turn
24
24
  * processing (its turn begun, its card opened) rather than silently dropped.
25
25
  */
26
- import { describe, it, expect, beforeEach } from 'vitest'
26
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
27
27
  import {
28
28
  handleSessionEvent,
29
29
  drainParkedTurnStartsForChat,
@@ -34,9 +34,15 @@ import { buildSilencePokeOptions } from '../gateway/liveness-wiring.js'
34
34
  import { makeLivenessFixture, makeTurn, statusKeyForTests } from './helpers/liveness-wiring-fixture.js'
35
35
  import { CHAT, enqueue, makeHarness } from './turn-mint-harness.js'
36
36
 
37
+ // Module-scope store + one-process `bun test` sweep: resetting on ENTRY alone
38
+ // leaves a mid-park case's entry behind for every later FILE, which makes the
39
+ // obligation sweep read the session as busy for the rest of the run (#4611).
37
40
  beforeEach(() => {
38
41
  __resetParkedTurnStartsForTest()
39
42
  })
43
+ afterEach(() => {
44
+ __resetParkedTurnStartsForTest()
45
+ })
40
46
 
41
47
  describe('silence-fallback drains parkedTurnStarts so a hung REPL cannot wedge the park gate', () => {
42
48
  it('empties the parked store AND redelivers the parked message into turn processing', async () => {
@@ -0,0 +1,65 @@
1
+ import { afterEach, describe, expect, it } from 'bun:test'
2
+ import {
3
+ handleSessionEvent,
4
+ __parkedTurnStartCountForTest,
5
+ __resetParkedTurnStartsForTest,
6
+ } from '../gateway/stream-render.js'
7
+ import { enqueue, makeHarness } from './turn-mint-harness.js'
8
+
9
+ /**
10
+ * Runtime alarm for the parked-turn-start leak guard (#4611).
11
+ *
12
+ * `parkedTurnStarts` is module-scope by design (one CLI session, one queue) and
13
+ * `bun test` runs all ~657 telegram-plugin files in ONE process, so a file that
14
+ * exits mid-park changes global state for every file after it:
15
+ * `gateway/obligation-wiring.ts` folds the parked count into `sessionBusy`, and
16
+ * `tests/represent-guard.test.ts`'s idle case then asserts `toHaveLength(1)`
17
+ * against `0`. Two PRs were ejected from the merge queue that way, with
18
+ * byte-identical retries passing, because bun's file order is not stable.
19
+ *
20
+ * `npm run lint:parked-turn-start-hermeticity` pins the WIRING statically; this
21
+ * pins the EFFECT, so a bunfig that is present but no longer loading the guard
22
+ * (wrong relative path, bun config-discovery change) fails a test rather than
23
+ * silently un-protecting the runner.
24
+ */
25
+ // This suite deliberately parks, so it follows the discipline the guard
26
+ // enforces for every other suite: reset on EXIT, not just on entry.
27
+ afterEach(() => {
28
+ __resetParkedTurnStartsForTest()
29
+ })
30
+
31
+ describe('bun test runs with the parked-turn-start leak guard installed', () => {
32
+ it('the preload registered the global afterEach', () => {
33
+ const installed = (globalThis as { __switchroomParkedTurnStartGuard?: { hook: () => void } })
34
+ .__switchroomParkedTurnStartGuard
35
+ expect(
36
+ installed,
37
+ 'guard absent — bunfig.toml `[test] preload` did not load parked-turn-start-guard.mjs',
38
+ ).toBeTruthy()
39
+ expect(typeof installed!.hook).toBe('function')
40
+ })
41
+
42
+ it('the hook throws on a leaked entry AND resets the store (one failure, not a cascade)', () => {
43
+ const { hook } = (globalThis as { __switchroomParkedTurnStartGuard: { hook: () => void } })
44
+ .__switchroomParkedTurnStartGuard
45
+
46
+ // Reproduce the #4611 leak shape: a mid-turn enqueue parks behind a live
47
+ // turn and is never dequeued — exactly queued-card-surface.test.ts:318.
48
+ const h = makeHarness()
49
+ handleSessionEvent(h.deps, enqueue('501'))
50
+ handleSessionEvent(h.deps, enqueue('502', 'real mid-turn message'))
51
+ expect(__parkedTurnStartCountForTest()).toBe(1)
52
+
53
+ expect(() => hook()).toThrow(/SWITCHROOM_PARKED_TURN_START_LEAK/)
54
+ // Reset-before-throw is what turns the ~1800-deep victim cascade into one
55
+ // attributable failure — and is why this test's own leak does not escape.
56
+ expect(__parkedTurnStartCountForTest()).toBe(0)
57
+
58
+ // A clean store is silent.
59
+ expect(() => hook()).not.toThrow()
60
+ })
61
+
62
+ it('leaves the store clean for the next file', () => {
63
+ expect(__parkedTurnStartCountForTest()).toBe(0)
64
+ })
65
+ })
@@ -14,7 +14,7 @@
14
14
  * • remove → the card is finalized as "folded into the current task".
15
15
  * • TTL → the card is finalized as timed-out, never left frozen.
16
16
  */
17
- import { describe, it, expect, beforeEach } from 'vitest'
17
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
18
18
  import {
19
19
  handleSessionEvent,
20
20
  __resetParkedTurnStartsForTest,
@@ -58,9 +58,17 @@ function withRecordingBot(h: Harness) {
58
58
  * stores the card id on the parked envelope) has run. */
59
59
  const settle = () => new Promise((r) => setTimeout(r, 0))
60
60
 
61
+ // The parked store is module-scope and `bun test` runs all ~657 files in ONE
62
+ // process, so resetting on ENTRY alone is not enough: a case that ends mid-park
63
+ // leaves the entry behind for every later FILE. #4611 — this suite's last case
64
+ // parks msg 502 and never dequeues, and the leftover made the obligation sweep
65
+ // read the session as busy for the rest of the run, failing represent-guard.
61
66
  beforeEach(() => {
62
67
  __resetParkedTurnStartsForTest()
63
68
  })
69
+ afterEach(() => {
70
+ __resetParkedTurnStartsForTest()
71
+ })
64
72
 
65
73
  describe('Part B — queued card is posted at park and adopted on dequeue', () => {
66
74
  it('parks with a reply-anchored "Queued" card, then EDITS that same card in place on dequeue', async () => {
@@ -19,7 +19,7 @@
19
19
  * the duplicate. This is the exact duplicate-reply class the shared-singleton
20
20
  * injection (never a re-`new`) exists to kill.
21
21
  */
22
- import { describe, it, expect, beforeEach } from 'vitest'
22
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
23
23
  import { readFileSync } from 'node:fs'
24
24
  import { tmpdir } from 'node:os'
25
25
  import {
@@ -47,6 +47,15 @@ import {
47
47
  import type { CurrentTurn } from '../gateway/gateway.js'
48
48
  import type { ReplyOwnerTier } from '../reply-owner-resolve.js'
49
49
 
50
+ // The parked turn-start store is module-scope and `bun test` runs every file in
51
+ // ONE process, so the two describe-scoped `beforeEach` resets below clean ENTRY
52
+ // only — a case that ends mid-park still leaks into the next FILE, where the
53
+ // obligation sweep reads the leftover as a busy session (#4611). File-level so
54
+ // it covers every case here, not just the two blocks that reset on entry.
55
+ afterEach(() => {
56
+ __resetParkedTurnStartsForTest()
57
+ })
58
+
50
59
  /** The owner-resolution shape `resolveReplyOwnerTurn` returns, including the
51
60
  * candidate set the content-gate bypass corroborates against. These fixtures
52
61
  * never exercise the supersede path, so the candidates mirror the resolved turn
@@ -20,7 +20,7 @@
20
20
  * These drive the REAL `handleSessionEvent` (extracted-module golden-harness
21
21
  * oracle, same standard as `stream-render-golden.test.ts`).
22
22
  */
23
- import { describe, it, expect, beforeEach, vi } from 'vitest'
23
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
24
24
  import {
25
25
  handleSessionEvent,
26
26
  __resetParkedTurnStartsForTest,
@@ -29,9 +29,15 @@ import {
29
29
  import { projectTranscriptLine } from '../session-tail.js'
30
30
  import { CHAT, enqueue, inbound, makeHarness } from './turn-mint-harness.js'
31
31
 
32
+ // Module-scope store + one-process `bun test` sweep: resetting on ENTRY alone
33
+ // leaves a mid-park case's entry behind for every later FILE, which makes the
34
+ // obligation sweep read the session as busy for the rest of the run (#4611).
32
35
  beforeEach(() => {
33
36
  __resetParkedTurnStartsForTest()
34
37
  })
38
+ afterEach(() => {
39
+ __resetParkedTurnStartsForTest()
40
+ })
35
41
 
36
42
  describe('#3927 FIX A — a mid-turn enqueue parks; the turn mints on dequeue', () => {
37
43
  it('a second enqueue while turn A is live opens NO card, does not steal the slot, ' +
@@ -25,7 +25,7 @@
25
25
  * mints straight on top of it. That is the exact orphan carrie hit, and it is
26
26
  * what FIX B finalizes.
27
27
  */
28
- import { describe, it, expect, beforeEach } from 'vitest'
28
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
29
29
  import {
30
30
  handleSessionEvent,
31
31
  __resetParkedTurnStartsForTest,
@@ -45,9 +45,15 @@ function syntheticEnqueue(chatId: string, text: string) {
45
45
  }
46
46
  }
47
47
 
48
+ // Module-scope store + one-process `bun test` sweep: resetting on ENTRY alone
49
+ // leaves a mid-park case's entry behind for every later FILE, which makes the
50
+ // obligation sweep read the session as busy for the rest of the run (#4611).
48
51
  beforeEach(() => {
49
52
  __resetParkedTurnStartsForTest()
50
53
  })
54
+ afterEach(() => {
55
+ __resetParkedTurnStartsForTest()
56
+ })
51
57
 
52
58
  describe('#3927 FIX B — superseding a live turn finalizes its card', () => {
53
59
  it('a dequeue-driven mint on top of a never-ended turn calls clearActivitySummary ' +