import type { Meta } from './types.js';
import { type SinkMessageFormat } from './sink-format.js';
/** How loudly a post asks to be heard. Ordered — `important` implies milestone. */
export type FeedPostLevel = 'milestone' | 'important';
/** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
export declare function parseFeedPostLevel(raw: string | undefined): FeedPostLevel;
export interface FeedSinkConfig {
    /**
     * argv to run, with `{placeholder}` tokens substituted. First element is the
     * program; it is spawned directly (no shell), so quoting is not a concern and
     * post text can never become shell syntax. Mutually exclusive with `channel`
     * — a sink is one shape or the other.
     */
    command?: string[];
    /**
     * In-process delivery through the same channel-provider registry `agents
     * send`/`agents notify` use — the composed `{message}` body, no argv, no
     * spawn. `'owner'` is the address alias (expands to `notify.owner.{channel,to}`
     * in agents.yaml, same as `agents notify`); any other value is a registered
     * channel name (or a `notify.transports` mapping) and requires `to`.
     */
    channel?: string;
    /** Recipient for a `channel` sink. Required unless `channel` is the `owner` alias. */
    to?: string;
    /**
     * Optional channel body template. Uses the same placeholders as `command`
     * argv (`{message}`, `{ticket}`, `{project}`, ...). Defaults to `{message}`.
     * A missing placeholder skips the sink, which lets a `{ticket}` template
     * declare that only ticket-backed posts belong in that destination.
     */
    message?: string;
    /** Lowest post level that reaches this sink. Defaults to `milestone` (all posts). */
    minLevel?: FeedPostLevel;
}
/** `feed.broadcast` in agents.yaml — sink name → what to run. */
export type FeedBroadcastConfig = Record<string, FeedSinkConfig>;
/** Everything a template may interpolate. Absent values skip templates that need them. */
export interface FeedBroadcastContext {
    /** Short subject line (~4–5 words). Phone line 1. */
    title?: string;
    /** The post body, verbatim. Phone line after the blank line. */
    text: string;
    level: FeedPostLevel;
    /** Tracker id for the work, e.g. `RUSH-2081`. */
    ticket?: string;
    /** Canonical clickable tracker URL for `ticket`, when the tracker can resolve it. */
    ticketUrl?: string;
    /** Repo/project the post came from. */
    project?: string;
    agent?: string;
    host?: string;
    session?: string;
    /** URLs attached to the post — the PR, the ticket, a shared plan. */
    links?: string[];
    /** Block-only: the block's stable id. Absent on a status post. */
    blockId?: string;
    /** Block-only: `approval` (has a safe default) or `decision` (needs a human). */
    class?: string;
    /** Block-only: cost-of-delay tag used by the urgency filter. */
    cost?: string;
    /** Block-only: the literal `agents focus <id>` command (for a `{focus}` sink). */
    focus?: string;
    /** Block-only: the answer choices the operator can pick, in order. */
    options?: string[];
    /** Block-only: the fallback applied if nobody answers in time. */
    safeDefault?: string;
    /** Block-only: minutes before `safeDefault` applies. */
    timeoutMinutes?: number;
}
/**
 * Map an open block onto the broadcast context, so a block reaches the same sinks
 * a post does instead of dying in the ledger.
 *
 * The `text` is the ask itself, front-loaded — a notification banner shows roughly
 * two lines, and a phone message is scanned, not read. `focus` carries the literal
 * command that unblocks it, so the message the operator receives contains the one
 * action they have to take rather than making them go find the session.
 *
 * Level is always `important`: a block is by definition an agent that has stopped
 * making progress, so there is no per-block level flag to get wrong.
 */
export declare function blockBroadcastContext(block: {
    blockId: string;
    sessionId: string;
    host?: string;
    questions?: Array<{
        text?: string;
        options?: Array<{
            label?: string;
        }>;
    }>;
    blockClass?: string;
    costOfDelay?: string;
    safeDefault?: string;
    timeoutMinutes?: number;
    ticket?: string;
    pr?: string;
}, extras?: {
    project?: string;
    agent?: string;
    title?: string;
    body?: string;
}): FeedBroadcastContext;
/**
 * Why a declared block reached nobody, or undefined when it got through.
 *
 * Pure so the fail-loud contract is testable without driving the CLI — the
 * original version lived inline in the command action and was consequently
 * never covered, which is how a `--json` early-return quietly bypassed it.
 *
 * Only a TOTAL failure counts. One sink failing among several is a warning, not
 * an error: the channels are redundant by design, and a dead `rush` login must
 * not mask a delivered desktop notification.
 */
export declare function blockDeliveryFailure(blocked: boolean, outcomes: SinkOutcome[]): string | undefined;
export interface PlannedSink {
    name: string;
    /** Command sink: argv to spawn (mutually exclusive with `channel`). */
    argv?: string[];
    /** Channel sink: provider channel name, or the `owner` alias. */
    channel?: string;
    /** Channel sink recipient. Unset for the `owner` alias — resolved at delivery. */
    to?: string;
    /** Channel sink body — the composed `{message}` for this post. */
    text?: string;
    /**
     * Owner-alias sinks only: the post context + its `message:` template, carried
     * so the owner fan-out can re-render the body PER DESTINATION — Slack in the
     * policy gets `mrkdwn` labeled links while iMessage stays plain (PHNX-3698).
     * `text` above is the plain default (dry-run display + the fallback when a
     * destination has no resolvable provider); this drives the real send. A
     * direct `channel:` sink resolves its one provider's format at plan time and
     * needs neither field.
     */
    ctx?: FeedBroadcastContext;
    messageTemplate?: string;
}
export interface SinkOutcome {
    name: string;
    ok: boolean;
    /** stderr tail when the sink failed, for the warning line. */
    error?: string;
}
/**
 * Short host label for a phone line — strip user@ and domain so
 * `muqsit@mac-mini.tailnet.ts.net` reads as `mac-mini`.
 */
export declare function shortHost(host: string | undefined): string | undefined;
/** First 8 hex chars of a session id for the footer (readable, not a full uuid). */
export declare function shortSessionChunk(session: string | undefined): string | undefined;
/**
 * Tap-to-view link for the session behind a post: the addressable console page
 * ({@link https://prix.dev/console/sessions/<id>}, prix/web). The footer already
 * carries a short session crumb for disambiguation; this rides the link trail so
 * the owner can open the full transcript straight from an iMessage broadcast
 * instead of hunting for it in the console.
 *
 * Accepts any real, path-safe session id — a Claude/Codex UUID *and* a native
 * `ses_…` id from OpenCode or another harness. The console shard uploader
 * (`traces/sync.ts`) syncs sessions with no harness filter, so all of them are
 * addressable; a UUID-only gate would silently drop the link for every non-Claude
 * harness (the whole point of the link). Reject only an id that could not resolve:
 * one with a path separator (URL-unsafe, via {@link isValidMailboxId}) or the bare
 * 8-char footer crumb (a truncated id that would 404).
 */
export declare function sessionConsoleUrl(session: string | undefined): string | undefined;
/**
 * Scrub em/en dashes from outbound phone copy (house rule + iMessage readability).
 * Collapses whitespace; does not invent meaning.
 */
export declare function scrubOutboundDashes(text: string): string;
/**
 * Footer like "Sent from my iPhone" — who posted, a session crumb, which box.
 *
 *   Sent from grok/a02da0e2 on mac-mini
 *
 * Agent name first; session chunk for disambiguation when many groks run;
 * host last. Skip the uninformative default label `agent`.
 *
 * In `mrkdwn` the crumb (`agent/short`) becomes a Slack labeled link to the
 * session's console page, so the human sentence reads identically while the
 * crumb turns blue and taps through (PHNX-3698). `plain` keeps the bare sentence
 * — it can't render a labeled link and must not dump the URL.
 */
export declare function composeBroadcastFooter(ctx: FeedBroadcastContext, format?: SinkMessageFormat): string | undefined;
export declare function truncateBroadcastBody(body: string): string;
export declare function composeBroadcastMessage(ctx: FeedBroadcastContext, format?: SinkMessageFormat): string;
/**
 * Substitute `{placeholder}` tokens in an argv template. Returns undefined when
 * the template needs a value this post does not have — the sink is then skipped
 * rather than run with an empty argument, which is how a `linear update --comment`
 * would otherwise comment on nothing.
 */
export declare function renderSinkArgv(template: string[], ctx: FeedBroadcastContext): string[] | undefined;
/**
 * Render one channel-message template with the same fail-closed placeholder
 * contract as argv. `format` (Slack `mrkdwn` vs `plain`) flows into the shared
 * `{message}` var so a Slack sink gets labeled links and an iMessage/owner sink
 * gets the plain sentence.
 */
export declare function renderSinkMessage(template: string, ctx: FeedBroadcastContext, format?: SinkMessageFormat): string | undefined;
/**
 * Which sinks this post reaches, in config order. Pure — the dry-run listing and
 * the real fan-out plan through here, so what `--dry-run` shows is what runs.
 *
 * A `channel:` sink is gated by the same `minLevel` rule as a `command:` sink —
 * one level check for both shapes, so a dry-run plan is truthful regardless of
 * which shape an operator's sink uses.
 *
 * `meta` is used only to resolve a channel name to its real provider for the
 * mrkdwn/plain format decision (`notify.transports`), the same map delivery uses;
 * it is optional so a test can plan without a config snapshot (identity mapping).
 */
export declare function planFeedBroadcast(config: FeedBroadcastConfig | undefined, ctx: FeedBroadcastContext, meta?: Meta): PlannedSink[];
/**
 * The effective sink config for a post: the operator's `feed.broadcast`, or —
 * when that is unset or empty — an implicit fallback straight to
 * `notify.owner`, for a post worth interrupting someone over.
 *
 * Before this, `broadcastPostedEvent`/`broadcastBlock` returned early the
 * moment `feed.broadcast` was empty, even when `notify.owner` was fully
 * configured — so the common case (an operator who set up owner notifications
 * but never wrote a `feed.broadcast` block) produced a `--blocked` post that
 * looked recorded and reached nobody. `agents notify` already treats
 * `notify.owner` as the default human destination; this makes an important
 * feed post/block use that same default instead of requiring a second,
 * redundant config block that says the same thing.
 *
 * The fallback only fires for `important` — a routine `milestone` post stays
 * record-only, matching the `minLevel` contract every declared sink already
 * follows. An operator-declared `feed.broadcast` (any non-empty config)
 * always wins outright; the fallback never layers on top of it.
 */
export declare function effectiveBroadcastConfig(config: FeedBroadcastConfig | undefined, level: FeedPostLevel, meta: Meta): FeedBroadcastConfig | undefined;
/** Sink name for the ephemeral local banner added by `feed post --notify`. */
export declare const DESKTOP_NOTIFY_SINK = "notify";
/**
 * Add a local desktop-banner sink when `feed post --notify` is set — the same
 * `notifyDesktop` banner `run --notify` raises, but for an authored post.
 *
 * `--notify` is a per-post opt-in that ADDS the local banner on top of whatever
 * `feed.broadcast`/owner delivery already runs; it never replaces a configured
 * sink. It carries no `minLevel`, so it fires for any level — a quiet milestone
 * banner on this box does not touch the phone the way an `important` post does.
 * And it routes through the `desktop` channel provider exactly like every other
 * `channel:` sink, so it appears in the outcomes and the `--json` payload rather
 * than a parallel code path the reporting cannot see.
 *
 * The desktop banner is inherently local — `notifyDesktop` reaches whoever is at
 * THIS machine — so a post authored on a headless box notifies that box (a no-op
 * with a stated reason where no notifier exists, per the desktop provider),
 * never the operator's Mac. That is the same locality `run --notify` has; the
 * phone hop stays the job of an `important`-level owner/broadcast sink.
 *
 * The banner is added under `DESKTOP_NOTIFY_SINK`, or the first free
 * `notify-2`/`notify-3`/… when the operator already declared a sink by that
 * name — so `--notify` can never silently overwrite a configured sink; both fire.
 */
export declare function withDesktopNotify(config: FeedBroadcastConfig | undefined, notify: boolean): FeedBroadcastConfig | undefined;
/**
 * Run the planned sinks. A `command:` sink is a direct spawn with a bounded
 * lifetime; a `channel:` sink delivers in-process. Either way a sink that
 * fails or is not installed/registered is reported, never thrown — the post
 * is already written and must not be undone by a mirror that could not be
 * reached.
 */
export declare function runFeedBroadcast(planned: PlannedSink[], meta: Meta, timeoutMs?: number): Promise<SinkOutcome[]>;
