/** * Notifications extension. * * Hosts a per-session loopback WebSocket notification server (the Rust core via * N-API) and bridges GJC session events + the `ask` tool to it so a remote client * (e.g. a Telegram bot) can both see action-needed signals and answer them * through SDK-native session capabilities: * * - `ask` (interactive): registers an {@link AskAnswerSource}; the ask tool races * the local UI against a remote reply. First valid answer wins; a local answer * aborts the remote wait (and broadcasts `action_resolved` resolvedBy=local). * - `ask` (workflow gate): observes emitted workflow gates and resolves the real * gate on a remote reply via `ctx.workflowGate`. * - `turn_end` -> `action_needed` (kind `idle`, deduped per turn). * - `session_shutdown` -> `session_closed` frame, stop server, deregister answer source. * * Enable with Settings notifications config, `GJC_NOTIFICATIONS=1` (a token is * generated), or `GJC_NOTIFICATIONS_TOKEN`. */ import { type RunSettlementProof } from "@gajae-code/agent-core"; import type { Tool } from "@gajae-code/ai/core"; import type { NotificationServer as NativeNotificationServer } from "@gajae-code/natives"; type NotificationServer = NativeNotificationServer; import { Settings } from "../../config/settings"; import type { ExtensionAPI, ExtensionContext } from "../../extensibility/extensions"; import { type WorkflowGateEmitter, type WorkflowGateTerminalProof } from "../../modes/shared/agent-wire/workflow-gate-broker"; import type { AgentSessionEvent } from "../../session/agent-session"; import type { AskAnswerSource, AskAnswerSourceResult, AskRemoteControl } from "../../tools"; import { type SessionHostRuntimePublication } from "../broker/lifecycle"; import { type SessionSdkHost } from "../host"; import { CursorRegistry, RevisionStore } from "../host/query"; import type { SdkFrame } from "../host/types"; import { type SdkPromptTerminalOutcome } from "../prompt-status"; import { type SdkStartupFailure } from "../startup-capability"; import { type NotificationConfig } from "./config"; import { NotificationSessionController } from "./session-control"; import { type EnsureDaemonResult } from "./telegram-daemon"; export type { IdentityControlSuccessPathInput, IdentityControlTerminalPathInput, TerminalSendOutcome, } from "./control-drain-lease"; export { isNativeControlDrainAvailable, runIdentityControlSuccessPath, runIdentityControlTerminalPath, } from "./control-drain-lease"; export type NotificationInboundAdmission = { outcome: "accept"; } | { outcome: "drop"; reason: "inbound_fenced" | "policy_suspended"; } | { outcome: "defer"; reason: "policy_suspended"; }; /** Exact production admission decision for daemon-originated session inbound. */ export declare function notificationInboundAdmission(input: { inboundFenced: boolean; policySuspended: boolean; notificationOrigin: boolean; controlCommand: boolean; }): NotificationInboundAdmission; type PromptTerminalDiagnostic = { reason?: unknown; loopStopReason?: string; assistantStopReason?: string; errorKind?: string; intentionalCancellation?: boolean; }; type PromptTerminalExtra = { finalText?: string; error?: { code: string; message: string; }; diagnostic?: PromptTerminalDiagnostic; diagnosticAlreadyLogged?: boolean; }; export declare function formatPromptSettlementDiagnostic(proof: Extract, now?: number): string; /** Where a `session_create` should run. Discriminated by `kind`. */ export type SessionCreateTarget = { kind: "existing_path"; path: string; } | { kind: "worktree"; repo: string; branch: string; } | { kind: "plain_dir"; path: string; }; /** Identifies the session a `session_close` targets. */ export interface SessionCloseTarget { sessionId: string; } /** Identifies the session a `session_resume` targets. */ export interface SessionResumeTarget { sessionIdOrPrefix: string; /** Optional repo/working-dir hint to disambiguate matches. */ path?: string; } export type LifecycleStatus = "ok" | "error"; export interface SessionCreateResponseFrame { type: "session_create_response"; requestId: string; status: LifecycleStatus; sessionId: string; target: SessionCreateTarget; } export interface SessionCloseResponseFrame { type: "session_close_response"; requestId: string; status: LifecycleStatus; sessionId: string; } export type ResumeMode = "reattached" | "cold_restarted"; export interface SessionResumeResponseFrame { type: "session_resume_response"; requestId: string; status: LifecycleStatus; sessionId: string; mode: ResumeMode; } export type LifecycleErrorReason = "unauthorized" | "rate_limited" | "duplicate_conflict" | "invalid_target" | "ambiguous_target" | "spawn_failed" | "discovery_timeout" | "readiness_timeout" | "close_refused" | "not_found" | "terminal_uncertain" | "unsupported_platform"; export interface ResumeCandidate { sessionId: string; path?: string; mtimeMs?: number; } export interface SessionLifecycleErrorFrame { type: "session_lifecycle_error"; requestId: string; status: LifecycleStatus; reason: LifecycleErrorReason; message: string; candidates?: ResumeCandidate[]; } export type SessionLifecycleResponse = SessionCreateResponseFrame | SessionCloseResponseFrame | SessionResumeResponseFrame | SessionLifecycleErrorFrame; /** * Replayable per-session readiness signal (mirror of the Rust `session_ready` * frame). Buffered and replayed to late clients so WS-open alone never implies * the session is live and surfaced. */ export interface SessionReadyFrame { type: "session_ready"; sessionId: string; lifecycleRequestId?: string; startupPromptRef?: string; repo?: string; branch?: string; title?: string; } /** * Best-effort real repository name (no git spawn): resolves the main worktree * root directory so linked worktrees report the repo (e.g. `gajae-code`) * instead of the worktree directory (e.g. `feat-foo-01047f11`). */ export declare function readGitRepoName(cwd: string): string | undefined; interface PendingInteractiveAsk { resolve: (result: AskAnswerSourceResult) => void; options: string[]; controls: readonly AskRemoteControl[]; actionId?: string; retireForDirectControl: () => RetireStatus; reissue: () => boolean; complete: (actionId: string) => void; completeDirect: () => void; fail: (actionId: string) => void; } interface UnattendedGatePresentation { gateId: string; sessionId: string; question: string; options: string[]; controls: readonly AskRemoteControl[]; recommendedIndex?: number; multi: boolean; allowEmpty: boolean; navigationLabel?: "Next" | "Done"; selectedOptions: string[]; workflowGateId?: string; onActivated?: (actionId: string, lease: { actionId: string; registrationEpoch: number; }) => void; onClosed?: () => void; } type RetireStatus = "retired" | "already_terminal" | "claimed" | "stale"; type DirectControlOutcome = "accepted" | "rejected" | "unknown"; interface PresentationRetentionOptions { publish?: boolean; sourceEpoch?: number; } type PreparedDirectControl = { status: "retired"; ordinal: number; } | { status: "queued"; ordinal: number; /** Exact proof retained from a previously published route, if any. */ terminalProof?: "retired" | "already_terminal"; }; export declare class PresentationArbiter { #private; private readonly server; private readonly redact; private readonly presentations; private readonly routes; private active; private readonly queue; private readonly retries; private readonly retiredProofs; /** Gate ids that have had a successfully registered presentation in this retention lifetime. */ private readonly publishedGateIds; private readonly directControls; /** Binds an in-flight direct control to the exact retained presentation it retired. */ private readonly directControlPreparations; /** Retained presentation identity generations fence same-gate replays. */ private readonly presentationGenerations; private readonly presentationSourceEpochs; private presentationGeneration; /** Explicit terminal proof for a direct control fenced before native publication. */ private readonly queuedDirectControls; /** Retained presentations that must wait for committed notification policy. */ private readonly deferredPublications; private publicationSuspended; private retryTimer; private retryTimerGateId; private retryTimerGeneration; private readonly terminalCancellationTimers; private headGeneration; private observedHead; static readonly maxRegistrationAttempts = 3; static readonly retryBaseDelayMs = 50; static readonly retryMaxDelayMs = 1000; /** Bound an unavailable interactive answer source without discarding its head silently. */ static readonly terminalCancellationDelayMs = 250; /** Revalidates the live endpoint queue head before bounded recovery. */ reconcile(): void; /** Explicit production recovery for a previously exhausted endpoint queue head. */ recover(gateId?: string): void; hasActivePresentation(): boolean; retireForDirectControl(gateId: string): RetireStatus; prepareDirectControl(gateId: string): PreparedDirectControl | { status: "claimed" | "stale"; }; finishDirectControl(gateId: string, prepared: PreparedDirectControl, outcome: DirectControlOutcome): void; constructor(server: NotificationServer, redact: () => boolean); /** Gate retention remains available while notification publication is suspended. */ setPublicationSuspended(suspended: boolean): void; /** Publish only deferred presentations from the still-authoritative source. */ activateDeferred(sourceEpoch?: number): void; retain(presentation: UnattendedGatePresentation, options?: PresentationRetentionOptions): void; routeFor(actionId: string): string | undefined; presentationFor(actionId: string): UnattendedGatePresentation | undefined; /** The native generic claim has already resolved this old action. */ toggle(actionId: string, label: string): boolean; /** Clears an interactive route only when it is still the route that settled. */ completeInteractive(gateId: string, actionId: string): void; /** Clears an interactive presentation after its route was retired for direct control. */ completeDirect(gateId: string): void; reissueAfterFailure(actionId: string): void; reissue(gateId: string): string | undefined; closeInteraction(actionId: string, reason: string): boolean; complete(gateId: string): WorkflowGateTerminalProof; cancelInteractive(): void; cancel(gateId: string, reason: string): void; dispose(): void; } interface SessionRuntime { server: NotificationServer; host: SessionSdkHost; /** Delivers one ring-positioned event envelope to every attached subscriber * connection, applying the same capability gate as event replay. */ broadcastEventFrame: (event: SdkFrame) => void; /** Owns stateRoot-backed revisions and removes their spills on terminal shutdown. */ revisions: RevisionStore; /** Releases all snapshot pins before the revision store is closed. */ cursors: CursorRegistry; /** Current endpoint session identity; never re-key an existing host across a switch. */ id: string; /** Discovery scope is fixed before publication; a live default endpoint is never rotated in place. */ endpointScope: "default" | "chat"; idleSeq: number; /** Stops delayed session-name observation when this runtime loses authority. */ stopSessionNameObserver: () => void; /** Interactive asks awaiting a remote answer, by action id. */ pendingInteractive: Map; /** Deregisters this session's ask answer source. */ disposeAnswerSource: () => void; /** Deregisters this session's Telegram file sink. */ disposeFileSink: () => void; /** Deregisters this session's workflow-gate listener. */ disposeGateListener: () => void; /** Whether notification-only delivery and answer resources are active. */ notificationsActive: boolean; /** Provider ownership state is independent from the already-published core SDK runtime. */ notificationOwnerState: "ready" | "retry" | "blocked"; /** * Ownership-relevant configuration identity this runtime's owner state was * proved under. A settled outcome may only be applied while it still matches, * so a credential/destination/enablement change forces a re-proof. */ notificationOwnerKey?: string; /** Rejects new SDK frames while a leased terminal response drains. */ inboundFenced: boolean; /** Set as soon as terminal teardown is requested, before startup settles. */ stopping: boolean; /** Recreates notification-only resources after `/notify on`. */ enableNotifications: () => void; /** Deregisters canonical workflow-gate terminal cleanup. */ disposeGateTerminalController: () => void; disposeAckRecoveryParticipant: () => void; disposeGateEmitterListener: () => void; /** Aborts and fences side turns while notification delivery is disabled. */ disableEphemeralTurns: () => void; waitForGateResolutionQuiescence: () => Promise; /** Awaits durable quiescence of every reconciliation transaction this runtime * admitted, joining their producers first. Never swallows evidence: producer * and store rejections are returned in `failures`, and a bounded-deadline * expiry is returned as `timedOut` (#4743). */ drainDurableReconciliation: () => Promise<{ timedOut: boolean; failures: unknown[]; }>; trackGateResolution: (resolution: Promise) => Promise; workflowGate?: WorkflowGateEmitter; gatePresentations?: PresentationArbiter; redact: boolean; /** Last stable policy's redaction state, retained while provisional policy is held. */ committedRedact: boolean; /** Provisional policy suppresses delivery without changing committed-side effects. */ policySuspended: boolean; /** Monotonic policy epoch fences asynchronous notification delivery. */ policyGeneration: number; /** Monotonic source lease epoch for workflow-gate presentation retention. */ workflowGatePublicationEpoch: number; /** True only after the exact host generation was registered with the broker index. */ brokerRegistrationActive: boolean; /** Terminal cleanup proof retained across retries; each owner is released at most once after proof. */ hostStopped: boolean; serverStopped: boolean; /** This runtime's own host-liveness publication; only its teardown may retract it. */ evidencePublication?: SessionHostRuntimePublication; brokerRegistrationReleased: boolean; verbosity: "lean" | "verbose"; /** Whether the agent loop is currently running (drives the typing indicator). */ busy: boolean; /** Prompt command/turn identities awaiting their corresponding agent_start. */ pendingPromptCorrelations: Array<{ commandId: string; turnId: string; }>; /** SDK run tokens bind an accepted queued follow-up to only its matching agent_start. */ pendingPromptCorrelationsBySdkRunToken: Map; /** Identity bound to the agent lifecycle currently in flight. */ activePromptCorrelation?: { commandId: string; turnId: string; }; /** Binds the executing Agent run to a correlated prompt so cleanup targets only it. */ bindPromptExecutionHandle: (correlation: { commandId: string; turnId: string; }, handle: string | undefined) => void; /** Reads the durable non-terminal claim for a correlated prompt, if any. */ peekPromptPendingOutcome: (correlation: { commandId: string; turnId: string; }) => SdkPromptTerminalOutcome | undefined; /** Claims, fences, finalizes, and publishes exactly one normalized prompt terminal. */ terminalizePrompt: (correlation: { commandId: string; turnId: string; }, outcome: SdkPromptTerminalOutcome, extra?: PromptTerminalExtra) => Promise; /** Transitions the authoritative reconciliation record at lifecycle ingress; terminal outcomes settle once. */ notePromptReconciliation: (correlation: { commandId: string; turnId: string; } | undefined, frame: { type: "agent_start" | "agent_end"; } | { type: "agent_failed"; error: unknown; }) => void | Promise; /** Settles and emits one sanitized correlated prompt failure. */ emitPromptFailure: (correlation: { commandId: string; turnId: string; }, error: unknown) => void; /** Records correlated lifecycle frames for replay and delivers them only to the accepted requester after acknowledgement. */ emitPromptLifecycle: (correlation: { commandId: string; turnId: string; } | undefined, frame: { type: "agent_start" | "agent_end"; sessionId: string; commandId?: string; turnId?: string; finalText?: string; outcome?: SdkPromptTerminalOutcome; } | { type: "agent_failed"; sessionId: string; commandId: string; turnId: string; error: { code: string; message: string; }; outcome?: SdkPromptTerminalOutcome; }) => void; /** Publishes one canonical agent-wire event to the client that owns the active prompt. */ emitPromptEvent: (event: AgentSessionEvent) => void; /** Inbound Telegram update ids injected but not yet consumed by a turn. */ pendingInbound: Set; /** Latest assistant text of the in-flight turn (from message_update). */ currentTurnText?: string; /** Assistant text already flushed before an ask this turn (turn-scoped dedupe * so turn_end does not re-emit the pre-ask lead-in). Reset each turn. */ preAskFlushedText?: string; /** Live streaming: opt-in flag, monotonic per-turn ref, and emit throttle state. */ stream: boolean; turnSeq?: number; liveRef?: string; lastLiveAt?: number; lastLiveText?: string; /** True between turn_end and the next turn_start: drops late async message_update * frames so a stale live edit can never be emitted after the finalized turn. */ turnClosed?: boolean; /** Finalized while provisional policy was held; flush exactly once on stable activation. */ pendingFinal?: { window: number; receipts: Array<{ text: string; messageRef?: string; origin: "user" | "autonomous" | "continuation"; }>; }; /** Monotonic user-request boundary for deferred lean delivery. */ settlementWindow: number; /** Provenance of the currently executing assistant turn. */ currentTurnSettlementOrigin?: "user" | "autonomous" | "continuation"; /** Immutable settlement boundary captured when the current turn begins. */ currentTurnSettlementWindow?: number; /** * Lean-mode deferred receipts for the current user-request settlement window. * Ordinary tool-loop turns retain latest-turn-wins behaviour. An autonomous * continuation has no new user request, so it appends instead of erasing the * prior receipt. The small fixed receipt bound prevents an unbounded idle wait * from retaining the full transcript. */ pendingSettled?: { window: number; receipts: Array<{ text: string; messageRef?: string; origin: "user" | "autonomous" | "continuation"; }>; }; /** SDK control frames received during provisional ownership; replayed only after stable activation. */ deferredInboundControls: Array<() => void>; /** Started tool calls awaiting a terminal activity frame, keyed by tool call id. */ inFlightTools: Map; /** Cancels the postmortem cleanup that emits `session_closed` on process teardown. */ cancelPostmortemCleanup: () => void; /** Disposes side-turn resources when their owning logical session becomes unavailable. */ abortEphemeralTurns: () => void; } /** Stable projection of the tool-owned safe-display seam (never the full Tool surface). */ type SafeSummaryTool = Pick; export declare function projectToolSummary(tool: SafeSummaryTool | undefined, kind: "args" | "result", value: unknown): string | undefined; type SessionStartStatus = "started" | "already" | "disabled" | "failed"; type SessionStartResult = { status: SessionStartStatus; runtime?: SessionRuntime; failure?: SdkStartupFailure; suppressExtensionError?: boolean; }; /** * Whether the notifications control channel is enabled. * * Trusted sources only: enabling it opens the session control/answer channel, so * a repository must not be able to turn it on. `$env` merges the caller's * `cwd/.env` into `process.env`; the sibling resolvers in `config.ts` and * `session-control.ts` already read an injected env record rather than the merged * view, and this direct read was the outlier. */ export declare function notificationsEnabled(): boolean; /** Workflow-gate answer shape. */ interface GateAnswer { selected: string[]; other?: boolean; custom?: string; } /** * Discriminated result of mapping a client answer to a workflow-gate answer. * `ok: false` means the reply is invalid and the caller must close the exact * claim/receipt and reissue the interaction rather than durably accepting it. */ type GateAnswerResult = { ok: true; answer: GateAnswer; } | { ok: false; reason: string; }; /** * Map a client answer to the workflow-gate answer shape. * * The protocol defines a numeric reply as an option index, so a number outside * `options` is invalid: it must NOT be converted into free text that passes the * ask schema and triggers a misleading success acknowledgement. * Only JSON strings enter the free-text/Other path. */ export declare function mapAnswerToGate(answerJson: string, options: string[]): GateAnswerResult; interface NotificationControlCommandPayload { name?: unknown; action?: unknown; level?: unknown; global?: unknown; selector?: unknown; instructions?: unknown; } export interface NotificationControlCommandResult { status: "ok" | "error" | "unavailable"; message: string; modelChoices?: Array<{ selector: string; label: string; }>; } export declare function executeNotificationControlCommand(command: NotificationControlCommandPayload | undefined, ctx: ExtensionContext, api: ExtensionAPI, expectedSessionId?: string): Promise; /** * Ask-answer source that bridges workflow-gate asks to the ACP permission * channel (`session/request_permission`). Used when the client does not * advertise ACP form elicitation (e.g. Paseo): the gate question is sent as * a permission request whose options are the answer choices, and the * selected optionId maps back to the answer. Only selector asks are bridged; * free-text asks have no permission-option representation and stay * unanswered (unchanged from today). Auto-approval follows the client's * permission mode, so gates never self-approve under `prompt`. */ export declare function createSdkPermissionAskAnswerSource(requestPermission: (params: Record, signal?: AbortSignal) => Promise): AskAnswerSource; interface EphemeralTurnAuthority { sessionId: string; endpointDigest: string; eventGeneration: number; } /** Host-owned, bounded idempotency and cancellation lifecycle for v3 side turns. */ export declare class EphemeralTurnHost { #private; constructor(sendTo: (connectionId: string, frame: Record) => void, execute: (question: string, signal: AbortSignal) => Promise<{ replyText: string; }>, now?: () => number); configureAuthority(authority: EphemeralTurnAuthority): void; disable(): void; enable(): void; dispose(): void; handle(connectionId: string, frame: Record): boolean; sessionUnavailable(sessionId: string): void; /** Testable event-ring eviction boundary; tombstones remain idempotency authority. */ evictTerminalEvents(): void; } /** * Ensures every configured chat-provider daemon is ready. * * This runs strictly AFTER the SDK publishes session identity and its core * endpoint, through the detached ownership coordinator: chat providers are * optional notification adapters, never session authority. A rejected ensure * therefore degrades notification delivery only — the coordinator records it * as `failed`, adapters stay withheld, and a later reconcile re-attempts. */ export declare function ensureConfiguredProviderDaemons(settings: Settings, cfg: NotificationConfig, ensureProviderDaemon?: (provider: "discord" | "slack", settings: Settings) => Promise): Promise; /** * Classify whether a session identity event must await notification endpoint startup. * Only an interactive selector resume may defer this ancillary startup; all other * events, including branches and unknown origins, fail closed to awaiting it. */ export declare function shouldAwaitNotificationStartup(event: { type: "session_switch" | "session_branch"; transition?: { origin: string; }; }): boolean; export declare function createNotificationsExtension(api: ExtensionAPI, options?: { settings?: Settings; ensureTelegramDaemon?: (input: { settings: Settings; }) => Promise; ensureProviderDaemon?: (provider: "discord" | "slack", settings: Settings) => Promise; /** Suppress auto-delivery for a GJC-spawned child under `sessionScope=primary`. */ spawnedByGjc?: boolean; controller?: NotificationSessionController; /** Whether this host mode can own the root SDK endpoint. Default: true. */ sdkHostModeSupported?: boolean; onSdkRequest?: (kind: "control" | "query", connectionId: string, frame: Record) => void; runBtwTurn?: (question: string, signal: AbortSignal) => Promise<{ replyText: string; }>; /** Observes settlement of optional session-branch startup after reconciliation completes. */ onBranchStartupSettled?: (receipt: { sessionId: string; status: SessionStartResult["status"]; }) => void; readNotificationFile?: (path: string) => Promise; readNotificationDiffStat?: (cwd: string) => Promise; /** * INTERNAL terminal-abort session seams, threaded directly from the owning * session — deliberately NOT on the public ExtensionContext so third-party * extensions cannot observe attempt epochs or cancel the session-global * preflight outside the durable admission path (review thread P2). */ terminalAbortSeams?: { getTerminalTurnEpoch: () => number | undefined; cancelPendingPreflightForTerminalAbort: () => void; captureTerminalAbortSteeringSnapshot?: () => void; discardTerminalAbortSteeringSnapshot?: (token: number) => void; rebindTerminalAbortSteeringSnapshot?: (token: number) => void; abortPromptAndWaitWithTerminal: (handle: string, options: { graceMs: number; terminal?: { scope: "turn" | "owned"; expectedEpoch?: number; }; }) => Promise; }; }): void;