import * as fs from "node:fs"; import { type NativeExactFileIdentity, type NativeExactUnlinkResult } from "@gajae-code/natives"; import type { Settings } from "../../config/settings"; import type { DaemonRuntimeInfo } from "../../daemon/control-types"; import { HEARTBEAT_TTL_MS } from "./daemon-paths"; import { type DaemonTransitionLock, type NotificationEndpointFile, type NotificationEndpointFileIdentity, type NotificationExactUnlinkResult } from "./notification-service"; import { type DaemonProcessReference } from "./telegram-daemon-control"; import { type TelegramSetupPreflight } from "./telegram-setup"; export { loadInstallationHostId, loadLegacyInstallationHostId, type MachineIdentityDeps, parseMacPlatformUuid, parseWindowsMachineGuid, } from "../../config/machine-identity"; export { DAEMON_GENERATION, NOTIFICATION_EVENT_SCHEMA_VERSION, NOTIFICATION_PROTOCOL_VERSION, SDK_LIFECYCLE_ROUTER_PROTOCOL_VERSION, SERVING_EPOCH, TELEGRAM_TRANSPORT_GENERATION, } from "./telegram-daemon-contract"; import { type AgentDirSessionLifecycleService } from "../lifecycle/client"; import { type NotificationSubscription, type SessionRouterProviderDeps } from "../router/session-router"; import { NotificationOperatorRuntime } from "./operator-runtime"; import { type AliasTable, type CallbackRoute } from "./telegram-reference"; import { type TopicEndpointBinding, type TopicRegistryCasAuthority, type TopicRegistryState } from "./topic-registry"; export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled" | "blocked"; /** Detailed result for orchestration that must distinguish a #2028 handoff from a fresh spawn. */ export type EnsureTelegramDaemonDetailedResult = "spawned" | "reloaded" | "attached" | "disabled" | "blocked_identity"; export type TelegramDaemonOwnershipPhase = "provisional" | "ready" | "retired"; export interface DaemonState { pid: number; /** OS process-start provenance; mandatory for PID-authorized ownership actions. */ incarnation: string; ownerId: string; /** Unique, durable identity for one ownership acquisition. */ acquisitionId?: string; /** A provisional owner is physical-live but MUST NOT be attached as ready. */ ownershipPhase?: TelegramDaemonOwnershipPhase; tokenFingerprint: string; chatId: string; startedAt: number; heartbeatAt: number; /** * Present only for the Windows source-launch handoff. `pid` starts as this * short-lived launcher PID and may be rebound exactly once to the daemon PID. */ launcherPid?: number; version: 1; /** * Operational daemon generation of the process that owns the lock, distinct * from both the persisted state-schema {@link DaemonState.version} and the * notification wire protocol version. Absent on pre-generation state. */ generation?: number; /** Lifecycle-serving compatibility epoch; absent pre-epoch records are epoch 1. */ servingEpoch?: number; stoppedAt?: number; } interface ExactFileStat { dev: bigint; ino: bigint; nlink: bigint; size: bigint; mtimeNs: bigint; isFile(): boolean; } export interface TelegramDaemonFs { mkdir(path: string, opts?: fs.MakeDirectoryOptions): Promise; readFile(path: string, encoding: BufferEncoding): Promise; writeFile(path: string, data: string, opts?: fs.WriteFileOptions): Promise; rename(oldPath: string, newPath: string): Promise; unlink(path: string): Promise; open(path: string, flags: string, mode?: number): Promise<{ sync?: () => Promise; close(): Promise; }>; readdir(path: string): Promise; chmod(path: string, mode: number): Promise; /** Crash-atomic persistence seams. Implementations without them fail closed. */ fsyncFile?(path: string): Promise; fsyncDirectory?(path: string): Promise; stat?(path: string): Promise<{ mtimeMs: number; size?: number; dev?: number; ino?: number; /** Hard-link count; required to prove a staging temp has no second name. */ nlink?: number; ctimeMs?: number; isDirectory?(): boolean; }>; lstat?(path: string, opts: { bigint: true; }): Promise; readEndpointFile?(file: string): Promise; exactUnlink?(file: string, identity: NotificationEndpointFileIdentity): Promise; } export interface SpawnResult { pid?: number; unref?: () => void; } export interface TelegramDaemonDeps { fs?: TelegramDaemonFs; now?: () => number; pid?: number; pidAlive?: (pid: number) => boolean; /** Opens an identity-stable process authority for destructive lifecycle operations. */ processReference?: (pid: number) => DaemonProcessReference | undefined; /** Returns immutable process-start provenance, or undefined when unsupported. */ pidIncarnation?: (pid: number) => string | undefined; spawn?: (command: string, args: string[], opts: { detached: boolean; stdio: "ignore"; logPath?: string; }) => SpawnResult; execPath?: string; /** Injectable platform seam for source-linked Windows daemon spawning. */ platform?: NodeJS.Platform; randomId?: () => string; /** * Signal delivery + poll timing for the stale-generation reload handoff in * {@link ensureTelegramDaemonRunning}. Defaults use real signals/timers; tests * inject them to drive the handoff deterministically. */ sendSignal?: (pid: number, signal: NodeJS.Signals) => void; sleep?: (ms: number) => Promise; waitStepMs?: number; /** Bounded startup-readiness timeout; injectable for deterministic handoff tests. */ readinessTimeoutMs?: number; } export declare const HEARTBEAT_INTERVAL_MS = 5000; export { HEARTBEAT_TTL_MS }; export declare const DAEMON_VERSION = 1; /** Capability token advertised when the server supports app-level ping/pong. */ export declare const CLIENT_PING_PONG_CAPABILITY = "client_ping_pong"; /** Capability required for typed controls and semantic Selected acknowledgement frames. */ export declare const ASK_SELECTED_ACK_CAPABILITY = "ask_selected_ack_v1"; export declare const ASK_CONTROLS_CAPABILITY = "ask_controls_v1"; /** Capability for the closed tool phase set: started, completed, failed, and cancelled. */ export declare const TOOL_ACTIVITY_CAPABILITY = "tool_activity_v2"; /** Receive-only compatibility capability for pre-v2 hosts. */ export declare const LEGACY_TOOL_ACTIVITY_CAPABILITY = "tool_activity_v1"; /** * File-lock options whose acquisition budget covers the full reload-reservation * critical section: the in-lock freshness poll plus the controller's * graceful+kill+readiness sequence, with headroom. A contender must be able to * wait out a legitimate slow reload and then attach, never fail startup. */ export declare function reloadReservationLockOptions(input: { freshnessWaitMs: number; readinessTimeoutMs: number; retryDelayMs?: number; }): { staleMs: number; retries: number; retryDelayMs: number; }; export declare const BTW_QUESTION_MAX_UNICODE_SCALARS = 4096; export declare const BTW_QUESTION_MAX_UTF8_BYTES = 16384; export { type DaemonPaths, daemonPaths } from "./daemon-paths"; export declare class TopicRegistryDurabilityUnavailableError extends Error { readonly code = "durability_unavailable"; constructor(cause: unknown); } type TopicRegistryExactReplace = (sourcePath: string, destinationPath: string, expectedSource: NativeExactFileIdentity, expectedDestination: NativeExactFileIdentity) => NativeExactUnlinkResult; /** Publish topic authority only after its staged bytes and replacement are durable. */ export declare function writeTopicRegistryAtomic(fsImpl: TelegramDaemonFs, file: string, data: unknown, platform?: NodeJS.Platform, exactReplace?: TopicRegistryExactReplace, expectedDestination?: NativeExactFileIdentity): Promise; /** * Shared-volume topic authority backed by the existing cross-process file lock. * A missing authority is the only valid bootstrap state; malformed and future * snapshots are never interpreted as empty state. */ export declare class FilesystemTopicRegistryCasAuthority implements TopicRegistryCasAuthority { #private; private readonly file; private readonly fsImpl; private readonly platform; private readonly exactReplace; private readonly installationHostId; private readonly previousInstallationHostIds; constructor(file: string, input: { installationHostId: string; previousInstallationHostIds?: readonly string[]; fs?: TelegramDaemonFs; platform?: NodeJS.Platform; exactReplace?: TopicRegistryExactReplace; }); read(): Promise; compareAndSet(expectedGeneration: number, next: TopicRegistryState): Promise; private readLocked; } export declare function tryCreateOwnershipLock(fsImpl: TelegramDaemonFs, file: string, initialization: OwnershipLockMetadata): Promise; type OwnershipLockMetadata = { pid: number; incarnation: string; ownerId?: string; acquisitionId?: string; startedAt: number; }; type LegacyOwnershipLockMetadata = { pid: number; incarnation?: string; startedAt: number; }; type V010OwnershipLockMetadata = { size: 0; mtimeMs?: number; dev?: number; ino?: number; ctimeMs?: number; }; type OwnershipLockRead = { kind: "missing"; } | { kind: "malformed"; raw: string; mtimeMs?: number; } | { kind: "v010"; metadata: V010OwnershipLockMetadata; } | { kind: "legacy"; metadata: LegacyOwnershipLockMetadata; } | { kind: "valid"; metadata: OwnershipLockMetadata; }; /** Read lock provenance without treating a corrupt legacy artifact as a filesystem failure. */ export declare function readOwnershipLock(fsImpl: TelegramDaemonFs, file: string): Promise; /** * A live initializer lock only proves that a concurrent publisher is active. * It never proves a ready daemon: legacy or unavailable provenance remains * blocked, while a canonical mismatch proves PID reuse and can be reclaimed * under the transition lock. */ export declare function liveOwnershipLockDecision(input: { lock: OwnershipLockRead; pidAlive: (pid: number) => boolean; pidIncarnation: (pid: number) => string | undefined; }): { acquired: false; attached: false; blocked: true; } | { acquired: false; attached: false; provisional: true; } | undefined; type OwnerHeartbeatSidecar = { pid: number; incarnation: string; ownerId: string; acquisitionId: string; heartbeatAt: number; /** * Session endpoints this owner had an OPEN WebSocket to when the heartbeat was * published. Optional: a sidecar written by an older daemon omits it and must * read back as unknown, never as zero. */ attachedEndpoints?: number; }; export interface OwnerFreshnessSnapshot { ownerTag: Pick | null; effectiveHeartbeatAt: number | undefined; legacyEmbedded: boolean; /** * Session endpoints the current owner reported as attached in its latest matching * heartbeat sidecar. `undefined` means the owner never published the field (older * daemon, no stable owner tag, or no matching sidecar) — that is unknown, not zero. */ attachedEndpoints: number | undefined; state: DaemonState | undefined; } export declare function readOwnerFreshnessSnapshot(input: { settings: Settings; fs?: TelegramDaemonFs; }): Promise; /** Outcome of a steady heartbeat sidecar renewal. */ export type OwnerHeartbeatSidecarRenewal = /** The sidecar was published under a still-matching ownership lock. */ "renewed" /** This process no longer matches the persisted state/lock; stop renewing. */ | "not_owner" /** * Ownership still held, but the sidecar publication failed (e.g. a transient * Windows EPERM/EBUSY while another process holds the destination). The * daemon must stay alive and publish again on the next cycle (#4200); a * persistently failing publication eventually stales the heartbeat for * outside observers, whose takeover then surfaces here as `not_owner`. */ | "publish_failed"; /** Marker-free steady heartbeat renewal. The final lock reread fences a stale writer. */ export declare function renewOwnerHeartbeatSidecar(input: { settings: Settings; ownerId: string; acquisitionId?: string; fs?: TelegramDaemonFs; now?: () => number; pid?: number; pidIncarnation?: (pid: number) => string | undefined; /** * Session endpoints the caller currently holds an OPEN WebSocket to. Omitted by * non-daemon callers (bootstrap/proof paths) so their sidecar reports unknown * rather than falsely claiming zero attachments. */ attachedEndpoints?: number; }): Promise; /** True when a path is permanently gone (not a transient I/O blip). */ export declare function isPermanentMissingPathError(error: unknown): boolean; export declare function hasSafeDaemonStateShape(state: unknown): state is DaemonState; type ParentDaemonStateBase = Omit & { incarnation?: undefined; acquisitionId?: undefined; ownershipPhase?: undefined; generation?: unknown; launcherPid?: undefined; }; type GenerationAbsentParentDaemonState = Omit & { generation?: undefined; }; type Generation3ReleaseDaemonState = Omit & { generation: 3; }; export type LegacyParentDaemonState = GenerationAbsentParentDaemonState | Generation3ReleaseDaemonState; export interface AttestedLegacyDaemonOwner { state: LegacyParentDaemonState; incarnation: string; } /** Revalidate the exact two-observation legacy proof immediately before signaling. */ export declare function readAttestedLegacyDaemonOwner(input: { settings: Settings; fs?: TelegramDaemonFs; now?: () => number; pidIncarnation?: (pid: number) => string | undefined; tokenFingerprint: string; chatId: string; }): Promise; /** True for a physically live owner with this configuration, including legacy generations. */ export declare function isPhysicalMatchingOwner(input: { state: DaemonState | undefined; tokenFingerprint: string; chatId: string; pidAlive: (pid: number) => boolean; pidIncarnation?: (pid: number) => string | undefined; }): boolean; /** True only for a fully-provenanced modern owner outside the generation-3 parent schema. */ export declare function isSignalableMatchingOwner(input: { state: DaemonState | undefined; tokenFingerprint: string; chatId: string; pidAlive: (pid: number) => boolean; pidIncarnation?: (pid: number) => string | undefined; }): boolean; export declare function isFreshLiveOwner(input: { state: DaemonState | undefined; now: number; tokenFingerprint: string; chatId: string; pidAlive: (pid: number) => boolean; pidIncarnation?: (pid: number) => string | undefined; effectiveHeartbeatAt?: number; }): boolean; /** True only when a physically live matching owner can serve this build's daemon lifecycle contract. */ export declare function isCurrentCompatibleOwner(input: { state: DaemonState | undefined; now: number; tokenFingerprint: string; chatId: string; pidAlive: (pid: number) => boolean; pidIncarnation?: (pid: number) => string | undefined; effectiveHeartbeatAt?: number; }): boolean; export declare function acquireDaemonOwnership(input: { settings: Settings; tokenFingerprint: string; chatId: string; fs?: TelegramDaemonFs; now?: () => number; pid?: number; pidAlive?: (pid: number) => boolean; pidIncarnation?: (pid: number) => string | undefined; randomId?: () => string; /** Permit one Windows source launcher PID to daemon PID handoff. */ allowPidRebind?: boolean; /** A caller-supplied opaque owner identity, used when the launcher PID is not durable. */ ownerId?: string; }): Promise<{ acquired: boolean; ownerId?: string; acquisitionId?: string; attached?: boolean; blocked?: boolean; provisional?: boolean; reason?: "identity_mismatch"; reloadRequired?: boolean; legacyReloadRequired?: boolean; }>; export declare function renewDaemonHeartbeat(input: { settings: Settings; ownerId: string; acquisitionId?: string; tokenFingerprint?: string; chatId?: string; fs?: TelegramDaemonFs; now?: () => number; pid?: number; generation?: number; pidIncarnation?: (pid: number) => string | undefined; sleep?: (ms: number) => Promise; stealRetries?: number; stealRetryDelayMs?: number; }): Promise; /** Acquire the lifecycle transition lock with bounded retry for bind/retire races. */ export declare function acquireTransitionLock(input: { fs: TelegramDaemonFs; path: string; pidAlive?: (pid: number) => boolean; pidIncarnation?: (pid: number) => string | undefined; sleep?: (ms: number) => Promise; retries?: number; retryDelayMs?: number; }): Promise; /** Retire only the unchanged provisional acquisition after bounded readiness fails. */ export declare function retireProvisionalDaemonOwnership(input: { settings: Settings; ownerId: string; acquisitionId?: string; pidIncarnation?: (pid: number) => string | undefined; pidAlive?: (pid: number) => boolean; /** Detached child PID, when the launcher successfully reported it. */ pid: number; /** PID which created the provisional reservation before the child was bound. */ launcherPid?: number; fs?: TelegramDaemonFs; now?: () => number; sleep?: (ms: number) => Promise; stealRetries?: number; stealRetryDelayMs?: number; /** Only no-child confirmation may retire a ready-like launcher publication. */ allowReadyWithoutChildPid?: boolean; }): Promise; /** Wait for a matching compatible owner to publish a ready state. Attach requires exact current-generation and serving-epoch equality (per isCurrentCompatibleOwner); a stale-generation owner is refused for attachment but may be replaced through the controlled reload path when it is fresh and signalable. Mutation paths require the same exact-generation equality. */ export declare function waitForTelegramDaemonReady(input: { settings: Settings; ownerId?: string; acquisitionId?: string; pid?: number; excludedPid?: number; tokenFingerprint: string; chatId: string; fs?: TelegramDaemonFs; now?: () => number; pidAlive?: (pid: number) => boolean; pidIncarnation?: (pid: number) => string | undefined; sleep?: (ms: number) => Promise; waitStepMs?: number; timeoutMs?: number; }): Promise; /** Confirm the provisional owner or retire only its unchanged acquisition. */ export declare function confirmTelegramDaemonSpawn(input: { settings: Settings; spawned: TelegramSpawnOwnerResult; tokenFingerprint: string; chatId: string; pid: number; fs?: TelegramDaemonFs; now?: () => number; pidAlive?: (pid: number) => boolean; pidIncarnation?: (pid: number) => string | undefined; sleep?: (ms: number) => Promise; waitStepMs?: number; timeoutMs?: number; /** Retain an unproven no-PID child lease during a successor handoff. */ preserveOnUnprovenChildExit?: boolean; }): Promise; export declare function releaseDaemonOwnership(input: { settings: Settings; ownerId: string; acquisitionId?: string; tokenFingerprint?: string; chatId?: string; pid?: number; generation?: number; pidIncarnation?: (pid: number) => string | undefined; fs?: TelegramDaemonFs; now?: () => number; }): Promise; /** * Record that this owner's process is gone, without claiming a clean handoff. * * {@link releaseDaemonOwnership} is the orderly path and runs only when the * daemon quiesced and persisted; it also unlinks the ownership lock, which is * correct for a handoff and wrong for a corpse. Every other way a daemon can * end - an uncaught error, a failed final persist, a signal - previously left * `ownershipPhase: "ready"` and a fresh-looking lock behind forever, so later * readers attached to an owner that had not existed for hours. * * This writes `stoppedAt` and nothing else. The lock is left in place for the * existing reclaim path to adjudicate, because a process on its way out is the * least qualified party to decide who owns what next. * * Fenced on full owner identity: if the state no longer names this exact * owner, acquisition, pid and incarnation, a successor already took over and * marking *their* state stopped would be the same lie in the other direction. */ export declare function markDaemonOwnerStopped(input: { settings: Pick; ownerId: string; acquisitionId?: string; pid?: number; generation?: number; pidIncarnation?: (pid: number) => string | undefined; fs?: TelegramDaemonFs; now?: () => number; }): Promise; /** Read the persisted daemon ownership state (or undefined when absent). */ export declare function readDaemonState(settings: Pick, fs?: TelegramDaemonFs): Promise; /** Injectable readers for {@link resolveTelegramSetupPreflight}, defaulting to the real OS/state probes. */ export interface ResolveTelegramSetupPreflightDeps { readDaemonState?: (settings: Settings) => Promise; pidAlive?: (pid: number) => boolean; pidIncarnation?: (pid: number) => string | undefined; } /** * Build the Telegram setup preflight from persisted daemon state. The daemon is * reported live ONLY when its PID is alive AND its current process incarnation * still matches the persisted incarnation. Skipping the incarnation check makes * a stale state file whose PID has been recycled by an unrelated process * masquerade as a live owner, which wrongly blocks discovery pairing. Both the * `notify setup` CLI and the /settings Notifications tab share this resolver so * pairing behaves identically on both surfaces. */ export declare function resolveTelegramSetupPreflight(settings: Settings, deps?: ResolveTelegramSetupPreflightDeps): Promise; export interface TelegramSpawnOwnerInput { settings: Settings; tokenFingerprint: string; chatId: string; /** Ephemeral outbound-only validation destination for this owner launch. */ validationTestSupergroupChatId?: string; } export interface TelegramSpawnAcquisition { readonly ownerId: string; readonly acquisitionId: string; /** PID of the launcher which reserved provisional ownership before spawn. */ readonly launcherPid?: number; /** Actual detached child which must publish the ready owner state. */ readonly pid?: number; } export type TelegramSpawnOwnerResult = { result: "owner_spawned"; acquisition: TelegramSpawnAcquisition; runtime: DaemonRuntimeInfo; warnings: string[]; } | { result: "attached"; runtime: DaemonRuntimeInfo; warnings: string[]; reloadRequired?: boolean; legacyReloadRequired?: boolean; } | { result: "blocked"; runtime: DaemonRuntimeInfo; warnings: string[]; reloadRequired?: boolean; }; /** * Build the detached spawn command/args for the daemon-internal entrypoint. * Source mode prepends the entry script so the respawn loads edited source; * a compiled binary self-spawns its own subcommand directly. */ export declare function buildTelegramDaemonSpawnArgs(input: { execPath?: string; ownerId: string; agentDir: string; validationTestSupergroupChatId?: string; }): { command: string; args: string[]; runtime: DaemonRuntimeInfo; }; /** Acquire ownership for the configured Telegram daemon and spawn a detached owner. */ export declare function spawnTelegramDaemonOwner(input: TelegramSpawnOwnerInput, deps?: TelegramDaemonDeps): Promise; /** * Owner-bound reclamation of a confirmed-dead daemon owner, mirroring the * daemon step of `gjc notify recovery`. It returns a structured, actionable * result and removes only identity-verified dead-owner artifacts while holding * the transition fence; live, successor, unknown, or unreadable evidence is * retained. */ export type DeadOwnerRecoveryResult = { recovered: true; reason: "cleared"; } | { recovered: false; reason: "not-confirmed-dead" | "unsafe-lock" | "transition-contended" | "lock-changed"; }; /** Preflight cleanup for a confirmed-dead owner. */ export declare function reclaimDeadDaemonOwner(input: { settings: Settings; fs?: TelegramDaemonFs; now?: () => number; pidAlive?: (pid: number) => boolean; pidIncarnation?: (pid: number) => string | undefined; }): Promise; /** Ensure the configured Telegram owner is running. */ export declare function ensureTelegramDaemonRunningDetailed(input: { settings: Settings; }, deps?: TelegramDaemonDeps): Promise; /** Map owner startup to the public daemon result. */ export declare function ensureTelegramDaemonRunning(input: { settings: Settings; }, deps?: TelegramDaemonDeps): Promise; export interface BotApi { call(method: string, body: unknown, opts?: { signal?: AbortSignal; noRetry?: boolean; }): Promise; } export interface TelegramTransportOptions { botToken: string; apiBase?: string; fetchImpl?: typeof fetch; setTimeoutImpl?: (callback: () => void, ms?: number) => Timer | NodeJS.Timeout; } /** Telegram Bot API transport: HTTP JSON/multipart details stay out of daemon orchestration. */ export declare class TelegramBotTransport implements BotApi { #private; constructor(opts: TelegramTransportOptions); call(method: string, body: unknown, opts?: { signal?: AbortSignal; noRetry?: boolean; }): Promise; } export type TelegramUpdateOutcome = "consumed" | "retry"; export type TelegramPollResult = { kind: "success"; updateCount: number; } | { kind: "aborted"; } | { kind: "getUpdates_failed"; error: string; } | { kind: "api_failure"; errorCode?: number; description: string; } | { kind: "conflict"; description: string; backoffMs: number; }; export interface TelegramUpdatePollerOptions { botApi: BotApi; runtime: NotificationOperatorRuntime; backoff: { next(): number; reset(): void; }; processUpdate: (update: unknown) => Promise; health?: TelegramPollHealth; } export declare class TelegramPollHealth { #private; record(result: TelegramPollResult): void; } /** Owns getUpdates offset, conflict backoff, and per-update error isolation. */ export declare class TelegramUpdatePoller { #private; constructor(opts: TelegramUpdatePollerOptions); pollOnce(signal?: AbortSignal): Promise; pollOnceResult(signal?: AbortSignal): Promise; } /** Mutable dispatch state shared by session frames and inbound Telegram updates. */ export type InboundReactionAction = "none" | "queued" | "consumed" | "retract"; /** Exact daemon reaction correction for a session-side inbound acknowledgement. */ export declare function inboundReactionAction(state: unknown, hasTarget: boolean): InboundReactionAction; export declare class TelegramEventDispatchState { readonly busy: Set; readonly inboundReactions: Map; readonly seenUpdateIds: Set; } /** * Cooperative control seam for the daemon run loop. Implemented by the * daemon-internal CLI / controller against the owner-scoped control-request * file so the daemon does not import the control module directly. */ export interface DaemonControlHooks { /** Returns true when a stop/reload has been requested for this owner. */ shouldStop(ownerId: string): Promise; /** Clear a consumed control request (best-effort). */ clear?(ownerId: string): Promise; } export interface TelegramDaemonOptions { settings: Settings; ownerId: string; botToken: string; chatId: string; /** * Exact ephemeral forum destination supplied to the daemon-internal validation * command. Production ownership, pairing, and durable topic authority remain * bound to `chatId`. */ validationTestSupergroupChatId?: string; apiBase?: string; fetchImpl?: typeof fetch; fs?: TelegramDaemonFs; now?: () => number; setTimeoutImpl?: (callback: () => void, ms?: number) => Timer | NodeJS.Timeout; clearTimeoutImpl?: typeof clearTimeout; setIntervalImpl?: typeof setInterval; clearIntervalImpl?: typeof clearInterval; btw?: { enabled: boolean; }; idleTimeoutMs?: number; /** TTL for durable topic-adoption intents (default 10 minutes). Observed, not a fixed contract. */ adoptionIntentTtlMs?: number; pid?: number; pidIncarnation?: (pid: number) => string | undefined; botApi?: BotApi; control?: DaemonControlHooks; /** SDK-owned lifecycle service seam; production constructs it from agentDir. */ createLifecycleService?: (agentDir: string) => AgentDirSessionLifecycleService; /** Narrow Router transport seams; provider callbacks are owned by Telegram. */ routerDeps?: SessionRouterProviderDeps; /** Rich text promotion (enabled by default; see rich-render.ts). */ rich?: { enabled: boolean; }; /** Opt-in rich-draft streaming of live turn previews (off by default; see rich-draft.ts). */ richDraft?: { enabled: boolean; }; /** Tool start/completion messages (off by default; explicit opt-in only). */ toolActivity?: { enabled: boolean; }; /** Controls which Telegram sends play an audible notification. Defaults to all. */ sound?: "all" | "important" | "none"; /** * Telegram forum-topic naming. `nameTemplate` supports the `{repo}`, * `{branch}`, and `{title}` placeholders; unset uses the GJC session title * and falls back to a short session id while the title is unavailable. */ topics?: { nameTemplate?: string; }; /** * Require every connected session to explicitly advertise Telegram topic * eligibility in its identity header. Production daemon owners MUST enable * this; the default is permissive only for direct embedded legacy clients and * tests, and is never a provenance input supplied by notification factories. */ requireTelegramTopicEligibility?: boolean; /** * Optional compare-and-set store for installations that share topic state * across hosts. When configured, every publication is fenced by it. */ topicRegistryAuthority?: TopicRegistryCasAuthority; /** Stable host-local identity. Required whenever a shared authority is configured. */ installationHostId?: string; /** * Orphan-owner reconciliation seam. Production wires the bounded orphan reap * (marker-registry-authorized process-group termination of superseded daemon * owners) so the daemon fences stale pollers before it becomes poll-capable * and re-runs reconciliation periodically. Injectable for deterministic tests. */ orphanReap?: () => Promise; /** Periodic reconciliation cadence; defaults to ORPHAN_REAP_INTERVAL_MS. */ orphanReapIntervalMs?: number; } interface AttachmentSession { /** Provider presentation metadata bound to one provider-local subscription. */ sessionId: string; logicalSessionId: string; /** * True only when the connected host explicitly owns a Telegram forum topic; * only such producer-admitted sessions may own forum topics. */ telegramTopicsEnabled: boolean; logicalSessionIdTrusted: boolean; readonly subscription: NotificationSubscription; readonly transport: { readyState: number; send(data: string): Promise; close(): void; }; /** Opaque local identity used only to fence presentation state. */ attachmentKey: string; /** Immutable SDK lifecycle generation from Router; never changed by replay payloads. */ lifecycleGeneration: number; /** Telegram event/replay generation used only for cursor and publication ids. */ replayGeneration: number; pending: Map; capable: boolean; ephemeralCapable: boolean; toolActivityCapability?: "v1" | "v2"; lastPongAt: number; awaitingNonce: string | undefined; pingTimer: NodeJS.Timeout | undefined; replayId: string; replayPending: boolean; replayQueue: Array<{ frame: Record; publicationId?: string; }>; activePublicationId?: string; recoveryLease?: { state: "pending" | "authorized" | "rejected"; logicalSessionId: string; binding: TopicEndpointBinding; token: number; }; } export declare class TelegramNotificationDaemon { #private; private readonly opts; readonly aliasTable: AliasTable; readonly messageRoutes: Map>; /** Restart-revoked aliases retained only to terminalize their old keyboards on an authoritative replay. */ private readonly reissueBacklog; private aliasPersistenceQueue; /** Telegram message id backing each streamed `${sessionId}:${coalesceKey}`, for in-place edits. */ private readonly liveMessages; /** Endpoint-bound ownership for visible or dispatching tool bubbles. */ private readonly toolActivityOwners; private readonly revokedToolEndpoints; /** Exact settlement of each admitted legacy-v1 start; retained only while visible. */ private readonly legacyToolStarts; private nextLegacyToolStartId; private readonly unresolvedToolTerminalizations; private toolTerminalizationChain; private toolActivityPolicyEpoch; private toolActivityStopping; private readonly replayToolActivityEpochs; private toolShutdownBarrier; private toolActivityAmbiguous; readonly sessions: Map; private readonly runtime; private readonly pollConflictBackoff; private readonly loopBackoff; private running; /** Once set, a concurrent startup await can never restore a running daemon. */ private stopRequested; private readonly fsImpl; private readonly botApi; private readonly effects; private readonly topics; /** Stable host-local identity; never persisted in shared topic authority. */ private installationHostId; private topicsPersistQueue; private recoveryBindingClaimQueue; /** Durable compensation fences retry under supervision until persistence succeeds. */ private readonly compensationFenceRetries; /** All archive paths for one session share one durable fence and remote dispatch. */ private readonly archiveFlights; /** Daemon edit attempts that can race an accepted user service message. */ private readonly daemonRenameAttempts; private readonly selectedAckPending; private readonly pool; private readonly poller; private readonly dispatchState; /** Bot-wide flood-control window; inbound polling remains eligible during it. */ private botCooldownUntil; private warnedBotCooldownUntil; /** Original markdown of rich messages we sent (chat+message_id), for restoring reply context on inbound replies. */ private readonly replyStore; /** Per-session debounce + monotonic draft-id state for opt-in draft streaming. */ private readonly draftStream; /** Identity-bearing sessions by repo/branch surface, used to avoid transient duplicate topics. */ private readonly topicOwnerByIdentity; /** Preserved initiator topics must not route through a rekeyed transport. */ private readonly preservedInitiatorTopics; /** Non-identity frames held until identity creates the correct thread. */ private readonly pendingThreadedFrames; /** Durable endpoint leases for sessions that already sent an authorized session_closed. */ private readonly closedEndpointKeys; private nextSocketLeaseToken; /** True once the daemon has nudged the user to enable Threaded Mode. */ private threadedFallbackNoticeSent; /** Sessions whose identity header was already sent flat (Threaded Mode off). */ private readonly flatIdentitySent; /** Cached delivery boundary for the private owner chat or validation forum. */ private pairedChatPrivacy; /** Latched once Telegram confirms this chat cannot host forum topics. */ private topicCapabilityRefused; /** Bot username from getMe, cached once at owner startup for group/forum command targeting. */ private botUsername; /** Sessions whose agent loop is currently busy (drives the typing indicator). */ private get busy(); /** Inbound update id → originating Telegram message, for delivery reactions. */ private get inboundReactions(); /** Attempt tombstones live for the daemon lifetime so a commit key can never send twice. */ private readonly selectedAckCache; private cacheSelectedAck; private getCachedSelectedAck; private finishSelectedAck; /** * Cooperatively stop the daemon: set the stop flag and abort the in-flight * long poll so the run loop wakes immediately instead of waiting out the * ~25s getUpdates timeout. Safe to call from a signal handler. */ requestStop(_reason?: "reload" | "stop" | "signal"): void; /** Handle a paired-chat /session_* command through the SDK lifecycle service. */ private handleLifecycleCommand; private refreshBotIdentity; private callBotApi; private validationTopicDestination; private readonly callBotApiClassified; constructor(opts: TelegramDaemonOptions); /** @internal Test-only access to durable publication receipt transitions. */ publicationReceiptHarnessForTest(): { claimPublication: (publicationId: string) => Promise; markPublicationAttempted: (publicationId: string) => Promise; markPublicationDelivered: (publicationId: string) => Promise; markPublicationRejected: (publicationId: string, definitiveProviderRejection?: boolean) => Promise; loadPresentationState: () => Promise; publicationShouldSuppress: (publicationId: string) => boolean; publicationSettlement: (publicationId: string) => PromiseWithResolvers; settlePublication: (publicationId: string) => void; drainPersistence: () => Promise; }; /** @internal Test-only access to attachment routing lifecycle transitions. */ attachmentRoutingHarnessForTest(): { attach: (subscription: NotificationSubscription) => void; remove: (subscription: NotificationSubscription, reason: "removed" | "replaced" | "replaced_same_generation") => Promise; ownsLogicalSession: (sessionId: string) => boolean; cleanupReceipts: () => { sessionId: string; subscriptionId: string; state: "pending" | "failed" | "completed"; reason?: string; }[]; }; /** @internal Test-only access to the durable archive retry drain. */ archiveReconciliationHarnessForTest(): { reconcilePendingTopicDeletes: () => Promise; archiveAuthorizedTopics: () => Promise; }; /** @internal Test-only access to explicit durable archive reconciliation. */ private archiveAuthorizedTopicsForTest; loadAliases(): Promise; persistAliases(): Promise; loadSeenUpdateIds(): Promise; persistSeenUpdateIds(): Promise; private pruneSeenUpdateIds; private rememberSeenUpdateId; private reserveSeenUpdateId; private releaseSeenUpdateId; /** Idempotent, identity-guarded attachment teardown. */ private enqueueToolTerminalization; private beginToolActivityShutdown; private deleteMessageRoutes; revokeCallbackAliases(socketLease: { session: AttachmentSession; token: number; logicalSessionId: string; }): void; private reissuePendingAction; private static readonly THREADED_FRAMES; private topicNameFor; /** * Render the operator-configured topic name template, or `undefined` when no * usable template applies so the caller uses the built-in composition. The * template is honored only if it is non-blank AND every placeholder it * references (`{repo}`, `{branch}`, `{title}`) has a value for this session, * which preserves the default session-title fallback and prevents * half-filled names with dangling separators. Unknown placeholders are left * verbatim. */ private renderTopicNameTemplate; private topicIdentityKey; private topicIdentityBase; private topicOwnerForIdentity; private admitLegacyToolStart; private legacyToolStartForTerminal; private settleLegacyToolStart; private cancelUnsentLegacyToolStart; private failLegacyToolStart; private settleRejectedLegacyToolSubmission; private cancelLegacyToolStartsForSession; private cleanLegacyToolStartsForCapabilityUpgrade; private cancelLegacyToolStartsForPolicyTransition; private toolActivityOwner; private renderThreadedFrame; private toolActivityFrameWithoutSummaries; private toolActivitySummariesAreCurrent; private toolActivityAuthorityIsCurrent; private toolActivityDeliveryIsCurrent; private submitThreadedFrame; private existingTopicForPrivateChat; private topicAuthorityLease; private topicAuthorityLeaseFromRegistry; private topicLeaseIsCurrent; /** Best-effort re-assertion for a durable user-owned topic name. */ private reconcileUserTopicName; private flushPendingThreadedFrames; /** * Resolve (creating once via `createForumTopic`) the forum topic for a * session. On capability failure (e.g. Threaded Mode off) this returns * `undefined`; callers then flat-deliver to a private paired chat (with a * one-time nudge) or drop fail-closed for a non-private chat. */ private ensureTopic; /** Best-effort delete of a session topic once its local notification endpoint shuts down. */ /** Join all close, compensation, orphan, and restart callers for one session. */ private archiveTopic; private persistTopics; loadTopics(): Promise; /** * Rehydrate durable adoption intents after restart. A sidecar whose topic * already committed is stale evidence and can be removed without touching * the retained user-created topic. */ loadAdoptionIntents(): Promise; private startAdoptionSweepTimer; private stopAdoptionSweepTimer; /** Retry crash-interrupted or ambiguous topic archives only when the durable backoff permits it. */ private reconcilePendingTopicDeletes; private startArchiveRetryTimer; private stopArchiveRetryTimer; /** Download one Telegram file with the Bot API's 20 MiB ceiling and one end-to-end deadline. */ private downloadTelegramFile; /** * Per-session private temp directories (mode 0700) holding inbound non-image * attachments. Keyed by session id and reused across transient reconnects; * removed when the daemon stops (see {@link cleanupAllAttachmentDirs}). */ private readonly attachmentDirs; /** Lazily create a private, unguessable 0700 temp dir for `sessionId`. */ private ensureAttachmentDir; /** Remove all per-session attachment directories. Called on daemon shutdown. */ private cleanupAllAttachmentDirs; /** * Resolve an inbound attachment to inline image bytes (forwarded as images) or * a securely-saved file path note (non-images). Non-image bytes are written * into a private per-session temp dir (0700) under an unguessable name via an * exclusive 0600 create (`wx`), so the files are not world-readable and the * write never follows a pre-existing symlink. The directory is removed when the * daemon stops. Returns base64 images to inline plus human-readable file notes * to append to the injected text. */ private resolveInboundAttachment; private resolveInboundAttachmentSerial; /** * Serialize all pool flushes. Every caller (`submitThreadedFrame`, the flat * fallback, the drain timer's `void this.flushPool()`, topic teardown) goes * through one promise chain, so two flushes never interleave — a live send can * never be in-flight while a finalized flush reads `liveMessages` and decides * to post a fresh (duplicate) final. Errors are swallowed so one failed flush * never poisons the queue (each flush is already best-effort internally). */ private flushChain; private flushPool; private submitPool; private flushPoolInner; /** * Track the Telegram message id backing a streamed `(sessionId, coalesceKey)` * so later live/finalized frames edit it in place. Evicts this session's stale * same-category entries (e.g. prior turns) so the map stays bounded. */ private recordLiveMessage; /** * Threaded Mode is unavailable (the bot owner has not enabled forum topics in * @BotFather, so `createForumTopic` fails). Deliver the rendered frame flat to * the paired chat instead of dropping it, and nudge the user once. Flat delivery * is gated on the paired chat being a private chat: for a group/supergroup/channel * (e.g. a legacy or hand-edited `chatId`) we keep dropping fail-closed so session * content never lands in a shared chat. Identity headers are sent at most once per * session in flat mode. */ private deliverFlatFallback; /** * Resolve and cache the outbound delivery boundary. The validation forum is * explicit and topic-only; all inbound and flat paths remain private-only. */ private validationMode; private resolvePairedChatPrivacy; private pairedChatAllowsTopics; /** Keep all flat delivery and inbound control paths private-only. */ private pairedChatIsPrivate; /** Tell the user once (per daemon run) how to enable Threaded Mode. */ private notifyThreadedFallback; private startFlushTimer; private stopFlushTimer; /** * Sessions this owner can actually deliver to right now. Only `WebSocket.OPEN` * counts: a CONNECTING socket is constructed but cannot carry a frame, and * CLOSING/CLOSED ones are gone. Every delivery path in this daemon refuses to * send unless `readyState === WebSocket.OPEN`, so OPEN is exactly the state * that means a notification can leave this process for that session. */ private attachedEndpointCount; private renewOwnershipHeartbeat; private renewActiveTopicLeases; /** * Ownership must be renewed independently of Telegram's 25-second long poll: * the ownership TTL is shorter than a single poll request. */ private startOwnershipHeartbeatTimer; private stopOwnershipHeartbeatTimer; private startTopicReconcileTimer; private stopTopicReconcileTimer; /** * Periodic orphan-owner reconciliation. The daemon re-runs the bounded orphan * reap on a fixed cadence so a stale poller that survived the startup sweep * (or appeared after it) is eventually fenced. Failures are logged, never * fatal: reconciliation is convergence work, not a run precondition. */ private startOrphanReapTimer; private stopOrphanReapTimer; /** Send a single `typing` chat action into a busy session's topic (best-effort). */ private sendTyping; /** Set a native reaction on an inbound thread message (best-effort). */ private setReaction; /** * Retract a native reaction by sending the empty reaction list the Bot API * requires; an empty `emoji` string is not a valid reaction and is silently * rejected, leaving the stale queued marker visible. */ private retractReaction; private startTypingTimer; private stopTypingTimer; handleSessionMessage(session: AttachmentSession, msg: any, publicationId?: string): Promise; private answerCallbackQueryBestEffort; private sendStaleGuidance; /** * Consume a user-created forum topic (`forum_topic_created` service message) * before edited/text routing. Authenticates the chat, user, privacy, and * lifecycle state; offers home, recent folders, or explicit path entry. The * selected path (or an exact `/session_create path `) creates a session * that adopts this topic — never a duplicate topic. Non-routable durable * intents prevent duplicate creation across concurrent first frames. */ private handleForumTopicCreatedUpdate; /** Consume Telegram forum-topic rename service messages before text routing. */ private handleForumTopicEdited; private processTelegramUpdate; handleTelegramUpdate(update: unknown): Promise; pollOnce(signal?: AbortSignal): Promise; /** Sync the bot's Telegram command menu to what the daemon actually handles. */ registerBotCommands(): Promise; run(): Promise; /** True when a signal-driven stop or an owner-scoped control request asks the loop to exit. */ private controlStopRequested; }