switchroom 0.18.12 → 0.18.14

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 (76) hide show
  1. package/dist/agent-scheduler/index.js +57 -9
  2. package/dist/auth-broker/index.js +174 -72
  3. package/dist/cli/autoaccept-poll.js +23 -0
  4. package/dist/cli/drive-write-pretool.mjs +24 -1
  5. package/dist/cli/foreground-hog-pretool.mjs +264 -0
  6. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  7. package/dist/cli/notion-write-pretool.mjs +9 -2
  8. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  9. package/dist/cli/switchroom.js +986 -3131
  10. package/dist/host-control/main.js +216 -2863
  11. package/dist/vault/approvals/kernel-server.js +67 -1
  12. package/dist/vault/broker/server.js +98 -45
  13. package/package.json +1 -1
  14. package/profiles/coding/CLAUDE.md.hbs +2 -0
  15. package/profiles/default/CLAUDE.md.hbs +2 -0
  16. package/skills/switchroom-architecture/telegram.md +0 -1
  17. package/telegram-plugin/auth-snapshot-format.ts +37 -5
  18. package/telegram-plugin/auto-fallback-fleet.ts +29 -1
  19. package/telegram-plugin/bridge/bridge.ts +2 -0
  20. package/telegram-plugin/dist/bridge/bridge.js +51 -3
  21. package/telegram-plugin/dist/gateway/gateway.js +1251 -2368
  22. package/telegram-plugin/dist/server.js +67 -3
  23. package/telegram-plugin/format.ts +19 -0
  24. package/telegram-plugin/gateway/approval-hold.ts +21 -2
  25. package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
  26. package/telegram-plugin/gateway/auth-command.ts +14 -0
  27. package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
  28. package/telegram-plugin/gateway/forward-origin.ts +235 -0
  29. package/telegram-plugin/gateway/gateway.ts +445 -83
  30. package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
  31. package/telegram-plugin/history.ts +106 -6
  32. package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
  33. package/telegram-plugin/model-unavailable.ts +61 -13
  34. package/telegram-plugin/outbound-field-redact.ts +69 -0
  35. package/telegram-plugin/render/render.ts +32 -14
  36. package/telegram-plugin/render/rich-render.ts +40 -32
  37. package/telegram-plugin/scoped-approval.ts +11 -2
  38. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  39. package/telegram-plugin/secret-detect/index.ts +12 -56
  40. package/telegram-plugin/send-gate-degraded.test.ts +131 -0
  41. package/telegram-plugin/send-gate.test.ts +25 -6
  42. package/telegram-plugin/send-gate.ts +82 -8
  43. package/telegram-plugin/session-tail.ts +82 -7
  44. package/telegram-plugin/stream-controller.ts +3 -2
  45. package/telegram-plugin/subagent-watcher.ts +71 -16
  46. package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
  47. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
  48. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  49. package/telegram-plugin/tests/forward-origin.test.ts +309 -0
  50. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  51. package/telegram-plugin/tests/history.test.ts +272 -0
  52. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  53. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
  54. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  55. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  56. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  57. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
  58. package/telegram-plugin/tests/render/render.test.ts +88 -0
  59. package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
  60. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  61. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  62. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  63. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  64. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
  65. package/telegram-plugin/tests/status-accent.test.ts +5 -3
  66. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
  67. package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
  68. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  69. package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
  70. package/telegram-plugin/tests/throttle-tier.test.ts +278 -0
  71. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  72. package/telegram-plugin/throttle-tier.ts +226 -0
  73. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
  74. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  75. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  76. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -4244,7 +4244,6 @@ var init_schema = __esm(() => {
4244
4244
  enabled: exports_external.boolean().default(true).describe("Master switch for the per-agent Telegram gateway sidecar. " + "When false, start.sh skips the gateway supervise loop and the " + "agent boots without bot-token requirements (smoke-test + " + "offline-dev use case)."),
4245
4245
  plugin: exports_external.enum(["switchroom", "official"]).optional().describe("Which Telegram MCP plugin to load. Default is 'switchroom' — the " + "enhanced fork with streaming edits, reactions, history, and " + "access control. Set to 'official' for the upstream marketplace " + "plugin (basic send/receive only)."),
4246
4246
  format: exports_external.enum(["html", "markdownv2", "text"]).optional().describe("Default reply format passed to the plugin"),
4247
- rate_limit_ms: exports_external.number().optional().describe("Minimum delay between outgoing messages in ms"),
4248
4247
  stream_mode: exports_external.enum(["pty", "checklist"]).optional().describe("How live progress is streamed to Telegram during a turn. " + "'pty' (default) surfaces text snapshots of Claude Code's TUI — " + "compatible but can flicker as Ink re-renders. 'checklist' drives " + "a structured progress card from session-tail events — stable " + "order, per-tool status emojis, fires only on semantic transitions."),
4249
4248
  stream_throttle_ms: exports_external.number().int().nonnegative().optional().describe("Throttle window in ms between successive in-place stream edits " + "during a turn. Lower = more responsive stream, higher = fewer API " + "calls. Floored at 250 by draft-stream itself. Default 400 ms for DMs " + "and 1000 ms for groups/forums (respects Telegram's ~1 edit/sec/message " + "practical ceiling). Override per-agent if a particular agent needs " + "snappier or quieter streaming."),
4250
4249
  clear_status_on_completion: exports_external.boolean().optional().describe("When true, the live activity/status feed (the in-place 'what it's " + "doing' message — Reading X, Searching the web for Y, …) is DELETED " + "when the turn's final answer lands, so only the reply remains. " + "Default false: the status message is left in the chat as a record " + "(its last step marked done) — no post-then-delete. Per-agent " + "override; cascades defaults → profile → agent (per-key)."),
@@ -4778,6 +4777,13 @@ var init_schema = __esm(() => {
4778
4777
  });
4779
4778
  });
4780
4779
 
4780
+ // src/util/atomic.ts
4781
+ import { closeSync, constants, fsyncSync, openSync, renameSync, rmSync, writeSync } from "node:fs";
4782
+ var TMP_OPEN_FLAGS;
4783
+ var init_atomic = __esm(() => {
4784
+ TMP_OPEN_FLAGS = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0);
4785
+ });
4786
+
4781
4787
  // src/config/merge.ts
4782
4788
  function dedupe(items) {
4783
4789
  const seen = new Set;
@@ -18249,6 +18255,13 @@ var ApprovalLookupRequestSchema = exports_external.object({
18249
18255
  action: exports_external.string().min(1),
18250
18256
  current_approver_set: exports_external.array(exports_external.string())
18251
18257
  });
18258
+ var ApprovalLookupByRequestRequestSchema = exports_external.object({
18259
+ v: exports_external.literal(1),
18260
+ op: exports_external.literal("approval_lookup_by_request"),
18261
+ agent_unit: exports_external.string().min(1),
18262
+ request_id: exports_external.string().regex(/^[0-9a-f]{32}$/),
18263
+ current_approver_set: exports_external.array(exports_external.string())
18264
+ });
18252
18265
  var ApprovalConsumeRequestSchema = exports_external.object({
18253
18266
  v: exports_external.literal(1),
18254
18267
  op: exports_external.literal("approval_consume"),
@@ -18303,6 +18316,7 @@ var RequestSchema = exports_external.discriminatedUnion("op", [
18303
18316
  RevokeGrantRequestSchema,
18304
18317
  ApprovalRequestRequestSchema,
18305
18318
  ApprovalLookupRequestSchema,
18319
+ ApprovalLookupByRequestRequestSchema,
18306
18320
  ApprovalConsumeRequestSchema,
18307
18321
  ApprovalRevokeRequestSchema,
18308
18322
  ApprovalListRequestSchema,
@@ -18664,6 +18678,30 @@ function lookupDecision(db, query, opts = {}, now = Date.now()) {
18664
18678
  ORDER BY granted_at DESC LIMIT 1`).get(query.agent_unit, query.scope, query.action);
18665
18679
  if (!row)
18666
18680
  return { state: "no_decision" };
18681
+ return evaluateDecisionRow(db, row, currentCanonical, opts, now);
18682
+ }
18683
+ function lookupDecisionByRequestId(db, query, opts = {}, now = Date.now()) {
18684
+ const currentCanonical = canonicalizeApproverSet(query.current_approver_set);
18685
+ const nonceRow = db.query(`SELECT * FROM approval_nonces WHERE request_id = ?`).get(query.request_id);
18686
+ if (!nonceRow)
18687
+ return { state: "no_decision" };
18688
+ const nonce = nonceFromRow(nonceRow);
18689
+ if (nonce.agent_unit !== query.agent_unit)
18690
+ return { state: "no_decision" };
18691
+ if (nonce.decision_id === null) {
18692
+ if (nonce.expires_at < now)
18693
+ return { state: "expired" };
18694
+ return { state: "pending", request_id: nonce.request_id };
18695
+ }
18696
+ const decRow = db.query(`SELECT * FROM approval_decisions WHERE id = ?`).get(nonce.decision_id);
18697
+ if (!decRow)
18698
+ return { state: "expired" };
18699
+ if (decRow.revoked_at != null) {
18700
+ return { state: "denied", decision: rowToDecision(decRow) };
18701
+ }
18702
+ return evaluateDecisionRow(db, decRow, currentCanonical, opts, now);
18703
+ }
18704
+ function evaluateDecisionRow(db, row, currentCanonical, opts, now) {
18667
18705
  const decision = rowToDecision(row);
18668
18706
  if (decision.decision === "deny_perm") {
18669
18707
  return { state: "denied", decision };
@@ -18860,6 +18898,7 @@ init_schema();
18860
18898
 
18861
18899
  // src/agents/scaffold.ts
18862
18900
  import { join as join4, resolve as resolve5 } from "node:path";
18901
+ init_atomic();
18863
18902
  init_schema();
18864
18903
 
18865
18904
  // src/config/users.ts
@@ -19301,6 +19340,33 @@ function handleRequest(socket, req, agent, db, peerUid, isOperator = false) {
19301
19340
  socket.write(encodeResponse({ ok: true, state: r.state, decision }));
19302
19341
  return;
19303
19342
  }
19343
+ if (req.op === "approval_lookup_by_request") {
19344
+ const acl = checkApprovalAclByAgent(agent, req.agent_unit);
19345
+ if (!acl.allow) {
19346
+ socket.write(encodeResponse(errorResponse("DENIED", acl.reason)));
19347
+ return;
19348
+ }
19349
+ const r = lookupDecisionByRequestId(db, {
19350
+ agent_unit: req.agent_unit,
19351
+ request_id: req.request_id,
19352
+ current_approver_set: req.current_approver_set
19353
+ });
19354
+ const decision = r.state === "granted" || r.state === "denied" ? {
19355
+ id: r.decision.id,
19356
+ agent_unit: r.decision.agent_unit,
19357
+ scope: r.decision.scope,
19358
+ action: r.decision.action,
19359
+ decision: r.decision.decision,
19360
+ granted_at: r.decision.granted_at,
19361
+ granted_by_user_id: r.decision.granted_by_user_id,
19362
+ ttl_expires_at: r.decision.ttl_expires_at,
19363
+ last_used_at: r.decision.last_used_at,
19364
+ revoked_at: r.decision.revoked_at,
19365
+ revoke_reason: r.decision.revoke_reason
19366
+ } : null;
19367
+ socket.write(encodeResponse({ ok: true, state: r.state, decision }));
19368
+ return;
19369
+ }
19304
19370
  if (req.op === "approval_consume") {
19305
19371
  const peek = getNonce(db, req.request_id);
19306
19372
  if (peek !== null) {
@@ -4244,7 +4244,6 @@ var init_schema = __esm(() => {
4244
4244
  enabled: exports_external.boolean().default(true).describe("Master switch for the per-agent Telegram gateway sidecar. " + "When false, start.sh skips the gateway supervise loop and the " + "agent boots without bot-token requirements (smoke-test + " + "offline-dev use case)."),
4245
4245
  plugin: exports_external.enum(["switchroom", "official"]).optional().describe("Which Telegram MCP plugin to load. Default is 'switchroom' — the " + "enhanced fork with streaming edits, reactions, history, and " + "access control. Set to 'official' for the upstream marketplace " + "plugin (basic send/receive only)."),
4246
4246
  format: exports_external.enum(["html", "markdownv2", "text"]).optional().describe("Default reply format passed to the plugin"),
4247
- rate_limit_ms: exports_external.number().optional().describe("Minimum delay between outgoing messages in ms"),
4248
4247
  stream_mode: exports_external.enum(["pty", "checklist"]).optional().describe("How live progress is streamed to Telegram during a turn. " + "'pty' (default) surfaces text snapshots of Claude Code's TUI — " + "compatible but can flicker as Ink re-renders. 'checklist' drives " + "a structured progress card from session-tail events — stable " + "order, per-tool status emojis, fires only on semantic transitions."),
4249
4248
  stream_throttle_ms: exports_external.number().int().nonnegative().optional().describe("Throttle window in ms between successive in-place stream edits " + "during a turn. Lower = more responsive stream, higher = fewer API " + "calls. Floored at 250 by draft-stream itself. Default 400 ms for DMs " + "and 1000 ms for groups/forums (respects Telegram's ~1 edit/sec/message " + "practical ceiling). Override per-agent if a particular agent needs " + "snappier or quieter streaming."),
4250
4249
  clear_status_on_completion: exports_external.boolean().optional().describe("When true, the live activity/status feed (the in-place 'what it's " + "doing' message — Reading X, Searching the web for Y, …) is DELETED " + "when the turn's final answer lands, so only the reply remains. " + "Default false: the status message is left in the chat as a record " + "(its last step marked done) — no post-then-delete. Per-agent " + "override; cascades defaults → profile → agent (per-key)."),
@@ -4778,6 +4777,13 @@ var init_schema = __esm(() => {
4778
4777
  });
4779
4778
  });
4780
4779
 
4780
+ // src/util/atomic.ts
4781
+ import { closeSync, constants, fsyncSync, openSync, renameSync, rmSync, writeSync } from "node:fs";
4782
+ var TMP_OPEN_FLAGS;
4783
+ var init_atomic = __esm(() => {
4784
+ TMP_OPEN_FLAGS = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0);
4785
+ });
4786
+
4781
4787
  // src/config/merge.ts
4782
4788
  function dedupe(items) {
4783
4789
  const seen = new Set;
@@ -18087,7 +18093,7 @@ var require_lib = __commonJS((exports, module) => {
18087
18093
 
18088
18094
  // src/vault/broker/server.ts
18089
18095
  import * as net from "node:net";
18090
- import { mkdirSync as mkdirSync6, chmodSync as chmodSync4, chownSync, existsSync as existsSync11, readFileSync as readFileSync10, readdirSync as readdirSync4, statSync as statSync6, unlinkSync as unlinkSync6, writeFileSync as writeFileSync3, renameSync as renameSync6 } from "node:fs";
18096
+ import { mkdirSync as mkdirSync6, chmodSync as chmodSync4, chownSync, existsSync as existsSync11, readFileSync as readFileSync10, readdirSync as readdirSync4, statSync as statSync6, unlinkSync as unlinkSync6, writeFileSync as writeFileSync3, renameSync as renameSync7 } from "node:fs";
18091
18097
 
18092
18098
  // src/agents/compose.ts
18093
18099
  import { createHash } from "node:crypto";
@@ -18095,6 +18101,7 @@ init_schema();
18095
18101
 
18096
18102
  // src/agents/scaffold.ts
18097
18103
  import { join as join4, resolve as resolve6 } from "node:path";
18104
+ init_atomic();
18098
18105
  init_schema();
18099
18106
 
18100
18107
  // src/config/users.ts
@@ -18424,14 +18431,14 @@ init_loader();
18424
18431
  import { randomBytes, scryptSync, createCipheriv, createDecipheriv } from "node:crypto";
18425
18432
  import {
18426
18433
  readFileSync as readFileSync5,
18427
- writeSync as writeSync2,
18434
+ writeSync as writeSync3,
18428
18435
  existsSync as existsSync6,
18429
- renameSync,
18436
+ renameSync as renameSync2,
18430
18437
  mkdirSync as mkdirSync2,
18431
18438
  unlinkSync as unlinkSync2,
18432
- fsyncSync as fsyncSync2,
18433
- openSync as openSync2,
18434
- closeSync as closeSync2,
18439
+ fsyncSync as fsyncSync3,
18440
+ openSync as openSync3,
18441
+ closeSync as closeSync3,
18435
18442
  lstatSync,
18436
18443
  realpathSync as realpathSync2
18437
18444
  } from "node:fs";
@@ -18440,10 +18447,10 @@ import { dirname, basename as basename2, resolve as resolve5 } from "node:path";
18440
18447
  // src/vault/flock.ts
18441
18448
  import {
18442
18449
  existsSync as existsSync5,
18443
- openSync,
18444
- closeSync,
18445
- writeSync,
18446
- fsyncSync,
18450
+ openSync as openSync2,
18451
+ closeSync as closeSync2,
18452
+ writeSync as writeSync2,
18453
+ fsyncSync as fsyncSync2,
18447
18454
  unlinkSync,
18448
18455
  readFileSync as readFileSync4,
18449
18456
  readdirSync as readdirSync3,
@@ -18568,7 +18575,7 @@ function acquireLock(vaultPath, options = {}) {
18568
18575
  while (true) {
18569
18576
  let fd = null;
18570
18577
  try {
18571
- fd = openSync(lockPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 384);
18578
+ fd = openSync2(lockPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 384);
18572
18579
  } catch (err) {
18573
18580
  const code = err?.code ?? "";
18574
18581
  if (code === "EEXIST") {
@@ -18604,11 +18611,11 @@ function acquireLock(vaultPath, options = {}) {
18604
18611
  ${Date.now()}
18605
18612
  ${process.argv[1] ?? ""}
18606
18613
  `;
18607
- writeSync(fd, meta);
18608
- fsyncSync(fd);
18614
+ writeSync2(fd, meta);
18615
+ fsyncSync2(fd);
18609
18616
  } catch {
18610
18617
  try {
18611
- closeSync(fd);
18618
+ closeSync2(fd);
18612
18619
  } catch {}
18613
18620
  try {
18614
18621
  unlinkSync(lockPath);
@@ -18619,7 +18626,7 @@ ${process.argv[1] ?? ""}
18619
18626
  return {
18620
18627
  release: () => {
18621
18628
  try {
18622
- closeSync(ownedFd);
18629
+ closeSync2(ownedFd);
18623
18630
  } catch {}
18624
18631
  try {
18625
18632
  unlinkSync(lockPath);
@@ -18675,20 +18682,20 @@ function atomicWriteFileSync(path, data, mode) {
18675
18682
  const dir = dirname(resolve5(effectivePath));
18676
18683
  const tmp = resolve5(dir, `.${basename2(effectivePath)}.${process.pid}.${Date.now()}.tmp`);
18677
18684
  try {
18678
- const fd = openSync2(tmp, "wx", mode);
18685
+ const fd = openSync3(tmp, "wx", mode);
18679
18686
  try {
18680
- writeSync2(fd, data);
18681
- fsyncSync2(fd);
18687
+ writeSync3(fd, data);
18688
+ fsyncSync3(fd);
18682
18689
  } finally {
18683
- closeSync2(fd);
18690
+ closeSync3(fd);
18684
18691
  }
18685
- renameSync(tmp, effectivePath);
18692
+ renameSync2(tmp, effectivePath);
18686
18693
  try {
18687
- const dirFd = openSync2(dir, "r");
18694
+ const dirFd = openSync3(dir, "r");
18688
18695
  try {
18689
- fsyncSync2(dirFd);
18696
+ fsyncSync3(dirFd);
18690
18697
  } finally {
18691
- closeSync2(dirFd);
18698
+ closeSync3(dirFd);
18692
18699
  }
18693
18700
  } catch {}
18694
18701
  } catch (err) {
@@ -19264,6 +19271,13 @@ var ApprovalLookupRequestSchema = exports_external.object({
19264
19271
  action: exports_external.string().min(1),
19265
19272
  current_approver_set: exports_external.array(exports_external.string())
19266
19273
  });
19274
+ var ApprovalLookupByRequestRequestSchema = exports_external.object({
19275
+ v: exports_external.literal(1),
19276
+ op: exports_external.literal("approval_lookup_by_request"),
19277
+ agent_unit: exports_external.string().min(1),
19278
+ request_id: exports_external.string().regex(/^[0-9a-f]{32}$/),
19279
+ current_approver_set: exports_external.array(exports_external.string())
19280
+ });
19267
19281
  var ApprovalConsumeRequestSchema = exports_external.object({
19268
19282
  v: exports_external.literal(1),
19269
19283
  op: exports_external.literal("approval_consume"),
@@ -19318,6 +19332,7 @@ var RequestSchema = exports_external.discriminatedUnion("op", [
19318
19332
  RevokeGrantRequestSchema,
19319
19333
  ApprovalRequestRequestSchema,
19320
19334
  ApprovalLookupRequestSchema,
19335
+ ApprovalLookupByRequestRequestSchema,
19321
19336
  ApprovalConsumeRequestSchema,
19322
19337
  ApprovalRevokeRequestSchema,
19323
19338
  ApprovalListRequestSchema,
@@ -19570,13 +19585,13 @@ import {
19570
19585
  copyFileSync as copyFileSync2,
19571
19586
  chmodSync,
19572
19587
  existsSync as existsSync7,
19573
- fsyncSync as fsyncSync3,
19588
+ fsyncSync as fsyncSync4,
19574
19589
  lstatSync as lstatSync2,
19575
19590
  mkdirSync as mkdirSync3,
19576
- openSync as openSync3,
19577
- closeSync as closeSync3,
19591
+ openSync as openSync4,
19592
+ closeSync as closeSync4,
19578
19593
  readFileSync as readFileSync7,
19579
- renameSync as renameSync2,
19594
+ renameSync as renameSync3,
19580
19595
  statSync as statSync4,
19581
19596
  symlinkSync,
19582
19597
  unlinkSync as unlinkSync3
@@ -19642,7 +19657,7 @@ function runMigration(home2, opts) {
19642
19657
  copyFileSync2(oldPath, tempNew);
19643
19658
  chmodSync(tempNew, 384);
19644
19659
  fsyncFile(tempNew);
19645
- renameSync2(tempNew, newPath);
19660
+ renameSync3(tempNew, newPath);
19646
19661
  fsyncDir(parent);
19647
19662
  atomicReplaceWithSymlink(oldPath, "vault/vault.enc");
19648
19663
  fsyncDir(switchroomRoot);
@@ -19676,22 +19691,22 @@ function atomicReplaceWithSymlink(target, linkTarget) {
19676
19691
  } catch {}
19677
19692
  }
19678
19693
  symlinkSync(linkTarget, tmp);
19679
- renameSync2(tmp, target);
19694
+ renameSync3(tmp, target);
19680
19695
  }
19681
19696
  function fsyncFile(path) {
19682
- const fd = openSync3(path, "r+");
19697
+ const fd = openSync4(path, "r+");
19683
19698
  try {
19684
- fsyncSync3(fd);
19699
+ fsyncSync4(fd);
19685
19700
  } finally {
19686
- closeSync3(fd);
19701
+ closeSync4(fd);
19687
19702
  }
19688
19703
  }
19689
19704
  function fsyncDir(path) {
19690
- const fd = openSync3(path, "r");
19705
+ const fd = openSync4(path, "r");
19691
19706
  try {
19692
- fsyncSync3(fd);
19707
+ fsyncSync4(fd);
19693
19708
  } finally {
19694
- closeSync3(fd);
19709
+ closeSync4(fd);
19695
19710
  }
19696
19711
  }
19697
19712
 
@@ -19702,15 +19717,15 @@ init_loader();
19702
19717
  import { createHmac, randomBytes as randomBytes2, createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2 } from "node:crypto";
19703
19718
  import {
19704
19719
  chmodSync as chmodSync2,
19705
- closeSync as closeSync4,
19720
+ closeSync as closeSync5,
19706
19721
  existsSync as existsSync8,
19707
- fsyncSync as fsyncSync4,
19722
+ fsyncSync as fsyncSync5,
19708
19723
  mkdirSync as mkdirSync4,
19709
- openSync as openSync4,
19724
+ openSync as openSync5,
19710
19725
  readFileSync as readFileSync8,
19711
- renameSync as renameSync3,
19726
+ renameSync as renameSync4,
19712
19727
  unlinkSync as unlinkSync4,
19713
- writeSync as writeSync3
19728
+ writeSync as writeSync4
19714
19729
  } from "node:fs";
19715
19730
  var FORMAT_VERSION = 1;
19716
19731
  var SALT_LEN = 16;
@@ -19803,7 +19818,7 @@ import * as path from "node:path";
19803
19818
 
19804
19819
  // src/util/audit-hashchain.ts
19805
19820
  import { createHash as createHash4 } from "node:crypto";
19806
- import { openSync as openSync5, readSync, fstatSync as fstatSync2, closeSync as closeSync5 } from "node:fs";
19821
+ import { openSync as openSync6, readSync, fstatSync as fstatSync2, closeSync as closeSync6 } from "node:fs";
19807
19822
  var CHAIN_GENESIS = "GENESIS";
19808
19823
  var SEP = "\x00";
19809
19824
  function rowHash(prev, seq, rowJsonNoChain) {
@@ -19824,7 +19839,7 @@ function chainRow(state, entry) {
19824
19839
  function readTail(path, tailBytes = 64 * 1024) {
19825
19840
  let fd;
19826
19841
  try {
19827
- fd = openSync5(path, "r");
19842
+ fd = openSync6(path, "r");
19828
19843
  } catch {
19829
19844
  return null;
19830
19845
  }
@@ -19840,7 +19855,7 @@ function readTail(path, tailBytes = 64 * 1024) {
19840
19855
  return null;
19841
19856
  } finally {
19842
19857
  try {
19843
- closeSync5(fd);
19858
+ closeSync6(fd);
19844
19859
  } catch {}
19845
19860
  }
19846
19861
  }
@@ -22196,6 +22211,9 @@ function lookupDecision(db, query, opts = {}, now = Date.now()) {
22196
22211
  ORDER BY granted_at DESC LIMIT 1`).get(query.agent_unit, query.scope, query.action);
22197
22212
  if (!row)
22198
22213
  return { state: "no_decision" };
22214
+ return evaluateDecisionRow(db, row, currentCanonical, opts, now);
22215
+ }
22216
+ function evaluateDecisionRow(db, row, currentCanonical, opts, now) {
22199
22217
  const decision = rowToDecision(row);
22200
22218
  if (decision.decision === "deny_perm") {
22201
22219
  return { state: "denied", decision };
@@ -23203,6 +23221,41 @@ class VaultBroker {
23203
23221
  if (req.token !== undefined && req.token !== "") {
23204
23222
  const v = await validateGrantForWrite(this.grantsDb, req.token, req.key);
23205
23223
  if (v.ok) {
23224
+ const writeGrantAgentSlug = v.grant.agent_slug;
23225
+ if (agentName !== null && writeGrantAgentSlug !== agentName) {
23226
+ this.auditLogger.write({
23227
+ ts: new Date().toISOString(),
23228
+ op: "put",
23229
+ key: req.key,
23230
+ caller: auditCaller,
23231
+ pid: auditPid,
23232
+ cgroup: auditCgroup,
23233
+ result: "denied:grant-identity-mismatch",
23234
+ method: "grant",
23235
+ grant_id: v.grant.id
23236
+ });
23237
+ socket.write(encodeResponse(errorResponse("DENIED", "grant-identity-mismatch")));
23238
+ return;
23239
+ }
23240
+ const existingForScope = this.secrets[req.key];
23241
+ if (existingForScope !== undefined) {
23242
+ const writeScope = checkEntryScope(existingForScope.scope, writeGrantAgentSlug);
23243
+ if (!writeScope.allow) {
23244
+ this.auditLogger.write({
23245
+ ts: new Date().toISOString(),
23246
+ op: "put",
23247
+ key: req.key,
23248
+ caller: auditCaller,
23249
+ pid: auditPid,
23250
+ cgroup: auditCgroup,
23251
+ result: `denied:${writeScope.reason}`,
23252
+ method: "grant",
23253
+ grant_id: v.grant.id
23254
+ });
23255
+ socket.write(encodeResponse(errorResponse("DENIED", writeScope.reason)));
23256
+ return;
23257
+ }
23258
+ }
23206
23259
  writeGrantId = v.grant.id;
23207
23260
  } else if (v.reason === "grant-expired" || v.reason === "grant-revoked") {
23208
23261
  this.auditLogger.write({
@@ -23550,7 +23603,7 @@ class VaultBroker {
23550
23603
  const tokenPath = path3.join(tokenDir, ".vault-token");
23551
23604
  const tmpPath = `${tokenPath}.tmp.${process.pid}`;
23552
23605
  writeFileSync3(tmpPath, mintResult.token, { mode: 384 });
23553
- renameSync6(tmpPath, tokenPath);
23606
+ renameSync7(tmpPath, tokenPath);
23554
23607
  try {
23555
23608
  const uid = allocateAgentUid(agent);
23556
23609
  chownSync(tokenPath, uid, uid);
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.18.12",
4
+ "version": "0.18.14",
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": {
@@ -51,6 +51,8 @@ Save proactively: architecture decisions, codebase patterns, conventions, known
51
51
 
52
52
  If sub-agents are configured, delegate implementation to `@worker` (background, own worktree) and research to `@researcher` (background). Keep your turns short — dispatch and acknowledge quickly so you stay available for the user.
53
53
 
54
+ If the user amends in-flight delegated work mid-turn, steer the running worker now (`SendMessage` to the worker by name, or by the agent id from its spawn result) instead of holding the update for handback — and say in your reply whether you folded the update into the running worker or queued it as a separate task; never classify silently. If unsure whether a message amends in-flight work, queue it and say so — queue is the default. If the steer lands too late (worker effectively done), say so and apply the update yourself.
55
+
54
56
  {{#if schedule}}
55
57
  ## Schedule
56
58
  You are running on a schedule: `{{schedule}}`. Use scheduled runs to check CI status, review open PRs, or surface stale issues.
@@ -92,6 +92,8 @@ The main session is for conversation. Execution belongs in sub-agents. Before ma
92
92
 
93
93
  **Anti-patterns:** starting a task inline then realizing it's complex mid-way; doing 5+ tool calls "because it's almost done"; polling sub-agent status in a loop.
94
94
 
95
+ **Steer running workers — don't sit on amendments.** When a mid-turn user message amends work you've already delegated to a running sub-agent, forward the amendment to that worker now — `SendMessage` to the worker by name (or by the agent id from the Agent tool's spawn result, if it has no name) — rather than holding it for handback. If the message is a new independent task, queue it instead; if you're unsure whether it amends the in-flight work, queue it and say so — queue is the default. Either way, state in your reply which you did ("folded your update into the running worker" / "queued as a separate task after the current one") — the steer-or-queue rule is that the classification is always visible in chat, never inferred. If the steer arrives too late (the worker is effectively done), say that and apply the update yourself in the parent.
96
+
95
97
  If no sub-agents are configured, do the work yourself.
96
98
 
97
99
  ## Session Continuity
@@ -81,7 +81,6 @@ agents:
81
81
  | Var | Source | Purpose |
82
82
  |-----|--------|---------|
83
83
  | `SWITCHROOM_TG_FORMAT` | `channels.telegram.format` | Default reply format |
84
- | `SWITCHROOM_TG_RATE_LIMIT_MS` | `channels.telegram.rate_limit_ms` | Min delay between outgoing messages |
85
84
  | `TELEGRAM_STATE_DIR` | Auto-set by scaffold | Path to `telegram/` dir |
86
85
  | `SWITCHROOM_AGENT_NAME` | Auto-set by scaffold | Agent name (used for self-restart detection) |
87
86
  | `SWITCHROOM_CONFIG` | Auto-set by scaffold | Path to switchroom.yaml |
@@ -704,6 +704,22 @@ export interface FallbackAnnouncementInput {
704
704
  * shows the target's headroom and is unchanged.
705
705
  */
706
706
  fleetSnapshots?: AccountSnapshot[];
707
+ /**
708
+ * 429 throttle tier enrichment — the reset time PARSED from the error
709
+ * prose ("resets 8:50am (TZ)" / "retry after 60s") that triggered the
710
+ * fallback. Fallback for the recovery line when the old account's probe
711
+ * carried no reset (probe failed / thin headers), so the announcement
712
+ * still names when the account frees. A probe-derived reset wins when
713
+ * present — it is the fresher, server-authoritative signal.
714
+ */
715
+ parsedResetAt?: Date | null;
716
+ /**
717
+ * 429 throttle tier escalation — the trigger was a long-reset transient
718
+ * RATE LIMIT, not a quota wall. Names the headline honestly ("rate limit
719
+ * on X" instead of a utilization-derived "5-hour limit on X", which would
720
+ * be wrong: a rate-limited account's utilization is typically LOW).
721
+ */
722
+ cause?: 'rate-limit';
707
723
  tz?: string;
708
724
  now?: Date;
709
725
  }
@@ -728,7 +744,12 @@ export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): st
728
744
  const lines: string[] = [];
729
745
 
730
746
  const limitWord = input.oldQuota ? limitWordFor(input.oldQuota) : 'quota';
731
- const headerLimit = limitWord === 'quota' ? 'quota cap' : `${limitWord} limit`;
747
+ const headerLimit =
748
+ input.cause === 'rate-limit'
749
+ ? 'rate limit'
750
+ : limitWord === 'quota'
751
+ ? 'quota cap'
752
+ : `${limitWord} limit`;
732
753
 
733
754
  if (!input.newLabel) {
734
755
  // All-blocked path — no swap occurred. Tell user what's broken and, so they
@@ -765,9 +786,14 @@ export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): st
765
786
  `${formatAbsolute(earliest.at, tz)} (in ${formatRelative(earliest.at, now)})`,
766
787
  );
767
788
  }
768
- } else if (input.oldQuota) {
789
+ } else {
769
790
  // Back-compat: no fleet snapshot supplied → old single-account shape.
770
- const recovery = recoveryAtFor(input.oldQuota);
791
+ // Probe-derived reset first; the prose-parsed reset (429 throttle tier
792
+ // enrichment) covers the probe-failed case.
793
+ const recovery =
794
+ (input.oldQuota ? recoveryAtFor(input.oldQuota) : null) ??
795
+ input.parsedResetAt ??
796
+ null;
771
797
  if (recovery) {
772
798
  lines.push(
773
799
  `${escapeMarkdown(input.oldLabel)} recovers ${formatAbsolute(recovery, tz)} ` +
@@ -794,8 +820,14 @@ export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): st
794
820
  lines.push(`Triggered by: agent **${escapeMarkdown(input.triggerAgent)}**`);
795
821
  lines.push('');
796
822
 
797
- if (input.oldQuota) {
798
- const recovery = recoveryAtFor(input.oldQuota);
823
+ {
824
+ // Probe-derived reset first; the prose-parsed reset (429 throttle tier
825
+ // enrichment) keeps the recovery line honest when the old account's
826
+ // probe failed at the moment of the wall.
827
+ const recovery =
828
+ (input.oldQuota ? recoveryAtFor(input.oldQuota) : null) ??
829
+ input.parsedResetAt ??
830
+ null;
799
831
  if (recovery) {
800
832
  lines.push(
801
833
  `\`${codeSpanSafe(input.oldLabel)}\` recovers ` +
@@ -183,6 +183,26 @@ export interface FleetFallbackDeps {
183
183
  /** Operator timezone for absolute reset times in the announcement. */
184
184
  tz?: string;
185
185
  now?: Date;
186
+ /**
187
+ * The reset time PARSED from the triggering error prose (429 throttle tier
188
+ * enrichment) — threaded into the announcement as the recovery-line
189
+ * fallback when the old account's live probe carried no reset.
190
+ */
191
+ parsedResetAt?: Date;
192
+ /**
193
+ * 429 throttle tier escalation: the trigger is a TERMINAL transient 429
194
+ * whose parsed reset lies beyond the retry-in-place threshold. Its wording
195
+ * explicitly NEGATES the usage-limit reading, so the old account's
196
+ * UTILIZATION probe typically classifies healthy — the healthy-idempotency
197
+ * guard would self-cancel the swap ("probed healthy / Stale event?"),
198
+ * silently dropping the spec's ">threshold → mark + fail over" leg. When
199
+ * set, the terminal parsed-reset signal is trusted over the utilization
200
+ * probe: the guard is bypassed and the swap proceeds (the broker mark
201
+ * honors the caller-passed `until` — the parsed reset). Staleness safety
202
+ * holds upstream: the flag is only set for a terminal error line the
203
+ * session-tail just read, never for replayed/late events.
204
+ */
205
+ rateLimitTrigger?: boolean;
186
206
  }
187
207
 
188
208
  /**
@@ -217,8 +237,12 @@ export async function runFleetAutoFallback(
217
237
  // normalization uses the same clock as the rest of the decision (a default
218
238
  // `new Date()` would diverge from `deps.now` and could mis-zero a window
219
239
  // whose reset is still future relative to the event's clock).
240
+ // 429 throttle tier: a rate-limit trigger's wording NEGATES the usage-limit
241
+ // reading, so healthy utilization is the EXPECTED state, not evidence of a
242
+ // stale event — the guard must not self-cancel that swap (see
243
+ // FleetFallbackDeps.rateLimitTrigger).
220
244
  const oldHealth = classifyHealth(oldSnap, now);
221
- if (oldHealth === 'healthy') {
245
+ if (oldHealth === 'healthy' && !deps.rateLimitTrigger) {
222
246
  return {
223
247
  kind: 'no-eligible-target',
224
248
  oldLabel: oldSnap.label,
@@ -255,6 +279,8 @@ export async function runFleetAutoFallback(
255
279
  // card enumerates EVERY account (5h%/7d% + recovery ETA), letting the
256
280
  // user verify the fleet is truly exhausted, not just the trigger account.
257
281
  fleetSnapshots: snapshots,
282
+ parsedResetAt: deps.parsedResetAt ?? null,
283
+ cause: deps.rateLimitTrigger ? 'rate-limit' : undefined,
258
284
  tz,
259
285
  now,
260
286
  }),
@@ -278,6 +304,8 @@ export async function runFleetAutoFallback(
278
304
  newLabel: rolledTo,
279
305
  newQuota,
280
306
  triggerAgent: deps.triggerAgent,
307
+ parsedResetAt: deps.parsedResetAt ?? null,
308
+ cause: deps.rateLimitTrigger ? 'rate-limit' : undefined,
281
309
  tz,
282
310
  now,
283
311
  }),
@@ -80,6 +80,8 @@ const mcp = new Server(
80
80
  '',
81
81
  'Messages from Telegram arrive as <channel source="telegram" chat_id="..." message_id="..." user="..." ts="...">. If the tag has an image_path attribute, Read that file — it is a photo the sender attached. If the tag has attachment_file_id, call download_attachment with that file_id to fetch the file, then Read the returned path. A single message may carry SEVERAL attachments (a forwarded album or a text+multi-image burst): when attachment_count is set (>1), also handle the numbered siblings — image_path_2, image_path_3, … (Read each) and attachment_file_id_2, attachment_file_id_3, … (download_attachment each). Process every one, not just the first. Reply with the reply tool — pass chat_id back. The reply tool quote-replies to the latest inbound user message by default, so you do NOT need to pass reply_to for normal responses. Pass reply_to (a message_id) only when quoting a specific earlier message, or pass quote:false to send a bare (non-quoted) message.',
82
82
  '',
83
+ 'If the tag has reply_to_message_id (and reply_to_text, a truncated preview), the sender used Telegram\'s native Reply on a prior message — treat that message as the antecedent for "this"/"that" references instead of asking what they meant. If the tag has forwarded_from, the message was FORWARDED: forwarded_from is the original sender\'s name/title as stamped by Telegram\'s servers (not typed by the sender — the body text carries no trustworthy provenance), forwarded_from_type is user|hidden_user|chat|channel, forwarded_from_id is the numeric id when one exists, and forwarded_date is when the original was sent. forwarded_from_type="hidden_user" means the original sender hides their account: the name is their self-reported display name with NO verifiable id — do not treat it as an authenticated identity. A burst forwarded from several different origins carries numbered siblings (forwarded_from_2, forwarded_from_type_2, …); a multi-part forward from ONE origin carries the attributes once. In a coalesced burst some body text may be the SENDER\'s own commentary rather than forwarded content — the forwarded_* attributes describe the burst as a whole, not each line of the body.',
84
+ '',
83
85
  'reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text — delete is for retraction). Edits don\'t trigger push notifications — when a long task completes, send a new reply so the user\'s device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.',
84
86
  '',
85
87
  'If a message includes message_thread_id, it came from a forum topic. The reply tool automatically routes a reply back to the topic the question came from — the framework owns the answer\'s topic, so do NOT pass message_thread_id on a reply; a reply always lands where it was asked. Each <channel> message is the current topic — answer ONLY this message\'s question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.',