/** A Telegram chat/topic a channel is bound to (folded into the channel). */ export interface TgBinding { chatId: number; threadId?: number; } /** A remembered session — the ONE registry, whatever created it (web or * Telegram). Enough to restore, resume, and route it. */ export interface Channel { sessionId: string; cwd: string; name?: string; branch?: string | null; /** Repo the worktree belongs to, to recreate a reclaimed checkout. */ repo?: string; /** Tab group id (client-owned metadata). */ group?: number | null; /** The environment's home base: pinned, never closable. Server-owned, so a * stale client's PUT cannot clear it. See `isHomeChannel`. */ home?: boolean; /** Present iff the session is bound to a Telegram chat/topic — what IS. */ telegram?: TgBinding | null; /** What the user WANTS: should this channel also live in Telegram? * Distinct from `telegram` — without an explicit intent, "not mirrored yet" * and "deliberately not mirrored" are indistinguishable, and the reconcile * loop would recreate the very topic we just deleted. */ mirror?: boolean; /** Agent profile applied at spawn (role/guardrails/secrets) — re-applied on * resume/restart so the guardrails aren't lost. */ profile?: string | null; /** The channel that spawned this one, or one attached to it by hand. Only * this parent is told when the agent finishes, blocks on a question or dies * — without that scoping a chatty channel would wake a boss on every turn, * and a wake in a large session is not free. The CHILD stores its parent and * never the reverse, so there is one writer per fact and the two directions * cannot disagree. */ parent?: string | null; /** Muted channel: it raises no global signal (favicon pip, title badge, * blink, chime). Client-owned like `name`/`group`, stored here so the mute * survives a reload and follows the user's other devices. */ muted?: boolean; } /** * Should this channel be mirrored into Telegram? * * Falling back to `!!c.telegram` is what makes the migration free: a channel * that already has a binding stays mirrored without writing anything, so a * running install doesn't un-mirror itself on deploy. It also makes the field * robust to a client that ignores it (`mirror` is client-owned, hence absent * from an old tab's PUT): with no intent, the binding decides. */ export declare function isMirrored(c: Channel): boolean; /** * Pure: is this channel the environment's home base — pinned and never closable? * * Two ways to be one, and both are needed. The explicit `home` flag is set on * the lead agent an instance starts life with. The Telegram clause is the rule * that predates the flag and is kept verbatim, so an instance already running * with a bound board does not lose its home base on upgrade. * * The `chatId < 0` half of that clause is load-bearing: a DM binding ALSO has no * threadId, but it is an ordinary channel that must stay closable — treating it * as home once made a bogus second "general" appear. */ export declare function isHomeChannel(c: Pick): boolean; /** * Pure: the channel an existing instance should adopt as its home base, or null. * * For cockpits that predate the flag. It only ever designates a channel already * playing the part — named `general`, in the launch directory, with no worktree * — and **refuses when it cannot tell**: zero candidates, or several. A wrong * adoption is irreversible from the UI, since the channel becomes precisely the * one that cannot be closed; doing nothing always stays recoverable. */ export declare function homeAdoptionTarget(channels: Channel[], launchCwd: string): string | null; /** * Pure: the sessionId a board group's General topic should ADOPT, or null. * * The web home base (`home: true`) carries no Telegram binding until a group is * bound, so `channelForTelegram(chatId, undefined)` misses it — and the bridge * then spawns a SECOND session for General, leaving two "general" channels (the * exact bug this fixes). Resuming the home session instead makes the one channel * gain the binding. Only a home WITHOUT a binding is a target: one already bound * is found by `channelForTelegram` and must not be re-adopted. */ export declare function homeChannelForGeneral(channels: Channel[]): string | null; /** * Pure: keep the FIRST record of each sessionId, drop later duplicates. One * agent must map to one channel — a spawn-time race once wrote two rows for one * session and the merge persisted both, so the agent showed twice in the left * column and survived reloads. Reading through this heals a corrupted file on * the next save. */ export declare function dedupById(list: Channel[]): Channel[]; export declare function loadChannels(): Channel[]; export declare function saveChannels(list: Channel[]): void; /** * A browser tab belongs to the instance it was LOADED from. The page carries * that instance's launch-dir key (stamped by `injectInstanceKey`), and a * mutating call (`PUT /channels`, `PUT /groups`) sends it back. A stale tab * whose server was stopped and replaced by another instance on the same port * would otherwise PUT its in-memory channels into the NEW instance's file. The * write is refused only on an EXPLICIT mismatch: a client that sends no key * (a pre-fix page, or a non-browser caller that never PUTs channels anyway) is * allowed, so the guard adds isolation without breaking anything. */ export declare function channelWriteAllowed(serverKey: string, clientKey: string | undefined | null): boolean; /** * Drop a channel that is another instance's HOME (lead) — a `home` channel whose * cwd is a different launch directory. Its "general" has no place in this * instance's file; it lands there only through the stale-tab contamination * above. Non-home agents legitimately run in worktrees with a different cwd, so * only `home` is filtered; an empty cwd is kept (own home, cwd not yet * asserted at `ready`). Applied on both read and write, so an already-poisoned * file self-heals on the next load or save. */ export declare function dropForeignHomes(list: Channel[], serverCwd: string): Channel[]; /** * Insert or update a channel by sessionId, shallow-merging the given fields * (an `undefined` field never clobbers an existing value). The server calls * this whenever a session reaches `ready`, so every session — web or Telegram — * lands in the one list. */ /** Pure core of upsertChannel — returns a new list; exported for testing. */ export declare function upsertInto(list: Channel[], patch: Partial & { sessionId: string; }): Channel[]; export declare function upsertChannel(patch: Partial & { sessionId: string; }): void; export declare function removeChannel(sessionId: string): void; /** Pure lookup of the channel bound to a Telegram chat/topic. */ export declare function findTelegramChannel(list: Channel[], chatId: number, threadId?: number): Channel | undefined; /** The channel bound to a given Telegram chat/topic, or undefined. */ export declare function channelForTelegram(chatId: number, threadId?: number): Channel | undefined; /** * Pure core of the PUT merge (exported for testing). The client drives order * and its own metadata (name, group); server-owned fields are preserved per * sessionId, and any stored channel the client omitted is kept when its session * is live or it has a Telegram binding — so persistence never drops a * live/Telegram session (invariant #6). */ export declare function mergeChannels(stored: Channel[], clientList: Channel[], liveIds: Set): Channel[]; export declare function mergeClientChannels(clientList: Channel[], liveIds: Set): Channel[]; /** Tab groups (id, name, collapsed, order), persisted the same way as channels. */ export declare function loadGroups(): any[]; export declare function saveGroups(list: any[]): void; /** The single group this instance is bound to (one board per instance), or null. * Instance-level, not a session — kept separate from the channel registry. */ export declare function loadTgGroup(): number | null; export declare function saveTgGroup(groupId: number | null): void; /** * The Telegram channels (`bindKey`) where tool calls are SHOWN. An allowlist * rather than a key → boolean map: the default (hidden) costs no entry, and a * missing or unreadable file degrades to exactly "everything hidden" — the * intended default. * * The setting belongs to the topic, not to the session: `/new` recreates a * session in the same topic and must keep the user's choice. */ export declare function loadTgToolKeys(): string[]; /** Pure core of setTgTools — returns a new list; exported for testing. */ export declare function setToolKeys(keys: string[], key: string, on: boolean): string[]; export declare function tgToolsEnabled(key: string): boolean; export declare function setTgTools(key: string, on: boolean): void; /** * The owner of direct messages: the Telegram id of the first user to write to * the bot in a DM. Without it, a stranger who finds the bot gets a Claude * session on the machine — groups were already bounded to the bound group, * DMs were not. */ export declare function loadTgOwner(): number | null; export declare function saveTgOwner(userId: number | null): void; /** * Every forum topic the bot has created or seen (the Telegram Bot API can't * list topics, so we track them ourselves). Used by the boot reconciliation to * find orphan topics — ones that exist but have no channel — and delete them. */ export declare function loadTgTopics(): number[]; export declare function saveTgTopics(list: number[]): void; export declare function addTgTopic(threadId: number): void; /** * One-time migration of the old separate `…-telegram.json` bindings into the * channel registry. Idempotent: renames the file once folded so it's never * re-applied. cwd/name are filled in later when the session next reaches ready. */ export declare function migrateTgBindings(): void; /** * Everything a caller needs to resume a session: where it runs, and what it * takes to rebuild its checkout. The registry is the authority — these fields * are recorded when the session is created and are server-owned. */ export interface SessionTarget { /** The channel's own directory — NOT the server's cwd. */ cwd: string; /** Profile whose secrets a guard gets, and whose guardrails a resume keeps. */ profile: string | null; /** Worktree branch + origin repo, to recreate a reclaimed checkout. */ branch: string | null; repo: string | null; /** False when no channel carries this sessionId: `cwd` is the fallback. */ known: boolean; } /** * The ONE lookup that answers "where does this session live". Pure on purpose: * every caller that acts on a session's behalf — the cron driver, the `start` * handler, any future webhook or "run now" button — reads it here instead of * naming a directory of its own. * * That single source is the whole point. `loadHistory` is keyed by the cwd, so * a worktree session resumed at the repo root wakes with no history at all; * when three callers each derived the directory separately, each of them got it * wrong once. * * An unknown sessionId falls back to `fallbackCwd` instead of failing: the * historical behaviour, and right for a root-directory channel whose registry * entry was lost. */ export declare function resolveSessionTarget(channels: readonly Channel[], sessionId: string, fallbackCwd: string): SessionTarget; /** * Where a RESUME should actually run, given what the caller sent. * * The registry wins. A caller may pass a cwd, a branch and a repo — the web * client does, the cron driver does — but for a session the registry knows, * those are ignored in favour of the record written when it was created. This * is what turns "always resume a worktree session with its worktree path" from * a rule to remember into something a caller cannot get wrong. * * What the caller sent is still honoured where the registry has nothing: an * unknown session (a lost entry), or a known channel with no branch/repo of its * own being reopened from the Recover panel. */ export declare function resumeTarget(channels: readonly Channel[], sessionId: string, sent: { cwd?: string; branch?: string; repo?: string; }, fallbackCwd: string): SessionTarget;