/** * 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 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; }; 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 − 40k) — 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 100k) — 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; /** * 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; /** * 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; /** * 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; /** * 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; /** * `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; }; /** * 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; /** * 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; export declare function startManagerLoop(): void; export interface ManageResult { ok: boolean; message: string; managed?: boolean; } /** * 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): Promise; //# sourceMappingURL=manage.d.ts.map