switchroom 0.15.9 → 0.15.10

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.
@@ -24526,6 +24526,8 @@ function getAgentLogs(name, follow) {
24526
24526
  function classifyChangeKind(path) {
24527
24527
  if (/\/telegram\/cron-(?:\d+|[0-9a-f]{12})\.sh$/.test(path))
24528
24528
  return "cron";
24529
+ if (path.includes("/.claude-cron/"))
24530
+ return "cron";
24529
24531
  if (path.includes("/.claude/skills/"))
24530
24532
  return "skill";
24531
24533
  if (path.endsWith("/.claude/settings.json"))
@@ -50323,8 +50325,8 @@ var {
50323
50325
  } = import__.default;
50324
50326
 
50325
50327
  // src/build-info.ts
50326
- var VERSION = "0.15.9";
50327
- var COMMIT_SHA = "6ed776e2";
50328
+ var VERSION = "0.15.10";
50329
+ var COMMIT_SHA = "bb9182e1";
50328
50330
 
50329
50331
  // src/cli/agent.ts
50330
50332
  init_source();
@@ -119,6 +119,40 @@
119
119
 
120
120
  .meta-item span { color: var(--text); }
121
121
 
122
+ .scope-toggle {
123
+ cursor: pointer;
124
+ color: var(--text);
125
+ border-bottom: 1px dotted var(--text-dim);
126
+ user-select: none;
127
+ }
128
+ .scope-toggle:hover { color: var(--blue); }
129
+ .scope-toggle:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
130
+ .scope-caret {
131
+ display: inline-block;
132
+ width: 0.9em;
133
+ margin-right: 0.15rem;
134
+ color: var(--text-dim);
135
+ font-size: 0.75em;
136
+ }
137
+ .scope-list {
138
+ list-style: none;
139
+ margin: 0.35rem 0 0;
140
+ padding: 0.5rem 0.75rem;
141
+ width: 100%;
142
+ background: var(--surface-hover);
143
+ border: 1px solid var(--border);
144
+ border-radius: 6px;
145
+ font-size: 0.78rem;
146
+ line-height: 1.5;
147
+ }
148
+ .scope-list li {
149
+ color: var(--text);
150
+ word-break: break-all;
151
+ padding: 0.1rem 0;
152
+ border-bottom: 1px solid var(--border);
153
+ }
154
+ .scope-list li:last-child { border-bottom: none; }
155
+
122
156
  .card-actions {
123
157
  padding: 0.75rem 1.25rem;
124
158
  border-top: 1px solid var(--border);
@@ -1225,6 +1259,7 @@
1225
1259
  const _dimC = (s) => `<span style="color:var(--text-dim)">${escapeHtml(s)}</span>`;
1226
1260
 
1227
1261
  let _accessBtnSeq = 0;
1262
+ let _scopeSeq = 0;
1228
1263
 
1229
1264
  // One OAuth-account card (Google or Microsoft — same shape; Microsoft
1230
1265
  // adds an account-type pill). When agentNames is supplied, renders a
@@ -1255,6 +1290,24 @@
1255
1290
  }).join('')}
1256
1291
  </div>`
1257
1292
  : '';
1293
+ // Scope: clickable "N scope(s)" that expands a full-width list of the
1294
+ // individual scopes below the meta row (the old version hid them in a
1295
+ // title tooltip only). The list is a sibling block after .card-meta so
1296
+ // it isn't squished inside the flex-wrapped meta-items.
1297
+ const scopeList = a.scope ? a.scope.split(/\s+/).filter(Boolean) : [];
1298
+ let scopeCount, scopeBlock = '';
1299
+ if (scopeList.length) {
1300
+ const sid = `scopes-${++_scopeSeq}`;
1301
+ scopeCount = `<span class="scope-toggle" id="${sid}-t" role="button" tabindex="0" aria-expanded="false" aria-controls="${sid}"
1302
+ onclick="toggleScopes('${sid}')"
1303
+ onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleScopes('${sid}');}"
1304
+ ><span class="scope-caret">▸</span>${escapeHtml(scopeList.length + ' scope' + (scopeList.length === 1 ? '' : 's'))}</span>`;
1305
+ scopeBlock = `<ul class="scope-list" id="${sid}" hidden>${
1306
+ scopeList.map(s => `<li title="${escapeHtml(s)}">${escapeHtml(s)}</li>`).join('')
1307
+ }</ul>`;
1308
+ } else {
1309
+ scopeCount = _dimC('—');
1310
+ }
1258
1311
  return `
1259
1312
  <div class="account-card">
1260
1313
  <div class="account-card-header">
@@ -1264,9 +1317,10 @@
1264
1317
  <div class="account-usage"><label style="color:var(--text-dim);opacity:.7">Enabled for: </label>${acl}</div>
1265
1318
  <div class="card-meta" style="padding:0">
1266
1319
  <div class="meta-item"><label>Expires </label><span>${expires}</span></div>
1267
- <div class="meta-item" title="${a.scope ? escapeHtml(a.scope) : ''}"><label>Scope </label><span>${a.scope ? escapeHtml(a.scope.split(' ').length + ' scope(s)') : _dimC('—')}</span></div>
1320
+ <div class="meta-item"><label>Scope </label><span>${scopeCount}</span></div>
1268
1321
  <div class="meta-item"><label>Client </label><span>${a.clientId ? escapeHtml(a.clientId.slice(0, 16) + '…') : _dimC('—')}</span></div>
1269
1322
  </div>
1323
+ ${scopeBlock}
1270
1324
  ${manage}
1271
1325
  </div>`;
1272
1326
  }
@@ -1531,6 +1585,22 @@
1531
1585
  }
1532
1586
  }
1533
1587
 
1588
+ // Expand/collapse a connection card's scope list in place. Self-contained
1589
+ // (no global state / re-render) — just flips the list's [hidden] and the
1590
+ // toggle's caret + aria-expanded.
1591
+ function toggleScopes(id) {
1592
+ const list = document.getElementById(id);
1593
+ const toggle = document.getElementById(id + '-t');
1594
+ if (!list) return;
1595
+ const open = list.hidden;
1596
+ list.hidden = !open;
1597
+ if (toggle) {
1598
+ toggle.setAttribute('aria-expanded', String(open));
1599
+ const caret = toggle.querySelector('.scope-caret');
1600
+ if (caret) caret.textContent = open ? '▾' : '▸';
1601
+ }
1602
+ }
1603
+
1534
1604
  async function toggleLogs(name) {
1535
1605
  if (openLogs.has(name)) {
1536
1606
  openLogs.delete(name);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.15.9",
3
+ "version": "0.15.10",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48238,6 +48238,16 @@ function isCronIdentity(name) {
48238
48238
  function resolveInjectTarget(agentName3, meta) {
48239
48239
  return meta?.session === "cron" ? cronIdentity(agentName3) : agentName3;
48240
48240
  }
48241
+ function deliverInjectWithFallback(agentName3, meta, send) {
48242
+ const target = resolveInjectTarget(agentName3, meta);
48243
+ const wantedCron = target !== agentName3;
48244
+ if (send(target))
48245
+ return { target, delivered: true, fellBackToMain: false };
48246
+ if (wantedCron && send(agentName3)) {
48247
+ return { target: agentName3, delivered: true, fellBackToMain: true };
48248
+ }
48249
+ return { target, delivered: false, fellBackToMain: false };
48250
+ }
48241
48251
 
48242
48252
  // gateway/obligation-ledger.ts
48243
48253
  class ObligationLedger {
@@ -53757,10 +53767,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
53757
53767
  }
53758
53768
 
53759
53769
  // ../src/build-info.ts
53760
- var VERSION = "0.15.9";
53761
- var COMMIT_SHA = "6ed776e2";
53762
- var COMMIT_DATE = "2026-06-13T00:47:35Z";
53763
- var LATEST_PR = 2300;
53770
+ var VERSION = "0.15.10";
53771
+ var COMMIT_SHA = "bb9182e1";
53772
+ var COMMIT_DATE = "2026-06-13T01:33:32Z";
53773
+ var LATEST_PR = 2302;
53764
53774
  var COMMITS_AHEAD_OF_TAG = 0;
53765
53775
 
53766
53776
  // gateway/boot-version.ts
@@ -57116,12 +57126,14 @@ var ipcServer = createIpcServer({
57116
57126
  onInjectInbound(_client, msg) {
57117
57127
  const promptKey = typeof msg.inbound.meta?.prompt_key === "string" ? msg.inbound.meta.prompt_key : "unknown";
57118
57128
  const source = typeof msg.inbound.meta?.source === "string" ? msg.inbound.meta.source : "unknown";
57119
- const target = resolveInjectTarget(msg.agentName, msg.inbound.meta);
57120
- const toCron = target !== msg.agentName;
57121
- const delivered = ipcServer.sendToAgent(target, msg.inbound);
57122
- if (delivered && !toCron)
57129
+ const { target, delivered, fellBackToMain } = deliverInjectWithFallback(msg.agentName, msg.inbound.meta, (t) => ipcServer.sendToAgent(t, msg.inbound));
57130
+ if (fellBackToMain) {
57131
+ process.stderr.write(`telegram gateway: cron fire fell back to main session (no cron bridge) agent=${msg.agentName} prompt_key=${promptKey}
57132
+ `);
57133
+ }
57134
+ if (delivered && target === msg.agentName)
57123
57135
  markClaudeBusyForInbound(msg.inbound);
57124
- process.stderr.write(`telegram gateway: inject_inbound agent=${msg.agentName} target=${target} source=${source} prompt_key=${promptKey} delivered=${delivered}
57136
+ process.stderr.write(`telegram gateway: inject_inbound agent=${msg.agentName} target=${target}${fellBackToMain ? " (fellback)" : ""} source=${source} prompt_key=${promptKey} delivered=${delivered}
57125
57137
  `);
57126
57138
  if (!delivered) {
57127
57139
  pendingInboundBuffer.push(target, msg.inbound);
@@ -43,3 +43,37 @@ export function baseAgent(name: string): string {
43
43
  export function resolveInjectTarget(agentName: string, meta: Record<string, string> | undefined): string {
44
44
  return meta?.session === "cron" ? cronIdentity(agentName) : agentName;
45
45
  }
46
+
47
+ export interface InjectDelivery {
48
+ /** The bridge identity the fire was actually delivered to (or last tried). */
49
+ target: string;
50
+ delivered: boolean;
51
+ /** True iff a Tier-1 (cron) fire fell back to the main agent bridge. */
52
+ fellBackToMain: boolean;
53
+ }
54
+
55
+ /**
56
+ * Deliver an inject with graceful Tier-1 fallback (cheap-crons JTBD: a cron
57
+ * must NEVER be dropped because of tier routing).
58
+ *
59
+ * Tries the routed target via `send` (returns true iff delivered). The
60
+ * cron-session bridge is boot-forked, so a frequent cron added by hot-reload
61
+ * or agent self-authoring has no live `<agent>-cron` bridge until the next
62
+ * restart — and a crashed cron session has none either. When a cron-routed
63
+ * fire isn't delivered, fall back to the MAIN agent bridge so the fire lands
64
+ * now (full session); it routes cheap again once the cron session is up.
65
+ * Cheap when available, never broken. Pure: `send` is injected.
66
+ */
67
+ export function deliverInjectWithFallback(
68
+ agentName: string,
69
+ meta: Record<string, string> | undefined,
70
+ send: (target: string) => boolean,
71
+ ): InjectDelivery {
72
+ const target = resolveInjectTarget(agentName, meta);
73
+ const wantedCron = target !== agentName;
74
+ if (send(target)) return { target, delivered: true, fellBackToMain: false };
75
+ if (wantedCron && send(agentName)) {
76
+ return { target: agentName, delivered: true, fellBackToMain: true };
77
+ }
78
+ return { target, delivered: false, fellBackToMain: false };
79
+ }
@@ -299,7 +299,7 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
299
299
  import { handleRequestMs365Approval } from './ms365-write-approval.js'
300
300
  import { buildDiffPreviewCard } from './diff-preview-card.js'
301
301
  import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
302
- import { isCronIdentity, resolveInjectTarget } from './cron-session.js'
302
+ import { isCronIdentity, deliverInjectWithFallback } from './cron-session.js'
303
303
  import {
304
304
  ObligationLedger,
305
305
  buildObligationRepresentInbound,
@@ -6425,19 +6425,34 @@ const ipcServer: IpcServer = createIpcServer({
6425
6425
  // unchanged. Route+buffer share the same target so a fire that lands
6426
6426
  // mid cron-session-spawn buffers under the cron identity and drains to
6427
6427
  // it on register.
6428
- const target = resolveInjectTarget(msg.agentName, msg.inbound.meta)
6429
- const toCron = target !== msg.agentName
6430
- const delivered = ipcServer.sendToAgent(target, msg.inbound)
6431
- // Status-silent (§2.4): a cron fire must NOT set the MAIN agent's
6432
- // currentTurn (progress card / silence-poke). The cron session is
6433
- // fire-and-forget; its reply is its only Telegram surface.
6434
- if (delivered && !toCron) markClaudeBusyForInbound(msg.inbound)
6428
+ // Graceful Tier-1 fallback (cheap-crons JTBD: a cron must NEVER be
6429
+ // dropped because of tier routing). A cron-routed fire whose `<agent>-cron`
6430
+ // bridge isn't connected (boot-forked session not up yet for a hot-added
6431
+ // frequent cron, or a crashed cron session) falls back to the MAIN agent
6432
+ // bridge so the fire lands now; it routes cheap again once the session is
6433
+ // up. See deliverInjectWithFallback.
6434
+ const { target, delivered, fellBackToMain } = deliverInjectWithFallback(
6435
+ msg.agentName,
6436
+ msg.inbound.meta,
6437
+ (t) => ipcServer.sendToAgent(t, msg.inbound),
6438
+ )
6439
+ if (fellBackToMain) {
6440
+ process.stderr.write(
6441
+ `telegram gateway: cron fire fell back to main session (no cron bridge) agent=${msg.agentName} prompt_key=${promptKey}\n`,
6442
+ )
6443
+ }
6444
+ // Status-silent (§2.4): a cron fire delivered to the CRON session must NOT
6445
+ // set the MAIN agent's currentTurn. But a fire that LANDED on the main
6446
+ // bridge (a non-cron fire, or one that fell back) IS a main-session turn —
6447
+ // surface it on the progress card, or the session looks dark.
6448
+ if (delivered && target === msg.agentName) markClaudeBusyForInbound(msg.inbound)
6435
6449
  process.stderr.write(
6436
- `telegram gateway: inject_inbound agent=${msg.agentName} target=${target} source=${source} prompt_key=${promptKey} delivered=${delivered}\n`,
6450
+ `telegram gateway: inject_inbound agent=${msg.agentName} target=${target}${fellBackToMain ? " (fellback)" : ""} source=${source} prompt_key=${promptKey} delivered=${delivered}\n`,
6437
6451
  )
6438
6452
  // #1150: same buffer-on-failure pattern as vault_grant_approved.
6439
- // Cron fires use this path too if a cron-driven wake-up lands
6440
- // mid bridge-reconnect, buffer it for the next register.
6453
+ // Only reached if BOTH the cron bridge and the main bridge are down
6454
+ // (e.g. mid-restart) buffer under the bridge we tried last so it
6455
+ // drains on the next register.
6441
6456
  if (!delivered) {
6442
6457
  pendingInboundBuffer.push(target, msg.inbound)
6443
6458
  }
@@ -5,6 +5,7 @@ import {
5
5
  cronIdentity,
6
6
  isCronIdentity,
7
7
  resolveInjectTarget,
8
+ deliverInjectWithFallback,
8
9
  } from '../gateway/cron-session.js'
9
10
 
10
11
  describe('cron-session identity helpers', () => {
@@ -30,3 +31,38 @@ describe('cron-session identity helpers', () => {
30
31
  expect(resolveInjectTarget('clerk', { source: 'telegram' })).toBe('clerk')
31
32
  })
32
33
  })
34
+
35
+ describe('deliverInjectWithFallback — a cron fire is never dropped by tier routing', () => {
36
+ it('delivers to the cron bridge when it is connected', () => {
37
+ const sent: string[] = []
38
+ const r = deliverInjectWithFallback('clerk', { session: 'cron' }, (t) => (sent.push(t), true))
39
+ expect(r).toEqual({ target: 'clerk-cron', delivered: true, fellBackToMain: false })
40
+ expect(sent).toEqual(['clerk-cron']) // tried cron only; it delivered
41
+ })
42
+
43
+ it('falls back to the MAIN bridge when the cron bridge is not connected', () => {
44
+ // The exact gap: a hot-added / agent-authored frequent cron whose
45
+ // boot-forked cron session isn't up. Must land on main, not vanish.
46
+ const sent: string[] = []
47
+ const r = deliverInjectWithFallback('clerk', { session: 'cron' }, (t) => {
48
+ sent.push(t)
49
+ return t === 'clerk' // cron bridge down, main up
50
+ })
51
+ expect(r).toEqual({ target: 'clerk', delivered: true, fellBackToMain: true })
52
+ expect(sent).toEqual(['clerk-cron', 'clerk']) // tried cron, then fell back to main
53
+ })
54
+
55
+ it('reports not-delivered only when BOTH cron and main are down (then it buffers)', () => {
56
+ const sent: string[] = []
57
+ const r = deliverInjectWithFallback('clerk', { session: 'cron' }, (t) => (sent.push(t), false))
58
+ expect(r).toEqual({ target: 'clerk-cron', delivered: false, fellBackToMain: false })
59
+ expect(sent).toEqual(['clerk-cron', 'clerk']) // tried both, both down
60
+ })
61
+
62
+ it('a non-cron (main) fire never tries a fallback', () => {
63
+ const sent: string[] = []
64
+ const r = deliverInjectWithFallback('clerk', { session: 'main' }, (t) => (sent.push(t), false))
65
+ expect(r).toEqual({ target: 'clerk', delivered: false, fellBackToMain: false })
66
+ expect(sent).toEqual(['clerk']) // only the main bridge, no cron fallback attempted
67
+ })
68
+ })