/** * Owner-thread sentinel: an owner Slack thread is never left silent. * * The failure it closes (2026-10-08, Atlas in #pro-think-tank): an agent says * in the owner's thread "I'm reading the attachments, ~15 min", then gets * stuck — a browser approval card goes top-level in the channel, Crewly * restarts, the card expires, the agent is stopped to free a slot — and the * thread says nothing until the owner asks "why no reply?". * * A thread is watched while the owner's last message there is unanswered, the * agent's last post there is a promise ("I'm doing…", "~N min", 「马上」), the * agent has owner work from that thread, or a card the agent waits on is open. * On any blocking event for that agent — card posted / expired, work blocked, * agent stopped, start failed or deferred, delivery held, Crewly restarted — * ONE short status line goes into the thread. A promise of "~N min" that * passes N×1.5 with no post gets one line saying what the agent is doing now, * and the agent is nudged once. * * Dedupe: one line per state change per thread; the same state is never * repeated within REPEAT_STATE_MS; informational lines are at least * MIN_INFO_GAP_MS apart. Any post by the agent in the thread resets it. * * Transport-free: posting, nudging and lookups are injected (see the wiring). * * @module services/messaging/owner-thread-sentinel.service * @see specs/2026-10-08-owner-thread-sentinel.md */ /** A Slack place: a thread, or a DM / channel top level. */ export interface SlackThreadRef { slackChannelId: string; threadTs?: string; } /** What happened to an agent that may leave an owner thread waiting. */ export type SentinelEvent = /** A decision / approval card the agent waits on was posted (`place`: where it went) */ { kind: 'card_posted'; decisionId: string; question: string; link?: string; place?: SlackThreadRef; browser?: boolean; } /** The card expired before the owner answered (`browser`: a held browser action) */ | { kind: 'card_expired'; decisionId: string; question: string; link?: string; browser?: boolean; why?: string; } /** No answer even after a re-ask: the card is parked */ | { kind: 'card_parked'; decisionId: string; question: string; link?: string; } /** Answered / withdrawn: the agent no longer waits on it (no line) */ | { kind: 'card_settled'; decisionId: string; } /** The agent blocked its work item (`thread`: the item's owner thread, when known) */ | { kind: 'work_blocked'; reason?: string; thread?: SlackThreadRef; } /** The agent was stopped by the harness */ | { kind: 'stopped'; why: 'slot' | 'idle' | 'pressure'; } /** The agent could not be started */ | { kind: 'start_failed'; detail?: string; } /** The start waits behind the running-agent cap */ | { kind: 'start_deferred'; } /** Messages to the agent are held (input box unreadable / foreign text / looks busy for long) */ | { kind: 'delivery_held'; why: 'unreadable' | 'foreign' | 'busy' | 'blocked'; }; /** One watched thread. */ export interface OwnerThreadEntry { key: string; slackChannelId: string; threadTs?: string; /** The agent the thread waits on (last one made responsible or that posted) */ agent: string; /** Owner's latest message here (epoch ms) */ ownerAt?: number; /** Agent's latest post here */ agentPostAt?: number; /** Clipped text of that post */ agentPreview?: string; /** The agent's latest post was a promise */ promise?: { at: number; minutes?: number; deadlineAt?: number; firedAt?: number; parkedWorkItemId?: string; }; /** The agent has owner work from this thread */ workOrigin?: boolean; /** Open cards the agent waits on (decision ids) */ cards: Array<{ id: string; at: number; }>; /** A browser approval expired: the agent is nudged once if it does not ask again */ reask?: { decisionId: string; dueAt: number; done?: boolean; }; /** When a card the agent waited on last closed (the promise clock gets a grace after it) */ cardClosedAt?: number; /** Last status line posted */ lastStatus?: { state: string; at: number; }; /** state → when it was last posted (flapping guard) */ recentStates: Record; /** Loaded after a restart while active: one line on the first tick */ restartPending?: boolean; updatedAt: number; } /** Injected behaviour. */ export interface OwnerThreadSentinelDeps { /** Post every status line, not only the owner-facing ones (tests / diagnostics). Default false. */ postAllStates?: boolean; /** Post a status line in the thread. False when it could not be posted. */ postStatus: (thread: SlackThreadRef, agent: string, text: string) => Promise; /** Deliver a reminder to the agent (waking it when stopped). False when it could not. */ nudgeAgent: (agent: string, text: string) => Promise; /** Display name ("Atlas"); defaults to the session name */ displayNameOf?: (agent: string) => string; /** The owner thread of the agent's open work item, if its work came from one */ ownerWorkThread?: (agent: string) => Promise; /** What the agent is doing now, in a few words ("working on “X”"), if known */ currentActivity?: (agent: string) => Promise; /** Persisted state; omitted in tests that do not exercise restarts */ storePath?: string; now?: () => number; /** * Whether an agent (session name) still exists on this machine. A watched * thread of an agent that is gone — its team moved to another machine, the * member removed — is dropped at load and on every tick, never re-stamped * "restarted" or counted as work in hand (crewly#1344). Omitted: every * agent counts as present. */ agentOnThisMachine?: (agent: string) => boolean; } /** A detected promise in an agent's post. */ export interface DetectedPromise { /** Minutes promised ("~15 min" → 15), when stated */ minutes?: number; } /** * Minutes stated in a text ("~15 min", "10-20 minutes" → 20, "1.5h" → 90, * 「半小时」 → 30, 「十五分钟」 → 15, 「几分钟」 → 5). * * @param text - The post * @returns Minutes, or undefined when none is stated */ export declare function parsePromisedMinutes(text: string): number | undefined; /** * Whether an agent's post is a promise / in-progress statement, and the * minutes it states. A post that only reports what was done is not one. * * @param text - The agent's post * @returns The promise, or null * * @example * ```typescript * detectPromise("I'm reading the attachments now, ~15 min"); // { minutes: 15 } * detectPromise('Done — the report is attached.'); // null * ``` */ export declare function detectPromise(text: string): DetectedPromise | null; /** * Key of a thread. * * @param ref - The place * @returns `:` */ export declare function threadKey(ref: SlackThreadRef): string; /** * The status line for an event, in plain English (harness text). * * @param event - What happened * @param name - Agent display name * @returns The line, or null when the event posts nothing */ export declare function statusLineFor(event: SentinelEvent, name: string): string | null; /** * Watches owner threads and posts one status line per blocking event. */ export declare class OwnerThreadSentinelService { private readonly deps; private readonly logger; private readonly entries; private timer; private ticking; /** * @param deps - Injected behaviour */ constructor(deps: OwnerThreadSentinelDeps); private now; private nameOf; /** Start the periodic evaluation. */ start(): void; /** Stop the periodic evaluation. */ stop(): void; /** @returns Watched threads (debug, tests) */ list(): OwnerThreadEntry[]; /** * The owner wrote in a Slack thread / DM and `agent` must answer. * * @param input - Where, who must answer, when */ noteOwnerMessage(input: SlackThreadRef & { agent: string; at?: number; }): void; /** * An agent posted in Slack. In a watched thread the post resets the * status state, and a promise ("~15 min") is remembered and timed. * * @param input - Who, where, what */ noteAgentPost(input: SlackThreadRef & { agent: string; text: string; interim?: boolean; at?: number; }): void; /** * Something blocked `agent`: post one status line in each owner thread * waiting on it (deduped). * * @param agent - The agent * @param event - What happened * @returns How many lines were posted */ noteBlocking(agent: string, event: SentinelEvent): Promise; /** * Whether the agent has an unanswered owner message or an open promise in * an owner thread within RECENT_PROMISE_MS — slot freeing and idle stops * spare it when another candidate exists. * * @param agent - The agent * @returns True when it owes an owner thread right now */ owesRecently(agent: string): boolean; /** * Whether `agent` owes the owner an answer (unanswered for under * CARD_THREAD_MAX_AGE_MS) or a promised result (within ACTIVE_WINDOW_MS) in a * watched thread — hours, not the 30 minutes of {@link owesRecently}. An open card is not counted: then the owner owes * the agent. Idle stops spare such an agent unless memory is in pressure. * * @param agent - The agent * @returns True while it owes an owner thread */ owesOwner(agent: string): boolean; /** * Whether the agent is in a conversation with the owner: the owner wrote to * it in a watched thread, or it promised the owner something there, within * `windowMs` (default OWNER_ACTIVE_MS, 2 h) — answered or not. The per-task * `/clear`, the idle stop and the slot-cap eviction spare such an agent * (crewly#1344): a clear or a stop in the middle of an owner conversation * costs the owner the context they were just given. * * @param agent - The agent * @param windowMs - How far back owner activity counts * @returns True while the owner conversation is live */ ownerActiveRecently(agent: string, windowMs?: number): boolean; /** * Age in ms of the newest promise (or unanswered owner message) the agent * owes in a watched thread, or null when it owes none. * * @param agent - The agent * @returns Milliseconds, or null */ owedAgeMs(agent: string): number | null; /** * What the agent last promised the owner (the preview of its post), for the * reminder it gets when it is brought back after a stop. * * @param agent - The agent * @returns Text, or null when it has no open promise */ promiseText(agent: string): string | null; /** * The agent's open promise that has not been turned into a WorkItem yet, * with a key that names thread + promise (`@`), so a * second stop or a restart cannot make a second WorkItem for the same one. * * @param agent - The agent * @returns Text, key and a `markParked` callback, or null when there is none */ parkablePromise(agent: string): { text: string; key: string; markParked: (workItemId: string) => void; } | null; /** * The owner thread `agent` owes right now (the newest one active within * CARD_THREAD_MAX_AGE_MS), for placing a card it asks. * * @param agent - The agent * @returns The thread, or null */ ownerThreadFor(agent: string): SlackThreadRef | null; /** * Agents an owner thread waits on: a promised follow-up, an unanswered * owner message, or an open card (boot restore treats them as work in hand). * * @returns Session names */ owingAgents(): string[]; /** * One pass: restart lines, overdue promises, browser re-asks, pruning. * * @returns How many lines / nudges went out */ tick(): Promise; /** A promise of "~N min" past N×1.5 with no post: say what the agent is doing, nudge it once. */ private checkDeadline; /** A browser approval expired and the agent did not ask again: nudge it once. */ private checkReask; /** Threads that get a line for this event. */ private targetsFor; /** Post a line unless dedupe holds it back. */ private emit; /** The entry for a place, created when missing. */ private ensure; /** The watched entry a post belongs to (a DM's top-level post: that DM's newest entry). */ private find; private persist; /** Whether the agent is still on this machine (true when the wiring cannot tell). */ private agentPresent; private load; } /** * Whether a watched thread is stale: no owner activity (the owner's last * message, else the entry's last change) for STALE_AFTER_MS. * * @param e - The entry * @param now - Clock * @returns True when the thread should be forgotten */ export declare function isStale(e: OwnerThreadEntry, now: number): boolean; /** * Agents an owner thread waits on, read straight from the persisted state — * for the boot restore, which can run before the sentinel is started. * * @param file - Store path * @param now - Clock * @returns Session names */ export declare function owingAgentsOnDisk(file: string, now?: number, agentOnThisMachine?: (agent: string) => boolean): string[]; /** @returns The wired sentinel, or null before it started (and in tests). */ export declare function getOwnerThreadSentinel(): OwnerThreadSentinelService | null; /** @param service - The sentinel to expose (null to clear) */ export declare function setOwnerThreadSentinel(service: OwnerThreadSentinelService | null): void; /** * Report a blocking event for an agent, if the sentinel runs. Never throws. * * @param agent - The agent * @param event - What happened */ export declare function reportOwnerThreadBlocking(agent: string, event: SentinelEvent): void; /** An agent's Slack post, as reported by {@link reportOwnerThreadAgentPost}. */ export type AgentSlackPost = SlackThreadRef & { agent: string; text: string; interim?: boolean; }; /** * Listen to every agent Slack post made on this machine, with its author * (crewly#1376: the owner completion report needs to know who posted). * * @param listener - Called once per post; must not throw (errors are swallowed) * @returns Unsubscribe */ export declare function onAgentSlackPost(listener: (post: AgentSlackPost) => void): () => void; /** * Report an agent's Slack post, if the sentinel runs. Never throws. * * @param input - Who, where, what */ export declare function reportOwnerThreadAgentPost(input: AgentSlackPost): void; //# sourceMappingURL=owner-thread-sentinel.service.d.ts.map