/** * daemon/manage.ts — keep a session working on a standing objective. * * THE PROBLEM THIS SOLVES. A session driven by a goal decides at the end of a * cycle whether the goal was met, and then stops. Left alone it stops for the * night; re-armed, it works for as long as you let it. Two nights of running * that by hand produced sixteen hours of unattended work and a list of ways the * arrangement breaks, all of which are answered here. * * WHY IT LIVES IN THE DAEMON RATHER THAN IN A SESSION. The first version was a * script driven from another Claude session, which worked and cost that session * its whole context — and worse, talking to the manager meant interrupting the * manager, because observing occupied the same turn the instruction would have * arrived on. An instrument that consumes the channel it is watched through * cannot be redirected without being stopped. So: a process with a mailbox. * Writing to a mailbox never requires the reader to be idle. * * WHAT IT DOES NOT DO. It does not judge the work. It re-arms an objective, it * carries one-shot instructions from the operator into the next arming, and it * says what it did. Everything requiring judgement stays with the person. */ /** * Is this session mid-turn right now, per the transcript reading taken the * same tick? True if the transcript says so directly (`working: true`), or * if it moved within `withinMs` even without a definite working flag. Falls * back to false — not working — whenever the signal itself is missing * (`null`/`undefined` input, or both fields unknown), which is the safe * default: it leaves arming and the blocked check exactly as they behaved * before this existed rather than suppressing them on a reading that never * arrived. */ export declare function sessionIsWorking(t: { working: boolean | null; lastAt: number | null; } | null | undefined, now: number, withinMs?: number): boolean; /** * Whether the pane is showing a Claude Code permission/approval prompt. * * Kept deliberately narrow. A busy pane and a stuck one both look the same * to a change-hash — nothing moves — so telling them apart has to come from * the actual words on screen, and the words checked here are ones a genuine * permission dialog produces and very little else does. A background-agent * status line ("Waiting for 1 background agent to finish") or a bare shell * prompt must both read as false here, or every long tool call starts * looking like a stuck session and the manager stops arming real work. */ export declare function pendingPrompt(content: string): boolean; export interface ManagedSession { sessionId: string; /** Human name at the time of starting, for logs only — sessions get renamed. */ name: string; /** The standing objective, re-armed whenever it lapses. */ objective: string; /** Operator instructions waiting to go out with the next arming. */ pending: string[]; /** Everything the manager has done, newest last, capped. */ history: { at: string; what: string; }[]; lastRearmAt: number; lastChangeAt: number; lastHash: string; paused: boolean; /** * The file the session hands over in — a TEMPLATE, not a fixed path. * * `{date}`, `{yyyy}`, `{mm}` and `{dd}` are expanded when the file is used * rather than when it is set, because a session that runs for days outlives * the day it was started on. A literal date written into the path was right * for one evening and then quietly wrong: the request kept naming yesterday's * file, and the drift grows by a day every day. */ handoverFile?: string; /** * This session's own warm-up margin, in K tokens below the trigger — * overrides HANDOVER_MARGIN_K_DEFAULT. Set with * `manage handover-at `. Per-session because no one margin is * right for every session's shape of work: one doing long single turns * wants more room below the trigger than one that idles between short * ones and can react quickly once asked. */ handoverMarginK?: number; /** * This session's own context window, in K tokens — defaults to 1000. * Exists because the window is a property of the MODEL a session is * running, not a constant of this file, and a different window changes * what "the full window" means for effectiveCompactK's fallback case. */ contextWindowK?: number; /** * A ring of recent context readings, so a rate of fill can be reported * rather than only a point-in-time number. Capped at 90 — 30 minutes at the * 20s tick — because older readings say nothing about how fast context is * filling NOW. Only ever appended to when a reading is actually available — * never carries a synthetic 0 for a tick where the transcript could not be * read. */ contextSamples?: { at: number; k: number; }[]; /** The path actually asked for, so a date rolling over mid-episode cannot * make the change check compare two different files. */ handoverAskedPath?: string; /** * Clear the session after the handover, rather than leaving it to compact. * OFF unless asked for — see the rollover block for what changed this. */ clearAfterHandover?: boolean; /** Consecutive armings typed but never seen to land. Bounded — see ARM_ATTEMPTS. */ armFails?: number; /** * Consecutive arm-failure CAPS crossed, never reset by hitting the cap. * * `armFails` resets to 0 the instant it reaches ARM_ATTEMPTS, which is right * for the backoff mechanics but wrong for detecting a genuinely stuck * session: a content-agnostic "are we stuck" check keyed off `armFails` * never sees anything but 0..ARM_ATTEMPTS-1. This survives that reset, so * `blockedReason` has something durable to look at. */ armFailStreak?: number; /** * When arming first crossed ARM_ATTEMPTS and the manager gave up typing at * this session. Distinct from `blockedSince` (when the operator was told): * this is set the moment the streak crosses the cap, `blockedSince` only * once the pane has also gone static long enough to confirm it. */ stuckSince?: number; /** When arming last logged "busy, not arming" for this session — the dedup * clock for that note, since the skip itself happens every tick while the * session stays mid-turn. */ busyNotedAt?: number; /** When arming last logged "arm deferred: input line holds …" — the dedup * clock for that note, since the skip itself repeats on every attempt * while the line stays occupied. A skip is not a failure and never reaches * armFails (see arm()/applyArmResult), so without a clock of its own its * note would either spam the history or be lost entirely. */ armSkipNotedAt?: number; /** * Said ONCE per demanded arm: the operator's own unsent text is holding it. * * A demanded arm (`lastRearmAt === 0`) meeting an occupied input line is * nobody's fault but it must not be silent — the operator asked for action * and the thing standing in the way is their own half-typed line. Said once * because repeating it every tick is how a channel teaches its reader to * ignore it. Cleared when the arm finally lands or a non-forced skip shows * the demand episode is over. */ forcedArmHoldNotedAt?: number; /** When a goal was last queued behind a running turn; at most one pending. */ queuedArmAt?: number; /** * When this session was first seen BLOCKED — waiting on a permission * prompt or similar, not merely idle. Kept across ticks so the operator * can be told how long it has actually been stuck, not just that it is. * Cleared the moment it stops looking blocked, so a later block re-alerts * fresh rather than inheriting a stale duration. */ blockedSince?: number; /** When the operator was last alerted about this BLOCKED session — the * dedup clock. See BLOCKED_REALERT_MS: without this, every 20-second tick * the pane still shows the prompt would be its own alert. */ blockedAlertedAt?: number; /** When a handover was last obtained, so it is not demanded every tick. */ handoverDoneAt?: number; /** The context reading when it was obtained, so "how much work since" is * answerable — a handover ages by work done, not by the clock. */ handoverDoneK?: number; /** When a handover was asked for, so a silent session can be given up on. */ handoverAskedAt?: number; /** What that file looked like when asked, so "changed" is measured not claimed. */ handoverWas?: string; /** When a clear was typed, so it is never typed twice WITHIN one rollover. */ clearTypedAt?: number; /** * When a clear was typed that has never been seen to land — ACROSS rollovers. * * The per-rollover guard was not enough and the gap was ugly: a session whose * context stays high keeps qualifying for rollover, so each new attempt typed * its own clear, and a session in a turn long enough to execute none of them * accumulated a queue of them. They would then all fire in sequence, the * first against the session they were meant for and the rest against whatever * fresh session had started since — which is the exact "wipe the session that * just started" failure the single-clear rule existed to prevent, reached by * going around it rather than through it. * * So the invariant is stronger than "one clear per rollover": at most one * unconfirmed clear per session, ever, and no new rollover may begin while * one is outstanding. */ clearPendingSince?: number; /** Context before the clear, so "it landed" is measured against something. */ contextAtClear?: number; /** The pane, so the process and thence the checkout can be found again. */ tty?: string; /** Screen work forbidden — the operator has the machine. Survives re-arming. */ noScreen?: boolean; /** When the current screen decision reverts by itself. A grant that only ends * when somebody remembers to end it outlives the reason it was given for. */ handsUntil?: number; /** Which state the timer was set in, so reverting means the opposite of it. */ handsWas?: boolean; /** * The checkout this session's state belongs to, decided once. * * It used to be resolved fresh on every write, from whatever the pane's * process happened to be reading. That is right for a session that moves and * catastrophic for several sessions at once: a mis-resolution then writes one * worker's state over another's, silently, in a checkout neither of them is * working in. It has already put a file in an unrelated repository once. * * So it is pinned when management starts. If the live answer later disagrees, * nothing is written and it is said out loud once — a mirror that follows the * pane to a new repository is not a mirror, it is a second author. */ repoRoot?: string; /** Said once when the pane's checkout stopped matching the pinned one. */ repoDriftReported?: boolean; /** * When the CURRENT screen grant was made. * * Needed to tell two identical-looking states apart: a pointer grant that * lapsed on its idle window, which is ours to renew, and the operator having * taken the screen back, which is not. The discriminator is whether their * hold started before or after the grant did. */ screenSince?: number; /** * Said once when the operator takes the screen back while a grant is open. * Their machine, their call — but a session that silently stopped doing * visual work looks exactly like one that is still doing it. */ screenStandOffReported?: boolean; /** * Said once while the manager withholds the screen and the pointer tool would * nonetheless allow a click — the two halves disagreeing. * * NOT resolved automatically, deliberately. The obvious move is for the * manager to adopt the grant it finds, and that would quietly remove the * protection the grant exists for: nothing in the file distinguishes a grant * the operator made from one a session wrote for itself. So the manager keeps * withholding and says so, which is the half of the problem it can fix — * every hour this went unreported was an hour of visual work not done. */ screenDisagreementReported?: boolean; /** * A bounded stretch of autonomous work on the tracker's open issues. * * This exists because the operator was writing the same long paragraph every * night — which list, in what order, report where, commit how, screen or no * screen, for how long — and any clause forgotten in a hurry was a rule that * silently did not apply. A shift is that paragraph reduced to its three * variables: how long, with the screen or without, and how many workers. */ shift?: { /** When it ends. Stopping claiming is not the same as being killed. */ until: number; /** Upper bound on concurrent workers. See the fleet design note. */ workers: number; /** Whether the screen was handed over for the duration. */ visual: boolean; startedAt: number; /** Said once, so the end of a shift is announced and not merely obeyed. */ endReported?: boolean; /** The "until passed" line has been logged; proactive typing stays off. */ lapseNoted?: boolean; /** Set by `supervise`: a bounded stretch with no issue-work rules in the brief. */ plain?: boolean; }; /** Idle-nudge interval in minutes (`supervise every`); unset keeps the ~46-50 min re-arm only. */ nudgeEveryMin?: number; /** When a "continue the goal" nudge was last typed. */ lastNudgeAt?: number; /** When an API-retry continue was last typed — the one-per-minute clock for that nudge. */ lastApiRetryAt?: number; /** When the API-retry banner was last NOTED. The note dedups; the nudge itself does not. */ apiRetryNotedAt?: number; startedAt: number; } /** * Real compaction points, read straight off a project's own transcripts — * ground truth, where a configured override or an assumed default are both * inferences about what OUGHT to happen. Shape, from a live transcript * (~/.claude/projects//*.jsonl), confirmed against a real file * carrying one of these events: * * { type: "system", subtype: "compact_boundary", timestamp: "2026-09-13T08:34:18.927Z", * compactMetadata: { trigger: "auto", preTokens: 784066, postTokens: 31163, ... } } * * Takes the MINIMUM of the most recent three, not the average or the latest * alone, because the two ways to be wrong here cost differently: a trigger * estimated too LOW costs one wasted summary; estimated too HIGH is a * session that compacts before its handover lands, which is not recoverable. * The minimum of a small recent sample is the cheap-failure estimate, same * reasoning as ASSUMED_OVERRIDE_PCT below. */ export declare function measuredCompactK(lines: string[]): { k: number; events: { at: string; preTokens: number; }[]; } | undefined; /** * The compaction point to plan around, in K — MEASURED when a project has * its own compact_boundary history, otherwise a percentage of the window * (a configured override, or ASSUMED_OVERRIDE_PCT). */ export declare function effectiveCompactK(input: { windowK: number; overridePct: number | undefined; measuredK?: number; }): number; /** Which band a reading falls in, against the three margins below the trigger. */ export declare function bandOf(usedK: number, effectiveK: number, marginK: number): "below" | "warm-up" | "refresh" | "immediate"; /** * Whether a handover is due — the pure decision, extracted so it is testable * without a pane, a process table, or a transcript file. * * THREE BANDS below the trigger, checked most-urgent first, because a high * enough reading qualifies for more than one and the most urgent is the one * that should win: * * IMMEDIATE (trigger − 15k) — no gating at all. This close, waiting for a * time or work signal is the wrong trade; the cost of asking again for * nothing is one redundant note, the cost of NOT asking is the session * compacting before it writes anything down. * * REFRESH (trigger − 30k) — due once REFRESH_GROWN_K of new work has * landed since the last handover, regardless of the clock. A handover * this close to the trigger that predates a lot of new work is stale * exactly where staleness costs the most. * * WARM-UP (trigger − marginK, default 50k) — the ordinary case, gated the * same way as before this was split into bands: due by the CLOCK * (`lastAskAt` old enough) or by WORK done since the last one. * * `contextK: undefined` — no reading available — returns due:false with a * named reason rather than being coerced through `0`, which would read as * "no context used" and could never cross any band. That silent failure * mode is exactly what a `number | null` reading treated as falsy would * produce; keeping it as its own case is the fix. * * `idleMs` gates every band separately from the band itself: a band being * satisfied says the trigger is close, not that this is a safe moment to * type. A band that is due but not idle enough reports due:false with a * reason naming which band it was and how idle the session actually was, so * the log reads as "was about to ask, held off" rather than "never * qualified" — a real distinction for anyone debugging why an ask was late. */ export declare function handoverDue(input: { contextK: number | undefined; effectiveK: number; marginK?: number; lastAskAt?: number; handoverDoneK?: number; idleMs: number; now: number; }): { due: boolean; reason: string; }; /** * The rate context is filling, from recent readings — first/last over a * trailing window rather than every point, because the question this answers * ("how fast, right now") is about the recent slope, not a session's whole * history. Undefined with fewer than two readings inside the window: a rate * needs two points, and guessing one from a single sample is worse than * saying nothing. */ export declare function contextSlope(samples: { at: number; k: number; }[], windowMs?: number, now?: number): { kPerMin: number | undefined; minutesTo: (targetK: number) => number | undefined; }; /** * Does a line read back off the pane actually carry the text just typed? * * The verification step in the read-back-first typing sequence (see arm()): * type with no newline, read the pane again, confirm before sending CR. Two * real failures this catches: a stray character surviving from something the * terminal did not fully clear, and — observed on 2026-09-13 — a long `/goal * …` line folding in the terminal and landing as a pasted message instead of * a slash command, which a length- or hash-based check would not catch but a * literal prefix match does. * * Tolerant of a leading `❯` prompt marker and trailing whitespace, because * `readBack` may be a raw line straight off the pane rather than one already * run through promptUnsentText's own stripping. * * Observed twice live on 2026-09-18: a real terminal WRAPS long input lines, * so the pane read-back shows the intended text broken across several lines * with continuation whitespace, the exact-prefix check failed, Enter was * never sent, and the typed goal was left sitting unsubmitted on the input * line. Both sides are therefore normalized the same way — strip a leading * `❯`, collapse every whitespace run (spaces, tabs, newlines) to a single * space, trim — before the startsWith compare. Collapsing whitespace is safe * here because the intended text is pasted verbatim: only the pane re-flows * it, so any whitespace difference in the read-back is wrapping, not * corruption, while word-level damage (truncation, a pasted-message fold, * wrong words) still fails the prefix check. * * Observed live on 2026-09-20: very long input lines (>400 chars) cause * Claude Code to either scroll the input box so only the visible tail is * shown in the read-back, or collapse a pasted line into a placeholder like * "[Pasted text #8]" followed by the tail. When the pane reads back only the * tail of the intended line, the prefix check fails, blocking all long goals. * Tail matching now accepts read-backs that strip the paste placeholder and * appear as a suffix of the intended line, provided they are ≥30 chars or * empty (pure placeholder). A short tail is rejected to avoid false positives. */ export declare function typedLineMatches(readBack: string, intended: string): boolean; /** * Whether the pane shows Claude Code's vim-keybinding status indicator — the * ONLY condition under which escapeInputMode's 'i' keystroke means anything. * * Sending 'i' on a pane WITHOUT vim mode enabled is not neutral: nothing * intercepts it as a modal command, so it lands as a literal character in an * ordinary input line — this project's own reference note on typing into a * Claude pane records exactly that ("a stray `i` lands literally"). Sent * unconditionally, the read-back-first sequence would then never see what it * typed match what it reads back, abort with Ctrl-U every time, and the * session would never arm — silently and permanently, on every non-vim * session. Checking the indicator before ever sending the keystroke is what * keeps the same sequence safe on both. */ export declare function needsVimEscape(paneText: string): boolean; /** * Whether a pane's tab title names a Claude session rather than a bare shell. * iTerm titles encode the foreground process: idle Claude tabs report * "(claude)", busy ones "(node)"; both are the session, only a title with * neither is a shell. `atPrompt` alone cannot tell them apart, because an * idle Claude pane sits at its own input line too. */ export declare function isClaudePane(name: string | undefined): boolean; export declare function inputLineDecision(input: { text: string; sameForTicks: number; idle: boolean; idleMs?: number; band?: string; }): { action: "type" | "skip" | "clear-then-type"; logCleared?: string; sameForTicks: number; idleMs?: number; band?: string; }; /** * Is there text sitting unsent in the session's prompt? * * TYPING ON TOP OF IT DESTROYS IT. The manager pastes into the same input line * a person types into, and it sends a backspace first to escape vi normal * mode — so an objective armed over half-typed text eats a character of that * text and then runs the two together as one prompt. The operator's sentence * and the standing objective arrive merged and mangled, and neither does what * it meant to. * * This never bit while nobody was at the keyboard, which is exactly the kind of * assumption that holds until an operator sits down at seven in the morning and * starts a sentence. * * The live input line is the one enclosed by the terminal's rules at the foot * of the pane, not the `❯` lines further up — those are scrollback, commands * that already ran. So the rule immediately above it is what identifies it. */ export declare function promptUnsentText(content: string): string | null; export declare function promptHasUnsentText(content: string): boolean; /** * Floor for {@link paneLooksIdle}: never call a pane idle before it has sat * this long unchanged, no matter how empty it looks — a pane can be static * for a few seconds between real turns too. */ export declare const PANE_IDLE_FLOOR_MS: number; /** * Escape hatch for the arm gate ONLY. sessionIsWorking() reads any transcript * activity within WORKING_RECENT_MS as "working", which holds for a session * whose turn has already ended but which is running a long background shell * (e.g. `pai worker run`) that keeps streaming progress into the transcript * for up to an hour. This looks at the pane instead: quiet long enough, * nothing typed, nothing spinning. */ export declare function paneLooksIdle(paneText: string, quietMs: number, floorMs?: number): boolean; /** * Expand the date tokens in a handover path, against the clock right now. * * Deliberately resolved at the moment of use. A managed session is meant to * outlive the day it started on, so any date fixed at the moment the path was * SET is a date that will be wrong by morning — and wrong in the quietest way, * since the file it names still exists and still opens. * * Exported for the tests, which is also where the accepted tokens are pinned. */ export declare function resolveHandoverPath(template: string, at?: Date): string; /** * The report's lines: one per UNPAUSED session, resetting its arming counter. * `kOf` is injectable so the policy can be tested without a live terminal. */ export declare function reportLines(managed: ManagedSession[], armingsSince: Map, now: number, kOf?: (m: ManagedSession) => number | null): string[]; /** * Resolve a session by whatever the caller knows — its id, or its name. * * The hook knows the working directory and the terminal session; a person * knows the name. Both have to land on the same record. */ export declare function resolveSession(idOrName: string): { sessionId: string; name: string; } | null; /** * Pure extraction of context usage from raw transcript JSONL lines. * * Split out of transcriptReading() so the one rule that matters here — NO * usage on the tail means the context reading is UNKNOWN, never a claimed * zero — is checkable without a process table, lsof, or a file on disk. * * `undefined` covers every case where nothing can be said: no parseable * lines, no assistant message on the tail, or an assistant message whose * `usage` field is absent. A real reading of zero tokens (a brand new * session) is not this case and is returned as `0`, which is why the * distinction is undefined-vs-number rather than falsy-vs-truthy. */ export declare function usageFromLines(lines: string[]): number | undefined; /** * The session's project directory. A `claude attach` viewer sits in HOME, so * its process cwd names no project; the transcript named by the session id * records the real one in the `cwd` of its newest record. Process cwd is the * fallback when the id is unknown or the transcript carries no cwd. */ export declare function sessionProjectCwd(claudePid: string, uuid?: string, root?: string, procCwd?: (pid: string) => string | null): string | null; /** * The transcript named by a Claude session id (full uuid or the short prefix * `claude attach` takes), found under any project dir. The id is the only * thing that identifies a background session's file: its viewer process sits * in HOME, so the cwd-derived project dir is some other session's. Cached per * id; re-resolved when the cached file is gone. */ export declare function sessionTranscriptFile(uuid: string, root?: string): string | null; /** * Where a handover goes when nobody has named a file — the convention this * project already uses without the manager's help, so a session asked cold * has somewhere sane to write rather than nowhere at all. * * `notesDirExists` is passed in rather than checked here so this stays pure * and testable without touching a filesystem: the caller does one existsSync * and hands in the answer. */ export declare function defaultHandoverTarget(cwd: string | null, notesDirExists: boolean): string | null; /** * The project's configured handover sink, from `.pai/project.yaml` * (`status.sink: forge` + `status.forge.anchor` / `repo`, the schema PAI's * forge-sink owns). Null for the default `todo` sink or no file, so the * caller falls through to Notes/TODO.md. Walks up from the session's cwd. * ponytail: line scan, not a YAML parse — only three scalar keys are read; * switch to the yaml package if the schema grows structure. */ export declare function projectSinkDescription(cwd: string | null): string | null; /** Precedence: explicit handoverFile > project status sink > Notes/TODO.md default. */ export declare function pickHandoverTarget(i: { handoverFile?: string; sinkDescription: string | null; defaultPath: string | null; }): { path?: string; description?: string; source: "explicit" | "project sink" | "default" | "none"; }; /** * The live transcript's tail. File mtime is not evidence of life: a stale * transcript keeps getting non-message records appended. Among the five most * recently modified files, the live one is the one whose last user/assistant * record is newest; files with no such record in the tail rank last. * Only the tail is read — these files reach tens of megabytes. */ export declare function liveTranscriptTail(dir: string): { file: string; raw: string; } | null; /** * Collapse text to a single line, for anything about to be TYPED at a prompt. * * At a prompt a newline is the submit key, so a multi-line objective does not * arrive as a long goal — it arrives as a short one, followed by its own * remainder as a second, contextless prompt, and the session acts on both. The * damage is silent: what was sent looks right in the state file and wrong only * on screen. * * Applied at the point text becomes keystrokes rather than at each point text * is set, so it covers every route in — shell heredoc, MCP call, appended * text, operator notes — including the ones added later. */ export declare function oneLine(text: string): string; /** * The standing rules, or "" when none are set. Never throws. * * A file whose first line is `@` followed by a path is a POINTER, and the rules * are read from there instead. That exists so the text can have one owner: the * same rules belong in the working repository, where they are version * controlled, reviewed with the code and read by sessions nobody is managing — * and copying them here as well would be two places holding one piece of * knowledge, which is a certainty of drift rather than a risk of it. Point at * the repository's copy and an edit there is in force at the next arming. * * One level of indirection only. A pointer to a pointer is a loop waiting to * be written, and nothing here is worth that. */ export declare function readStandingRules(path?: string): string; /** Where the rules are read from, for saying so out loud. */ export declare function standingRulesSource(path?: string): string; /** * A path with the home directory folded back to `~`, and the reverse. * * The pointer is written by a machine and read by a person, and an absolute * path carries the account name of whoever ran the command. That is nobody's * business in a file that may be copied to another machine, pasted into a bug * report or checked in by accident — and the same rules file on two machines * should not need two different pointers. */ export declare function foldHome(p: string, home?: string): string; export declare function expandHome(p: string, home?: string): string; /** Write the standing rules. Passing empty text removes them. */ export declare function writeStandingRules(text: string, path?: string): void; /** * Pure wrapper around {@link fitGoal} for callers that only want the line — * pinned by test/manage-goal-line-fit.test.ts so the shrink order above * cannot drift without a failing test. */ export declare function fitGoalLine(parts: { objective: string; agentish: string; rulesPointer: string; screenGrant?: string; }, max: number): string; /** * `until 08:00` — an END, rather than a length. * * The two are not interchangeable in use, even though either can be converted * into the other. A person deciding at midnight says "until eight" because * eight is when they will be back; working out that this is seven and three * quarter hours is arithmetic done at the exact moment somebody is too tired to * do it, and a slip lands as a permission that ends in the dark. * * Reads the NEXT time the clock shows that hour, so "until 08:00" said at * midnight means this morning and said at nine means tomorrow. Returns null * when no such phrase is there, which is most of the time. */ export declare function parseUntilClock(text: string, now?: Date): number | null; /** * Read a shift request out of a sentence. * * Deliberately forgiving about wording and strict about values: the caller is a * model turning "you are free to work on the issues for eight hours with your * controls, two workers at most" into an action, and the failure to avoid is a * shift that silently lasts a different length than the one that was said. */ export declare function parseShift(text: string, now?: Date): { hours: number; visual: boolean; workers: number; }; /** "self" or omitted means the caller's own session; anything else is taken as given. */ export declare function resolveManageTarget(session: string | undefined, callerId: string | undefined): string | null; /** Put goal / until / every on a managed entry; the screen is never granted by supervise. */ export declare function applySupervise(m: ManagedSession, goal: string, until: number | null, every: number | null): void; /** * Change the standing objective on a managed entry. * * EVERY ask for arming is now (2026-10-17 14:48), and a changed objective is * an ask: the new text must reach the session, not whenever the lapse timer * next gets around to it, but on the very next tick. `0` is the sentinel the * arm gates read as "arm now" — idle or queued behind a running turn, which * is safe because a queued line lands at the turn boundary and interrupts * nothing. */ export declare function setObjective(m: ManagedSession, objective: string): void; /** * Queue an operator instruction, and demand the arming that delivers it. * * The queue only goes out with an arming, so an instruction that did not * demand one waited for the lapse timer — an arming that had nothing to do * with what the operator just said, and one they may wait the better part of * an hour for. Every ask is now: the instruction rides out on the next tick. */ export declare function queueInstruction(m: ManagedSession, instruction: string): void; export declare const AGENT_REFUSAL = "refused: only the operator ends or shortens a goal (say \"manage off\" yourself in a session, or run it from your own terminal)"; /** Written by hooks/operator-manage-token.mjs from the operator's own typed prompt. */ export declare function writeOperatorToken(session: string, action: string, now?: number): void; /** * Agents cannot end or shorten a goal. Returns the refusal, or null when the * call may go ahead. `operator` is set only by the CLI (outside any agent * environment) and by the prompt hook; otherwise a one-shot token written from * the operator's typed prompt is required. * ponytail: a process that writes the daemon socket directly can still claim * `operator`; the socket is local-user trust, tighten with a peer credential. */ export declare function refuseAgentManage(sessionIdOrName: string, rawArg: string, opts: { until?: string; }, operator: boolean, now?: number): string | null; /** "08:00", "until 8pm", "8h", "90m" → epoch ms; null when it reads as none of these. */ export declare function parseUntilSpec(spec: string, now?: Date): number | null; /** Minutes between idle nudges: 30, "30", "30m", "1h". Null outside 5..480. */ export declare function parseEveryMin(v: string | number | undefined): number | null; /** When the next nudge (with `every`) or the ~46-50 min lapse re-arm falls due. */ export declare function nextDueAt(m: ManagedSession): number | null; /** * Should a short "continue the goal" line be typed now? Only for a session * with an `every` interval whose goal marker is still shown (a missing marker * is the full re-arm, handled by reasonToArm), idle for that long, with an * EMPTY input line — text there is somebody's, same rule as pointer-guard. * The caller has already ruled out a busy session. */ export declare function nudgeDue(m: ManagedSession, content: string, now: number): boolean; export declare const NUDGE_TEXT = "Continue working toward your standing goal."; /** * The API-retry banner, seen live 2026-10-17 14:50: * * Goal still active · the API was unavailable or the connection dropped · * retrying in 1 min (1/3) · send a message to retry now * * Claude Code retries three times, then gives up — and the goal sits frozen * over a session that is not working but sleeping between retries. The banner * itself offers the way out — any message retries now — so the manager takes * it: one minimal continue per minute, for as long as the banner lasts, * deterministically and with no cap, because a stretch of API trouble can * outlast any count. */ export declare const API_RETRY: RegExp; /** At most one retry-breaking message per minute while the banner persists. */ export declare const API_RETRY_EVERY_MS = 60000; export declare const API_RETRY_TEXT = "Continue now \u2014 the API is back. Pick the turn up where it dropped."; /** * Type the retry-breaking continue when this pane shows the banner and a * minute has passed since the last one. Never over text sitting unsent on the * input line (skip silently; the next tick tries again), never while paused, * never past a shift's end — the banner is not a reason to restart a * stood-down session. Returns true when a line went out. * * The tick calls it before the busy gate: the retry wait IS the busy state * here, and typing mid-turn is safe because the line queues at the turn * boundary. `type` is injectable so the caller's exact behavior is testable * without a terminal; the tick passes nothing and gets the real one. */ export declare function apiRetryNudge(m: ManagedSession, content: string, now: number, type?: (sessionId: string, text: string) => unknown): boolean; /** One block for every managed session: name, goal, until, next due, context, last handover, state. */ export declare function statusLines(sessions: ManagedSession[], now: number, kOf?: (m: ManagedSession) => number | null): string[]; /** * The standing objective for a shift, written once here instead of by hand * every night. It says what to work on and what "done" means; how to work is * the standing rules, which ride along with every arming. */ export declare function shiftObjective(): string; /** * What a goal is made of, in the order a reader needs it. * * Task first, because that is what the session is being asked to do. Standing * rules second, because they qualify the task rather than replace it. The * screen state and any one-shot operator note last, because they are about * right now rather than about the job. * * Exported so the composition can be pinned by a test: this string is typed * into a live session, and a mistake in it is a mistake that arrives as * keystrokes. */ export declare function composeGoal(objective: string, rules: string, hands: string, extra: string, rulesPath?: string): string; /** * The screen grant the manager is currently holding open, if any. * * One place, because the operator can hand the screen over two ways — a shift, * or `hands on for eight hours` — and a rule that only knew about one of them * would silently not apply to the other. Both are the same fact: the screen is * the session's until a stated moment. */ export declare function screenLease(m: ManagedSession, now: number): { until: number; since: number; } | null; /** A file-name-safe slug for a session, stable across armings. */ export declare function briefSlug(m: Pick): string; /** * The line typed at the session: `/goal` plus a pointer to the brief, plus as * much of the objective's first words as fits. Never longer than `max`. */ export declare function buildGoalLine(briefPath: string, objective: string, until?: number | null, max?: number): string; /** * Opens EVERY armed goal, so Claude Code's evaluator has no done-state to judge * met: a goal that names one ("until the fix is in build N") is cleared the * moment the session says it is. Only the operator ends a goal. Never dropped * to fit the line — the objective is shortened first. */ export declare function noDoneStatePrefix(until: number | null): string; /** Everything that does not fit on the typed line, as the file it points to. */ export declare function buildBrief(parts: { objective: string; agentish: string; rulesSource: string; shiftRules: string; hands: string; pending: string[]; }): string; /** * Did it arm? Within a bounded wait the pane must show the goal marker with an * empty input line. If our own line is still sitting in the input, it is * cleared — never left in the operator's prompt — and the arm counts as failed. * * Text is not proof of a landing (a stranded line shows up in the transcript * area too, which is what the earlier fragment check could not tell apart). */ export declare function confirmArmed(typed: string, deps: { readPane: () => string | Promise; sleep: (ms: number) => Promise; clearInput: () => void; }, waits?: number, waitMs?: number): Promise; /** * Read the input line back after a paste, polling while it is still empty: under * load the terminal has not rendered the paste after a fixed 300 ms. Returns as * soon as the line is non-empty (the caller decides match vs mangled), or the * empty line once the bound is spent. Never decides anything itself. */ export declare function readBackTyped(deps: { read: () => Promise; sleep: (ms: number) => Promise; delaysMs?: number[]; }): Promise<{ line: string; waitedMs: number; }>; /** * Wrap `run` so a call made while the previous one is still pending is skipped * (onSkip) instead of started. The tick yields to the event loop on every pane * read, so under load one outlasts TICK_MS and the next interval would arm the * same session a second time. */ export declare function nonOverlapping(run: () => Promise, onSkip: () => void): () => Promise; /** Run fn unless `id` is already in `busy`; returns undefined when refused. */ export declare function tryExclusive(busy: Set, id: string, fn: () => Promise): Promise; /** * The outcome of one arm attempt. * * Three values, because two of them were once one boolean and the confusion * was not harmless. "armed" — typed, read back, and seen to land. "failed" — * the attempt genuinely went wrong: the pane could not be read, the paste was * refused, the line read back mangled, or the landing was never confirmed. * "skipped" — a guard declined to type: the input line holds the operator's * own unsent text. A skip is the guard working as designed, not the arming * failing, so it counts toward nothing and simply retries next tick; counting * it as a failure is what killed a demanded arm on 2026-10-17 14:42 (see * applyArmResult). */ export type ArmResult = "armed" | "failed" | "skipped"; export declare function arm(m: ManagedSession, reason: string): Promise; /** * The book-keeping one arm attempt's outcome earns, shared by every caller * that retries on the tick. * * ONLY a "failed" counts. A skip is not here because it is not a fault: the * guard that declined (the operator's own text on the input line) is doing * its job, and the demand — or the lapse — is still pending on the next tick. * Counting skips is the 2026-10-17 14:42 fault: three ticks of the operator * typing crossed this cap, the cap consumed the forced-arm sentinel, and the * manager went silent exactly when it had been told to act. */ export declare function applyArmResult(m: ManagedSession, result: ArmResult, now: number): void; /** * Whether the stuck backoff — arming having crossed ARM_ATTEMPTS — may hold * this session's arm back. * * A DEMANDED arm (`lastRearmAt === 0`) is never held by it. This is the other * half of the 2026-10-17 14:42 fix: once the cap had marked the session stuck * mid-demand, this gate muted every later tick, so the demanded arm was never * even typed. A demand outranks a backoff it had no part in earning. */ export declare function stuckBackoffHolds(m: Pick): boolean; /** * Why a managed session should be treated as BLOCKED — waiting on a human * decision arming cannot supply — rather than merely idle or slow. * * Two paths, checked in this order because they answer different questions. * The fast path reads the words on screen and needs nothing else: a * permission prompt IS a permission prompt the instant it appears, however * recently the pane last changed. The slow path exists for what the fast one * cannot see — a native macOS dialog steals keyboard focus and leaves no * trace in the terminal's own buffer — so it infers the same state from * arming's own repeated failure instead: ARM_ATTEMPTS have already come back * unconfirmed AND the screen has still not moved since. Neither signal alone * would be trustworthy (a slow turn fails to arm too; a quiet pane is often * just a quiet pane) but together they are the two ways this actually * happens. * * Returns the reason string rather than a boolean because the caller puts it * straight into the operator alert — computing it a second time to describe * what the first computation found is how the two descriptions drift apart. */ export declare function blockedReason(m: Pick, content: string, now: number): string | null; /** * The session's turn ended (last transcript record is an assistant turn with no * tool call pending), settled for STOP_SETTLE_MS, the prompt is empty and * nothing spins or runs in the background. Pure; never true over typed input. */ export declare function turnStoppedIdle(t: { working: boolean | null; lastAt: number | null; } | null | undefined, paneText: string, now: number): boolean; export declare const GOAL_GONE_REASON = "goal gone before until, re-armed"; export declare const FORCED_ARM_REASON = "asked to arm now"; /** How long one queued arm is awaited before another may be typed. */ export declare const QUEUED_ARM_WAIT_MS: number; export declare const GOAL_GONE_BUSY_REASON = "goal gone before until, re-armed (session busy, queued)"; export declare const GOAL_FORCED_BUSY_REASON = "asked to arm now (session busy, queued)"; /** * An arm due while the session is mid-turn: Claude Code queues a submitted * line and runs it at the next turn boundary, so arming now interrupts * nothing. Same guards as the idle rule (not paused, until ahead, input line * empty) plus at most one pending arm. Clears the pending mark once the marker * is back or the turn is over. * * Forced arms too (`lastRearmAt === 0`, set by `now`, a new objective, a * queued instruction, or supervise with a new goal), because a session arming * ITSELF through the MCP tool is by definition mid-turn when it asks — * waiting for an idle pane defers its request until a hard-working session * happens to go quiet, which may be never. Unlike the goal-gone case the * forced arm fires with the old marker still on screen: the point of the call * is to replace that goal with the new text. */ export declare function busyGoalGoneReason(m: ManagedSession, content: string, now: number, busy: boolean): string | null; /** A shift/supervision whose `until` has passed: nothing proactive is typed into the session any more. */ export declare const shiftOver: (m: ManagedSession, now: number) => boolean; export declare function reasonToArm(m: ManagedSession, content: string, now: number): string | null; export declare function startManagerLoop(): void; export interface ManageResult { ok: boolean; message: string; managed?: boolean; } /** * The off path's one job: make the session stop being managed, under every key * it may be filed under. * * managers.json is keyed by sessionId, and a relaunched pane gets a new one — * so `delete state[resolveSession(name)]` removes nothing (or a different, * newer entry) while the stale same-name entry survives and keeps arming the * session the operator just stopped. Returns the stale keys it removed so the * caller can name them in the log. */ export declare function stopManaging(s: Record, sessionId: string, name: string): string[]; /** * The whole operator surface, in one call. * * `/manage ` start managing this session with that objective * `/manage ` once running, an instruction carried into the next arming * `/manage` what is it doing * `/manage off` stop * `/manage pause|resume` stop arming without forgetting the objective * `/manage now` arm immediately, whatever the signals say */ export declare function handleManage(sessionIdOrName: string, rawArg: string, opts?: { goal?: string; until?: string; every?: string | number; }): Promise; //# sourceMappingURL=manage.d.ts.map