switchroom 0.15.9 → 0.15.11

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.
@@ -12313,88 +12313,10 @@ function resolveEscalationRouting(input, opts) {
12313
12313
  return resolveCronRouting({ ...input, kind: "prompt" }, opts);
12314
12314
  }
12315
12315
 
12316
- // src/scheduler/cron-cadence.ts
12317
- function csvSmallestGap(field) {
12318
- if (!field.includes(","))
12319
- return null;
12320
- const parts = field.split(",").map((s) => Number(s)).filter((n) => Number.isInteger(n) && n >= 0);
12321
- if (parts.length < 2)
12322
- return null;
12323
- const sorted = [...parts].sort((a, b) => a - b);
12324
- let smallest = Infinity;
12325
- for (let i = 1;i < sorted.length; i++) {
12326
- const gap = sorted[i] - sorted[i - 1];
12327
- if (gap > 0 && gap < smallest)
12328
- smallest = gap;
12329
- }
12330
- return Number.isFinite(smallest) ? smallest : null;
12331
- }
12332
- function estimateCronGapMin(expr) {
12333
- const fields = expr.trim().split(/\s+/);
12334
- if (fields.length < 5)
12335
- return Infinity;
12336
- const [min, hour] = fields;
12337
- if (min === "*")
12338
- return 1;
12339
- const minStep = min.match(/^\*\/(\d+)$/);
12340
- if (minStep) {
12341
- const n = Number(minStep[1]);
12342
- return n > 0 ? n : Infinity;
12343
- }
12344
- const minCsv = csvSmallestGap(min);
12345
- if (minCsv !== null)
12346
- return minCsv;
12347
- if (!/^\d+$/.test(min))
12348
- return Infinity;
12349
- if (hour === "*")
12350
- return 60;
12351
- const hourStep = hour.match(/^\*\/(\d+)$/);
12352
- if (hourStep) {
12353
- const n = Number(hourStep[1]);
12354
- return n > 0 ? n * 60 : Infinity;
12355
- }
12356
- const hourCsv = csvSmallestGap(hour);
12357
- if (hourCsv !== null)
12358
- return hourCsv * 60;
12359
- if (/^\d+$/.test(hour))
12360
- return 1440;
12361
- return Infinity;
12362
- }
12363
-
12364
12316
  // src/scheduler/tier-selector.ts
12365
12317
  var DEFAULT_FREQUENT_GAP_MIN = 60;
12366
- function recommendCronTier(input, frequentGapMin = DEFAULT_FREQUENT_GAP_MIN) {
12367
- if (input.kind === "poll") {
12368
- return { tier: "poll", source: "explicit", reason: "declared kind: poll (model-free check)" };
12369
- }
12370
- if (input.context === "fresh") {
12371
- return { tier: "cheap", source: "explicit", reason: "declared context: fresh (cheap cron session)" };
12372
- }
12373
- if (input.context === "agent") {
12374
- return { tier: "main", source: "explicit", reason: "declared context: agent (full live session)" };
12375
- }
12376
- if (input.model !== undefined) {
12377
- return isKnownCheapModel(input.model) ? { tier: "cheap", source: "explicit", reason: `cheap model '${input.model}' → cheap cron session` } : { tier: "main", source: "explicit", reason: `model '${input.model}' is not a known-cheap id → full live session` };
12378
- }
12379
- if (input.smallestGapMin <= frequentGapMin) {
12380
- return {
12381
- tier: "cheap",
12382
- source: "cadence-default",
12383
- reason: `fires every ~${input.smallestGapMin}min (≤ ${frequentGapMin}min) — defaulting to a cheap ` + `session; set context: agent (or an Opus/custom model) if this needs the agent's full context`
12384
- };
12385
- }
12386
- return {
12387
- tier: "main",
12388
- source: "cadence-default",
12389
- reason: `fires every ~${input.smallestGapMin}min (> ${frequentGapMin}min) — defaulting to the agent's ` + `full session; set model: sonnet (or context: fresh) to run it cheaply`
12390
- };
12391
- }
12392
- function applyDefaultTier(entry, frequentGapMin = DEFAULT_FREQUENT_GAP_MIN) {
12393
- if (entry.kind === "poll" || entry.context !== undefined || entry.model !== undefined) {
12394
- return entry;
12395
- }
12396
- const rec = recommendCronTier({ smallestGapMin: estimateCronGapMin(entry.cron) }, frequentGapMin);
12397
- return rec.tier === "cheap" ? { ...entry, context: "fresh" } : entry;
12318
+ function applyDefaultTier(entry, _frequentGapMin = DEFAULT_FREQUENT_GAP_MIN) {
12319
+ return entry;
12398
12320
  }
12399
12321
 
12400
12322
  // src/agent-scheduler/cheap-cron-wiring.ts
@@ -15435,87 +15435,9 @@ var init_cron_routing = __esm(() => {
15435
15435
  OPUS_MODEL_RE = /opus/i;
15436
15436
  });
15437
15437
 
15438
- // src/scheduler/cron-cadence.ts
15439
- function csvSmallestGap(field) {
15440
- if (!field.includes(","))
15441
- return null;
15442
- const parts = field.split(",").map((s) => Number(s)).filter((n) => Number.isInteger(n) && n >= 0);
15443
- if (parts.length < 2)
15444
- return null;
15445
- const sorted = [...parts].sort((a, b) => a - b);
15446
- let smallest = Infinity;
15447
- for (let i = 1;i < sorted.length; i++) {
15448
- const gap = sorted[i] - sorted[i - 1];
15449
- if (gap > 0 && gap < smallest)
15450
- smallest = gap;
15451
- }
15452
- return Number.isFinite(smallest) ? smallest : null;
15453
- }
15454
- function estimateCronGapMin(expr) {
15455
- const fields = expr.trim().split(/\s+/);
15456
- if (fields.length < 5)
15457
- return Infinity;
15458
- const [min, hour] = fields;
15459
- if (min === "*")
15460
- return 1;
15461
- const minStep = min.match(/^\*\/(\d+)$/);
15462
- if (minStep) {
15463
- const n = Number(minStep[1]);
15464
- return n > 0 ? n : Infinity;
15465
- }
15466
- const minCsv = csvSmallestGap(min);
15467
- if (minCsv !== null)
15468
- return minCsv;
15469
- if (!/^\d+$/.test(min))
15470
- return Infinity;
15471
- if (hour === "*")
15472
- return 60;
15473
- const hourStep = hour.match(/^\*\/(\d+)$/);
15474
- if (hourStep) {
15475
- const n = Number(hourStep[1]);
15476
- return n > 0 ? n * 60 : Infinity;
15477
- }
15478
- const hourCsv = csvSmallestGap(hour);
15479
- if (hourCsv !== null)
15480
- return hourCsv * 60;
15481
- if (/^\d+$/.test(hour))
15482
- return 1440;
15483
- return Infinity;
15484
- }
15485
-
15486
15438
  // src/scheduler/tier-selector.ts
15487
- function recommendCronTier(input, frequentGapMin = DEFAULT_FREQUENT_GAP_MIN) {
15488
- if (input.kind === "poll") {
15489
- return { tier: "poll", source: "explicit", reason: "declared kind: poll (model-free check)" };
15490
- }
15491
- if (input.context === "fresh") {
15492
- return { tier: "cheap", source: "explicit", reason: "declared context: fresh (cheap cron session)" };
15493
- }
15494
- if (input.context === "agent") {
15495
- return { tier: "main", source: "explicit", reason: "declared context: agent (full live session)" };
15496
- }
15497
- if (input.model !== undefined) {
15498
- return isKnownCheapModel(input.model) ? { tier: "cheap", source: "explicit", reason: `cheap model '${input.model}' \u2192 cheap cron session` } : { tier: "main", source: "explicit", reason: `model '${input.model}' is not a known-cheap id \u2192 full live session` };
15499
- }
15500
- if (input.smallestGapMin <= frequentGapMin) {
15501
- return {
15502
- tier: "cheap",
15503
- source: "cadence-default",
15504
- reason: `fires every ~${input.smallestGapMin}min (\u2264 ${frequentGapMin}min) \u2014 defaulting to a cheap ` + `session; set context: agent (or an Opus/custom model) if this needs the agent's full context`
15505
- };
15506
- }
15507
- return {
15508
- tier: "main",
15509
- source: "cadence-default",
15510
- reason: `fires every ~${input.smallestGapMin}min (> ${frequentGapMin}min) \u2014 defaulting to the agent's ` + `full session; set model: sonnet (or context: fresh) to run it cheaply`
15511
- };
15512
- }
15513
- function applyDefaultTier(entry, frequentGapMin = DEFAULT_FREQUENT_GAP_MIN) {
15514
- if (entry.kind === "poll" || entry.context !== undefined || entry.model !== undefined) {
15515
- return entry;
15516
- }
15517
- const rec = recommendCronTier({ smallestGapMin: estimateCronGapMin(entry.cron) }, frequentGapMin);
15518
- return rec.tier === "cheap" ? { ...entry, context: "fresh" } : entry;
15439
+ function applyDefaultTier(entry, _frequentGapMin = DEFAULT_FREQUENT_GAP_MIN) {
15440
+ return entry;
15519
15441
  }
15520
15442
  var DEFAULT_FREQUENT_GAP_MIN = 60;
15521
15443
  var init_tier_selector = __esm(() => {
@@ -24526,6 +24448,8 @@ function getAgentLogs(name, follow) {
24526
24448
  function classifyChangeKind(path) {
24527
24449
  if (/\/telegram\/cron-(?:\d+|[0-9a-f]{12})\.sh$/.test(path))
24528
24450
  return "cron";
24451
+ if (path.includes("/.claude-cron/"))
24452
+ return "cron";
24529
24453
  if (path.includes("/.claude/skills/"))
24530
24454
  return "skill";
24531
24455
  if (path.endsWith("/.claude/settings.json"))
@@ -50323,8 +50247,8 @@ var {
50323
50247
  } = import__.default;
50324
50248
 
50325
50249
  // src/build-info.ts
50326
- var VERSION = "0.15.9";
50327
- var COMMIT_SHA = "6ed776e2";
50250
+ var VERSION = "0.15.11";
50251
+ var COMMIT_SHA = "43331954";
50328
50252
 
50329
50253
  // src/cli/agent.ts
50330
50254
  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.11",
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": {
@@ -17,15 +17,19 @@
17
17
  set -u
18
18
 
19
19
  # Runtime kill-switch. The fork is baked into start.sh whenever {{name}} has a
20
- # context:fresh cron entry (a config property), but the session only actually
21
- # runs when SWITCHROOM_CHEAP_CRON is on at runtime. Exit 78 (EX_CONFIG) so the
22
- # supervisor does NOT respawn-loop when the feature is off a clean idle.
20
+ # cron entry the value-gate routes to a cheap session, but the session only
21
+ # actually runs when cheap-cron is enabled at runtime. Cheap-cron is ON by
22
+ # DEFAULT (matches isCheapCronEnabled in src/scheduler/cron-routing.tsonly
23
+ # SWITCHROOM_CHEAP_CRON=0/false/off disables it); the old "off unless =1" gate
24
+ # here meant the cron session quarantined itself even with cheap-by-default on,
25
+ # so every Tier-1 fire fell back to the main session and saved nothing. Exit 78
26
+ # (EX_CONFIG) on the explicit kill-switch so the supervisor cleanly idles.
23
27
  case "${SWITCHROOM_CHEAP_CRON:-}" in
24
- 1 | true | on | ON) : ;;
25
- *)
26
- echo "cron-session: SWITCHROOM_CHEAP_CRON off — not starting (exit 78, no respawn)" >&2
28
+ 0 | false | off | OFF | False | FALSE)
29
+ echo "cron-session: SWITCHROOM_CHEAP_CRON disabled (=0) — not starting (exit 78, no respawn)" >&2
27
30
  exit 78
28
31
  ;;
32
+ *) : ;;
29
33
  esac
30
34
 
31
35
  CRON_NAME="{{name}}-cron"
@@ -28268,6 +28268,7 @@ __export(exports_history, {
28268
28268
  pruneMessagesOlderThanDays: () => pruneMessagesOlderThanDays,
28269
28269
  lookupMessageRoleAndText: () => lookupMessageRoleAndText,
28270
28270
  initHistory: () => initHistory,
28271
+ hasOutboundDeliveredSince: () => hasOutboundDeliveredSince,
28271
28272
  getRecentOutboundCount: () => getRecentOutboundCount,
28272
28273
  getLatestInboundMessageId: () => getLatestInboundMessageId,
28273
28274
  deleteFromHistory: () => deleteFromHistory,
@@ -28462,6 +28463,26 @@ function getRecentOutboundCount(chatId, withinSeconds) {
28462
28463
  const row = requireDb().prepare("SELECT COUNT(*) as cnt FROM messages WHERE chat_id = ? AND role = ? AND ts >= ?").get(chatId, "assistant", cutoff);
28463
28464
  return row?.cnt ?? 0;
28464
28465
  }
28466
+ function hasOutboundDeliveredSince(chatId, sinceMs, threadId) {
28467
+ try {
28468
+ const cutoffSec = Math.floor(sinceMs / 1000);
28469
+ const params = [chatId, cutoffSec];
28470
+ let sql = "SELECT 1 FROM messages WHERE chat_id = ? AND role = 'assistant' AND ts >= ? AND LENGTH(text) >= 200";
28471
+ if (threadId !== undefined) {
28472
+ if (threadId === null) {
28473
+ sql += " AND thread_id IS NULL";
28474
+ } else {
28475
+ sql += " AND thread_id = ?";
28476
+ params.push(threadId);
28477
+ }
28478
+ }
28479
+ sql += " LIMIT 1";
28480
+ const row = requireDb().prepare(sql).get(...params);
28481
+ return row != null;
28482
+ } catch {
28483
+ return false;
28484
+ }
28485
+ }
28465
28486
  function query(opts) {
28466
28487
  const limit = Math.min(MAX_LIMIT, Math.max(1, opts.limit ?? DEFAULT_LIMIT));
28467
28488
  const params = [opts.chat_id];
@@ -48238,6 +48259,16 @@ function isCronIdentity(name) {
48238
48259
  function resolveInjectTarget(agentName3, meta) {
48239
48260
  return meta?.session === "cron" ? cronIdentity(agentName3) : agentName3;
48240
48261
  }
48262
+ function deliverInjectWithFallback(agentName3, meta, send) {
48263
+ const target = resolveInjectTarget(agentName3, meta);
48264
+ const wantedCron = target !== agentName3;
48265
+ if (send(target))
48266
+ return { target, delivered: true, fellBackToMain: false };
48267
+ if (wantedCron && send(agentName3)) {
48268
+ return { target: agentName3, delivered: true, fellBackToMain: true };
48269
+ }
48270
+ return { target, delivered: false, fellBackToMain: false };
48271
+ }
48241
48272
 
48242
48273
  // gateway/obligation-ledger.ts
48243
48274
  class ObligationLedger {
@@ -48295,21 +48326,23 @@ class ObligationLedger {
48295
48326
  return best;
48296
48327
  }
48297
48328
  decideAtIdle(opts) {
48298
- const useEligible = opts != null && (opts.graceMs > 0 || opts.backgroundWorkActive === true);
48299
- const o = useEligible ? this.oldestEligible(opts.now, opts.graceMs, opts.backgroundWorkActive === true, opts.backgroundGraceMs ?? 0) : this.oldest();
48329
+ const useEligible = opts != null && (opts.graceMs > 0 || opts.backgroundWorkActive === true || (opts.representGraceMs ?? 0) > 0);
48330
+ const o = useEligible ? this.oldestEligible(opts.now, opts.graceMs, opts.backgroundWorkActive === true, opts.backgroundGraceMs ?? 0, opts.representGraceMs ?? 0) : this.oldest();
48300
48331
  if (o === undefined)
48301
48332
  return { action: "none" };
48302
48333
  if (o.representCount >= this.maxRepresents)
48303
48334
  return { action: "escalate", obligation: o };
48304
48335
  return { action: "represent", obligation: o };
48305
48336
  }
48306
- oldestEligible(now, graceMs, backgroundWorkActive, backgroundGraceMs) {
48337
+ oldestEligible(now, graceMs, backgroundWorkActive, backgroundGraceMs, representGraceMs) {
48307
48338
  let best;
48308
48339
  for (const o of this.open.values()) {
48309
48340
  if (o.lastTurnEndedAt != null && now - o.lastTurnEndedAt < graceMs)
48310
48341
  continue;
48311
48342
  if (backgroundWorkActive && backgroundGraceMs > 0 && now - o.openedAt < backgroundGraceMs)
48312
48343
  continue;
48344
+ if (representGraceMs > 0 && o.lastRepresentedAt != null && now - o.lastRepresentedAt < representGraceMs)
48345
+ continue;
48313
48346
  if (best === undefined || o.openedAt < best.openedAt)
48314
48347
  best = o;
48315
48348
  }
@@ -48322,18 +48355,21 @@ class ObligationLedger {
48322
48355
  o.lastTurnEndedAt = ts;
48323
48356
  this.persist();
48324
48357
  }
48325
- resolveCloseTarget(echoedTurnId, liveTurnId) {
48358
+ resolveCloseTarget(echoedTurnId, liveTurnId, routedOriginId) {
48326
48359
  if (echoedTurnId != null)
48327
48360
  return echoedTurnId;
48328
- if (liveTurnId != null && this.open.size === 1 && this.open.has(liveTurnId))
48361
+ if (routedOriginId != null)
48362
+ return routedOriginId;
48363
+ if (liveTurnId != null && this.open.has(liveTurnId))
48329
48364
  return liveTurnId;
48330
48365
  return null;
48331
48366
  }
48332
- markRepresented(originTurnId) {
48367
+ markRepresented(originTurnId, now = Date.now()) {
48333
48368
  const o = this.open.get(originTurnId);
48334
48369
  if (o === undefined)
48335
48370
  return 0;
48336
48371
  o.representCount += 1;
48372
+ o.lastRepresentedAt = now;
48337
48373
  this.persist();
48338
48374
  return o.representCount;
48339
48375
  }
@@ -53757,10 +53793,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
53757
53793
  }
53758
53794
 
53759
53795
  // ../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;
53796
+ var VERSION = "0.15.11";
53797
+ var COMMIT_SHA = "43331954";
53798
+ var COMMIT_DATE = "2026-06-13T03:24:01Z";
53799
+ var LATEST_PR = 2308;
53764
53800
  var COMMITS_AHEAD_OF_TAG = 0;
53765
53801
 
53766
53802
  // gateway/boot-version.ts
@@ -54993,6 +55029,13 @@ var OBLIGATION_BACKGROUND_WORK_GRACE_MS = (() => {
54993
55029
  const n = Number(raw);
54994
55030
  return Number.isFinite(n) && n >= 0 ? n : 1200000;
54995
55031
  })();
55032
+ var OBLIGATION_REPRESENT_GRACE_MS = (() => {
55033
+ const raw = process.env.SWITCHROOM_OBLIGATION_REPRESENT_GRACE_MS;
55034
+ if (raw == null || raw === "")
55035
+ return 120000;
55036
+ const n = Number(raw);
55037
+ return Number.isFinite(n) && n >= 0 ? n : 120000;
55038
+ })();
54996
55039
  var TURN_ACTIVE_MARKER_FRESH_MS = 90000;
54997
55040
  var AUTOCLASSIFY_MIDTURN_SHADOW = process.env.SWITCHROOM_AUTOCLASSIFY_MIDTURN_SHADOW !== "0";
54998
55041
  var lastAgentOutputAt = new Map;
@@ -55148,11 +55191,12 @@ function hasDifferentThreadedRecentTurn(chatId, liveThreadId) {
55148
55191
  }
55149
55192
  return false;
55150
55193
  }
55151
- function closeObligationOnSubstantiveReply(args, liveTurn) {
55194
+ function closeObligationOnSubstantiveReply(args, liveTurn, routedOriginTurn) {
55152
55195
  if (!OBLIGATION_LEDGER_ENABLED)
55153
55196
  return;
55154
55197
  const echoed = findTurnByOriginId(args.origin_turn_id);
55155
- const target = obligationLedger.resolveCloseTarget(echoed?.turnId, liveTurn?.turnId);
55198
+ const routedOriginId = routedOriginTurn != null && echoed == null ? routedOriginTurn.turnId : null;
55199
+ const target = obligationLedger.resolveCloseTarget(echoed?.turnId, liveTurn?.turnId, routedOriginId);
55156
55200
  if (target != null)
55157
55201
  obligationLedger.close(target);
55158
55202
  }
@@ -56528,11 +56572,12 @@ function obligationSweep() {
56528
56572
  const agent = process.env.SWITCHROOM_AGENT_NAME ?? "";
56529
56573
  const now = Date.now();
56530
56574
  const backgroundWorkActive = OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 && agentHasInFlightBackgroundWork(now);
56531
- const decision = obligationLedger.decideAtIdle(OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive ? {
56575
+ const decision = obligationLedger.decideAtIdle(OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive || OBLIGATION_REPRESENT_GRACE_MS > 0 ? {
56532
56576
  now,
56533
56577
  graceMs: OBLIGATION_ESCALATE_GRACE_MS,
56534
56578
  backgroundWorkActive,
56535
- backgroundGraceMs: OBLIGATION_BACKGROUND_WORK_GRACE_MS
56579
+ backgroundGraceMs: OBLIGATION_BACKGROUND_WORK_GRACE_MS,
56580
+ representGraceMs: OBLIGATION_REPRESENT_GRACE_MS
56536
56581
  } : undefined);
56537
56582
  const o = decision.obligation;
56538
56583
  if (decision.action === "none" || o == null) {
@@ -56552,6 +56597,12 @@ function obligationSweep() {
56552
56597
  `);
56553
56598
  return;
56554
56599
  }
56600
+ if (HISTORY_ENABLED && hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId)) {
56601
+ process.stderr.write(`telegram gateway: obligation closed silently \u2014 outbound delivered since open origin=${o.originTurnId}
56602
+ `);
56603
+ obligationLedger.close(o.originTurnId);
56604
+ return;
56605
+ }
56555
56606
  driveEscalation({
56556
56607
  escId: o.originTurnId,
56557
56608
  inFlight: obligationEscalateInFlight,
@@ -57116,12 +57167,14 @@ var ipcServer = createIpcServer({
57116
57167
  onInjectInbound(_client, msg) {
57117
57168
  const promptKey = typeof msg.inbound.meta?.prompt_key === "string" ? msg.inbound.meta.prompt_key : "unknown";
57118
57169
  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)
57170
+ const { target, delivered, fellBackToMain } = deliverInjectWithFallback(msg.agentName, msg.inbound.meta, (t) => ipcServer.sendToAgent(t, msg.inbound));
57171
+ if (fellBackToMain) {
57172
+ process.stderr.write(`telegram gateway: cron fire fell back to main session (no cron bridge) agent=${msg.agentName} prompt_key=${promptKey}
57173
+ `);
57174
+ }
57175
+ if (delivered && target === msg.agentName)
57123
57176
  markClaudeBusyForInbound(msg.inbound);
57124
- process.stderr.write(`telegram gateway: inject_inbound agent=${msg.agentName} target=${target} source=${source} prompt_key=${promptKey} delivered=${delivered}
57177
+ process.stderr.write(`telegram gateway: inject_inbound agent=${msg.agentName} target=${target}${fellBackToMain ? " (fellback)" : ""} source=${source} prompt_key=${promptKey} delivered=${delivered}
57125
57178
  `);
57126
57179
  if (!delivered) {
57127
57180
  pendingInboundBuffer.push(target, msg.inbound);
@@ -57432,12 +57485,14 @@ ${url}`;
57432
57485
  effectiveText = text;
57433
57486
  }
57434
57487
  assertAllowedChat(chat_id);
57488
+ let replyRoutedOriginTurn = null;
57435
57489
  let threadId;
57436
57490
  if (TURN_ORIGIN_ROUTING_ENABLED) {
57437
57491
  const explicit = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
57438
57492
  const echoedTurn = findTurnByOriginId(args.origin_turn_id);
57439
57493
  const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(chat_id, args.reply_to) : null;
57440
57494
  const originTurn = echoedTurn ?? quotedTurn;
57495
+ replyRoutedOriginTurn = originTurn ?? null;
57441
57496
  threadId = resolveAnswerThreadWithLog(chat_id, Number.isFinite(explicit) ? explicit : undefined, originTurn, originTurn == null ? null : echoedTurn != null ? "echo" : "quoted", turn, "reply");
57442
57497
  } else {
57443
57498
  threadId = resolveThreadId(chat_id, args.message_thread_id ?? (turn?.sessionThreadId != null ? turn.sessionThreadId : undefined));
@@ -57571,7 +57626,7 @@ ${url}`;
57571
57626
  disableNotification
57572
57627
  });
57573
57628
  if (turn2.finalAnswerSubstantive)
57574
- closeObligationOnSubstantiveReply(args, turn2);
57629
+ closeObligationOnSubstantiveReply(args, turn2, replyRoutedOriginTurn);
57575
57630
  }
57576
57631
  outboundDedup.record(chat_id, threadId, decision.mergedText, Date.now(), turn2?.registryKey ?? null);
57577
57632
  silentAnchorEditDone = true;
@@ -57774,7 +57829,7 @@ ${url}`;
57774
57829
  turn.finalAnswerSubstantive = isSubstantiveFinalReply({ text: rawText, disableNotification });
57775
57830
  finalizeStatusReaction(chat_id, threadId, "done");
57776
57831
  if (turn.finalAnswerSubstantive)
57777
- closeObligationOnSubstantiveReply(args, turn);
57832
+ closeObligationOnSubstantiveReply(args, turn, replyRoutedOriginTurn);
57778
57833
  }
57779
57834
  releaseTurnBufferGate(statusKey(chat_id, threadId), turn ?? undefined);
57780
57835
  if (turn?.finalAnswerDelivered === true) {
@@ -57794,13 +57849,19 @@ async function executeStreamReply(args) {
57794
57849
  throw new Error("stream_reply: chat_id is required");
57795
57850
  if (args.text == null || args.text === "")
57796
57851
  throw new Error("stream_reply: text is required and cannot be empty");
57852
+ let streamRoutedOriginTurn = null;
57853
+ let streamOriginVia = null;
57854
+ if (TURN_ORIGIN_ROUTING_ENABLED) {
57855
+ const echoedTurn = findTurnByOriginId(args.origin_turn_id);
57856
+ const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(String(args.chat_id), args.reply_to) : null;
57857
+ const originTurn = echoedTurn ?? quotedTurn;
57858
+ streamRoutedOriginTurn = originTurn ?? null;
57859
+ streamOriginVia = originTurn == null ? null : echoedTurn != null ? "echo" : "quoted";
57860
+ }
57797
57861
  if (args.message_thread_id == null) {
57798
57862
  let injected;
57799
57863
  if (TURN_ORIGIN_ROUTING_ENABLED) {
57800
- const echoedTurn = findTurnByOriginId(args.origin_turn_id);
57801
- const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(String(args.chat_id), args.reply_to) : null;
57802
- const originTurn = echoedTurn ?? quotedTurn;
57803
- injected = resolveAnswerThreadWithLog(String(args.chat_id), undefined, originTurn, originTurn == null ? null : echoedTurn != null ? "echo" : "quoted", turn, "stream_reply");
57864
+ injected = resolveAnswerThreadWithLog(String(args.chat_id), undefined, streamRoutedOriginTurn, streamOriginVia, turn, "stream_reply");
57804
57865
  } else {
57805
57866
  injected = turn?.sessionThreadId;
57806
57867
  }
@@ -57943,7 +58004,7 @@ async function executeStreamReply(args) {
57943
58004
  done: args.done === true
57944
58005
  });
57945
58006
  if (turn.finalAnswerSubstantive)
57946
- closeObligationOnSubstantiveReply(args, turn);
58007
+ closeObligationOnSubstantiveReply(args, turn, streamRoutedOriginTurn);
57947
58008
  const streamThreadIdForClear = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
57948
58009
  clearSilentEndState(statusKey(streamChatId, streamThreadIdForClear));
57949
58010
  }
@@ -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
+ }