switchroom 0.15.8 → 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.
- package/dist/agent-scheduler/index.js +87 -2
- package/dist/cli/switchroom.js +95 -8
- package/dist/cli/ui/index.html +71 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +22 -10
- package/telegram-plugin/gateway/cron-session.ts +34 -0
- package/telegram-plugin/gateway/gateway.ts +26 -11
- package/telegram-plugin/tests/cron-session.test.ts +36 -0
|
@@ -12276,7 +12276,7 @@ function isKnownCheapModel(model) {
|
|
|
12276
12276
|
}
|
|
12277
12277
|
function isCheapCronEnabled(env = process.env) {
|
|
12278
12278
|
const v = (env.SWITCHROOM_CHEAP_CRON ?? "").toLowerCase();
|
|
12279
|
-
return v === "
|
|
12279
|
+
return !(v === "0" || v === "false" || v === "off");
|
|
12280
12280
|
}
|
|
12281
12281
|
function resolveCronModel(model) {
|
|
12282
12282
|
return isKnownCheapModel(model) ? model : DEFAULT_CRON_MODEL;
|
|
@@ -12313,6 +12313,90 @@ 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
|
+
// src/scheduler/tier-selector.ts
|
|
12365
|
+
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;
|
|
12398
|
+
}
|
|
12399
|
+
|
|
12316
12400
|
// src/agent-scheduler/cheap-cron-wiring.ts
|
|
12317
12401
|
import { lookup as dnsLookup } from "node:dns/promises";
|
|
12318
12402
|
|
|
@@ -14296,7 +14380,8 @@ function registerAgentSchedule(opts) {
|
|
|
14296
14380
|
}
|
|
14297
14381
|
}
|
|
14298
14382
|
const cheapEnabled = opts.cheapCron?.enabled ?? false;
|
|
14299
|
-
const
|
|
14383
|
+
const routed = cheapEnabled ? applyDefaultTier(entry) : entry;
|
|
14384
|
+
const routing = resolveCronRouting(routed, { cheapCronEnabled: cheapEnabled });
|
|
14300
14385
|
const threadId = resolveEntryThreadId(entry, opts.channel);
|
|
14301
14386
|
const record = (fields) => opts.sink.recordFire({
|
|
14302
14387
|
agent: entry.agent,
|
package/dist/cli/switchroom.js
CHANGED
|
@@ -15435,6 +15435,93 @@ 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
|
+
// 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;
|
|
15519
|
+
}
|
|
15520
|
+
var DEFAULT_FREQUENT_GAP_MIN = 60;
|
|
15521
|
+
var init_tier_selector = __esm(() => {
|
|
15522
|
+
init_cron_routing();
|
|
15523
|
+
});
|
|
15524
|
+
|
|
15438
15525
|
// src/config/timezone.ts
|
|
15439
15526
|
import { readFileSync as readFileSync3, readlinkSync } from "node:fs";
|
|
15440
15527
|
function defaultReadEtcTimezone() {
|
|
@@ -23262,7 +23349,7 @@ function describeAgents(config) {
|
|
|
23262
23349
|
const resolved = resolveAgentConfig(config.defaults, config.profiles, agent);
|
|
23263
23350
|
const profile = agent.extends ?? "default";
|
|
23264
23351
|
const uid = allocateAgentUid(name);
|
|
23265
|
-
const cronSession = scheduleNeedsCronSession((resolved.schedule ?? []).map((e) => ({ kind: e.kind, model: e.model, context: e.context })), { cheapCronEnabled: true });
|
|
23352
|
+
const cronSession = scheduleNeedsCronSession((resolved.schedule ?? []).map((e) => applyDefaultTier({ cron: e.cron, kind: e.kind, model: e.model, context: e.context })), { cheapCronEnabled: true });
|
|
23266
23353
|
const resources = resolveResourceDefaults(name, profile, resolved.resources, { cronSession });
|
|
23267
23354
|
const strippedCaps = readStrippedCaps(agent);
|
|
23268
23355
|
out.push({
|
|
@@ -23760,6 +23847,7 @@ function emitAgentService(lines, a, imageTag, buildMode, buildContext, homePrefi
|
|
|
23760
23847
|
var AGENT_UID_MIN = 10001, AGENT_UID_MAX = 10999, RESOURCE_BY_PROFILE, CRON_SESSION_MEM_BUMP_MIB = 512, CRON_SESSION_PIDS_BUMP = 128, BIND_MOUNT_SOURCE_DENYLIST, BIND_MOUNT_TARGET_DENYLIST, BIND_MOUNT_EXACT_SOURCE_DENY, CONTAINER_CONFIG_PATH = "/state/config/switchroom.yaml";
|
|
23761
23848
|
var init_compose = __esm(() => {
|
|
23762
23849
|
init_cron_routing();
|
|
23850
|
+
init_tier_selector();
|
|
23763
23851
|
init_merge();
|
|
23764
23852
|
init_timezone();
|
|
23765
23853
|
init_peercred();
|
|
@@ -24438,6 +24526,8 @@ function getAgentLogs(name, follow) {
|
|
|
24438
24526
|
function classifyChangeKind(path) {
|
|
24439
24527
|
if (/\/telegram\/cron-(?:\d+|[0-9a-f]{12})\.sh$/.test(path))
|
|
24440
24528
|
return "cron";
|
|
24529
|
+
if (path.includes("/.claude-cron/"))
|
|
24530
|
+
return "cron";
|
|
24441
24531
|
if (path.includes("/.claude/skills/"))
|
|
24442
24532
|
return "skill";
|
|
24443
24533
|
if (path.endsWith("/.claude/settings.json"))
|
|
@@ -50235,8 +50325,8 @@ var {
|
|
|
50235
50325
|
} = import__.default;
|
|
50236
50326
|
|
|
50237
50327
|
// src/build-info.ts
|
|
50238
|
-
var VERSION = "0.15.
|
|
50239
|
-
var COMMIT_SHA = "
|
|
50328
|
+
var VERSION = "0.15.10";
|
|
50329
|
+
var COMMIT_SHA = "bb9182e1";
|
|
50240
50330
|
|
|
50241
50331
|
// src/cli/agent.ts
|
|
50242
50332
|
init_source();
|
|
@@ -50347,6 +50437,7 @@ var LEGACY_CRON_SCRIPT_BASENAME_RE = /^cron-(\d+)\.sh$/;
|
|
|
50347
50437
|
// src/agents/scaffold.ts
|
|
50348
50438
|
init_schema();
|
|
50349
50439
|
init_cron_routing();
|
|
50440
|
+
init_tier_selector();
|
|
50350
50441
|
init_merge();
|
|
50351
50442
|
init_timezone();
|
|
50352
50443
|
|
|
@@ -51489,11 +51580,7 @@ function renderFleetInvariants() {
|
|
|
51489
51580
|
`);
|
|
51490
51581
|
}
|
|
51491
51582
|
function buildCronSessionContext(agentConfig) {
|
|
51492
|
-
const entries = (agentConfig.schedule ?? []).map((e) => ({
|
|
51493
|
-
kind: e.kind,
|
|
51494
|
-
model: e.model,
|
|
51495
|
-
context: e.context
|
|
51496
|
-
}));
|
|
51583
|
+
const entries = (agentConfig.schedule ?? []).map((e) => applyDefaultTier({ cron: e.cron, kind: e.kind, model: e.model, context: e.context }));
|
|
51497
51584
|
return {
|
|
51498
51585
|
cronSessionEnabled: scheduleNeedsCronSession(entries, { cheapCronEnabled: true }),
|
|
51499
51586
|
cronModelQ: shellSingleQuote(DEFAULT_CRON_MODEL)
|
package/dist/cli/ui/index.html
CHANGED
|
@@ -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"
|
|
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
|
@@ -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,11 +53767,11 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
|
|
|
53757
53767
|
}
|
|
53758
53768
|
|
|
53759
53769
|
// ../src/build-info.ts
|
|
53760
|
-
var VERSION = "0.15.
|
|
53761
|
-
var COMMIT_SHA = "
|
|
53762
|
-
var COMMIT_DATE = "2026-06-
|
|
53763
|
-
var LATEST_PR =
|
|
53764
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
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;
|
|
53774
|
+
var COMMITS_AHEAD_OF_TAG = 0;
|
|
53765
53775
|
|
|
53766
53776
|
// gateway/boot-version.ts
|
|
53767
53777
|
function formatRelativeAgo(iso) {
|
|
@@ -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 =
|
|
57120
|
-
|
|
57121
|
-
|
|
57122
|
-
|
|
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,
|
|
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
|
-
|
|
6429
|
-
|
|
6430
|
-
|
|
6431
|
-
//
|
|
6432
|
-
//
|
|
6433
|
-
//
|
|
6434
|
-
|
|
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
|
-
//
|
|
6440
|
-
// mid
|
|
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
|
+
})
|