switchroom 0.16.47 → 0.17.0

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 (106) hide show
  1. package/dist/agent-scheduler/index.js +3 -1
  2. package/dist/auth-broker/index.js +24 -8
  3. package/dist/cli/drive-write-pretool.mjs +0 -5
  4. package/dist/cli/notion-write-pretool.mjs +3 -1
  5. package/dist/cli/switchroom.js +1358 -1030
  6. package/dist/cli/ui/index.html +84 -12
  7. package/dist/host-control/main.js +53 -17
  8. package/dist/vault/approvals/kernel-server.js +4 -1
  9. package/dist/vault/broker/server.js +201 -56
  10. package/package.json +3 -3
  11. package/profiles/_base/cron-session.sh.hbs +1 -1
  12. package/profiles/_base/start.sh.hbs +54 -3
  13. package/skills/switchroom-architecture/telegram.md +8 -15
  14. package/skills/switchroom-cli/SKILL.md +4 -5
  15. package/skills/telegram-test-harness/SKILL.md +1 -1
  16. package/telegram-plugin/README.md +18 -29
  17. package/telegram-plugin/bridge/bridge.ts +1 -41
  18. package/telegram-plugin/bridge/tool-filter.ts +3 -4
  19. package/telegram-plugin/dist/bridge/bridge.js +8 -43
  20. package/telegram-plugin/dist/gateway/gateway.js +682 -773
  21. package/telegram-plugin/dist/server.js +8 -43
  22. package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
  23. package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
  24. package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
  25. package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
  26. package/telegram-plugin/gateway/gateway.ts +518 -624
  27. package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
  28. package/telegram-plugin/gateway/inbound-spool.ts +108 -10
  29. package/telegram-plugin/gateway/model-command.ts +51 -3
  30. package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
  31. package/telegram-plugin/gateway/represent-guard.ts +28 -11
  32. package/telegram-plugin/gateway/status-pin-store.ts +124 -45
  33. package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
  34. package/telegram-plugin/history.ts +5 -0
  35. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
  36. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
  37. package/telegram-plugin/registry/subagents-schema.ts +126 -1
  38. package/telegram-plugin/registry/turns-schema.ts +65 -1
  39. package/telegram-plugin/session-tail.ts +26 -4
  40. package/telegram-plugin/slot-banner-driver.ts +42 -2
  41. package/telegram-plugin/status-query-telemetry.ts +100 -0
  42. package/telegram-plugin/stream-reply-handler.ts +15 -16
  43. package/telegram-plugin/subagent-watcher.ts +182 -30
  44. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
  45. package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
  46. package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
  47. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
  48. package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
  49. package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
  50. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
  51. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
  52. package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
  53. package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
  54. package/telegram-plugin/tests/model-command.test.ts +54 -1
  55. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
  56. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
  57. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
  58. package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
  59. package/telegram-plugin/tests/registry-turns.test.ts +67 -0
  60. package/telegram-plugin/tests/represent-guard.test.ts +42 -6
  61. package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
  62. package/telegram-plugin/tests/session-tail.test.ts +10 -1
  63. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
  64. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
  65. package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
  66. package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
  67. package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
  68. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
  69. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
  70. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
  71. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
  72. package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
  73. package/telegram-plugin/tests/tool-filter.test.ts +1 -3
  74. package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
  75. package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
  76. package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
  77. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
  78. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
  79. package/telegram-plugin/tool-activity-summary.ts +19 -0
  80. package/telegram-plugin/turn-flush-safety.ts +16 -1
  81. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
  82. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
  83. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
  84. package/telegram-plugin/worker-activity-feed.ts +75 -15
  85. package/vendor/hindsight-memory/CHANGELOG.md +24 -0
  86. package/vendor/hindsight-memory/README.md +5 -0
  87. package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
  88. package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
  89. package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
  90. package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
  91. package/vendor/hindsight-memory/scripts/recall.py +74 -1
  92. package/vendor/hindsight-memory/scripts/retain.py +8 -1
  93. package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
  94. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
  95. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
  96. package/vendor/hindsight-memory/settings.json +4 -0
  97. package/vendor/hindsight-memory/tests/test_client.py +130 -0
  98. package/vendor/hindsight-memory/tests/test_config.py +47 -0
  99. package/vendor/hindsight-memory/tests/test_content.py +18 -0
  100. package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
  101. package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
  102. package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
  103. package/telegram-plugin/silent-reply.ts +0 -58
  104. package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
  105. package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
  106. package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
@@ -4151,7 +4151,9 @@ var init_schema = __esm(() => {
4151
4151
  enabled: exports_external.boolean().optional().describe("Master switch for the session-handoff briefing (default true)."),
4152
4152
  max_turns_in_briefing: exports_external.number().int().positive().optional().describe("Cap on recent user/assistant turn pairs fed to the summarizer."),
4153
4153
  resume_mode: exports_external.enum(["auto", "continue", "handoff", "none"]).optional().describe("How to resume the next session. 'handoff' (default as of #362) " + "never passes --continue; a fresh Claude starts each restart and " + "reads a briefing assembled from recent Telegram messages, Hindsight " + "recall, and today's daily memory file. 'auto' uses --continue when " + "the latest JSONL is smaller than resume_max_bytes, else falls back " + "to the handoff briefing. 'continue' always passes --continue. " + "'none' starts completely fresh every time."),
4154
- resume_max_bytes: exports_external.number().int().positive().optional().describe("Byte threshold above which 'auto' mode falls back to handoff " + "instead of --continue. Default 2_000_000 (~2MB). Large transcripts " + "can blow out the context window even with prefix caching, and " + "--continue replay is known-fragile at scale.")
4154
+ resume_max_bytes: exports_external.number().int().positive().optional().describe("Byte threshold above which 'auto' mode falls back to handoff " + "instead of --continue. Default 2_000_000 (~2MB). Large transcripts " + "can blow out the context window even with prefix caching, and " + "--continue replay is known-fragile at scale."),
4155
+ session_retention_max_count: exports_external.number().int().nonnegative().optional().describe("Session-JSONL retention (issue #2792): keep at most this many " + "newest session transcripts under .claude/projects; older ones " + "past both this count and the age bound are pruned by the Stop " + "hook. The newest sessions (and the handoff source) are always " + "kept. Default 20; set 0 to disable the count bound."),
4156
+ session_retention_max_age_days: exports_external.number().int().nonnegative().optional().describe("Session-JSONL retention (issue #2792): prune session transcripts " + "older than this many days (a file is deleted only when it is BOTH " + "over the count bound and older than this). Default 30; set 0 to " + "disable the age bound.")
4155
4157
  }).optional();
4156
4158
  webhookDispatchRule = exports_external.object({
4157
4159
  description: exports_external.string().optional(),
@@ -12411,7 +12413,7 @@ var init_loader = __esm(() => {
12411
12413
 
12412
12414
  // src/vault/broker/server.ts
12413
12415
  import * as net from "node:net";
12414
- import { mkdirSync as mkdirSync5, chmodSync as chmodSync4, chownSync, existsSync as existsSync8, readFileSync as readFileSync8, readdirSync as readdirSync3, statSync as statSync4, unlinkSync as unlinkSync4, writeFileSync as writeFileSync3, renameSync as renameSync3 } from "node:fs";
12416
+ import { mkdirSync as mkdirSync5, chmodSync as chmodSync4, chownSync, existsSync as existsSync10, readFileSync as readFileSync8, readdirSync as readdirSync3, statSync as statSync5, unlinkSync as unlinkSync6, writeFileSync, renameSync as renameSync6 } from "node:fs";
12415
12417
 
12416
12418
  // src/agents/compose.ts
12417
12419
  import { createHash } from "node:crypto";
@@ -12482,23 +12484,46 @@ function getPeerCred(fd) {
12482
12484
  }
12483
12485
  }
12484
12486
 
12485
- // src/vault/broker/peercred.ts
12486
- var SOCKET_PATH_AGENT_RE = /^\/run\/switchroom\/broker\/([a-zA-Z0-9][a-zA-Z0-9_-]*)\.sock$/;
12487
- var SOCKET_PATH_AGENT_SUBDIR_RE = /^\/run\/switchroom\/broker\/([a-zA-Z0-9][a-zA-Z0-9_-]*)\/sock$/;
12488
- var RESERVED_AGENT_NAMES = new Set(["operator", "hostd"]);
12489
- function socketPathToIdentity(socketPath) {
12487
+ // src/broker-common/peercred-path.ts
12488
+ function matchSocketName(socketPath, patterns, maxNameLen) {
12490
12489
  if (typeof socketPath !== "string" || socketPath.length === 0)
12491
12490
  return null;
12492
- const m = socketPath.match(SOCKET_PATH_AGENT_RE) ?? socketPath.match(SOCKET_PATH_AGENT_SUBDIR_RE);
12493
- if (!m)
12491
+ let name = null;
12492
+ for (const re of patterns) {
12493
+ const m = socketPath.match(re);
12494
+ if (m && typeof m[1] === "string") {
12495
+ name = m[1];
12496
+ break;
12497
+ }
12498
+ }
12499
+ if (name === null || name.length === 0)
12500
+ return null;
12501
+ if (maxNameLen !== undefined && name.length > maxNameLen)
12494
12502
  return null;
12495
- const name = m[1];
12496
- if (name === "operator")
12503
+ return name;
12504
+ }
12505
+ function parseSocketIdentity(socketPath, spec) {
12506
+ const name = matchSocketName(socketPath, spec.patterns, spec.maxNameLen);
12507
+ if (name === null)
12508
+ return null;
12509
+ const operatorName = spec.operatorName ?? "operator";
12510
+ if (name === operatorName)
12497
12511
  return { kind: "operator" };
12498
- if (RESERVED_AGENT_NAMES.has(name))
12512
+ if (spec.reservedNames.has(name))
12499
12513
  return null;
12500
12514
  return { kind: "agent", name };
12501
12515
  }
12516
+
12517
+ // src/vault/broker/peercred.ts
12518
+ var SOCKET_PATH_AGENT_RE = /^\/run\/switchroom\/broker\/([a-zA-Z0-9][a-zA-Z0-9_-]*)\.sock$/;
12519
+ var SOCKET_PATH_AGENT_SUBDIR_RE = /^\/run\/switchroom\/broker\/([a-zA-Z0-9][a-zA-Z0-9_-]*)\/sock$/;
12520
+ var RESERVED_AGENT_NAMES = new Set(["operator", "hostd"]);
12521
+ function socketPathToIdentity(socketPath) {
12522
+ return parseSocketIdentity(socketPath, {
12523
+ patterns: [SOCKET_PATH_AGENT_RE, SOCKET_PATH_AGENT_SUBDIR_RE],
12524
+ reservedNames: RESERVED_AGENT_NAMES
12525
+ });
12526
+ }
12502
12527
  function socketPathToAgent(socketPath) {
12503
12528
  const identity = socketPathToIdentity(socketPath);
12504
12529
  return identity?.kind === "agent" ? identity.name : null;
@@ -12769,11 +12794,14 @@ import * as path3 from "node:path";
12769
12794
  import { randomBytes, scryptSync, createCipheriv, createDecipheriv } from "node:crypto";
12770
12795
  import {
12771
12796
  readFileSync as readFileSync3,
12772
- writeFileSync,
12797
+ writeSync as writeSync2,
12773
12798
  existsSync as existsSync3,
12774
12799
  renameSync,
12775
12800
  mkdirSync,
12776
12801
  unlinkSync as unlinkSync2,
12802
+ fsyncSync as fsyncSync2,
12803
+ openSync as openSync2,
12804
+ closeSync as closeSync2,
12777
12805
  lstatSync,
12778
12806
  realpathSync
12779
12807
  } from "node:fs";
@@ -13017,8 +13045,22 @@ function atomicWriteFileSync(path, data, mode) {
13017
13045
  const dir = dirname(resolve2(effectivePath));
13018
13046
  const tmp = resolve2(dir, `.${basename(effectivePath)}.${process.pid}.${Date.now()}.tmp`);
13019
13047
  try {
13020
- writeFileSync(tmp, data, { encoding: "utf8", mode });
13048
+ const fd = openSync2(tmp, "wx", mode);
13049
+ try {
13050
+ writeSync2(fd, data);
13051
+ fsyncSync2(fd);
13052
+ } finally {
13053
+ closeSync2(fd);
13054
+ }
13021
13055
  renameSync(tmp, effectivePath);
13056
+ try {
13057
+ const dirFd = openSync2(dir, "r");
13058
+ try {
13059
+ fsyncSync2(dirFd);
13060
+ } finally {
13061
+ closeSync2(dirFd);
13062
+ }
13063
+ } catch {}
13022
13064
  } catch (err) {
13023
13065
  try {
13024
13066
  if (existsSync3(tmp))
@@ -13289,7 +13331,18 @@ init_loader();
13289
13331
 
13290
13332
  // src/vault/auto-unlock.ts
13291
13333
  import { createHmac, randomBytes as randomBytes2, createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2 } from "node:crypto";
13292
- import { chmodSync as chmodSync2, existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "node:fs";
13334
+ import {
13335
+ chmodSync as chmodSync2,
13336
+ closeSync as closeSync4,
13337
+ existsSync as existsSync7,
13338
+ fsyncSync as fsyncSync4,
13339
+ mkdirSync as mkdirSync3,
13340
+ openSync as openSync4,
13341
+ readFileSync as readFileSync7,
13342
+ renameSync as renameSync3,
13343
+ unlinkSync as unlinkSync4,
13344
+ writeSync as writeSync3
13345
+ } from "node:fs";
13293
13346
  var FORMAT_VERSION = 1;
13294
13347
  var SALT_LEN = 16;
13295
13348
  var NONCE_LEN = 12;
@@ -13959,7 +14012,7 @@ import * as path from "node:path";
13959
14012
 
13960
14013
  // src/util/audit-hashchain.ts
13961
14014
  import { createHash as createHash4 } from "node:crypto";
13962
- import { openSync as openSync4, readSync, fstatSync as fstatSync2, closeSync as closeSync4 } from "node:fs";
14015
+ import { openSync as openSync5, readSync, fstatSync as fstatSync2, closeSync as closeSync5 } from "node:fs";
13963
14016
  var CHAIN_GENESIS = "GENESIS";
13964
14017
  var SEP = "\x00";
13965
14018
  function rowHash(prev, seq, rowJsonNoChain) {
@@ -13980,7 +14033,7 @@ function chainRow(state, entry) {
13980
14033
  function readTail(path, tailBytes = 64 * 1024) {
13981
14034
  let fd;
13982
14035
  try {
13983
- fd = openSync4(path, "r");
14036
+ fd = openSync5(path, "r");
13984
14037
  } catch {
13985
14038
  return null;
13986
14039
  }
@@ -13996,7 +14049,7 @@ function readTail(path, tailBytes = 64 * 1024) {
13996
14049
  return null;
13997
14050
  } finally {
13998
14051
  try {
13999
- closeSync4(fd);
14052
+ closeSync5(fd);
14000
14053
  } catch {}
14001
14054
  }
14002
14055
  }
@@ -14073,6 +14126,45 @@ function safeAuditLogPath(requestedPath) {
14073
14126
  }
14074
14127
 
14075
14128
  // src/vault/broker/audit-log.ts
14129
+ var DEFAULT_AUDIT_MAX_BYTES = 32 * 1024 * 1024;
14130
+ var DEFAULT_AUDIT_MAX_FILES = 5;
14131
+ function resolveRotation(opts) {
14132
+ const envBytes = Number(process.env.SWITCHROOM_VAULT_AUDIT_MAX_BYTES);
14133
+ const envFiles = Number(process.env.SWITCHROOM_VAULT_AUDIT_MAX_FILES);
14134
+ const maxBytes = opts.maxBytes !== undefined && opts.maxBytes !== 0 ? opts.maxBytes : Number.isFinite(envBytes) && envBytes !== 0 ? envBytes : DEFAULT_AUDIT_MAX_BYTES;
14135
+ const maxFiles = opts.maxFiles !== undefined && opts.maxFiles > 0 ? opts.maxFiles : Number.isFinite(envFiles) && envFiles > 0 ? envFiles : DEFAULT_AUDIT_MAX_FILES;
14136
+ return { maxBytes, maxFiles };
14137
+ }
14138
+ function rotateAuditLog(logPath, maxFiles) {
14139
+ const oldest = `${logPath}.${maxFiles}`;
14140
+ if (fs.existsSync(oldest)) {
14141
+ try {
14142
+ fs.unlinkSync(oldest);
14143
+ } catch (err) {
14144
+ process.stderr.write(`[vault-audit] ERROR: could not drop oldest rotation ${oldest}: ${err.message}
14145
+ `);
14146
+ }
14147
+ }
14148
+ for (let n = maxFiles - 1;n >= 1; n--) {
14149
+ const from = `${logPath}.${n}`;
14150
+ const to = `${logPath}.${n + 1}`;
14151
+ if (!fs.existsSync(from))
14152
+ continue;
14153
+ try {
14154
+ fs.renameSync(from, to);
14155
+ } catch (err) {
14156
+ process.stderr.write(`[vault-audit] ERROR: could not rotate ${from} → ${to}: ${err.message}
14157
+ `);
14158
+ return;
14159
+ }
14160
+ }
14161
+ try {
14162
+ fs.renameSync(logPath, `${logPath}.1`);
14163
+ } catch (err) {
14164
+ process.stderr.write(`[vault-audit] ERROR: could not rotate active audit log ${logPath}: ${err.message}
14165
+ `);
14166
+ }
14167
+ }
14076
14168
  function defaultAuditLogPath() {
14077
14169
  return path.join(os.homedir(), ".switchroom", "vault-audit.log");
14078
14170
  }
@@ -14085,8 +14177,17 @@ function callerFromPeer(peer) {
14085
14177
  function createAuditLogger(opts = {}) {
14086
14178
  const logPath = safeAuditLogPath(opts.path ?? defaultAuditLogPath());
14087
14179
  let chain = seedChain(logPath);
14180
+ const rotation = resolveRotation(opts);
14088
14181
  return {
14089
14182
  write(entry) {
14183
+ if (rotation.maxBytes > 0) {
14184
+ try {
14185
+ const size = fs.statSync(logPath).size;
14186
+ if (size >= rotation.maxBytes) {
14187
+ rotateAuditLog(logPath, rotation.maxFiles);
14188
+ }
14189
+ } catch {}
14190
+ }
14090
14191
  const { line, next } = chainRow(chain, entry);
14091
14192
  let fd;
14092
14193
  try {
@@ -16068,19 +16169,63 @@ var DEFAULT_MAX_TTL_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
16068
16169
 
16069
16170
  // src/vault/grants-db.ts
16070
16171
  var DEFAULT_GRANTS_DB_PATH = path2.join(os2.homedir(), ".switchroom", "vault-grants.db");
16071
- function openGrantsDb(dbPath = DEFAULT_GRANTS_DB_PATH) {
16072
- const dir = path2.dirname(dbPath);
16073
- fs2.mkdirSync(dir, { recursive: true });
16172
+ var WAL_SIDECAR_SUFFIXES = ["-wal", "-shm"];
16173
+ function isGrantsDbCorruption(err) {
16174
+ if (!err || typeof err !== "object")
16175
+ return false;
16176
+ const code = String(err.code ?? "").toUpperCase();
16177
+ if (code === "SQLITE_CORRUPT" || code.startsWith("SQLITE_CORRUPT_")) {
16178
+ return true;
16179
+ }
16180
+ const message = String(err.message ?? "").toLowerCase();
16181
+ return message.includes("database disk image is malformed") || message.includes("database is corrupt");
16182
+ }
16183
+ function quarantineGrantsDb(dbPath) {
16184
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
16185
+ const quarantinePath = `${dbPath}.corrupt-${stamp}`;
16186
+ fs2.renameSync(dbPath, quarantinePath);
16187
+ for (const suffix of WAL_SIDECAR_SUFFIXES) {
16188
+ const sidecar = `${dbPath}${suffix}`;
16189
+ if (fs2.existsSync(sidecar)) {
16190
+ try {
16191
+ fs2.renameSync(sidecar, `${quarantinePath}${suffix}`);
16192
+ } catch {}
16193
+ }
16194
+ }
16195
+ return quarantinePath;
16196
+ }
16197
+ function openAndMigrate(dbPath) {
16074
16198
  const db = new Database(dbPath, { create: true });
16075
16199
  try {
16076
- fs2.chmodSync(dbPath, 384);
16077
- } catch {}
16078
- db.run("PRAGMA journal_mode=WAL");
16079
- db.run("PRAGMA foreign_keys=ON");
16080
- migrateGrantsSchema(db);
16081
- migrateApprovalSchema(db);
16200
+ try {
16201
+ fs2.chmodSync(dbPath, 384);
16202
+ } catch {}
16203
+ db.run("PRAGMA journal_mode=WAL");
16204
+ db.run("PRAGMA busy_timeout=5000");
16205
+ db.run("PRAGMA foreign_keys=ON");
16206
+ migrateGrantsSchema(db);
16207
+ migrateApprovalSchema(db);
16208
+ } catch (err) {
16209
+ try {
16210
+ db.close();
16211
+ } catch {}
16212
+ throw err;
16213
+ }
16082
16214
  return db;
16083
16215
  }
16216
+ function openGrantsDb(dbPath = DEFAULT_GRANTS_DB_PATH, opener = openAndMigrate) {
16217
+ const dir = path2.dirname(dbPath);
16218
+ fs2.mkdirSync(dir, { recursive: true });
16219
+ try {
16220
+ return opener(dbPath);
16221
+ } catch (err) {
16222
+ if (!isGrantsDbCorruption(err))
16223
+ throw err;
16224
+ const quarantinePath = quarantineGrantsDb(dbPath);
16225
+ console.error(`[vault-broker] grants DB at ${dbPath} is corrupt (${err.message ?? err}); quarantined to ${quarantinePath} and recreating an empty grants DB. ` + "Existing grants must be re-approved via the vault approval flow.");
16226
+ return opener(dbPath);
16227
+ }
16228
+ }
16084
16229
 
16085
16230
  // src/vault/approvals/kernel.ts
16086
16231
  import { randomBytes as randomBytes5, randomUUID } from "node:crypto";
@@ -16431,9 +16576,9 @@ class VaultBroker {
16431
16576
  chmodSync4(parentDir, 448);
16432
16577
  } catch {}
16433
16578
  for (const p of [this.socketPath, this.unlockSocketPath]) {
16434
- if (existsSync8(p)) {
16579
+ if (existsSync10(p)) {
16435
16580
  try {
16436
- unlinkSync4(p);
16581
+ unlinkSync6(p);
16437
16582
  } catch {}
16438
16583
  }
16439
16584
  }
@@ -16466,9 +16611,9 @@ class VaultBroker {
16466
16611
  return;
16467
16612
  try {
16468
16613
  if (ready) {
16469
- writeFileSync3(p, "", { mode: 384 });
16470
- } else if (existsSync8(p)) {
16471
- unlinkSync4(p);
16614
+ writeFileSync(p, "", { mode: 384 });
16615
+ } else if (existsSync10(p)) {
16616
+ unlinkSync6(p);
16472
16617
  }
16473
16618
  } catch {}
16474
16619
  }
@@ -16500,24 +16645,24 @@ class VaultBroker {
16500
16645
  try {
16501
16646
  entry.server.close();
16502
16647
  } catch {}
16503
- if (existsSync8(sockPath)) {
16648
+ if (existsSync10(sockPath)) {
16504
16649
  try {
16505
- unlinkSync4(sockPath);
16650
+ unlinkSync6(sockPath);
16506
16651
  } catch {}
16507
16652
  }
16508
16653
  }
16509
16654
  this.agentServers.clear();
16510
16655
  for (const p of [this.socketPath, this.unlockSocketPath]) {
16511
- if (p && existsSync8(p)) {
16656
+ if (p && existsSync10(p)) {
16512
16657
  try {
16513
- unlinkSync4(p);
16658
+ unlinkSync6(p);
16514
16659
  } catch {}
16515
16660
  }
16516
16661
  }
16517
16662
  try {
16518
16663
  const pidPath = resolvePath(PID_FILE_DEFAULT);
16519
- if (existsSync8(pidPath))
16520
- unlinkSync4(pidPath);
16664
+ if (existsSync10(pidPath))
16665
+ unlinkSync6(pidPath);
16521
16666
  } catch {}
16522
16667
  }
16523
16668
  getStatus() {
@@ -16542,7 +16687,7 @@ class VaultBroker {
16542
16687
  return new Promise((resolveP, rejectP) => {
16543
16688
  if (abs.endsWith("/sock")) {
16544
16689
  const dir = abs.slice(0, -"/sock".length);
16545
- if (existsSync8(dir)) {
16690
+ if (existsSync10(dir)) {
16546
16691
  try {
16547
16692
  chownSync(dir, 0, 0);
16548
16693
  } catch {}
@@ -16551,9 +16696,9 @@ class VaultBroker {
16551
16696
  } catch {}
16552
16697
  }
16553
16698
  }
16554
- if (existsSync8(abs)) {
16699
+ if (existsSync10(abs)) {
16555
16700
  try {
16556
- unlinkSync4(abs);
16701
+ unlinkSync6(abs);
16557
16702
  } catch (err) {
16558
16703
  const msg = err instanceof Error ? err.message : String(err);
16559
16704
  process.stderr.write(`[vault-broker] could not unlink stale socket agent=${agentName} sock=${abs}: ${msg}
@@ -16583,9 +16728,9 @@ class VaultBroker {
16583
16728
  } catch {}
16584
16729
  this.agentServers.set(abs, { server, agentName });
16585
16730
  const unlockAbs = unlockSocketFor(abs);
16586
- if (existsSync8(unlockAbs)) {
16731
+ if (existsSync10(unlockAbs)) {
16587
16732
  try {
16588
- unlinkSync4(unlockAbs);
16733
+ unlinkSync6(unlockAbs);
16589
16734
  } catch {}
16590
16735
  }
16591
16736
  const unlockServer = net.createServer((sock) => {
@@ -16638,7 +16783,7 @@ class VaultBroker {
16638
16783
  if (!Number.isFinite(uid) || uid <= 0)
16639
16784
  return;
16640
16785
  try {
16641
- if (existsSync8(this.vaultPath))
16786
+ if (existsSync10(this.vaultPath))
16642
16787
  chownSync(this.vaultPath, uid, uid);
16643
16788
  } catch {}
16644
16789
  }
@@ -16651,7 +16796,7 @@ class VaultBroker {
16651
16796
  const unlockAbs = unlockSocketFor(abs);
16652
16797
  if (abs.endsWith("/sock")) {
16653
16798
  const dir = abs.slice(0, -"/sock".length);
16654
- if (existsSync8(dir)) {
16799
+ if (existsSync10(dir)) {
16655
16800
  try {
16656
16801
  chownSync(dir, 0, 0);
16657
16802
  } catch {}
@@ -16661,9 +16806,9 @@ class VaultBroker {
16661
16806
  }
16662
16807
  }
16663
16808
  for (const p of [abs, unlockAbs]) {
16664
- if (existsSync8(p)) {
16809
+ if (existsSync10(p)) {
16665
16810
  try {
16666
- unlinkSync4(p);
16811
+ unlinkSync6(p);
16667
16812
  } catch {}
16668
16813
  }
16669
16814
  }
@@ -17524,8 +17669,8 @@ class VaultBroker {
17524
17669
  mkdirSync5(tokenDir, { recursive: true });
17525
17670
  const tokenPath = path3.join(tokenDir, ".vault-token");
17526
17671
  const tmpPath = `${tokenPath}.tmp.${process.pid}`;
17527
- writeFileSync3(tmpPath, mintResult.token, { mode: 384 });
17528
- renameSync3(tmpPath, tokenPath);
17672
+ writeFileSync(tmpPath, mintResult.token, { mode: 384 });
17673
+ renameSync6(tmpPath, tokenPath);
17529
17674
  try {
17530
17675
  const uid = allocateAgentUid(agent);
17531
17676
  chownSync(tokenPath, uid, uid);
@@ -17585,9 +17730,9 @@ class VaultBroker {
17585
17730
  const row = this.grantsDb.query("SELECT agent_slug FROM vault_grants WHERE id = ?").get(id);
17586
17731
  if (row && AgentNameSchema.safeParse(row.agent_slug).success) {
17587
17732
  const tokenPath = path3.join(os3.homedir(), ".switchroom", "agents", row.agent_slug, ".vault-token");
17588
- if (existsSync8(tokenPath)) {
17733
+ if (existsSync10(tokenPath)) {
17589
17734
  try {
17590
- unlinkSync4(tokenPath);
17735
+ unlinkSync6(tokenPath);
17591
17736
  } catch {}
17592
17737
  }
17593
17738
  }
@@ -17810,7 +17955,7 @@ class VaultBroker {
17810
17955
  _writePidFile() {
17811
17956
  try {
17812
17957
  const pidPath = resolvePath(PID_FILE_DEFAULT);
17813
- writeFileSync3(pidPath, String(process.pid) + `
17958
+ writeFileSync(pidPath, String(process.pid) + `
17814
17959
  `, { mode: 384 });
17815
17960
  } catch {}
17816
17961
  }
@@ -17823,10 +17968,10 @@ class VaultBroker {
17823
17968
  const envPath = process.env.SWITCHROOM_VAULT_BROKER_AUTO_UNLOCK_PATH;
17824
17969
  const configuredPath = (envPath && envPath.length > 0 ? envPath : undefined) ?? this.config?.vault?.broker?.autoUnlockCredentialPath ?? DEFAULT_AUTO_UNLOCK_PATH;
17825
17970
  const filePath = resolvePath(configuredPath);
17826
- if (!existsSync8(filePath))
17971
+ if (!existsSync10(filePath))
17827
17972
  return false;
17828
17973
  try {
17829
- if (statSync4(filePath).size === 0)
17974
+ if (statSync5(filePath).size === 0)
17830
17975
  return false;
17831
17976
  } catch {
17832
17977
  return false;
@@ -17938,7 +18083,7 @@ async function main() {
17938
18083
  const vaultPath = process.env.SWITCHROOM_VAULT_PATH;
17939
18084
  let perAgentTargets = [];
17940
18085
  try {
17941
- if (existsSync8(perAgentDir)) {
18086
+ if (existsSync10(perAgentDir)) {
17942
18087
  const entries = readdirSync3(perAgentDir, { withFileTypes: true });
17943
18088
  const flat = [];
17944
18089
  const subdirs = [];
@@ -17993,7 +18138,7 @@ async function main() {
17993
18138
  }
17994
18139
  const operatorUidStr = process.env.SWITCHROOM_BROKER_OPERATOR_UID;
17995
18140
  const operatorDir = "/run/switchroom/broker/operator";
17996
- if (operatorUidStr !== undefined && existsSync8(operatorDir)) {
18141
+ if (operatorUidStr !== undefined && existsSync10(operatorDir)) {
17997
18142
  const operatorUid = parseInt(operatorUidStr, 10);
17998
18143
  if (!Number.isFinite(operatorUid) || operatorUid <= 0) {
17999
18144
  process.stderr.write(`[vault-broker] SWITCHROOM_BROKER_OPERATOR_UID='${operatorUidStr}' is not a positive integer; skipping operator listener
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.16.47",
4
+ "version": "0.17.0",
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": {
@@ -24,9 +24,9 @@
24
24
  "build": "node scripts/build.mjs",
25
25
  "build:cli": "node scripts/build.mjs && bun build --compile --target=bun-linux-x64 --minify bin/switchroom.ts --outfile switchroom-linux-amd64",
26
26
  "pretest": "npm run build",
27
- "test": "vitest run && bun test telegram-plugin/tests/history.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/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/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/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.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/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
27
+ "test": "vitest run && bun test telegram-plugin/tests/history.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/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/quota-cache.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/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/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.test.ts telegram-plugin/tests/subagent-tracker-hooks.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/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
28
28
  "test:vitest": "vitest run",
29
- "test:bun": "bun test src/vault/grants.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-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/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/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/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/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/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/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",
29
+ "test:bun": "bun test 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-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/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/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/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/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/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
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-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs && node scripts/check-web-subscription-honest.mjs",
32
32
  "lint:tsc": "tsc --noEmit",
@@ -95,7 +95,7 @@ cd "{{agentDir}}" || exit 1
95
95
  # can distinguish cron spend from main-session spend per agent.
96
96
  # FAIL-OPEN: missing key OR unreachable proxy → strip routing env, fall back to
97
97
  # direct OAuth. An outage must never take the cron session dark.
98
- if [ -n "$SWITCHROOM_LITELLM" ] && command -v switchroom >/dev/null 2>&1; then
98
+ if [ -n "${SWITCHROOM_LITELLM:-}" ] && command -v switchroom >/dev/null 2>&1; then
99
99
  sr_ll_key="$(switchroom vault get "litellm/{{name}}/api-key" 2>/dev/null || true)"
100
100
  sr_ll_ok=""
101
101
  if [ -z "$sr_ll_key" ]; then
@@ -64,7 +64,7 @@ if [ "$SWITCHROOM_RUNTIME" = "docker" ] && [ -z "$SWITCHROOM_DOCKER_TMUX_INNER"
64
64
  #
65
65
  # SWITCHROOM_AGENT_NAME is injected by compose env (compose.ts:1816)
66
66
  # so it is available here, before the inner-pass export at line ~320.
67
- if [ -n "$SWITCHROOM_LITELLM" ] && [ -z "$ANTHROPIC_CUSTOM_HEADERS" ] && command -v switchroom >/dev/null 2>&1; then
67
+ if [ -n "${SWITCHROOM_LITELLM:-}" ] && [ -z "$ANTHROPIC_CUSTOM_HEADERS" ] && command -v switchroom >/dev/null 2>&1; then
68
68
  sr_ll_key="$(switchroom vault get "litellm/$SWITCHROOM_AGENT_NAME/api-key" 2>/dev/null || true)"
69
69
  sr_ll_ok=""
70
70
  if [ -z "$sr_ll_key" ]; then
@@ -657,8 +657,24 @@ mkdir -p "$TELEGRAM_STATE_DIR" 2>/dev/null || true
657
657
  # .handoff-briefing.md and merged alongside (or instead of) .handoff.md.
658
658
  HANDOFF_FILE="{{agentDir}}/.handoff.md"
659
659
  HANDOFF_BRIEFING_FILE="{{agentDir}}/.handoff-briefing.md"
660
+ # Decide whether the on-disk briefing is stale and must be rebuilt (#2790).
661
+ # A clean shutdown leaves a non-empty .handoff.md; a *later* hard crash never
662
+ # fires the Stop hook, so that stale briefing would otherwise be re-injected
663
+ # — hiding the crashed session's turns and reorienting the agent from old
664
+ # context. Rebuild when .handoff.md is empty OR when the newest session JSONL
665
+ # is newer than .handoff.md (i.e. a session ran after the briefing was written).
666
+ _LATEST_JSONL=$(_find_latest_jsonl "$CLAUDE_CONFIG_DIR/projects")
667
+ _HANDOFF_STALE=0
660
668
  if [ ! -s "$HANDOFF_FILE" ]; then
661
- _LATEST_JSONL=$(_find_latest_jsonl "$CLAUDE_CONFIG_DIR/projects")
669
+ _HANDOFF_STALE=1
670
+ elif [ -n "$_LATEST_JSONL" ]; then
671
+ _JSONL_MOD=$(_stat_mtime "$_LATEST_JSONL")
672
+ _HANDOFF_MOD=$(_stat_mtime "$HANDOFF_FILE")
673
+ if [ "$_JSONL_MOD" -gt "$_HANDOFF_MOD" ]; then
674
+ _HANDOFF_STALE=1
675
+ fi
676
+ fi
677
+ if [ "$_HANDOFF_STALE" = "1" ]; then
662
678
  if [ -n "$_LATEST_JSONL" ]; then
663
679
  _LAST_MOD=$(_stat_mtime "$_LATEST_JSONL")
664
680
  _NOW=$(date +%s)
@@ -704,6 +720,11 @@ $_HANDOFF_CONTENT"
704
720
  APPEND_PROMPT="$_HANDOFF_CONTENT"
705
721
  fi
706
722
  fi
723
+ # Consume the briefing sidecars once injected (#2790). Leaving them on disk
724
+ # lets a later hard crash (which never fires the Stop hook to refresh them)
725
+ # re-serve this now-stale briefing on the next boot. Removing them means the
726
+ # staleness guard above starts from a clean slate every boot.
727
+ rm -f "$HANDOFF_BRIEFING_FILE" "$HANDOFF_FILE" 2>/dev/null || true
707
728
  {{else}}
708
729
  APPEND_PROMPT={{#if systemPromptAppendShellQuoted}}{{{systemPromptAppendShellQuoted}}}{{else}}""{{/if}}
709
730
  {{/if}}
@@ -807,6 +828,36 @@ if command -v switchroom >/dev/null 2>&1; then
807
828
  unset sr_wk_pair sr_wk_env sr_wk_key sr_wk_val
808
829
  fi
809
830
 
831
+ # Fleet-wide webkite render config (#2805). Seed the image-baked default
832
+ # (/opt/switchroom/webkite/config.toml) into the XDG location webkite reads
833
+ # ($HOME/.config/webkite/config.toml) so JS-heavy SPA/booking pages render
834
+ # via the baked-in cloakbrowser instead of returning a static shell.
835
+ #
836
+ # REFRESH-from-image, not copy-if-absent. $HOME (/state/agent/home) is a
837
+ # host-PERSISTENT bind mount (see src/agents/compose.ts), so a copy-if-absent
838
+ # seed would write once and then the stale home copy would shadow every future
839
+ # image-shipped config update forever — the config would be un-updatable
840
+ # fleet-wide after first boot. Instead re-sync the target from the baked
841
+ # default whenever they differ, so an image bump always propagates.
842
+ #
843
+ # Operator override still wins: an operator-authored
844
+ # ~/.switchroom/webkite/config.toml is bind-mounted RO onto this exact target
845
+ # (compose.ts). We detect that mount and leave it untouched; even absent the
846
+ # mountpoint check, a cp onto the RO mount fails harmlessly and the override
847
+ # is preserved.
848
+ if [ -f /opt/switchroom/webkite/config.toml ]; then
849
+ sr_wk_target="$HOME/.config/webkite/config.toml"
850
+ if mountpoint -q "$sr_wk_target" 2>/dev/null; then
851
+ : # operator override bind-mounted RO — never clobber it
852
+ elif ! cmp -s /opt/switchroom/webkite/config.toml "$sr_wk_target" 2>/dev/null; then
853
+ mkdir -p "$HOME/.config/webkite" 2>/dev/null || true
854
+ cp /opt/switchroom/webkite/config.toml "$sr_wk_target" 2>/dev/null \
855
+ && echo "webkite: synced fleet render config to \$HOME/.config/webkite/config.toml" >&2 \
856
+ || echo "webkite: could not sync render config (non-fatal)" >&2
857
+ fi
858
+ unset sr_wk_target
859
+ fi
860
+
810
861
  # LiteLLM routing (opt-in, #litellm). When SWITCHROOM_LITELLM is set (compose
811
862
  # env, gated on litellm.enabled && keyConfirmed), route the unmodified `claude`
812
863
  # CLI through the operator's LiteLLM proxy at ANTHROPIC_BASE_URL: fetch the
@@ -833,7 +884,7 @@ fi
833
884
  # per-session cost/issue tracking is handled out-of-band by correlating
834
885
  # LiteLLM's request log against switchroom's turn ledger, never by mutating the
835
886
  # claude protocol.
836
- if [ -n "$SWITCHROOM_LITELLM" ] && command -v switchroom >/dev/null 2>&1; then
887
+ if [ -n "${SWITCHROOM_LITELLM:-}" ] && command -v switchroom >/dev/null 2>&1; then
837
888
  sr_ll_key="$(switchroom vault get "litellm/$SWITCHROOM_AGENT_NAME/api-key" 2>/dev/null || true)"
838
889
  sr_ll_ok=""
839
890
  if [ -z "$sr_ll_key" ]; then