switchroom 0.20.7 → 0.20.9

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 (52) hide show
  1. package/dist/agent-scheduler/index.js +111 -14
  2. package/dist/auth-broker/index.js +113 -30
  3. package/dist/cli/autoaccept-poll.js +5 -3
  4. package/dist/cli/drive-write-pretool.mjs +5 -3
  5. package/dist/cli/ms-365-write-pretool.mjs +5 -3
  6. package/dist/cli/notion-write-pretool.mjs +67 -6
  7. package/dist/cli/switchroom.js +389 -31
  8. package/dist/host-control/main.js +69 -8
  9. package/dist/vault/approvals/kernel-server.js +68 -7
  10. package/dist/vault/broker/server.js +68 -7
  11. package/package.json +1 -1
  12. package/profiles/default/CLAUDE.md.hbs +12 -13
  13. package/telegram-plugin/ask-user.ts +6 -7
  14. package/telegram-plugin/bridge/ipc-client.ts +17 -1
  15. package/telegram-plugin/dist/bridge/bridge.js +5 -2
  16. package/telegram-plugin/dist/gateway/gateway.js +410 -178
  17. package/telegram-plugin/dist/server.js +5 -2
  18. package/telegram-plugin/gateway/auth-broker-client.ts +1 -1
  19. package/telegram-plugin/gateway/auth-command.ts +4 -2
  20. package/telegram-plugin/gateway/boot-reason.ts +61 -0
  21. package/telegram-plugin/gateway/checklist-fallback.ts +8 -1
  22. package/telegram-plugin/gateway/cron-session.ts +66 -0
  23. package/telegram-plugin/gateway/gateway.ts +36 -34
  24. package/telegram-plugin/gateway/narrative-lane.ts +21 -1
  25. package/telegram-plugin/gateway/outbound-send-path.ts +9 -1
  26. package/telegram-plugin/gateway/represent-delivery-guard.ts +33 -2
  27. package/telegram-plugin/gateway/stream-render.ts +11 -2
  28. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +21 -1
  29. package/telegram-plugin/gateway/subagent-handback-marker.ts +94 -0
  30. package/telegram-plugin/gateway/throttle-tier-wiring.ts +93 -18
  31. package/telegram-plugin/render/emphasis-guard.ts +92 -12
  32. package/telegram-plugin/render/line-start-guard.ts +27 -2
  33. package/telegram-plugin/sticker-aliases.ts +12 -14
  34. package/telegram-plugin/tests/ask-user.test.ts +15 -0
  35. package/telegram-plugin/tests/boot-card-reason.test.ts +88 -0
  36. package/telegram-plugin/tests/checklist-fallback.test.ts +21 -0
  37. package/telegram-plugin/tests/cron-bridge-drain-spool-ack.test.ts +150 -0
  38. package/telegram-plugin/tests/handback-tasknotif-dedup.test.ts +248 -0
  39. package/telegram-plugin/tests/ipc-client-reconnect-rejection.test.ts +70 -0
  40. package/telegram-plugin/tests/narrative-lane-golden.test.ts +86 -0
  41. package/telegram-plugin/tests/queued-card-surface.test.ts +66 -0
  42. package/telegram-plugin/tests/render/emphasis-guard.test.ts +105 -6
  43. package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +123 -36
  44. package/telegram-plugin/tests/reply-quote-wire.test.ts +47 -0
  45. package/telegram-plugin/tests/represent-guard.test.ts +45 -0
  46. package/telegram-plugin/tests/sticker-aliases.test.ts +43 -0
  47. package/telegram-plugin/tests/throttle-tier-probe-only.test.ts +216 -0
  48. package/telegram-plugin/tests/throttle-tier-route-429-wiring.test.ts +92 -0
  49. package/telegram-plugin/tests/throttle-tier-route-429.test.ts +71 -0
  50. package/telegram-plugin/tests/turn-flush-safety.test.ts +83 -0
  51. package/telegram-plugin/throttle-tier.ts +59 -0
  52. package/telegram-plugin/turn-flush-safety.ts +79 -0
@@ -12004,6 +12004,29 @@ var OverlayDocSchema = exports_external.object({
12004
12004
  // src/config/overlay-loader.ts
12005
12005
  var OVERLAY_SOURCE = Symbol.for("switchroom.config.overlay-source");
12006
12006
  var OVERLAY_TITLE = Symbol.for("switchroom.config.overlay-title");
12007
+ var OVERLAY_READ_FAILURES = Symbol.for("switchroom.config.overlay-read-failures");
12008
+ function recordReadFailure(agentCfg, failure) {
12009
+ const node = agentCfg;
12010
+ const existing = node[OVERLAY_READ_FAILURES];
12011
+ if (Array.isArray(existing)) {
12012
+ existing.push(failure);
12013
+ return;
12014
+ }
12015
+ Object.defineProperty(agentCfg, OVERLAY_READ_FAILURES, {
12016
+ value: [failure],
12017
+ enumerable: false,
12018
+ configurable: true,
12019
+ writable: false
12020
+ });
12021
+ }
12022
+ function overlayReadFailures(config, agent, source) {
12023
+ const agentCfg = config.agents?.[agent];
12024
+ if (!agentCfg)
12025
+ return [];
12026
+ const list = agentCfg[OVERLAY_READ_FAILURES];
12027
+ const all = Array.isArray(list) ? list : [];
12028
+ return source ? all.filter((f) => f.source === source) : all;
12029
+ }
12007
12030
  function deriveOverlayTitle(raw, fileName) {
12008
12031
  const titleFromComment = raw.match(/^#[^\S\n]*name:[^\S\n]*(\S.*?)[^\S\n]*$/m)?.[1];
12009
12032
  if (titleFromComment)
@@ -12013,17 +12036,39 @@ function deriveOverlayTitle(raw, fileName) {
12013
12036
  return;
12014
12037
  return base.length > 0 ? base : undefined;
12015
12038
  }
12039
+ function readOverlayFile(agentName, file, agentCfg, warnings, source) {
12040
+ try {
12041
+ return readFileSync(file, "utf-8");
12042
+ } catch (err) {
12043
+ const code = err.code;
12044
+ if (code === "ENOENT")
12045
+ return;
12046
+ const w = {
12047
+ agent: agentName,
12048
+ file,
12049
+ reason: `read error: ${err.message}`,
12050
+ code: code ?? "EUNKNOWN"
12051
+ };
12052
+ recordReadFailure(agentCfg, { file, code: w.code, source });
12053
+ warnings.push(w);
12054
+ console.warn(`[switchroom] overlay-loader: agent='${agentName}' file='${file}': ${w.reason}`);
12055
+ return;
12056
+ }
12057
+ }
12016
12058
  function overlayDirFor(agentName, subdir) {
12017
12059
  const base = resolveDualPath(`~/.switchroom/agents/${agentName}/${subdir}`);
12018
12060
  return resolve2(base);
12019
12061
  }
12020
- function listYamlFiles(dir) {
12062
+ function listYamlFiles(dir, onUnreadableDir) {
12021
12063
  if (!existsSync2(dir))
12022
12064
  return [];
12023
12065
  let entries;
12024
12066
  try {
12025
12067
  entries = readdirSync(dir);
12026
- } catch {
12068
+ } catch (err) {
12069
+ const code = err.code;
12070
+ if (code !== "ENOENT")
12071
+ onUnreadableDir?.(code ?? "EUNKNOWN");
12027
12072
  return [];
12028
12073
  }
12029
12074
  const out = [];
@@ -12061,12 +12106,24 @@ function applyAgentOverlays(config) {
12061
12106
  for (const [agentName, agentCfg] of Object.entries(agents)) {
12062
12107
  try {
12063
12108
  const scheduleDir = overlayDirFor(agentName, "schedule.d");
12064
- const files = listYamlFiles(scheduleDir);
12109
+ const files = listYamlFiles(scheduleDir, (code) => {
12110
+ const w = {
12111
+ agent: agentName,
12112
+ file: scheduleDir,
12113
+ reason: `read error: cannot list overlay directory (${code})`,
12114
+ code
12115
+ };
12116
+ recordReadFailure(agentCfg, { file: scheduleDir, code, source: "schedule" });
12117
+ warnings.push(w);
12118
+ console.warn(`[switchroom] overlay-loader: agent='${agentName}' file='${scheduleDir}': ${w.reason}`);
12119
+ });
12065
12120
  if (files.length > 0) {
12066
12121
  const merged = [...agentCfg.schedule ?? []];
12067
12122
  for (const file of files) {
12123
+ const raw = readOverlayFile(agentName, file, agentCfg, warnings, "schedule");
12124
+ if (raw === undefined)
12125
+ continue;
12068
12126
  try {
12069
- const raw = readFileSync(file, "utf-8");
12070
12127
  const parsed = $parse(raw);
12071
12128
  const doc = OverlayDocSchema.parse(parsed);
12072
12129
  const title = deriveOverlayTitle(raw, basename(file));
@@ -12101,13 +12158,25 @@ function applyAgentOverlays(config) {
12101
12158
  }
12102
12159
  try {
12103
12160
  const skillsDir = overlayDirFor(agentName, "skills.d");
12104
- const skillFiles = listYamlFiles(skillsDir);
12161
+ const skillFiles = listYamlFiles(skillsDir, (code) => {
12162
+ const w = {
12163
+ agent: agentName,
12164
+ file: skillsDir,
12165
+ reason: `read error: cannot list overlay directory (${code})`,
12166
+ code
12167
+ };
12168
+ recordReadFailure(agentCfg, { file: skillsDir, code, source: "skills" });
12169
+ warnings.push(w);
12170
+ console.warn(`[switchroom] overlay-loader: agent='${agentName}' file='${skillsDir}': ${w.reason}`);
12171
+ });
12105
12172
  if (skillFiles.length === 0) {} else {
12106
12173
  const merged = [...agentCfg.skills ?? []];
12107
12174
  const seen = new Set(merged);
12108
12175
  for (const file of skillFiles) {
12176
+ const raw = readOverlayFile(agentName, file, agentCfg, warnings, "skills");
12177
+ if (raw === undefined)
12178
+ continue;
12109
12179
  try {
12110
- const raw = readFileSync(file, "utf-8");
12111
12180
  const parsed = $parse(raw);
12112
12181
  const doc = OverlayDocSchema.parse(parsed);
12113
12182
  for (const skillName of doc.skills ?? []) {
@@ -13975,7 +14044,8 @@ var MarkThrottledRequestSchema = exports_external.object({
13975
14044
  v: exports_external.literal(PROTOCOL_VERSION),
13976
14045
  op: exports_external.literal("mark-throttled"),
13977
14046
  id: exports_external.string().min(1),
13978
- until: exports_external.number().int().positive()
14047
+ until: exports_external.number().int().positive(),
14048
+ probeOnly: exports_external.boolean().optional()
13979
14049
  });
13980
14050
  var RefreshAccountRequestSchema = exports_external.object({
13981
14051
  v: exports_external.literal(PROTOCOL_VERSION),
@@ -14392,12 +14462,13 @@ class AuthBrokerClient {
14392
14462
  const data = await this.send(req);
14393
14463
  return data;
14394
14464
  }
14395
- async markThrottled(until) {
14465
+ async markThrottled(until, probeOnly = false) {
14396
14466
  const data = await this.send({
14397
14467
  v: PROTOCOL_VERSION,
14398
14468
  id: randomUUID(),
14399
14469
  op: "mark-throttled",
14400
- until
14470
+ until,
14471
+ ...probeOnly ? { probeOnly: true } : {}
14401
14472
  });
14402
14473
  return data;
14403
14474
  }
@@ -15264,6 +15335,17 @@ function createScheduleReloader(opts) {
15264
15335
  }
15265
15336
  };
15266
15337
  }
15338
+ function formatReadFailures(failures) {
15339
+ return failures.map((f) => `${f.file} (${f.code})`).join(", ") + " — fix ownership/mode so the agent uid can read the file " + "(a root-euid writer leaves cron overlays root-owned 0600; " + "`switchroom apply` / a reconcile ownership sweep heals this)";
15340
+ }
15341
+ function loadAgentEntriesStrict(configPath, agentName) {
15342
+ const config = loadConfig(configPath);
15343
+ const failures = overlayReadFailures(config, agentName, "schedule");
15344
+ if (failures.length > 0) {
15345
+ throw new Error(`schedule.d overlay unreadable — refusing a reload that may be ` + `missing entries: ${formatReadFailures(failures)}`);
15346
+ }
15347
+ return collectScheduleEntries(config).filter((e) => e.agent === agentName);
15348
+ }
15267
15349
  function resolveReloadPollMs(env) {
15268
15350
  const raw = Number.parseInt(env.SWITCHROOM_SCHEDULER_RELOAD_POLL_MS ?? "", 10);
15269
15351
  return Number.isFinite(raw) && raw >= 1000 ? raw : 30000;
@@ -15303,6 +15385,11 @@ async function main() {
15303
15385
  const config = loadConfig(configPath);
15304
15386
  const allEntries = collectScheduleEntries(config);
15305
15387
  const entries = allEntries.filter((e) => e.agent === agentName);
15388
+ const bootReadFailures = overlayReadFailures(config, agentName, "schedule");
15389
+ if (bootReadFailures.length > 0) {
15390
+ process.stderr.write(`agent-scheduler: ${agentName} WARNING: ${bootReadFailures.length} ` + `schedule.d overlay file(s) unreadable at boot — their cron entries ` + `are NOT registered: ${formatReadFailures(bootReadFailures)}
15391
+ `);
15392
+ }
15306
15393
  const channel = resolveChannelTarget(config, agentName);
15307
15394
  if (channel === null) {
15308
15395
  process.stderr.write(`agent-scheduler: ${agentName} has no resolvable chat target ` + `(missing telegram.forum_chat_id) — exiting
@@ -15519,15 +15606,24 @@ Briefly and plainly tell the user these scheduled runs did not ` + "happen so th
15519
15606
  let reloader;
15520
15607
  let reloadTimer;
15521
15608
  if (isHotReloadEnabled(process.env)) {
15609
+ let lastReloadError;
15522
15610
  reloader = createScheduleReloader({
15523
- loadEntries: () => collectScheduleEntries(loadConfig(configPath)).filter((e) => e.agent === agentName),
15611
+ loadEntries: () => loadAgentEntriesStrict(configPath, agentName),
15524
15612
  register: registerForEntries,
15525
15613
  initialTasks: tasks,
15526
15614
  initialEntries: entries,
15527
- log: (m) => process.stdout.write(`agent-scheduler: ${agentName} ${m}
15528
- `),
15529
- onError: (e) => process.stderr.write(`agent-scheduler: ${agentName} reload skipped (config error, keeping current schedule): ${e.message}
15530
- `)
15615
+ log: (m) => {
15616
+ lastReloadError = undefined;
15617
+ process.stdout.write(`agent-scheduler: ${agentName} ${m}
15618
+ `);
15619
+ },
15620
+ onError: (e) => {
15621
+ if (e.message === lastReloadError)
15622
+ return;
15623
+ lastReloadError = e.message;
15624
+ process.stderr.write(`agent-scheduler: ${agentName} reload skipped (config error, keeping current schedule): ${e.message}
15625
+ `);
15626
+ }
15531
15627
  });
15532
15628
  reloadTimer = setInterval(() => reloader.tick(), resolveReloadPollMs(process.env));
15533
15629
  }
@@ -15560,6 +15656,7 @@ export {
15560
15656
  registerAgentSchedule,
15561
15657
  recoverPendingEscalations,
15562
15658
  main,
15659
+ loadAgentEntriesStrict,
15563
15660
  isHotReloadEnabled,
15564
15661
  ipcDispatcher,
15565
15662
  createScheduleReloader
@@ -12030,6 +12030,20 @@ var init_overlay_schema = __esm(() => {
12030
12030
  // src/config/overlay-loader.ts
12031
12031
  import { existsSync as existsSync2, readFileSync, readdirSync, statSync } from "node:fs";
12032
12032
  import { basename, resolve as resolve2 } from "node:path";
12033
+ function recordReadFailure(agentCfg, failure) {
12034
+ const node = agentCfg;
12035
+ const existing = node[OVERLAY_READ_FAILURES];
12036
+ if (Array.isArray(existing)) {
12037
+ existing.push(failure);
12038
+ return;
12039
+ }
12040
+ Object.defineProperty(agentCfg, OVERLAY_READ_FAILURES, {
12041
+ value: [failure],
12042
+ enumerable: false,
12043
+ configurable: true,
12044
+ writable: false
12045
+ });
12046
+ }
12033
12047
  function deriveOverlayTitle(raw, fileName) {
12034
12048
  const titleFromComment = raw.match(/^#[^\S\n]*name:[^\S\n]*(\S.*?)[^\S\n]*$/m)?.[1];
12035
12049
  if (titleFromComment)
@@ -12039,17 +12053,39 @@ function deriveOverlayTitle(raw, fileName) {
12039
12053
  return;
12040
12054
  return base.length > 0 ? base : undefined;
12041
12055
  }
12056
+ function readOverlayFile(agentName, file, agentCfg, warnings, source) {
12057
+ try {
12058
+ return readFileSync(file, "utf-8");
12059
+ } catch (err) {
12060
+ const code = err.code;
12061
+ if (code === "ENOENT")
12062
+ return;
12063
+ const w = {
12064
+ agent: agentName,
12065
+ file,
12066
+ reason: `read error: ${err.message}`,
12067
+ code: code ?? "EUNKNOWN"
12068
+ };
12069
+ recordReadFailure(agentCfg, { file, code: w.code, source });
12070
+ warnings.push(w);
12071
+ console.warn(`[switchroom] overlay-loader: agent='${agentName}' file='${file}': ${w.reason}`);
12072
+ return;
12073
+ }
12074
+ }
12042
12075
  function overlayDirFor(agentName, subdir) {
12043
12076
  const base = resolveDualPath(`~/.switchroom/agents/${agentName}/${subdir}`);
12044
12077
  return resolve2(base);
12045
12078
  }
12046
- function listYamlFiles(dir) {
12079
+ function listYamlFiles(dir, onUnreadableDir) {
12047
12080
  if (!existsSync2(dir))
12048
12081
  return [];
12049
12082
  let entries;
12050
12083
  try {
12051
12084
  entries = readdirSync(dir);
12052
- } catch {
12085
+ } catch (err) {
12086
+ const code = err.code;
12087
+ if (code !== "ENOENT")
12088
+ onUnreadableDir?.(code ?? "EUNKNOWN");
12053
12089
  return [];
12054
12090
  }
12055
12091
  const out = [];
@@ -12087,12 +12123,24 @@ function applyAgentOverlays(config) {
12087
12123
  for (const [agentName, agentCfg] of Object.entries(agents)) {
12088
12124
  try {
12089
12125
  const scheduleDir = overlayDirFor(agentName, "schedule.d");
12090
- const files = listYamlFiles(scheduleDir);
12126
+ const files = listYamlFiles(scheduleDir, (code) => {
12127
+ const w = {
12128
+ agent: agentName,
12129
+ file: scheduleDir,
12130
+ reason: `read error: cannot list overlay directory (${code})`,
12131
+ code
12132
+ };
12133
+ recordReadFailure(agentCfg, { file: scheduleDir, code, source: "schedule" });
12134
+ warnings.push(w);
12135
+ console.warn(`[switchroom] overlay-loader: agent='${agentName}' file='${scheduleDir}': ${w.reason}`);
12136
+ });
12091
12137
  if (files.length > 0) {
12092
12138
  const merged = [...agentCfg.schedule ?? []];
12093
12139
  for (const file of files) {
12140
+ const raw = readOverlayFile(agentName, file, agentCfg, warnings, "schedule");
12141
+ if (raw === undefined)
12142
+ continue;
12094
12143
  try {
12095
- const raw = readFileSync(file, "utf-8");
12096
12144
  const parsed = import_yaml.parse(raw);
12097
12145
  const doc = OverlayDocSchema.parse(parsed);
12098
12146
  const title = deriveOverlayTitle(raw, basename(file));
@@ -12127,13 +12175,25 @@ function applyAgentOverlays(config) {
12127
12175
  }
12128
12176
  try {
12129
12177
  const skillsDir = overlayDirFor(agentName, "skills.d");
12130
- const skillFiles = listYamlFiles(skillsDir);
12178
+ const skillFiles = listYamlFiles(skillsDir, (code) => {
12179
+ const w = {
12180
+ agent: agentName,
12181
+ file: skillsDir,
12182
+ reason: `read error: cannot list overlay directory (${code})`,
12183
+ code
12184
+ };
12185
+ recordReadFailure(agentCfg, { file: skillsDir, code, source: "skills" });
12186
+ warnings.push(w);
12187
+ console.warn(`[switchroom] overlay-loader: agent='${agentName}' file='${skillsDir}': ${w.reason}`);
12188
+ });
12131
12189
  if (skillFiles.length === 0) {} else {
12132
12190
  const merged = [...agentCfg.skills ?? []];
12133
12191
  const seen = new Set(merged);
12134
12192
  for (const file of skillFiles) {
12193
+ const raw = readOverlayFile(agentName, file, agentCfg, warnings, "skills");
12194
+ if (raw === undefined)
12195
+ continue;
12135
12196
  try {
12136
- const raw = readFileSync(file, "utf-8");
12137
12197
  const parsed = import_yaml.parse(raw);
12138
12198
  const doc = OverlayDocSchema.parse(parsed);
12139
12199
  for (const skillName of doc.skills ?? []) {
@@ -12161,7 +12221,7 @@ function applyAgentOverlays(config) {
12161
12221
  }
12162
12222
  return { config, warnings };
12163
12223
  }
12164
- var import_yaml, OVERLAY_SOURCE, OVERLAY_TITLE;
12224
+ var import_yaml, OVERLAY_SOURCE, OVERLAY_TITLE, OVERLAY_READ_FAILURES;
12165
12225
  var init_overlay_loader = __esm(() => {
12166
12226
  init_zod();
12167
12227
  init_overlay_schema();
@@ -12169,6 +12229,7 @@ var init_overlay_loader = __esm(() => {
12169
12229
  import_yaml = __toESM(require_dist(), 1);
12170
12230
  OVERLAY_SOURCE = Symbol.for("switchroom.config.overlay-source");
12171
12231
  OVERLAY_TITLE = Symbol.for("switchroom.config.overlay-title");
12232
+ OVERLAY_READ_FAILURES = Symbol.for("switchroom.config.overlay-read-failures");
12172
12233
  });
12173
12234
 
12174
12235
  // src/config/merge.ts
@@ -21225,7 +21286,8 @@ var MarkThrottledRequestSchema = exports_external.object({
21225
21286
  v: exports_external.literal(PROTOCOL_VERSION),
21226
21287
  op: exports_external.literal("mark-throttled"),
21227
21288
  id: exports_external.string().min(1),
21228
- until: exports_external.number().int().positive()
21289
+ until: exports_external.number().int().positive(),
21290
+ probeOnly: exports_external.boolean().optional()
21229
21291
  });
21230
21292
  var RefreshAccountRequestSchema = exports_external.object({
21231
21293
  v: exports_external.literal(PROTOCOL_VERSION),
@@ -21507,8 +21569,8 @@ var AUTH_BROKER_ROOT2 = "/run/switchroom/auth-broker";
21507
21569
  var REFRESH_TICK_INTERVAL_MS = 60 * 1000;
21508
21570
  var MARK_EXHAUSTED_DEFAULT_MS = 5 * 60 * 60 * 1000;
21509
21571
  var MARK_THROTTLED_MAX_MS = 30 * 60 * 1000;
21510
- var THROTTLE_ESCALATION_HITS = 3;
21511
21572
  var THROTTLE_ESCALATION_WINDOW_MS = 10 * 60 * 1000;
21573
+ var THROTTLE_ESCALATION_PROBE_MIN_INTERVAL_MS = 60 * 1000;
21512
21574
  var MARK_THROTTLED_MIN_INTERVAL_MS = 5 * 1000;
21513
21575
  var MARK_CORROBORATION_PROBE_DELAY_MS = 90 * 1000;
21514
21576
  var AUDIT_ROTATE_BYTES = 10 * 1024 * 1024;
@@ -21602,6 +21664,7 @@ class AuthBroker {
21602
21664
  externalSpendCache = null;
21603
21665
  externalSpendInFlight = null;
21604
21666
  probeInFlight = new Map;
21667
+ lastEscalationProbeAt = {};
21605
21668
  shaIndex = {};
21606
21669
  thresholdViolations = {};
21607
21670
  notificationClaims = {};
@@ -21986,7 +22049,7 @@ class AuthBroker {
21986
22049
  await this.opMarkExhausted(socket, reqId, identity, req.until);
21987
22050
  break;
21988
22051
  case "mark-throttled":
21989
- await this.opMarkThrottled(socket, reqId, identity, req.until);
22052
+ await this.opMarkThrottled(socket, reqId, identity, req.until, req.probeOnly);
21990
22053
  break;
21991
22054
  case "refresh-account": {
21992
22055
  const provider = req.provider ?? "anthropic";
@@ -22818,7 +22881,7 @@ class AuthBroker {
22818
22881
  this.audit({ op: "mark-exhausted", identity, account, accountKind: "claude", ok: true });
22819
22882
  socket.write(encodeSuccess(id, { account, rolled, rolledTo }));
22820
22883
  }
22821
- async opMarkThrottled(socket, id, identity, until) {
22884
+ async opMarkThrottled(socket, id, identity, until, probeOnly = false) {
22822
22885
  const account = this.callerAccount(identity);
22823
22886
  if (!account) {
22824
22887
  this.audit({ op: "mark-throttled", identity, accountKind: "claude", ok: false, error: "no-active-account" });
@@ -22826,6 +22889,17 @@ class AuthBroker {
22826
22889
  return;
22827
22890
  }
22828
22891
  const now = this.now();
22892
+ if (probeOnly) {
22893
+ const { escalated: escalated2, rolledTo: rolledTo2 } = await this.maybeEscalateThrottle(account, identity, now);
22894
+ this.audit({ op: "mark-throttled", identity, account, accountKind: "claude", ok: true });
22895
+ socket.write(encodeSuccess(id, {
22896
+ account,
22897
+ throttled_until: this.quota[account]?.throttled_until ?? 0,
22898
+ escalated: escalated2,
22899
+ rolledTo: escalated2 ? rolledTo2 : null
22900
+ }));
22901
+ return;
22902
+ }
22829
22903
  const throttledUntil = Math.min(Math.max(until, now + 1000), now + MARK_THROTTLED_MAX_MS);
22830
22904
  const entry = this.quota[account] ?? {};
22831
22905
  const priorHits = entry.throttle_hits ?? [];
@@ -22847,31 +22921,19 @@ class AuthBroker {
22847
22921
  }
22848
22922
  const hits = priorHits.filter((t) => now - t < THROTTLE_ESCALATION_WINDOW_MS);
22849
22923
  hits.push(now);
22850
- const escalate = hits.length >= THROTTLE_ESCALATION_HITS;
22851
22924
  this.quota[account] = {
22852
22925
  ...entry,
22853
22926
  throttled_until: throttledUntil,
22854
- throttle_hits: escalate ? [] : hits
22927
+ throttle_hits: hits
22855
22928
  };
22856
22929
  this.persistQuota();
22857
22930
  this.audit({ op: "mark-throttled", identity, account, accountKind: "claude", ok: true });
22858
- process.stdout.write(`auth-broker: mark-throttled ${account} until ${new Date(throttledUntil).toISOString()} ` + `(hit ${hits.length}/${THROTTLE_ESCALATION_HITS} in window)
22859
- `);
22860
- let escalated = false;
22861
- let rolledTo = null;
22862
- if (escalate) {
22863
- const probe = await this.probeThrottleEscalation(account);
22864
- if (probe.exhausted) {
22865
- escalated = true;
22866
- process.stdout.write(`auth-broker: throttle-escalation probe corroborates wall on ${account} — mark-exhausted + roll
22867
- `);
22868
- this.audit({ op: "mark-exhausted", identity, account, accountKind: "claude", ok: true, reason: "throttle-escalation" });
22869
- const roll = await this.markExhaustedAndRoll(account, probe.until ?? undefined, identity);
22870
- rolledTo = roll.rolledTo;
22871
- } else {
22872
- process.stdout.write(`auth-broker: throttle-escalation probe on ${account} did NOT corroborate a wall — staying throttled
22931
+ process.stdout.write(`auth-broker: mark-throttled ${account} until ${new Date(throttledUntil).toISOString()} ` + `(hit ${hits.length} in window)
22873
22932
  `);
22874
- }
22933
+ const { escalated, rolledTo } = await this.maybeEscalateThrottle(account, identity, now);
22934
+ if (escalated) {
22935
+ this.quota[account] = { ...this.quota[account], throttle_hits: [] };
22936
+ this.persistQuota();
22875
22937
  }
22876
22938
  socket.write(encodeSuccess(id, {
22877
22939
  account,
@@ -22880,6 +22942,27 @@ class AuthBroker {
22880
22942
  rolledTo: escalated ? rolledTo : null
22881
22943
  }));
22882
22944
  }
22945
+ async maybeEscalateThrottle(account, identity, now) {
22946
+ const lastProbeAt = this.lastEscalationProbeAt[account];
22947
+ const probeAllowed = lastProbeAt === undefined || now - lastProbeAt >= THROTTLE_ESCALATION_PROBE_MIN_INTERVAL_MS;
22948
+ if (!probeAllowed) {
22949
+ process.stdout.write(`auth-broker: throttle-escalation probe on ${account} rate-bounded — skipped
22950
+ `);
22951
+ return { escalated: false, rolledTo: null };
22952
+ }
22953
+ this.lastEscalationProbeAt[account] = now;
22954
+ const probe = await this.probeThrottleEscalation(account);
22955
+ if (!probe.exhausted) {
22956
+ process.stdout.write(`auth-broker: throttle-escalation probe on ${account} did NOT corroborate a wall — staying put
22957
+ `);
22958
+ return { escalated: false, rolledTo: null };
22959
+ }
22960
+ process.stdout.write(`auth-broker: throttle-escalation probe corroborates wall on ${account} — mark-exhausted + roll
22961
+ `);
22962
+ this.audit({ op: "mark-exhausted", identity, account, accountKind: "claude", ok: true, reason: "throttle-escalation" });
22963
+ const roll = await this.markExhaustedAndRoll(account, probe.until ?? undefined, identity);
22964
+ return { escalated: true, rolledTo: roll.rolledTo };
22965
+ }
22883
22966
  async probeThrottleEscalation(account) {
22884
22967
  const creds = readAccountCredentials(account, this.home);
22885
22968
  const token = creds?.claudeAiOauth?.accessToken;
@@ -22887,7 +22970,7 @@ class AuthBroker {
22887
22970
  return { exhausted: false, until: null };
22888
22971
  let result;
22889
22972
  try {
22890
- result = await this.fetchQuotaImpl({ accessToken: token });
22973
+ result = await this.probeQuotaSingleFlight(account, token);
22891
22974
  } catch (err) {
22892
22975
  this.logErr(`throttle-escalation probe ${account}: ${err.message}`);
22893
22976
  return { exhausted: false, until: null };
@@ -4958,7 +4958,8 @@ var MarkThrottledRequestSchema = exports_external.object({
4958
4958
  v: exports_external.literal(PROTOCOL_VERSION),
4959
4959
  op: exports_external.literal("mark-throttled"),
4960
4960
  id: exports_external.string().min(1),
4961
- until: exports_external.number().int().positive()
4961
+ until: exports_external.number().int().positive(),
4962
+ probeOnly: exports_external.boolean().optional()
4962
4963
  });
4963
4964
  var RefreshAccountRequestSchema = exports_external.object({
4964
4965
  v: exports_external.literal(PROTOCOL_VERSION),
@@ -5375,12 +5376,13 @@ class AuthBrokerClient {
5375
5376
  const data = await this.send(req);
5376
5377
  return data;
5377
5378
  }
5378
- async markThrottled(until) {
5379
+ async markThrottled(until, probeOnly = false) {
5379
5380
  const data = await this.send({
5380
5381
  v: PROTOCOL_VERSION,
5381
5382
  id: randomUUID2(),
5382
5383
  op: "mark-throttled",
5383
- until
5384
+ until,
5385
+ ...probeOnly ? { probeOnly: true } : {}
5384
5386
  });
5385
5387
  return data;
5386
5388
  }
@@ -4034,7 +4034,8 @@ var init_protocol = __esm(() => {
4034
4034
  v: exports_external.literal(PROTOCOL_VERSION),
4035
4035
  op: exports_external.literal("mark-throttled"),
4036
4036
  id: exports_external.string().min(1),
4037
- until: exports_external.number().int().positive()
4037
+ until: exports_external.number().int().positive(),
4038
+ probeOnly: exports_external.boolean().optional()
4038
4039
  });
4039
4040
  RefreshAccountRequestSchema = exports_external.object({
4040
4041
  v: exports_external.literal(PROTOCOL_VERSION),
@@ -4430,12 +4431,13 @@ class AuthBrokerClient {
4430
4431
  const data = await this.send(req);
4431
4432
  return data;
4432
4433
  }
4433
- async markThrottled(until) {
4434
+ async markThrottled(until, probeOnly = false) {
4434
4435
  const data = await this.send({
4435
4436
  v: PROTOCOL_VERSION,
4436
4437
  id: randomUUID(),
4437
4438
  op: "mark-throttled",
4438
- until
4439
+ until,
4440
+ ...probeOnly ? { probeOnly: true } : {}
4439
4441
  });
4440
4442
  return data;
4441
4443
  }
@@ -4036,7 +4036,8 @@ var init_protocol = __esm(() => {
4036
4036
  v: exports_external.literal(PROTOCOL_VERSION),
4037
4037
  op: exports_external.literal("mark-throttled"),
4038
4038
  id: exports_external.string().min(1),
4039
- until: exports_external.number().int().positive()
4039
+ until: exports_external.number().int().positive(),
4040
+ probeOnly: exports_external.boolean().optional()
4040
4041
  });
4041
4042
  RefreshAccountRequestSchema = exports_external.object({
4042
4043
  v: exports_external.literal(PROTOCOL_VERSION),
@@ -4432,12 +4433,13 @@ class AuthBrokerClient {
4432
4433
  const data = await this.send(req);
4433
4434
  return data;
4434
4435
  }
4435
- async markThrottled(until) {
4436
+ async markThrottled(until, probeOnly = false) {
4436
4437
  const data = await this.send({
4437
4438
  v: PROTOCOL_VERSION,
4438
4439
  id: randomUUID(),
4439
4440
  op: "mark-throttled",
4440
- until
4441
+ until,
4442
+ ...probeOnly ? { probeOnly: true } : {}
4441
4443
  });
4442
4444
  return data;
4443
4445
  }
@@ -12767,6 +12767,21 @@ var OverlayDocSchema = exports_external.object({
12767
12767
  // src/config/overlay-loader.ts
12768
12768
  var OVERLAY_SOURCE = Symbol.for("switchroom.config.overlay-source");
12769
12769
  var OVERLAY_TITLE = Symbol.for("switchroom.config.overlay-title");
12770
+ var OVERLAY_READ_FAILURES = Symbol.for("switchroom.config.overlay-read-failures");
12771
+ function recordReadFailure(agentCfg, failure) {
12772
+ const node = agentCfg;
12773
+ const existing = node[OVERLAY_READ_FAILURES];
12774
+ if (Array.isArray(existing)) {
12775
+ existing.push(failure);
12776
+ return;
12777
+ }
12778
+ Object.defineProperty(agentCfg, OVERLAY_READ_FAILURES, {
12779
+ value: [failure],
12780
+ enumerable: false,
12781
+ configurable: true,
12782
+ writable: false
12783
+ });
12784
+ }
12770
12785
  function deriveOverlayTitle(raw, fileName) {
12771
12786
  const titleFromComment = raw.match(/^#[^\S\n]*name:[^\S\n]*(\S.*?)[^\S\n]*$/m)?.[1];
12772
12787
  if (titleFromComment)
@@ -12776,17 +12791,39 @@ function deriveOverlayTitle(raw, fileName) {
12776
12791
  return;
12777
12792
  return base.length > 0 ? base : undefined;
12778
12793
  }
12794
+ function readOverlayFile(agentName, file, agentCfg, warnings, source) {
12795
+ try {
12796
+ return readFileSync(file, "utf-8");
12797
+ } catch (err) {
12798
+ const code = err.code;
12799
+ if (code === "ENOENT")
12800
+ return;
12801
+ const w = {
12802
+ agent: agentName,
12803
+ file,
12804
+ reason: `read error: ${err.message}`,
12805
+ code: code ?? "EUNKNOWN"
12806
+ };
12807
+ recordReadFailure(agentCfg, { file, code: w.code, source });
12808
+ warnings.push(w);
12809
+ console.warn(`[switchroom] overlay-loader: agent='${agentName}' file='${file}': ${w.reason}`);
12810
+ return;
12811
+ }
12812
+ }
12779
12813
  function overlayDirFor(agentName, subdir) {
12780
12814
  const base = resolveDualPath(`~/.switchroom/agents/${agentName}/${subdir}`);
12781
12815
  return resolve2(base);
12782
12816
  }
12783
- function listYamlFiles(dir) {
12817
+ function listYamlFiles(dir, onUnreadableDir) {
12784
12818
  if (!existsSync2(dir))
12785
12819
  return [];
12786
12820
  let entries;
12787
12821
  try {
12788
12822
  entries = readdirSync(dir);
12789
- } catch {
12823
+ } catch (err) {
12824
+ const code = err.code;
12825
+ if (code !== "ENOENT")
12826
+ onUnreadableDir?.(code ?? "EUNKNOWN");
12790
12827
  return [];
12791
12828
  }
12792
12829
  const out = [];
@@ -12824,12 +12861,24 @@ function applyAgentOverlays(config) {
12824
12861
  for (const [agentName, agentCfg] of Object.entries(agents)) {
12825
12862
  try {
12826
12863
  const scheduleDir = overlayDirFor(agentName, "schedule.d");
12827
- const files = listYamlFiles(scheduleDir);
12864
+ const files = listYamlFiles(scheduleDir, (code) => {
12865
+ const w = {
12866
+ agent: agentName,
12867
+ file: scheduleDir,
12868
+ reason: `read error: cannot list overlay directory (${code})`,
12869
+ code
12870
+ };
12871
+ recordReadFailure(agentCfg, { file: scheduleDir, code, source: "schedule" });
12872
+ warnings.push(w);
12873
+ console.warn(`[switchroom] overlay-loader: agent='${agentName}' file='${scheduleDir}': ${w.reason}`);
12874
+ });
12828
12875
  if (files.length > 0) {
12829
12876
  const merged = [...agentCfg.schedule ?? []];
12830
12877
  for (const file of files) {
12878
+ const raw = readOverlayFile(agentName, file, agentCfg, warnings, "schedule");
12879
+ if (raw === undefined)
12880
+ continue;
12831
12881
  try {
12832
- const raw = readFileSync(file, "utf-8");
12833
12882
  const parsed = $parse(raw);
12834
12883
  const doc = OverlayDocSchema.parse(parsed);
12835
12884
  const title = deriveOverlayTitle(raw, basename(file));
@@ -12864,13 +12913,25 @@ function applyAgentOverlays(config) {
12864
12913
  }
12865
12914
  try {
12866
12915
  const skillsDir = overlayDirFor(agentName, "skills.d");
12867
- const skillFiles = listYamlFiles(skillsDir);
12916
+ const skillFiles = listYamlFiles(skillsDir, (code) => {
12917
+ const w = {
12918
+ agent: agentName,
12919
+ file: skillsDir,
12920
+ reason: `read error: cannot list overlay directory (${code})`,
12921
+ code
12922
+ };
12923
+ recordReadFailure(agentCfg, { file: skillsDir, code, source: "skills" });
12924
+ warnings.push(w);
12925
+ console.warn(`[switchroom] overlay-loader: agent='${agentName}' file='${skillsDir}': ${w.reason}`);
12926
+ });
12868
12927
  if (skillFiles.length === 0) {} else {
12869
12928
  const merged = [...agentCfg.skills ?? []];
12870
12929
  const seen = new Set(merged);
12871
12930
  for (const file of skillFiles) {
12931
+ const raw = readOverlayFile(agentName, file, agentCfg, warnings, "skills");
12932
+ if (raw === undefined)
12933
+ continue;
12872
12934
  try {
12873
- const raw = readFileSync(file, "utf-8");
12874
12935
  const parsed = $parse(raw);
12875
12936
  const doc = OverlayDocSchema.parse(parsed);
12876
12937
  for (const skillName of doc.skills ?? []) {