/** * Can a *locally injected* turn ask its operator a yes/no question? * * One predicate, shared by the tick that has to warn about the answer and the * status row that has to report it, because two spellings of it would drift and * the whole point is that the health line and the tick agree. * * It is deliberately a question about **configuration**, not about the mounted * tool set, and that distinction is the correction to a first attempt that got * it wrong. omp-telegram mounts `telegram_ask` in `before_agent_start` and * removes it again in `agent_end` (`restorePromptTools`), so the tool exists * only *during* a turn. Anything that looks at `getActiveTools()` between turns * — which is exactly where a heartbeat composes its prompt — sees the base set * and would report the tool missing on every tick, including a correctly * configured fleet. A false alarm every interval is worse than no alarm: it * trains the operator to ignore the line that is supposed to mean something. * * What actually decides the mounting is knowable ahead of the turn. omp-telegram * resolves a prompt target from the turn's own `` wrapper, or * failing that from `notifyTarget()`, which needs `notifyMode` set to "away" or * "always" *and* a destination it can reach. A locally injected tick never has a * wrapper, so for ticks the predicate reduces to config keys readable before the * turn starts — with one deliberate narrowing, documented at the destination * check below: only `notifyChat` counts, because a forum topic depends on live * per-session state no file can evidence. * * That is exactly what failed on 2026-08-09 06:17Z: the fleet's access.json had * no `notifyMode`, so the locally injected tick could not ask the Learning-loop * yes/no question the package floor requires — while the status line reported * `telegram ok (@tbcoder_bot; inbound configured)` throughout (#114). A health * row that stays green through a broken contract is worse than no row. * * The legacy `away: true` boolean counts: `loadAccess()` migrates it to * `notifyMode: "away"` on read, so a fleet still carrying it resolves a target * and must not be reported as broken. * * Since omp-telegram 0.11.0 there is a second way for a locally injected turn to * resolve a target, and it counts here for the same reason the legacy boolean * does: `profile: "daemon"` makes `notifyTarget()` resolve without `notifyMode` * at all, because the profile *is* the headless contract — explicit-only * outbound, no idle notify post, and `telegram_ask` targeted at the paired owner * on every turn including a cron tick. A fleet carrying it has the surface #114 * was about, so demanding `notifyMode` beside it would be the false alarm this * file exists to avoid. The `notifyMode` paths are all still live: an older * plugin ignores the `profile` key entirely, and a host running one keeps * working unchanged. */ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; /** The tool the package floor names for the Learning-loop yes/no amendment * approval (`## Learning loop`, step 2, in `briefs/orchestrator.md`). Held as * a constant because the prose that tells a turn to call it and the check that * says whether it can be called must name the same thing. */ export const TELEGRAM_APPROVAL_TOOL = "telegram_ask"; export type ApprovalSurface = | { kind: "ready"; /** `notifyMode` from access.json, when the surface read it (#169): whether end-of-turn text reaches the operator's chat, so the tick's delivery rule can say so truthfully. */ notifyMode?: string } | { kind: "missing"; reason: string }; /** Whether the Telegram bridge is running the headless contract, or merely * configured well enough to answer a question. * * Two questions, not one, which is why this is a separate answer from * {@link ApprovalSurface}: a fleet can be perfectly *askable* and still be * narrating every turn into its operator's chat. `interactive` carries the * reason because both readers of it — a tick prompt and a status row — have to * name the remedy, and one spelling of it keeps them in agreement. */ export type DaemonProfileSurface = { kind: "daemon" } | { kind: "interactive"; reason: string }; /** One checked read of a JSON property, so nothing below asserts a shape the * parse never proved. Anything that is not a plain object, or a key that is * absent, answers undefined — which every caller here already treats as * "unconfigured". */ function field(value: unknown, key: string): unknown { if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined; return Object.hasOwn(value, key) ? Reflect.get(value, key) : undefined; } /** * Whether omp-telegram would find a bot token — its rule, not a reasonable one. * * This gates the heartbeat, so it has to predict another package's answer, and * anywhere it is more generous than that package the gate fails *open*: it would * report a token, let the tick fire, and leave the fleet dispatching with an * outbound channel that never worked. So it mirrors `resolveToken()` in * omp-telegram's `access.ts` exactly — process environment first, then the first * line of `/.env` matching `/^TELEGRAM_BOT_TOKEN=(.*)$/` after a * trim, first match winning even when its value is empty. * * That regex is deliberately strict where a dotenv reader would not be: * `export TELEGRAM_BOT_TOKEN=…` and `TELEGRAM_BOT_TOKEN = …` are both files * omp-telegram reads no token from, so both must close this gate. * * `escalate.ts` parses the same file more permissively on purpose, and the two * are not a contradiction to be tidied away: that one asks "can *conductor* send * a page with this", and it can, because it does the parsing and the request * itself. This one asks "will *omp-telegram* have a token", and only omp-telegram * decides that. */ export function hasBotToken(stateDir: string): boolean { const fromEnv = process.env["TELEGRAM_BOT_TOKEN"]; if (fromEnv !== undefined && fromEnv.length > 0) return true; let raw: string; try { raw = readFileSync(join(stateDir, ".env"), "utf8"); } catch { return false; } for (const line of raw.split("\n")) { const match = /^TELEGRAM_BOT_TOKEN=(.*)$/.exec(line.trim()); if (match === null) continue; // First match wins even when empty, exactly as `resolveToken()` returns on // its first hit — a file whose first token line is blank has no token, and // reading past it would disagree with the package this predicts. return (match[1] ?? "").length > 0; } return false; } /** * Whether omp-telegram would have *bound* a token, which is what its own tools * depend on and is strictly narrower than the token existing. * * `startBot()` is the only thing that assigns `token`, and `session_start` calls * it only when the bridge is switched on: * * ```js * if (pi.getFlag("telegram") === true || process.env.OMP_TELEGRAM === "1" || access.enabled) await startBot(ctx); * ``` * * So a token sitting beside a bridge that was disabled at start was never * picked up, and flipping `enabled` to true in the file afterwards does not pick * it up either — only `/telegram on` or a restart does. Both halves therefore * have to be true *at the same moment*, which is why this is sampled once rather * than re-read. * * `pi.getFlag("telegram")` is omp's own launch flag and is not visible to this * extension, so a session started with it but with `enabled: false` on disk * reads as unbound here. That is the conservative direction: the cost is a * fallback instruction the orchestrator can carry out, against an amendment * recorded as approved that nobody answered. */ export function bridgeTokenBound(accessPath: string): boolean { if (!hasBotToken(dirname(accessPath))) return false; if (process.env["OMP_TELEGRAM"] === "1") return true; let access: unknown; try { access = JSON.parse(readFileSync(accessPath, "utf8")); } catch { return false; } return field(access, "enabled") === true; } /** Reads the omp-telegram access file and answers whether a locally injected * turn would resolve an answerable destination. * * A bot token is *not* checked here, deliberately: without one nothing outbound * works at all, so it is a whole-channel fault rather than an approval-surface * one. Both callers settle it first — the tick through its escalation-channel * gate, which refuses to fire at all, and `probeTelegramHealth` through a * `getMe` that has already succeeded. This function is only ever asked about a * bridge that can send. Never throws: an unreadable or malformed file is a * missing surface with the path named, not an exception taken out of a * heartbeat. */ export function readApprovalSurface(path: string): ApprovalSurface { let access: unknown; try { access = JSON.parse(readFileSync(path, "utf8")); } catch { return { kind: "missing", reason: `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: cannot read ${path}` }; } if (access === null || typeof access !== "object" || Array.isArray(access)) { return { kind: "missing", reason: `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: ${path} is not an object`, }; } const mode = field(access, "notifyMode"); // Three spellings of the same fact, and none of them is redundant. // `notifyMode` is what an interactive host sets; `away: true` is the retired // boolean `loadAccess()` migrates on read; `profile: "daemon"` is the headless // contract, which omp-telegram ≥ 0.11.0 treats as its own reason to resolve a // notify target (`notifyTarget()` gates on `notifyMode || profile === "daemon"` // and `before_agent_start` mounts `telegram_ask` on the same disjunction). A // daemon-profile fleet with no `notifyMode` is therefore *more* answerable than // the configuration #114 asked for, not less, and reporting it broken would be // the every-interval false alarm this file was written to prevent. const active = mode === "away" || mode === "always" || field(access, "away") === true || field(access, "profile") === "daemon"; if (!active) { return { kind: "missing", reason: `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: no notifyMode in ${path} — ` + `set it to "always" and set notifyChat to the paired owner id`, }; } // Only `notifyChat` counts, and `topicsChat` deliberately does not, even // though omp-telegram can resolve a forum topic. `notifyTarget()` is handed a // topic destination only when `ownTopic && access.topicsChat`, and `ownTopic` // is per-session runtime state — the thread this process actually claimed — // assigned during topic setup and cleared on failure. A file cannot prove it. // So a fleet carrying `topicsChat` alone may or may not have the tool mounted, // and this check answers the safe way: #114 exists because a health signal // said "ok" through a broken contract, and repeating that with a stale // `topicsChat` would be the same bug wearing a different key. `notifyChat` is // resolvable from the file alone, so it is what "configured" means here. const destination = field(access, "notifyChat"); if (typeof destination !== "string" || destination.length === 0) { const topicsChat = field(access, "topicsChat"); const viaTopic = typeof topicsChat === "string" && topicsChat.length > 0 ? " — topicsChat only resolves once this session has claimed its forum topic, which this file cannot show" : ""; return { kind: "missing", reason: `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: notifyMode is set but ${path} names no notifyChat` + `${viaTopic} — set notifyChat to the paired owner id`, }; } // Naming a destination is not the same as being able to answer in it, and // `buildPromptTarget()` applies two more gates before a target exists. Both // are decidable from this file, so both belong here — a `ready` that skipped // them would say the approval works while a stale or wrong chat id silently // reproduces #114. // // First, `pairedOwnerId()` is the single entry of `allowFrom`; anything else // yields no responder and therefore no target. const allowFrom = field(access, "allowFrom"); const owner = Array.isArray(allowFrom) && allowFrom.length === 1 ? String(allowFrom[0]) : undefined; if (owner === undefined) { return { kind: "missing", reason: `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: ${path} has no single paired owner in allowFrom, ` + "so no one is authorised to answer", }; } // Then `canAnswerPrompt()`, which is the same DM/group policy inbound turns // use. `isDmChat()` calls anything not starting with "-" a DM, and a DM only // authorises the owner's *own* chat — a DM chat id equals its user id, so a // notifyChat pointing at any other person is unanswerable by the paired owner. if (!destination.startsWith("-")) { if (destination !== owner) { return { kind: "missing", reason: `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: notifyChat ${destination} in ${path} is a DM that ` + `is not the paired owner's (${owner}), and only the owner's own DM authorises an answer — ` + "set notifyChat to the paired owner id", }; } return { kind: "ready", notifyMode: mode === undefined ? undefined : String(mode) }; } // A group destination needs a policy entry, and that policy must admit the // owner: an empty or absent `allowFrom` means everyone in the group, else the // owner has to be listed. const policy = field(field(access, "groups"), destination); if (policy === null || typeof policy !== "object") { return { kind: "missing", reason: `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: notifyChat ${destination} in ${path} is a group with ` + "no entry under groups, so no answer from it is authorised", }; } const groupAllow = field(policy, "allowFrom"); const admitted = !Array.isArray(groupAllow) || groupAllow.length === 0 || groupAllow.map(String).includes(owner); if (!admitted) { return { kind: "missing", reason: `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: group ${destination} in ${path} does not admit the ` + `paired owner (${owner}) in its allowFrom, so their answer would be rejected`, }; } return { kind: "ready", notifyMode: mode === undefined ? undefined : String(mode) }; } /** * Reads the same access file and answers the other question: does visible * assistant text stay out of the operator's chat? * * A separate read rather than a field on {@link ApprovalSurface}, because the * two faults have different remedies and a caller may care about only one. This * one is about *noise*: without the profile, omp-telegram relays a real Telegram * message per assistant turn for as long as a conversation is marked active * (`outbound.ts` `onTurnEnd`), and — because the fleet sets `notifyMode` for the * approval surface above — posts every local run's closing text to the notify * chat from its `agent_end` handler. So a correctly *askable* fleet is exactly * the one that narrates: the two settings pull against each other, which is why * both are checked and only the profile silences the second. * * One key decides it, deliberately. `profile: "daemon"` is omp-telegram ≥ 0.11.0's * whole headless contract — explicit-only outbound, no idle/final notify post, * `telegram_ask` targeted at the paired owner on every turn — so verifying it is * one comparison rather than three inferences about `streaming`, `notifyMode` and * turn origin that would drift the moment the plugin changes. Anything other * than the literal string is `interactive`: `loadAccess()` drops every other * value to undefined, so `"DAEMON"` and `5` are not the contract on the host * either, and reporting them as one would promise a suppression nothing applied. * * Never throws, and never writes: conductor reads this file, the operator's * `/telegram set profile daemon` is what changes it. An unreadable or corrupt * file answers `interactive` for the reason the whole module fails closed — a * green signal over an unproven contract is what #114 was. */ export function readDaemonProfile(accessPath: string): DaemonProfileSurface { const remedy = "assistant text can auto-relay to Telegram — run `/telegram set profile daemon`"; let access: unknown; try { access = JSON.parse(readFileSync(accessPath, "utf8")); } catch { return { kind: "interactive", reason: `cannot read or parse ${accessPath}, so no telegram profile is proven; ${remedy}`, }; } if (access === null || typeof access !== "object" || Array.isArray(access)) { return { kind: "interactive", reason: `${accessPath} is not an object, so no telegram profile is proven; ${remedy}`, }; } const profile = field(access, "profile"); if (profile === "daemon") return { kind: "daemon" }; // An absent key reads as "default" rather than "undefined": that is the word // the plugin's own `/telegram status` line and its `profile: daemon | default` // help text use, and an operator matching a status row against this reason // should not have to translate. const observed = profile === undefined ? "default" : JSON.stringify(profile); return { kind: "interactive", reason: `telegram profile is ${observed}; ${remedy}` }; }