switchroom 0.15.12 → 0.15.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.
- package/dist/agent-scheduler/index.js +324 -14
- package/dist/auth-broker/index.js +61 -4
- package/dist/cli/notion-write-pretool.mjs +61 -4
- package/dist/cli/switchroom.js +402 -113
- package/dist/host-control/main.js +61 -4
- package/dist/vault/approvals/kernel-server.js +62 -5
- package/dist/vault/broker/server.js +62 -5
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +30 -13
- package/profiles/_shared/agent-self-service.md.hbs +37 -0
- package/telegram-plugin/bridge/bridge.ts +38 -1
- package/telegram-plugin/dist/bridge/bridge.js +31 -1
- package/telegram-plugin/dist/gateway/gateway.js +536 -53
- package/telegram-plugin/dist/server.js +31 -1
- package/telegram-plugin/gateway/gateway.ts +169 -6
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -1
- package/telegram-plugin/gateway/ipc-server.ts +29 -0
- package/telegram-plugin/gateway/linear-activity.ts +145 -0
- package/telegram-plugin/runtime-metrics.ts +14 -0
- package/telegram-plugin/scoped-approval.ts +253 -0
- package/telegram-plugin/tests/bridge-liveness-override.test.ts +21 -0
- package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +54 -0
- package/telegram-plugin/tests/linear-agent-activity.test.ts +1 -1
- package/telegram-plugin/tests/linear-create-issue.test.ts +211 -0
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +13 -0
- package/telegram-plugin/tests/runtime-metrics.test.ts +9 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +254 -0
- package/telegram-plugin/tests/send-outbound-wiring.test.ts +63 -0
|
@@ -10989,11 +10989,29 @@ var PollSpecSchema = exports_external.discriminatedUnion("type", [
|
|
|
10989
10989
|
HttpDiffPollSchema,
|
|
10990
10990
|
TelegramReactionsPollSchema
|
|
10991
10991
|
]);
|
|
10992
|
+
var TelegramMessageActionSchema = exports_external.object({
|
|
10993
|
+
type: exports_external.literal("telegram-message"),
|
|
10994
|
+
text: exports_external.string().min(1).describe("Operator-authored message text. Posts to the AGENT'S OWN chat only " + "(the chat is not configurable — fenced by construction), into the " + "entry-level `topic` when set. Supports ONLY deterministic placeholders " + "{{date}} / {{time}} (UTC, from the fire clock) and {{agent}}. NO vault " + "secrets (use a webhook action for secret-bearing requests) and NO model " + "output — the text is fully determined by config."),
|
|
10995
|
+
parse_mode: exports_external.enum(["html", "text"]).default("html").describe("Telegram parse mode for the message body.")
|
|
10996
|
+
});
|
|
10997
|
+
var WebhookActionSchema = exports_external.object({
|
|
10998
|
+
type: exports_external.literal("webhook"),
|
|
10999
|
+
url: exports_external.string().url().describe("Webhook target. SAME egress fence as an http-diff poll (§6.1): https " + "only, host on the operator egress allowlist, loopback/private/link-local " + "rejected, resolved IP DNS-rebind-pinned. Not agent-writable without an " + "operator commit."),
|
|
11000
|
+
method: exports_external.enum(["GET", "POST"]).default("POST"),
|
|
11001
|
+
headers: exports_external.record(exports_external.string()).optional().describe("Static headers; {{secret}} substitution applies."),
|
|
11002
|
+
body: exports_external.string().optional().describe("Static request body; {{secret}} substitution applies."),
|
|
11003
|
+
secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault keys this webhook may inject into headers/body via {{name}}. Each " + "is HOST-PINNED (§6.1): a secret may only be sent to the host it is bound " + "to in operator config, so an approved action cannot exfil it elsewhere.")
|
|
11004
|
+
});
|
|
11005
|
+
var ActionSpecSchema = exports_external.discriminatedUnion("type", [
|
|
11006
|
+
TelegramMessageActionSchema,
|
|
11007
|
+
WebhookActionSchema
|
|
11008
|
+
]);
|
|
10992
11009
|
var ScheduleEntrySchema = exports_external.object({
|
|
10993
11010
|
cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
|
|
10994
|
-
prompt: exports_external.string().describe("Prompt to send at the scheduled time (the escalation prompt when kind=poll; templated with {{diff}})."),
|
|
10995
|
-
kind: exports_external.enum(["poll", "prompt"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit.
|
|
11011
|
+
prompt: exports_external.string().optional().describe("Prompt to send at the scheduled time (the escalation prompt when " + "kind=poll; templated with {{diff}}). Required for kind prompt/poll; " + "absent for kind=action (an action has no model fire, so no prompt)."),
|
|
11012
|
+
kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt are honoured only when " + "SWITCHROOM_CHEAP_CRON is on; an action is model-free regardless (the " + "kill-switch governs model tiering, not deterministic actions)."),
|
|
10996
11013
|
poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
|
|
11014
|
+
action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
|
|
10997
11015
|
model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) — the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
|
|
10998
11016
|
context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent). Honoured only when " + "SWITCHROOM_CHEAP_CRON is on."),
|
|
10999
11017
|
secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default — broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary — " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
|
|
@@ -11010,13 +11028,41 @@ var ScheduleEntrySchema = exports_external.object({
|
|
|
11010
11028
|
message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
|
|
11011
11029
|
});
|
|
11012
11030
|
}
|
|
11013
|
-
if (kind
|
|
11031
|
+
if (kind !== "poll" && entry.poll) {
|
|
11014
11032
|
ctx.addIssue({
|
|
11015
11033
|
code: exports_external.ZodIssueCode.custom,
|
|
11016
11034
|
path: ["poll"],
|
|
11017
11035
|
message: "`poll` is only valid when kind: poll."
|
|
11018
11036
|
});
|
|
11019
11037
|
}
|
|
11038
|
+
if (kind === "action" && !entry.action) {
|
|
11039
|
+
ctx.addIssue({
|
|
11040
|
+
code: exports_external.ZodIssueCode.custom,
|
|
11041
|
+
path: ["action"],
|
|
11042
|
+
message: "kind: action requires an `action` spec (telegram-message or webhook)."
|
|
11043
|
+
});
|
|
11044
|
+
}
|
|
11045
|
+
if (kind !== "action" && entry.action) {
|
|
11046
|
+
ctx.addIssue({
|
|
11047
|
+
code: exports_external.ZodIssueCode.custom,
|
|
11048
|
+
path: ["action"],
|
|
11049
|
+
message: "`action` is only valid when kind: action."
|
|
11050
|
+
});
|
|
11051
|
+
}
|
|
11052
|
+
if (kind !== "action" && (entry.prompt === undefined || entry.prompt.trim() === "")) {
|
|
11053
|
+
ctx.addIssue({
|
|
11054
|
+
code: exports_external.ZodIssueCode.custom,
|
|
11055
|
+
path: ["prompt"],
|
|
11056
|
+
message: `kind: ${kind} requires a non-empty \`prompt\`.`
|
|
11057
|
+
});
|
|
11058
|
+
}
|
|
11059
|
+
if (kind === "action" && entry.prompt !== undefined) {
|
|
11060
|
+
ctx.addIssue({
|
|
11061
|
+
code: exports_external.ZodIssueCode.custom,
|
|
11062
|
+
path: ["prompt"],
|
|
11063
|
+
message: "`prompt` is not valid for kind: action (an action never fires a model)."
|
|
11064
|
+
});
|
|
11065
|
+
}
|
|
11020
11066
|
});
|
|
11021
11067
|
var AgentSoulSchema = exports_external.object({
|
|
11022
11068
|
name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),
|
|
@@ -11150,7 +11196,8 @@ var TelegramChannelSchema = exports_external.object({
|
|
|
11150
11196
|
linear_agent: exports_external.object({
|
|
11151
11197
|
enabled: exports_external.boolean(),
|
|
11152
11198
|
token: exports_external.string().describe("vault:<key> reference to the Linear OAuth app token (actor=app). " + "Resolved at runtime via the vault broker (canonically " + "vault:linear/<agent>/token). Never an inline literal."),
|
|
11153
|
-
workspace_id: exports_external.string().optional().describe("Optional Linear workspace (organization) id this agent is " + "installed into. Informational — used for setup hints and " + "multi-workspace disambiguation; the token already scopes the " + "app to its workspace.")
|
|
11199
|
+
workspace_id: exports_external.string().optional().describe("Optional Linear workspace (organization) id this agent is " + "installed into. Informational — used for setup hints and " + "multi-workspace disambiguation; the token already scopes the " + "app to its workspace."),
|
|
11200
|
+
default_team_id: exports_external.string().optional().describe("Optional Linear team id new captured issues file into when the " + "agent doesn't pass an explicit team_id. Unnecessary for a " + "single-team workspace (auto-resolved); set it only when the " + "workspace has multiple teams. Manage via " + "`switchroom linear-agent set-team <agent> <team>`.")
|
|
11154
11201
|
}).optional().describe("Linear first-class agent integration (#2298). When enabled, the " + "agent appears in a Linear workspace as an app actor (own name/" + "avatar, @-mentionable, delegate-assignable). Linear AgentSessionEvent " + "webhooks (mention / delegation) wake the agent instantly via the " + "same gateway inject path as webhook_dispatch, tagged " + 'meta.source="linear" with the agent_session_id, and the agent ' + "responds with structured AgentActivity (thought/message/complete/" + "error) via the linear_agent_activity MCP tool. Builds the " + "session-lifecycle layer on top of the plain webhook_sources:[linear] " + "+ webhook_dispatch support (#2272). The OAuth app token is stored in " + "the vault and referenced here as vault:linear/<agent>/token; run " + "`switchroom linear-agent setup <agent>` to provision it. Off by " + "default — opt in per agent. Cascades from " + "defaults.channels.telegram.linear_agent."),
|
|
11155
11202
|
chat_id: exports_external.string().regex(/^-\d+$/, 'supergroup chat_id must be a negative integer as a string (e.g. "-1001234567890")').optional().describe("Per-agent supergroup ID — overrides fleet `telegram.forum_chat_id`. " + "When set, requires `default_topic_id`. Negative integer as string. " + "Forbidden when `dm_only: true`. See docs/rfcs/supergroup-mode.md."),
|
|
11156
11203
|
default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Defaults to General (topic 1) when " + "`chat_id` is set and this is omitted — set it only to pin a different " + "fallback topic. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field — the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
|
|
@@ -12024,6 +12071,16 @@ function mergeAgentConfig(defaultsIn, agentIn) {
|
|
|
12024
12071
|
}
|
|
12025
12072
|
merged.reaction_dispatch = combined;
|
|
12026
12073
|
}
|
|
12074
|
+
const linearEnabled = merged.channels?.telegram?.linear_agent?.enabled === true;
|
|
12075
|
+
if (linearEnabled) {
|
|
12076
|
+
const rd = merged.reaction_dispatch;
|
|
12077
|
+
if (!rd || rd.emojis === undefined) {
|
|
12078
|
+
merged.reaction_dispatch = {
|
|
12079
|
+
enabled: rd?.enabled ?? true,
|
|
12080
|
+
emojis: ["\uD83D\uDC68\uD83D\uDCBB", "\uD83D\uDCCC"]
|
|
12081
|
+
};
|
|
12082
|
+
}
|
|
12083
|
+
}
|
|
12027
12084
|
if (defaults.resources || merged.resources) {
|
|
12028
12085
|
const d = defaults.resources ?? {};
|
|
12029
12086
|
const a = merged.resources ?? {};
|
|
@@ -12233,17 +12290,19 @@ function collectScheduleEntries(config) {
|
|
|
12233
12290
|
const schedule = resolved.schedule ?? [];
|
|
12234
12291
|
for (let i = 0;i < schedule.length; i++) {
|
|
12235
12292
|
const entry = schedule[i];
|
|
12293
|
+
const auditMaterial = entry.prompt ?? `action:${JSON.stringify(entry.action ?? {})}`;
|
|
12236
12294
|
out.push({
|
|
12237
12295
|
agent,
|
|
12238
12296
|
scheduleIndex: i,
|
|
12239
12297
|
cron: entry.cron,
|
|
12240
|
-
prompt: entry.prompt,
|
|
12241
|
-
promptKey: createHash("sha256").update(
|
|
12298
|
+
...entry.prompt !== undefined ? { prompt: entry.prompt } : {},
|
|
12299
|
+
promptKey: createHash("sha256").update(auditMaterial).digest("hex").slice(0, 12),
|
|
12242
12300
|
...entry.topic !== undefined ? { topic: entry.topic } : {},
|
|
12243
12301
|
...entry.kind !== undefined ? { kind: entry.kind } : {},
|
|
12244
12302
|
...entry.model !== undefined ? { model: entry.model } : {},
|
|
12245
12303
|
...entry.context !== undefined ? { context: entry.context } : {},
|
|
12246
|
-
...entry.poll !== undefined ? { poll: entry.poll } : {}
|
|
12304
|
+
...entry.poll !== undefined ? { poll: entry.poll } : {},
|
|
12305
|
+
...entry.action !== undefined ? { action: entry.action } : {}
|
|
12247
12306
|
});
|
|
12248
12307
|
}
|
|
12249
12308
|
}
|
|
@@ -12259,7 +12318,7 @@ function dispatchAsInbound(entry, options, dispatcher) {
|
|
|
12259
12318
|
user: "cron",
|
|
12260
12319
|
userId: 0,
|
|
12261
12320
|
ts,
|
|
12262
|
-
text: entry.prompt,
|
|
12321
|
+
text: entry.prompt ?? "",
|
|
12263
12322
|
meta: {
|
|
12264
12323
|
source: "cron",
|
|
12265
12324
|
schedule_index: String(entry.scheduleIndex),
|
|
@@ -12287,6 +12346,9 @@ function resolveCronModel(model) {
|
|
|
12287
12346
|
return isKnownCheapModel(model) ? model : DEFAULT_CRON_MODEL;
|
|
12288
12347
|
}
|
|
12289
12348
|
function resolveCronRouting(input, opts) {
|
|
12349
|
+
if ((input.kind ?? "prompt") === "action") {
|
|
12350
|
+
return { tier: "action", session: null, customModelDowngrade: false };
|
|
12351
|
+
}
|
|
12290
12352
|
if (!opts.cheapCronEnabled) {
|
|
12291
12353
|
return { tier: "main", session: "main", customModelDowngrade: false };
|
|
12292
12354
|
}
|
|
@@ -12318,10 +12380,97 @@ function resolveEscalationRouting(input, opts) {
|
|
|
12318
12380
|
return resolveCronRouting({ ...input, kind: "prompt" }, opts);
|
|
12319
12381
|
}
|
|
12320
12382
|
|
|
12383
|
+
// src/scheduler/cron-cadence.ts
|
|
12384
|
+
function csvSmallestGap(field) {
|
|
12385
|
+
if (!field.includes(","))
|
|
12386
|
+
return null;
|
|
12387
|
+
const parts = field.split(",").map((s) => Number(s)).filter((n) => Number.isInteger(n) && n >= 0);
|
|
12388
|
+
if (parts.length < 2)
|
|
12389
|
+
return null;
|
|
12390
|
+
const sorted = [...parts].sort((a, b) => a - b);
|
|
12391
|
+
let smallest = Infinity;
|
|
12392
|
+
for (let i = 1;i < sorted.length; i++) {
|
|
12393
|
+
const gap = sorted[i] - sorted[i - 1];
|
|
12394
|
+
if (gap > 0 && gap < smallest)
|
|
12395
|
+
smallest = gap;
|
|
12396
|
+
}
|
|
12397
|
+
return Number.isFinite(smallest) ? smallest : null;
|
|
12398
|
+
}
|
|
12399
|
+
function estimateCronGapMin(expr) {
|
|
12400
|
+
const fields = expr.trim().split(/\s+/);
|
|
12401
|
+
if (fields.length < 5)
|
|
12402
|
+
return Infinity;
|
|
12403
|
+
const [min, hour] = fields;
|
|
12404
|
+
if (min === "*")
|
|
12405
|
+
return 1;
|
|
12406
|
+
const minStep = min.match(/^\*\/(\d+)$/);
|
|
12407
|
+
if (minStep) {
|
|
12408
|
+
const n = Number(minStep[1]);
|
|
12409
|
+
return n > 0 ? n : Infinity;
|
|
12410
|
+
}
|
|
12411
|
+
const minCsv = csvSmallestGap(min);
|
|
12412
|
+
if (minCsv !== null)
|
|
12413
|
+
return minCsv;
|
|
12414
|
+
if (!/^\d+$/.test(min))
|
|
12415
|
+
return Infinity;
|
|
12416
|
+
if (hour === "*")
|
|
12417
|
+
return 60;
|
|
12418
|
+
const hourStep = hour.match(/^\*\/(\d+)$/);
|
|
12419
|
+
if (hourStep) {
|
|
12420
|
+
const n = Number(hourStep[1]);
|
|
12421
|
+
return n > 0 ? n * 60 : Infinity;
|
|
12422
|
+
}
|
|
12423
|
+
const hourCsv = csvSmallestGap(hour);
|
|
12424
|
+
if (hourCsv !== null)
|
|
12425
|
+
return hourCsv * 60;
|
|
12426
|
+
if (/^\d+$/.test(hour))
|
|
12427
|
+
return 1440;
|
|
12428
|
+
return Infinity;
|
|
12429
|
+
}
|
|
12430
|
+
|
|
12321
12431
|
// src/scheduler/tier-selector.ts
|
|
12322
12432
|
var DEFAULT_FREQUENT_GAP_MIN = 60;
|
|
12323
|
-
function
|
|
12324
|
-
|
|
12433
|
+
function resolveCronAutoTier(env = process.env) {
|
|
12434
|
+
const v = (env.SWITCHROOM_CRON_AUTO_TIER ?? "").toLowerCase();
|
|
12435
|
+
return v === "1" || v === "true" || v === "on";
|
|
12436
|
+
}
|
|
12437
|
+
function recommendCronTier(input, frequentGapMin = DEFAULT_FREQUENT_GAP_MIN) {
|
|
12438
|
+
if (input.kind === "action") {
|
|
12439
|
+
return { tier: "action", source: "explicit", reason: "declared kind: action (model-free verb)" };
|
|
12440
|
+
}
|
|
12441
|
+
if (input.kind === "poll") {
|
|
12442
|
+
return { tier: "poll", source: "explicit", reason: "declared kind: poll (model-free check)" };
|
|
12443
|
+
}
|
|
12444
|
+
if (input.context === "fresh") {
|
|
12445
|
+
return { tier: "cheap", source: "explicit", reason: "declared context: fresh (cheap cron session)" };
|
|
12446
|
+
}
|
|
12447
|
+
if (input.context === "agent") {
|
|
12448
|
+
return { tier: "main", source: "explicit", reason: "declared context: agent (full live session)" };
|
|
12449
|
+
}
|
|
12450
|
+
if (input.model !== undefined) {
|
|
12451
|
+
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` };
|
|
12452
|
+
}
|
|
12453
|
+
if (input.smallestGapMin <= frequentGapMin) {
|
|
12454
|
+
return {
|
|
12455
|
+
tier: "cheap",
|
|
12456
|
+
source: "cadence-default",
|
|
12457
|
+
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`
|
|
12458
|
+
};
|
|
12459
|
+
}
|
|
12460
|
+
return {
|
|
12461
|
+
tier: "main",
|
|
12462
|
+
source: "cadence-default",
|
|
12463
|
+
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`
|
|
12464
|
+
};
|
|
12465
|
+
}
|
|
12466
|
+
function applyDefaultTier(entry, frequentGapMin = DEFAULT_FREQUENT_GAP_MIN, autoTierEnabled = resolveCronAutoTier()) {
|
|
12467
|
+
if (!autoTierEnabled)
|
|
12468
|
+
return entry;
|
|
12469
|
+
if (entry.kind !== undefined || entry.context !== undefined || entry.model !== undefined) {
|
|
12470
|
+
return entry;
|
|
12471
|
+
}
|
|
12472
|
+
const rec = recommendCronTier({ smallestGapMin: estimateCronGapMin(entry.cron) }, frequentGapMin);
|
|
12473
|
+
return rec.tier === "cheap" ? { ...entry, context: "fresh" } : entry;
|
|
12325
12474
|
}
|
|
12326
12475
|
|
|
12327
12476
|
// src/agent-scheduler/cheap-cron-wiring.ts
|
|
@@ -12538,6 +12687,99 @@ async function readCapped(res, maxBytes) {
|
|
|
12538
12687
|
return new TextDecoder().decode(buf);
|
|
12539
12688
|
}
|
|
12540
12689
|
|
|
12690
|
+
// src/scheduler/action-engine.ts
|
|
12691
|
+
var PLACEHOLDER_RE = /\{\{\s*(date|time|agent)\s*\}\}/g;
|
|
12692
|
+
function substituteDeterministic(text, ctx) {
|
|
12693
|
+
const d = new Date(ctx.now);
|
|
12694
|
+
const date = d.toISOString().slice(0, 10);
|
|
12695
|
+
const time = d.toISOString().slice(11, 16);
|
|
12696
|
+
return text.replace(PLACEHOLDER_RE, (_, name) => {
|
|
12697
|
+
if (name === "date")
|
|
12698
|
+
return date;
|
|
12699
|
+
if (name === "time")
|
|
12700
|
+
return time;
|
|
12701
|
+
return ctx.agent;
|
|
12702
|
+
});
|
|
12703
|
+
}
|
|
12704
|
+
function substituteSecrets2(s, secretValues) {
|
|
12705
|
+
return s.replace(/\{\{([a-zA-Z0-9_\-/]+)\}\}/g, (_, name) => secretValues[name] ?? "");
|
|
12706
|
+
}
|
|
12707
|
+
async function runTelegramMessage(spec, deps) {
|
|
12708
|
+
const text = substituteDeterministic(spec.text, { now: deps.now(), agent: deps.agent });
|
|
12709
|
+
let delivered;
|
|
12710
|
+
try {
|
|
12711
|
+
delivered = await deps.sendMessage(text, { parseMode: spec.parse_mode });
|
|
12712
|
+
} catch (e) {
|
|
12713
|
+
return { ok: false, summary: "", error: `send failed: ${e.message}` };
|
|
12714
|
+
}
|
|
12715
|
+
return delivered ? { ok: true, summary: `message sent (${text.length} chars)` } : { ok: false, summary: "", error: "message not delivered (no gateway / send rejected)" };
|
|
12716
|
+
}
|
|
12717
|
+
async function runWebhook(spec, deps) {
|
|
12718
|
+
const gate = checkEgressUrl(spec.url, deps.allow);
|
|
12719
|
+
if (!gate.ok)
|
|
12720
|
+
return { ok: false, summary: "", error: `egress denied: ${gate.reason}` };
|
|
12721
|
+
const host = normalizeHost(new URL(spec.url).hostname);
|
|
12722
|
+
for (const s of spec.secrets) {
|
|
12723
|
+
const sc = checkSecretBinding(s, host, deps.allow);
|
|
12724
|
+
if (!sc.ok)
|
|
12725
|
+
return { ok: false, summary: "", error: `egress denied: ${sc.reason}` };
|
|
12726
|
+
}
|
|
12727
|
+
try {
|
|
12728
|
+
const ip = await deps.lookup(host);
|
|
12729
|
+
if (ip && isBlockedAddress(ip))
|
|
12730
|
+
return { ok: false, summary: "", error: `resolved ip ${ip} blocked` };
|
|
12731
|
+
} catch (e) {
|
|
12732
|
+
return { ok: false, summary: "", error: `dns lookup failed: ${e.message}` };
|
|
12733
|
+
}
|
|
12734
|
+
const secretValues = {};
|
|
12735
|
+
for (const name of spec.secrets) {
|
|
12736
|
+
try {
|
|
12737
|
+
secretValues[name] = await deps.resolveSecret(name);
|
|
12738
|
+
} catch (e) {
|
|
12739
|
+
return { ok: false, summary: "", error: `secret ${name}: ${e.message}` };
|
|
12740
|
+
}
|
|
12741
|
+
}
|
|
12742
|
+
const headers = {};
|
|
12743
|
+
for (const [k, v] of Object.entries(spec.headers ?? {})) {
|
|
12744
|
+
headers[k] = substituteSecrets2(v, secretValues);
|
|
12745
|
+
}
|
|
12746
|
+
const body = spec.body !== undefined ? substituteSecrets2(spec.body, secretValues) : undefined;
|
|
12747
|
+
const controller = new AbortController;
|
|
12748
|
+
const timer = setTimeout(() => controller.abort(), deps.timeoutMs ?? 5000);
|
|
12749
|
+
try {
|
|
12750
|
+
const res = await deps.fetchImpl(spec.url, {
|
|
12751
|
+
method: spec.method,
|
|
12752
|
+
headers,
|
|
12753
|
+
...body !== undefined ? { body } : {},
|
|
12754
|
+
redirect: "error",
|
|
12755
|
+
signal: controller.signal
|
|
12756
|
+
});
|
|
12757
|
+
const buf = await res.arrayBuffer();
|
|
12758
|
+
if (buf.byteLength > (deps.maxBytes ?? 1024 * 1024)) {
|
|
12759
|
+
return { ok: false, summary: "", error: `response ${buf.byteLength}B exceeds cap` };
|
|
12760
|
+
}
|
|
12761
|
+
if (!res.ok)
|
|
12762
|
+
return { ok: false, summary: "", error: `http ${res.status}` };
|
|
12763
|
+
return { ok: true, summary: `webhook ${spec.method} ${host} → ${res.status}` };
|
|
12764
|
+
} catch (e) {
|
|
12765
|
+
return { ok: false, summary: "", error: `fetch failed: ${e.message}` };
|
|
12766
|
+
} finally {
|
|
12767
|
+
clearTimeout(timer);
|
|
12768
|
+
}
|
|
12769
|
+
}
|
|
12770
|
+
async function runAction(spec, deps) {
|
|
12771
|
+
switch (spec.type) {
|
|
12772
|
+
case "telegram-message":
|
|
12773
|
+
return runTelegramMessage(spec, deps);
|
|
12774
|
+
case "webhook":
|
|
12775
|
+
return runWebhook(spec, deps);
|
|
12776
|
+
default: {
|
|
12777
|
+
const _never = spec;
|
|
12778
|
+
return { ok: false, summary: "", error: `unknown action type: ${JSON.stringify(_never)}` };
|
|
12779
|
+
}
|
|
12780
|
+
}
|
|
12781
|
+
}
|
|
12782
|
+
|
|
12541
12783
|
// src/scheduler/poll-state.ts
|
|
12542
12784
|
import { mkdirSync, readFileSync as readFileSync3, renameSync, writeFileSync } from "node:fs";
|
|
12543
12785
|
import { dirname } from "node:path";
|
|
@@ -13061,6 +13303,8 @@ function buildCheapCronHooks(config, env, deps = {}) {
|
|
|
13061
13303
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
13062
13304
|
const lookup = deps.lookup ?? defaultLookup;
|
|
13063
13305
|
const now = deps.now ?? Date.now;
|
|
13306
|
+
const agentName = deps.agentName ?? env.SWITCHROOM_AGENT_NAME ?? "-";
|
|
13307
|
+
const postOutbound = deps.postOutbound;
|
|
13064
13308
|
const runPoll = async (spec, prevCursor) => {
|
|
13065
13309
|
if (spec.type === "http-diff") {
|
|
13066
13310
|
return runHttpDiffPoll(spec, prevCursor, {
|
|
@@ -13073,7 +13317,18 @@ function buildCheapCronHooks(config, env, deps = {}) {
|
|
|
13073
13317
|
}
|
|
13074
13318
|
return { hit: false, baseline: false, error: `poll type '${spec.type}' not yet wired (staged)` };
|
|
13075
13319
|
};
|
|
13076
|
-
|
|
13320
|
+
const runAction2 = async (spec, ctx) => {
|
|
13321
|
+
return runAction(spec, {
|
|
13322
|
+
fetchImpl,
|
|
13323
|
+
resolveSecret,
|
|
13324
|
+
lookup,
|
|
13325
|
+
allow: egress,
|
|
13326
|
+
now,
|
|
13327
|
+
agent: agentName,
|
|
13328
|
+
sendMessage: async (text, opts) => postOutbound ? postOutbound({ threadId: ctx.threadId, text, parseMode: opts.parseMode }) : false
|
|
13329
|
+
});
|
|
13330
|
+
};
|
|
13331
|
+
return { enabled: true, pollState, runPoll, runAction: runAction2 };
|
|
13077
13332
|
}
|
|
13078
13333
|
|
|
13079
13334
|
// src/telegram/topic-router.ts
|
|
@@ -13865,6 +14120,17 @@ function createInjectIpcClient(options) {
|
|
|
13865
14120
|
return false;
|
|
13866
14121
|
try {
|
|
13867
14122
|
return socket.write(JSON.stringify(msg) + `
|
|
14123
|
+
`);
|
|
14124
|
+
} catch (err) {
|
|
14125
|
+
log(`scheduler ipc: write failed: ${err.message}`);
|
|
14126
|
+
return false;
|
|
14127
|
+
}
|
|
14128
|
+
},
|
|
14129
|
+
sendOutbound(msg) {
|
|
14130
|
+
if (!socket || !connected)
|
|
14131
|
+
return false;
|
|
14132
|
+
try {
|
|
14133
|
+
return socket.write(JSON.stringify(msg) + `
|
|
13868
14134
|
`);
|
|
13869
14135
|
} catch (err) {
|
|
13870
14136
|
log(`scheduler ipc: write failed: ${err.message}`);
|
|
@@ -14218,7 +14484,7 @@ function readRecentFires(jsonlPath) {
|
|
|
14218
14484
|
|
|
14219
14485
|
// src/agent-scheduler/index.ts
|
|
14220
14486
|
function templateEscalationPrompt(prompt, diff) {
|
|
14221
|
-
return prompt.replace(/\{\{\s*diff\s*\}\}/g, diff);
|
|
14487
|
+
return (prompt ?? "").replace(/\{\{\s*diff\s*\}\}/g, diff);
|
|
14222
14488
|
}
|
|
14223
14489
|
function recoverPendingEscalations(opts) {
|
|
14224
14490
|
let recovered = 0;
|
|
@@ -14363,6 +14629,28 @@ function registerAgentSchedule(opts) {
|
|
|
14363
14629
|
});
|
|
14364
14630
|
return;
|
|
14365
14631
|
}
|
|
14632
|
+
if (routing.tier === "action") {
|
|
14633
|
+
if (!opts.cheapCron?.runAction || !entry.action) {
|
|
14634
|
+
record({
|
|
14635
|
+
exitCode: -4,
|
|
14636
|
+
summary: !entry.action ? "action skipped — no action spec on entry" : "action skipped — action transport unavailable",
|
|
14637
|
+
tier: "action"
|
|
14638
|
+
});
|
|
14639
|
+
return;
|
|
14640
|
+
}
|
|
14641
|
+
let outcome;
|
|
14642
|
+
try {
|
|
14643
|
+
outcome = await opts.cheapCron.runAction(entry.action, { threadId });
|
|
14644
|
+
} catch (err) {
|
|
14645
|
+
outcome = { ok: false, summary: "", error: err.message };
|
|
14646
|
+
}
|
|
14647
|
+
record({
|
|
14648
|
+
exitCode: outcome.ok ? 0 : -4,
|
|
14649
|
+
summary: outcome.ok ? `action ok: ${outcome.summary}` : `action error: ${outcome.error}`,
|
|
14650
|
+
tier: "action"
|
|
14651
|
+
});
|
|
14652
|
+
return;
|
|
14653
|
+
}
|
|
14366
14654
|
let delivered = false;
|
|
14367
14655
|
let summary = "";
|
|
14368
14656
|
try {
|
|
@@ -14536,6 +14824,19 @@ async function main() {
|
|
|
14536
14824
|
const cheapEnabledForReplay = isCheapCronEnabled(process.env);
|
|
14537
14825
|
for (const m of missed) {
|
|
14538
14826
|
const startedAt = Date.now();
|
|
14827
|
+
if (m.entry.kind === "action") {
|
|
14828
|
+
sink.recordFire({
|
|
14829
|
+
agent: m.entry.agent,
|
|
14830
|
+
scheduleIndex: m.entry.scheduleIndex,
|
|
14831
|
+
promptKey: m.entry.promptKey,
|
|
14832
|
+
exitCode: 0,
|
|
14833
|
+
outputSummary: "action replay skipped — runs on next tick (never double-fired)",
|
|
14834
|
+
startedAt,
|
|
14835
|
+
finishedAt: Date.now(),
|
|
14836
|
+
tier: "action"
|
|
14837
|
+
});
|
|
14838
|
+
continue;
|
|
14839
|
+
}
|
|
14539
14840
|
if (cheapEnabledForReplay && m.entry.kind === "poll") {
|
|
14540
14841
|
sink.recordFire({
|
|
14541
14842
|
agent: m.entry.agent,
|
|
@@ -14566,7 +14867,8 @@ async function main() {
|
|
|
14566
14867
|
process.stdout.write(`agent-scheduler: ${staleSkipped.length} scheduled run(s) ` + `skipped (older than ${windowMinutes}min window) — notifying user
|
|
14567
14868
|
`);
|
|
14568
14869
|
const lines = staleSkipped.map((s) => {
|
|
14569
|
-
const
|
|
14870
|
+
const label = s.entry.prompt ?? `action: ${s.entry.action?.type ?? "?"}`;
|
|
14871
|
+
const oneLine = label.replace(/\s+/g, " ").trim().slice(0, 80);
|
|
14570
14872
|
return `- "${oneLine}" — cron \`${s.entry.cron}\`, ` + `most recent missed run ~${new Date(s.expectedFireMs).toISOString()}`;
|
|
14571
14873
|
});
|
|
14572
14874
|
const noticeText = "[switchroom scheduler notice] While this agent was offline, the " + "following scheduled task(s) had at least one run skipped. They were " + `older than the ${windowMinutes}-minute catch-up window, so they ` + `will NOT be re-run:
|
|
@@ -14622,7 +14924,15 @@ Briefly and plainly tell the user these scheduled runs did not ` + "happen so th
|
|
|
14622
14924
|
await client.close().catch(() => {});
|
|
14623
14925
|
}
|
|
14624
14926
|
} : undefined;
|
|
14625
|
-
const
|
|
14927
|
+
const postOutbound = (args) => ipcClient.sendOutbound({
|
|
14928
|
+
type: "send_outbound",
|
|
14929
|
+
agentName,
|
|
14930
|
+
chatId: channel.chatId,
|
|
14931
|
+
...args.threadId != null ? { threadId: args.threadId } : {},
|
|
14932
|
+
text: args.text,
|
|
14933
|
+
parseMode: args.parseMode
|
|
14934
|
+
});
|
|
14935
|
+
const cheapCron = buildCheapCronHooks(config, process.env, { agentName, postOutbound });
|
|
14626
14936
|
if (cheapCron) {
|
|
14627
14937
|
const recovered = recoverPendingEscalations({
|
|
14628
14938
|
entries,
|
|
@@ -10989,11 +10989,29 @@ var PollSpecSchema = exports_external.discriminatedUnion("type", [
|
|
|
10989
10989
|
HttpDiffPollSchema,
|
|
10990
10990
|
TelegramReactionsPollSchema
|
|
10991
10991
|
]);
|
|
10992
|
+
var TelegramMessageActionSchema = exports_external.object({
|
|
10993
|
+
type: exports_external.literal("telegram-message"),
|
|
10994
|
+
text: exports_external.string().min(1).describe("Operator-authored message text. Posts to the AGENT'S OWN chat only " + "(the chat is not configurable — fenced by construction), into the " + "entry-level `topic` when set. Supports ONLY deterministic placeholders " + "{{date}} / {{time}} (UTC, from the fire clock) and {{agent}}. NO vault " + "secrets (use a webhook action for secret-bearing requests) and NO model " + "output — the text is fully determined by config."),
|
|
10995
|
+
parse_mode: exports_external.enum(["html", "text"]).default("html").describe("Telegram parse mode for the message body.")
|
|
10996
|
+
});
|
|
10997
|
+
var WebhookActionSchema = exports_external.object({
|
|
10998
|
+
type: exports_external.literal("webhook"),
|
|
10999
|
+
url: exports_external.string().url().describe("Webhook target. SAME egress fence as an http-diff poll (§6.1): https " + "only, host on the operator egress allowlist, loopback/private/link-local " + "rejected, resolved IP DNS-rebind-pinned. Not agent-writable without an " + "operator commit."),
|
|
11000
|
+
method: exports_external.enum(["GET", "POST"]).default("POST"),
|
|
11001
|
+
headers: exports_external.record(exports_external.string()).optional().describe("Static headers; {{secret}} substitution applies."),
|
|
11002
|
+
body: exports_external.string().optional().describe("Static request body; {{secret}} substitution applies."),
|
|
11003
|
+
secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault keys this webhook may inject into headers/body via {{name}}. Each " + "is HOST-PINNED (§6.1): a secret may only be sent to the host it is bound " + "to in operator config, so an approved action cannot exfil it elsewhere.")
|
|
11004
|
+
});
|
|
11005
|
+
var ActionSpecSchema = exports_external.discriminatedUnion("type", [
|
|
11006
|
+
TelegramMessageActionSchema,
|
|
11007
|
+
WebhookActionSchema
|
|
11008
|
+
]);
|
|
10992
11009
|
var ScheduleEntrySchema = exports_external.object({
|
|
10993
11010
|
cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
|
|
10994
|
-
prompt: exports_external.string().describe("Prompt to send at the scheduled time (the escalation prompt when kind=poll; templated with {{diff}})."),
|
|
10995
|
-
kind: exports_external.enum(["poll", "prompt"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit.
|
|
11011
|
+
prompt: exports_external.string().optional().describe("Prompt to send at the scheduled time (the escalation prompt when " + "kind=poll; templated with {{diff}}). Required for kind prompt/poll; " + "absent for kind=action (an action has no model fire, so no prompt)."),
|
|
11012
|
+
kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt are honoured only when " + "SWITCHROOM_CHEAP_CRON is on; an action is model-free regardless (the " + "kill-switch governs model tiering, not deterministic actions)."),
|
|
10996
11013
|
poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
|
|
11014
|
+
action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
|
|
10997
11015
|
model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) — the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
|
|
10998
11016
|
context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent). Honoured only when " + "SWITCHROOM_CHEAP_CRON is on."),
|
|
10999
11017
|
secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default — broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary — " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
|
|
@@ -11010,13 +11028,41 @@ var ScheduleEntrySchema = exports_external.object({
|
|
|
11010
11028
|
message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
|
|
11011
11029
|
});
|
|
11012
11030
|
}
|
|
11013
|
-
if (kind
|
|
11031
|
+
if (kind !== "poll" && entry.poll) {
|
|
11014
11032
|
ctx.addIssue({
|
|
11015
11033
|
code: exports_external.ZodIssueCode.custom,
|
|
11016
11034
|
path: ["poll"],
|
|
11017
11035
|
message: "`poll` is only valid when kind: poll."
|
|
11018
11036
|
});
|
|
11019
11037
|
}
|
|
11038
|
+
if (kind === "action" && !entry.action) {
|
|
11039
|
+
ctx.addIssue({
|
|
11040
|
+
code: exports_external.ZodIssueCode.custom,
|
|
11041
|
+
path: ["action"],
|
|
11042
|
+
message: "kind: action requires an `action` spec (telegram-message or webhook)."
|
|
11043
|
+
});
|
|
11044
|
+
}
|
|
11045
|
+
if (kind !== "action" && entry.action) {
|
|
11046
|
+
ctx.addIssue({
|
|
11047
|
+
code: exports_external.ZodIssueCode.custom,
|
|
11048
|
+
path: ["action"],
|
|
11049
|
+
message: "`action` is only valid when kind: action."
|
|
11050
|
+
});
|
|
11051
|
+
}
|
|
11052
|
+
if (kind !== "action" && (entry.prompt === undefined || entry.prompt.trim() === "")) {
|
|
11053
|
+
ctx.addIssue({
|
|
11054
|
+
code: exports_external.ZodIssueCode.custom,
|
|
11055
|
+
path: ["prompt"],
|
|
11056
|
+
message: `kind: ${kind} requires a non-empty \`prompt\`.`
|
|
11057
|
+
});
|
|
11058
|
+
}
|
|
11059
|
+
if (kind === "action" && entry.prompt !== undefined) {
|
|
11060
|
+
ctx.addIssue({
|
|
11061
|
+
code: exports_external.ZodIssueCode.custom,
|
|
11062
|
+
path: ["prompt"],
|
|
11063
|
+
message: "`prompt` is not valid for kind: action (an action never fires a model)."
|
|
11064
|
+
});
|
|
11065
|
+
}
|
|
11020
11066
|
});
|
|
11021
11067
|
var AgentSoulSchema = exports_external.object({
|
|
11022
11068
|
name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),
|
|
@@ -11150,7 +11196,8 @@ var TelegramChannelSchema = exports_external.object({
|
|
|
11150
11196
|
linear_agent: exports_external.object({
|
|
11151
11197
|
enabled: exports_external.boolean(),
|
|
11152
11198
|
token: exports_external.string().describe("vault:<key> reference to the Linear OAuth app token (actor=app). " + "Resolved at runtime via the vault broker (canonically " + "vault:linear/<agent>/token). Never an inline literal."),
|
|
11153
|
-
workspace_id: exports_external.string().optional().describe("Optional Linear workspace (organization) id this agent is " + "installed into. Informational — used for setup hints and " + "multi-workspace disambiguation; the token already scopes the " + "app to its workspace.")
|
|
11199
|
+
workspace_id: exports_external.string().optional().describe("Optional Linear workspace (organization) id this agent is " + "installed into. Informational — used for setup hints and " + "multi-workspace disambiguation; the token already scopes the " + "app to its workspace."),
|
|
11200
|
+
default_team_id: exports_external.string().optional().describe("Optional Linear team id new captured issues file into when the " + "agent doesn't pass an explicit team_id. Unnecessary for a " + "single-team workspace (auto-resolved); set it only when the " + "workspace has multiple teams. Manage via " + "`switchroom linear-agent set-team <agent> <team>`.")
|
|
11154
11201
|
}).optional().describe("Linear first-class agent integration (#2298). When enabled, the " + "agent appears in a Linear workspace as an app actor (own name/" + "avatar, @-mentionable, delegate-assignable). Linear AgentSessionEvent " + "webhooks (mention / delegation) wake the agent instantly via the " + "same gateway inject path as webhook_dispatch, tagged " + 'meta.source="linear" with the agent_session_id, and the agent ' + "responds with structured AgentActivity (thought/message/complete/" + "error) via the linear_agent_activity MCP tool. Builds the " + "session-lifecycle layer on top of the plain webhook_sources:[linear] " + "+ webhook_dispatch support (#2272). The OAuth app token is stored in " + "the vault and referenced here as vault:linear/<agent>/token; run " + "`switchroom linear-agent setup <agent>` to provision it. Off by " + "default — opt in per agent. Cascades from " + "defaults.channels.telegram.linear_agent."),
|
|
11155
11202
|
chat_id: exports_external.string().regex(/^-\d+$/, 'supergroup chat_id must be a negative integer as a string (e.g. "-1001234567890")').optional().describe("Per-agent supergroup ID — overrides fleet `telegram.forum_chat_id`. " + "When set, requires `default_topic_id`. Negative integer as string. " + "Forbidden when `dm_only: true`. See docs/rfcs/supergroup-mode.md."),
|
|
11156
11203
|
default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Defaults to General (topic 1) when " + "`chat_id` is set and this is omitted — set it only to pin a different " + "fallback topic. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field — the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
|
|
@@ -12024,6 +12071,16 @@ function mergeAgentConfig(defaultsIn, agentIn) {
|
|
|
12024
12071
|
}
|
|
12025
12072
|
merged.reaction_dispatch = combined;
|
|
12026
12073
|
}
|
|
12074
|
+
const linearEnabled = merged.channels?.telegram?.linear_agent?.enabled === true;
|
|
12075
|
+
if (linearEnabled) {
|
|
12076
|
+
const rd = merged.reaction_dispatch;
|
|
12077
|
+
if (!rd || rd.emojis === undefined) {
|
|
12078
|
+
merged.reaction_dispatch = {
|
|
12079
|
+
enabled: rd?.enabled ?? true,
|
|
12080
|
+
emojis: ["\uD83D\uDC68\uD83D\uDCBB", "\uD83D\uDCCC"]
|
|
12081
|
+
};
|
|
12082
|
+
}
|
|
12083
|
+
}
|
|
12027
12084
|
if (defaults.resources || merged.resources) {
|
|
12028
12085
|
const d = defaults.resources ?? {};
|
|
12029
12086
|
const a = merged.resources ?? {};
|