/** * Fresh Task Conversation Service * * Claude Code agents keep one conversation forever, so a small new task * re-reads hundreds of thousands of tokens of unrelated history on every * turn (81% of measured usage came from turns over 300k context). This * service starts a fresh conversation when a team member is handed a task * whose ROOT differs from the last one it was given: * * 1. The old conversation is saved server-side — a handover file under * `/handover/` (same format the orchestrator uses at * restart) and a concise copy through the memory `remember` path, which * mirrors into the wiki. No agent turn is spent on it. * 2. `/clear` is written to the PTY the same way context compaction writes * its command (Escape, 200 ms, command + CR), then a short pause for the * prompt to come back. * 3. The dispatch goes on with a one-line note pointing at the handover. * 4. In the background the new transcript Claude Code starts is found and * its id stored, so a restart resumes the NEW conversation; if it cannot * be found the stored id is cleared so a restart starts fresh instead of * resuming the huge pre-clear one. * * Retries, verifications and reviews of the same task share its root * (`:retry:N`, `:verify:`, `:review:…`), so they keep their * context. Never the orchestrator, only Claude Code, only when the agent is * idle and nobody else just wrote to it; any failure falls back to plain * delivery — nothing here throws into dispatch. * * Two floors since crewly#1344 (audit of 2026-10-16: 137 clears in 48 h, some * of 56k-token conversations, and agents cleared mid-conversation with the * owner): a conversation is only cleared once its last turn is at least * `memberFreshContextTokens()` (400k, above Claude Code's own 350k * auto-compact, so compaction runs first), and never while the agent is in a * conversation with the owner (an owner thread the sentinel watches, active * in the last two hours). Below the floor a new task simply joins the * conversation — that is what the agent remembers by. * * Idle-boundary context cap: one task can run for 1000+ turns, so a new root * alone does not bound the context. A periodic sweep * ({@link FreshTaskConversationService.startContextCapSweep}) saves and * clears an idle Claude Code member whose last turn carried more than * `CREWLY_MEMBER_CONTEXT_CAP_TOKENS` (450k by default, `0` disables), then * writes one line naming its active WorkItem (when it has one) and the * handover. Members working from chat with no WorkItem are capped too. Same safety * rules — never the orchestrator, never mid-turn, never while a message is * queued for or being delivered to it — plus at most one cap per 20 minutes. * * @module services/agent/fresh-task-conversation.service */ import type { WorkItem } from '../../types/v2/work-item.types.js'; /** * The root task id of a work item: its id with any follow-up suffix * (`:retry:N`, `:verify:…`, `:review:…`) removed, so every follow-up of one * task maps to the same root. * * @param workItemId - WorkItem id * @returns The root id * * @example * ```typescript * rootWorkItemId('abc:retry:2'); // 'abc' * rootWorkItemId('abc:verify:abc'); // 'abc' * rootWorkItemId('abc:retry:1:retry:2'); // 'abc' * ``` */ export declare function rootWorkItemId(workItemId: string): string; /** * Whether the feature is switched on by the environment. * * @param env - Environment (tests) * @returns False when CREWLY_FRESH_TASK_CONVERSATION is 0 / false / off / no */ export declare function freshTaskConversationEnvEnabled(env?: NodeJS.ProcessEnv): boolean; /** Everything the clear decision depends on. */ export interface FreshTaskDecisionInput { /** Agent session */ sessionName: string; /** Its runtime (from session persistence) */ runtimeType?: string; /** Root of the last task delivered to it, if any */ previousRoot: string | null; /** Root of the task being delivered */ newRoot: string; /** Env + settings kill-switch */ enabled: boolean; /** Agent is mid-turn */ busy: boolean; /** Someone else wrote to the session moments ago */ recentDelivery: boolean; /** Roots of OTHER work the agent is actively running */ otherActiveRoots: string[]; /** * The agent already started this very task a while ago (e.g. the lead * handed it over directly and this is a re-delivery). Clearing now would * wipe the live context of work in progress. */ alreadyStarted?: boolean; /** * The agent is in a conversation with the owner: an owner thread the * sentinel watches was active in the last * {@link FRESH_TASK_CONVERSATION_CONSTANTS.OWNER_CONVERSATION_ACTIVE_MS}. * A clear would wipe the conversation the owner is in (crewly#1344). */ ownerConversationActive?: boolean; /** * Last-turn context of the current conversation in tokens; null when the * transcript cannot be read. Only checked when `minContextTokens` is given. */ contextTokens?: number | null; /** * The smallest conversation a clear may close (`memberFreshContextTokens()`). * An unknown size counts as under it: a conversation that cannot be * measured is not cleared on a guess. */ minContextTokens?: number; } /** The clear decision and why. */ export interface FreshTaskDecision { /** Clear before delivering */ clear: boolean; /** Short reason (logged) */ reason: string; } /** * Decide whether to start a fresh conversation before delivering a task. * * @param input - Decision inputs * @returns The decision */ export declare function decideFreshConversation(input: FreshTaskDecisionInput): FreshTaskDecision; /** What busy is judged from (see {@link isFreshTaskAgentBusy}). */ export interface FreshTaskBusySignals { /** The runtime's turn state (hooks / transcript), null when unavailable */ turnState: 'turn' | 'background' | 'idle' | 'unknown' | null; /** The member's stored workingStatus is in_progress */ workingInProgress: boolean; /** How long the PTY has been quiet (ms), null when it never wrote */ ptyQuietMs: number | null; } /** * Whether an agent is mid-turn, for the `/clear` decision. * * The runtime decides first: a model writing a long tool input, a long tool * call or a background subagent are all silent on the PTY, and reading 8 s * of silence as idle cleared Atlas 18 s after it promised the agreement PDF * (crewly#1015 §4). Only when the runtime has no verdict do the stored * working status and the PTY quiet time decide. * * @param signals - Turn state, working status, PTY quiet time * @returns True when the agent must not be cleared */ export declare function isFreshTaskAgentBusy(signals: FreshTaskBusySignals): boolean; /** * The line put in front of the task text after a fresh start. * * @param handoverPath - Handover file written for the old conversation * @returns One line */ export declare function freshConversationNote(handoverPath: string): string; /** * The context cap for members, from the environment. * * @param env - Environment (tests) * @returns Tokens; 0 means the cap is off. Unset or invalid → the default. */ export declare function memberContextCapTokens(env?: NodeJS.ProcessEnv): number; /** Everything the idle-boundary context-cap decision depends on. */ export interface ContextCapDecisionInput { /** Agent session */ sessionName: string; /** Its runtime */ runtimeType?: string; /** Cap in tokens (0 = off) */ capTokens: number; /** Env + settings kill-switch of the fresh-conversation feature */ enabled: boolean; /** Last turn's context, or null when unknown */ contextTokens: number | null; /** Agent is mid-turn */ busy: boolean; /** How long the PTY has been quiet (null = unknown) */ quietMs: number | null; /** A message is being delivered, was just delivered, or a prepare/clear is running */ deliveryActive: boolean; /** Messages are queued for the agent */ queuedMessages: boolean; /** When the session was last capped (ms), if ever */ lastCapAt: number | null; /** Now (ms) */ now: number; /** Id of the WorkItem the agent is on (null = none; still capped) */ activeWorkItemId: string | null; } /** * Decide whether to cap (save + clear + re-orient) a member's conversation. * Pure; the order of checks is also the order of the logged reasons. * * @param input - Decision inputs * @returns The decision */ export declare function decideContextCap(input: ContextCapDecisionInput): FreshTaskDecision; /** * The one line written after a context-cap clear. It must carry the WorkItem * id (or, with no WorkItem, the handover path): the new conversation id is * found by it. * * @param args - WorkItem (null when the member works from chat), handover file and the old context size * @returns One line */ export declare function contextCapReorientation(args: { workItem: Pick | null; handoverPath: string; contextTokens: number; }): string; /** What the service needs to know about a session. */ export interface FreshTaskSessionInfo { /** Runtime */ runtimeType?: string; /** Working directory (Claude Code transcripts are keyed on it) */ cwd?: string; /** Current Claude conversation id */ sessionId?: string; } /** Collaborators; the defaults talk to the running backend. */ export interface FreshTaskDeps { /** Session runtime / cwd / conversation id */ getSessionInfo: (sessionName: string) => FreshTaskSessionInfo | null; /** Conversation ids of every OTHER registered session */ getClaimedSessionIds: (sessionName: string) => Set; /** Store the new conversation id */ updateSessionId: (sessionName: string, id: string) => void; /** Forget the conversation id (restart starts fresh) */ clearSessionId: (sessionName: string) => void; /** Agent is mid-turn */ isBusy: (sessionName: string) => Promise; /** Settings flag (general.freshConversationPerTask, default true) */ settingEnabled: () => Promise; /** Open work items targeting the session */ getActiveItems: (sessionName: string) => Promise; /** Raw PTY write; false when the session is gone */ writeToSession: (sessionName: string, data: string) => boolean; /** Store the concise handover through memory (mirrors into the wiki) */ remember: (args: { agentId: string; projectPath?: string; content: string; title: string; }) => Promise; /** Crewly home (handover + state files) */ crewlyHome: () => string; /** Claude home override (tests); replaces every other Claude home */ claudeHome?: string; /** * Claude home of the owner's other Claude Code account a session runs on * (issue #942: the account's config dir), or null on the default login */ claudeAccountHome?: (sessionName: string) => string | null; /** Clock */ now: () => number; /** Sleep */ sleep: (ms: number) => Promise; /** Environment */ env: NodeJS.ProcessEnv; /** Registered agent sessions (context-cap sweep) */ listSessions: () => string[]; /** Messages are queued for the session and not yet written */ hasQueuedMessages: (sessionName: string) => boolean; /** How long the session's PTY has been quiet (ms), or null when unknown */ getQuietMs: (sessionName: string) => number | null; /** Write one message into the session (paste + Enter); false when the session is gone */ sendMessage: (sessionName: string, text: string) => Promise; /** * The agent is in a conversation with the owner right now (an owner thread * active in the last two hours); such an agent is never cleared for a new * task. Default: the owner-thread sentinel. */ ownerConversationActive: (sessionName: string) => boolean; } /** Result of {@link FreshTaskConversationService.prepareForTask}. */ export interface PrepareForTaskResult { /** A fresh conversation was started */ cleared: boolean; /** Handover file for the old conversation (when cleared) */ handoverPath?: string; } /** Result of {@link FreshTaskConversationService.capContextIfNeeded}. */ export interface ContextCapResult { /** The conversation was saved, cleared and the agent re-oriented */ capped: boolean; /** Why (logged; the decision reason when not capped) */ reason: string; /** Handover file for the old conversation (when capped) */ handoverPath?: string; /** WorkItem the agent was re-oriented on (when capped) */ workItemId?: string; } /** * Singleton that starts a fresh Claude Code conversation per new task, and * caps a long task's conversation at idle boundaries. */ export declare class FreshTaskConversationService { private static instance; private readonly logger; private deps; private state; /** Per-session in-flight prepare (serialises prepares; waited on by terminal writes) */ private readonly inFlight; /** Last time a message was written to a session (terminal write / deliver) */ private readonly lastDelivery; /** Deliveries currently being written to a session (see {@link beginDelivery}) */ private readonly deliveriesInFlight; /** Last context-cap clear per session (rate limit) */ private readonly lastCapAt; /** Context-cap sweep timer */ private capSweepTimer; /** A sweep is running (sweeps never overlap) */ private capSweepRunning; private constructor(); /** * @returns The singleton */ static getInstance(): FreshTaskConversationService; /** * Replace the singleton with one using the given collaborators (tests). * * @param deps - Overrides for the default deps * @returns The new singleton */ static createForTesting(deps: Partial): FreshTaskConversationService; /** Drop the singleton (tests). */ static resetInstance(): void; /** * Mark a delivery to a session as in progress until the returned function * is called. Neither a new-task clear nor a context-cap clear starts while * one is running: it would wipe the message before the agent reads it. * * @param sessionName - Session being written to * @returns Call when the delivery has finished (idempotent) */ beginDelivery(sessionName: string): () => void; /** * Whether a delivery is being written to the session right now. * * @param sessionName - Session * @returns True while a {@link beginDelivery} is open */ isDelivering(sessionName: string): boolean; /** * Record that a message is being written to a session. A clear right after * would wipe it before the agent reads it, so {@link prepareForTask} skips * clearing for a short window (this includes the dispatcher's own briefs, * so two tasks dispatched back to back never clear each other). * * @param sessionName - Session written to */ noteDelivery(sessionName: string): void; /** * Wait (bounded) while a prepare — possibly a `/clear` — is running for the * session, so a message written now is not wiped by it. * * @param sessionName - Session about to be written to * @param maxMs - Upper bound on the wait */ waitIfClearing(sessionName: string, maxMs?: number): Promise; /** * Root of the last task delivered to a session. * * @param sessionName - Session * @returns Root id or null */ getLastRoot(sessionName: string): string | null; /** * Called right before a task is written to an agent. Starts a fresh * conversation when the task's root differs from the previous one and it * is safe to; always records the new root. Never throws. * * @param sessionName - Target session * @param workItem - Task being delivered * @returns Whether it cleared, and the handover file when it did */ prepareForTask(sessionName: string, workItem: Pick): Promise; /** * Start the periodic context-cap sweep (idempotent). The timer is unref'd * so it never keeps the process alive. * * @param intervalMs - Sweep interval */ startContextCapSweep(intervalMs?: number): void; /** Stop the periodic context-cap sweep. */ stopContextCapSweep(): void; /** * Check every registered session once. Sessions are handled one after * another and sweeps never overlap. Never throws. * * @returns Per-session results (sessions skipped cheaply are omitted) */ runContextCapSweep(): Promise>; /** * If the member is idle between turns and its last turn carried more than * the cap, save the conversation (handover file + wiki), `/clear` it and * write one line naming its WorkItem and the handover. Runs in the same * per-session chain as {@link prepareForTask}, so terminal writes wait for * it. Never throws. * * @param sessionName - Member session * @returns What happened */ capContextIfNeeded(sessionName: string): Promise; private doCapContext; /** * The WorkItem a member is on: running before accepted before proposed, * then the most recently started. */ private pickCurrentItem; private doPrepare; /** * Save the current conversation (handover file + memory/wiki copy, no * agent turn) and write `/clear`. Shared by the new-task and the * context-cap paths. * * @param sessionName - Agent session * @param conv - Its cwd and current conversation id * @param handover - Task the old conversation was on, and why it closed * @returns The handover file and the time `/clear` was written, or null * when nothing was cleared (transcript missing, session gone) */ private saveAndClear; /** * Claude homes a session's transcripts may be under, current one first. * * On another of the owner's Claude Code accounts (issue #942) that is the * account's config dir, then the default `~/.claude` — a session that * switched accounts may still have its stored conversation there. * * @param sessionName - Agent session * @returns Claude homes in search order; never empty */ private claudeHomesOf; /** * A session's conversation transcript: the existing file in any of its * Claude homes (realpath or raw cwd slug), else where its current home * would hold it. * * @param sessionName - Agent session * @param sessionId - Conversation id * @param cwd - The agent's working directory * @returns Absolute transcript path (may not exist) */ private transcriptOf; /** * Write the handover file (same shape as the orchestrator's restart handover). * * @returns Its path */ private writeHandover; /** * The capped text stored through memory: where the handover is plus the * newest part of what was said. */ private conciseHandover; /** * After `/clear`, find the transcript Claude Code starts for this agent and * store its id; if none shows up in time, clear the stored id so a restart * starts fresh rather than resuming the pre-clear conversation. * * A candidate is a `.jsonl` in the agent's transcript directory that was * STARTED after the clear (its first entry, not just a later write), is not * the old id or claimed by another session, and contains this task's * work-item id — the dispatch text written right after the clear carries * it. Several agents share one cwd and their transcripts mention each * other's session names, so matching on the name alone picked another * agent's conversation (Atlas resumed Ella's, 2026-09-28). */ private trackNewConversation; /** * One scan of the transcript directory. * * @returns The new conversation id, or null */ private findNewTranscript; /** * Whether a transcript's first timestamped entry is at or after `since`, * i.e. the conversation began after the clear rather than being an older * one that happened to be written to recently. */ private startedAfter; /** Whether the start of a transcript contains the given text. */ private fileMentions; private statePath; private loadState; private recordRoot; } //# sourceMappingURL=fresh-task-conversation.service.d.ts.map