import type { PlatformAdapter, OutputStream } from '../../platform/index.js'; import { runAgent } from '../agents/index.js'; export interface SessionHookConfig { command: string; args?: string[]; timeout?: number; } export interface SessionHooksFile { onNew?: SessionHookConfig; onMessageEnd?: SessionHookConfig; } type HookName = 'onNew' | 'onMessageEnd'; export declare function isOnNewHookConfigured(): boolean; export declare function isOnMessageEndHookConfigured(): boolean; /** Per-hook context fed to the subprocess (via stdin JSON) and the formatters. */ export interface SessionHookContext { channel: string; sessionId: string; sessionName: string; executionId?: string | null; profile?: string | null; } /** Caller-supplied formatters — keep wording per-hook so existing UX text doesn't change. * All formatters return short single-line strings; runSessionHook appends them via OutputStream, * so they share the same Slack thread/group as surrounding agent output. */ export interface SessionHookFormat { /** Status line shown immediately when the hook starts. */ statusLine: () => string; /** Preview line shown when the hook produced non-empty stdout. */ previewLine: (output: string) => string; /** Error line shown when spawn failed / non-zero exit / timeout. */ errorLine: (err: string) => string; /** Optional line when stdout was empty after a successful run. Return null to stay silent. */ emptyLine?: () => string | null; } /** When set, the hook stdout is injected as a fresh agent turn against `targetSessionId`. * All assistant output is appended into the same OutputStream so it visually * continues the hook's status/preview lines. */ export interface SessionHookInject { targetSessionId: string; profileName: string | null; /** Session-pool key for the injected turn. * - onNew: an ISOLATED key (onNewInjectSessionKey(channel)) so resuming the old session for * the pre-close turn never collides with the channel's live pool slot — see the race below. * - onMessageEnd: the channel itself, because the injection continues the live conversation. */ sessionKey: string; /** Forwarded to runAgent as `trigger`, useful for telemetry / cost grouping. */ trigger?: string; } /** Pool key for the onNew hook's injected "pre-close" turn. * * MUST be distinct from the channel's live session-pool slot (which is keyed by the channel * itself, see ClaudeAdapter / facade `sessionKey: options.channel`). Race it guards against: * `!new` fires this hook fire-and-forget, then synchronously closeSession(channel) + * resetChannelSession(channel). The hook's memory-write subprocess finishes LATER and injects * its stdout as a final turn that RESUMES the old session. If that turn used `channel` as its * pool key it would re-create (resurrect) a live session under the channel slot AFTER the reset * — so the next message of the brand-new conversation (also keyed by channel) would reuse the * resurrected OLD session instead of starting fresh. Routing the pre-close turn to a dedicated * key keeps it off the live slot; we close that key once the turn is done. */ export declare function onNewInjectSessionKey(channel: string): string; /** Injected-turn dependencies — seam for unit tests (default binds the real runAgent + a * backend-aware session close). */ export interface InjectDeps { runAgent: typeof runAgent; /** Close the pooled session created for the injected turn (by sessionKey). */ closeInjectedSession: (channel: string, sessionKey: string) => void | Promise; } /** Inject hook stdout as a fresh agent turn against `inject.targetSessionId`, routed through * `stream`. Runs on `inject.sessionKey`; for onNew that is an isolated key which we close * afterwards (the resurrected old session must not linger on / collide with the channel slot). * For onMessageEnd the key IS the channel (live conversation) and is left open. */ export declare function runHookInjection(output: string, spec: SessionHookSpec, stream: OutputStream, deps?: InjectDeps): Promise; export interface SessionHookSpec { name: HookName; ctx: SessionHookContext; format: SessionHookFormat; inject?: SessionHookInject | null; } /** Unified pipeline: post status line → spawn subprocess → post preview/error line → * optionally inject stdout as a fresh agent turn. Every Slack write goes through * `stream` so hook output and the follow-up agent turn share one continuous thread. * * Caller controls the `stream`: * - onMessageEnd: pass the assistant turn's stream so hook lines extend the same * reply chain (no top-level leak). * - onNew: pass a fresh stream anchored at the last assistant message's thread parent * (resolved via resolveSessionThreadTs). */ export declare function runSessionHook(spec: SessionHookSpec, stream: OutputStream, deps?: InjectDeps): Promise; /** Resolve the profile name to use when injecting the onNew hook's stdout as a fresh * agent turn. Priority order: * 1. session-registry (per-session truth — correct for thread-spawned sessions whose * profile is NOT mirrored into the channel-level conversation-ledger). * 2. conversation-ledger (channel-level fallback — correct for user-conversation * sessions where the registry may lack a profileName-bearing record). * * The two sources can disagree on a channel that hosts both kinds of session — e.g. * a thread session running profile `deepseek-pro` while the user's main conversation * is on profile `plan`. Reading from the ledger alone causes Cortex to resume the * thread session with the wrong profile, which routes through the wrong gateway mode * and triggers Anthropic's "Invalid signature in thinking block" 400. * * Exported (rather than inlined) so the priority logic is unit-testable in isolation * without spinning up the real repo singletons. */ export interface ProfileLookupDeps { lookupRegistryProfile: (sessionId: string) => Promise; lookupLedgerProfile: (channel: string) => Promise; } export declare function resolveOnNewProfileName(channel: string, sessionId: string, deps: ProfileLookupDeps): Promise; /** Fire-and-forget onNew hook used by the !new command and the "New" status button. * Returns immediately; the hook subprocess and any injected agent turn run async * via the unified pipeline. The session is closed by the caller in parallel — * sessionId is captured up-front so the JSONL still resolves after deletion. */ export declare function fireAndForgetPreCloseHook(channel: string, adapter: PlatformAdapter, threadAnchorId?: string | null): Promise; /** Synchronous variant of fireAndForgetPreCloseHook — awaits the hook to completion. * Reserved for cases where the caller wants to block on the !new pipeline (e.g. * test harness, scripted teardown). */ export declare function runPreCloseHook(channel: string, adapter: PlatformAdapter, threadAnchorId?: string | null): Promise; export interface OnMessageEndArgs { channel: string; sessionId: string; sessionName: string; executionId: string; profile?: string | null; /** OutputStream to extend — pass the assistant turn's stream so the hook lines * thread under the just-finished reply rather than leaking to top-level. */ stream: OutputStream; } /** Run the onMessageEnd hook against the just-finished assistant turn's vm. * No-op when not configured. */ export declare function runMessageEndSessionHook(args: OnMessageEndArgs): Promise; export {};