SLOPSHOPPER

forward-compaction

The model writes its own handoff through one tool and the plugin installs it at Claude Code's compaction, with facts recorded from typed events and every later…

newguardprompttoolprocesstimer
A shopper browsing a rack in a slop shop
README

forward-compaction

Compaction for Claude Code where the model writes its own handoff: the main chat and every sub-agent hand off through one tool, and everything after the call stays verbatim.

Claude Code plugin Version License: MIT Tests Built with Professor

forward_compaction({
  "objective": "…", "done": "…", "remaining": "…", "next": "…",
  "load_files": [{ "path": "/abs/plan.md", "why": "the plan I am executing" }]
})

<sup>One call, one argument per section. At Claude Code's next compaction the plugin replaces everything before the call with recorded facts plus this handoff, and keeps every message after it word for word.</sup>


Claude Code compacts by asking a summarizer to paraphrase the whole conversation. The paraphrase loses what matters most to the party being compacted: the person's exact words, a sub-agent's brief, the skills in force, and the frontier — what is done, what is left, what comes next.

forward-compaction lets the model write that frontier itself, while it still holds the context, and builds the rest from facts the plugin recorded as they happened.

  • ✍️ The model writes the frontier. forward_compaction takes one argument per section — objective, done, remaining and next — and assembles them into one markdown handoff. It is stored, never summarized, and installed verbatim.
  • 🧾 Facts are recorded, never reconstructed. The person's messages (main chat) or the brief (sub-agent), the messages delivered to it, the skills loaded, the agents and the background shells come from typed hook events as they happened. Nothing is grepped out of a transcript or re-read from disk. Each party's newest prompts (the person's ten newest messages, or a sub-agent's brief and the ten newest delivered to it) and the skills it loaded (the latest text of each) are also held in Claude Code's session state, so a plugin reload (/reload-plugins) does not lose them. The files touched and the task list come from each finished tool call.
  • ⏩ Everything after the call is kept. The model need not stop after it hands off: every message from the call on stays in the new context as it was, so no work done after the handoff is lost. Anything delivered between the call and the install that those messages lack (a notification of an agent or command finishing, the person's message, a coordinator's message) is carried in the wake under its own heading, as the model has not read it.
  • 👥 Every party. The main chat and every sub-agent use the same tool; each has its own stored handoff, nudges, readings and ceiling. A model on the ignore list (Haiku by default) takes no handoff and is held to its ceiling, then compacted mechanically (Ignored models).
  • 🛟 A ceiling as the safety net. A party that never hands off is compacted mechanically at its ceiling: the same recorded facts and its newest messages, kept verbatim.
  • 📜 Every decision logged to a JSONL file per session, and every step to a debug log, with the numbers as Claude Code's API gave them.

📊 Benchmark

No benchmark of forward-compaction has been measured yet. The figures the earlier plugin of this repository published measured a different mechanism (a summarizer run at a milestone) and are not claimed for this one.

🚀 Quick start

# 1. Turn on function hooks, and let Claude Code ask to compact from about 63k
export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
export CLAUDE_CODE_AUTO_COMPACT_WINDOW=100000

# 2. Install
claude plugin marketplace add https://github.com/rezzminator/sub-agent-compact.git
claude plugin install forward-compaction@forward-compaction

Both variables also work in the env block of settings.json. With the defaults the main chat is told its context size from 15% of its window, again every 5%, and asked to hand off at its next clean milestone; it is compacted mechanically at 60% of its window if it never does. A sub-agent is measured against a 200k max context: nudged from 80k every 20k, compacted mechanically at 160k.

Early access. This plugin is built on Claude Code's function hooks, an early-access surface that may change between releases. It is tested on Claude Code 2.1.309.

Moving from the earlier plugin of this repository? The CHANGELOG entry for 1.0.0-dev.1 maps every old setting.

🧠 How it works

flowchart LR
    A[Tool call] --> B{Past a<br/>nudge point?}
    B -- no --> A
    B -- yes --> C[Nudge after the result:<br/>hand off at a clean point]
    C --> D[Model calls forward_compaction:<br/>handoff stored]
    D --> E[Next compaction door:<br/>facts + handoff + every later message]
    E --> A
    B -- at the ceiling,<br/>nothing stored --> F[Mechanical wake:<br/>facts + newest messages]
    F --> A

A party's context is its last measured reading: the input, cache-read, cache-write and output tokens of its last completed request, as turn.step reports them. The nudge, the ceiling and the mechanical decision use it, so a nudge may come one step late. No number is ever estimated from characters: a party with no reading at all, none yet or none since an install, gets no nudge and no ceiling, and an automatic ask from it with nothing stored is held as no-reading until its next reading decides.

  1. Nudges. After a tool result, once a party's context passes its nudge start (FORWARD_COMPACTION_MAIN_NUDGE_START or FORWARD_COMPACTION_SUBAGENT_NUDGE_START, a share of its max context), the model reads one line saying how large its context is, as tokens and as a share of its max context, and that every call re-sends all of it. One more follows every FORWARD_COMPACTION_{MAIN|SUBAGENT}_NUDGE_EVERY. The line asks for a handoff at the next clean milestone, once the step in hand is finished and its results are written down, and before the next step starts: the model picks the milestone, not whether, and skips it only when nothing is left but writing its result and its final answer. A held compaction ask adds no nudge of its own. After an install, the first reading's level is absorbed silently (a sub-agent's floor of system prompt, tools and skills is already 55-60k), so the next nudge goes only when the context crosses the level above it. A model on the ignore list gets no nudge. A sub-agent gets one step to pick its milestone: one nudge level past its first nudge since its last install (100k with the defaults), its calls wait for its handoff. Every call but forward_compaction, look_back and ToolSearch is refused, unrun, with a line saying to hand off now with what it has read and concluded, then repeat the call; the first nudge names the point. Once the handoff is stored every call runs again. Every nudge and refusal also says a handoff costs nothing: the model continues from it, as many times as the work needs, and never cuts its task short to avoid one. The main chat is never held this way: its person steers it.
  2. Handing off. The model calls forward_compaction (the model sees mcp__forward-compaction__forward_compaction). The plugin checks the section arguments and files and stores the handoff in memory for that party, or refuses the call and stores nothing, naming what to fix. A later call replaces the stored one.
  3. Installing. The stored handoff installs at the next door Claude Code opens: for the main chat at the end of the turn (the plugin asks Claude Code to compact) or at an automatic ask if one comes first; for a sub-agent at Claude Code's next automatic ask. No summarizer request runs.
  4. The ceiling. An automatic ask with nothing stored is held. Once the party reaches its ceiling with still nothing stored, or Claude Code asks again with no response in between (its "prompt is too long" recovery), the plugin installs a mechanical wake.

The tool

forward_compaction({objective, done, remaining, next, blocked, findings, load_files, drop_skills}), with its schema in the prompt's tool list (not deferred), so a nudged model needs no ToolSearch.

  • objective, done, remaining, next (required): non-blank strings — what the work is for and what done means; the finished pieces, each with how it was verified; every step left, in order; the first concrete action after the wake.
  • blocked, findings (optional): strings — what waits on whom; decisions with why, errors verbatim. Absent or blank, the section is left out.
  • The plugin assembles the handoff as ## Objective, ## Done, ## Remaining, ## Next, then ## Blocked and ## Findings when given, each heading followed by its argument verbatim and a blank line between sections. The assembled handoff is at most 20,000 characters. Nothing is ever trimmed.
  • load_files (optional): at most 8 {path, lines?, why} entries, each an absolute path to a regular file of at most 1,048,576 bytes, why 1–200 characters, lines a range like 10-40 or 1-60,160-185. Each file is read when the handoff installs and carried whole. Carried files get a room of 5% of the party's max context, turned into characters at the party's measured characters per token (see the wake's budget below). A call whose files, one or together, exceed it is refused, never trimmed, and a lines range counts only its lines. With no ratio measured yet the files are accepted without a size check, and a warning row says so. A file that has grown past the room by install is named as not carried, never cut.
  • drop_skills (optional): at most 32 names of skills or commands the party loaded and no longer needs. A name is dropped only when two handoffs in a row name it: the second one's wake leaves it out, and so does every later wake until the party loads it again. A handoff that does not name it starts the count again. A skill or command whose file's frontmatter sets forward-compaction-keep: true, or that FORWARD_COMPACTION_KEEP_SKILLS names, is never dropped, whoever asks; the wake marks it pinned: never dropped. The receipt says, per name, whether it was dropped, waits for the next handoff, is pinned, or was never loaded.

The result is a receipt saying when the handoff installs — for the main chat at the end of this turn, for a sub-agent at Claude Code's next ask (with where that ask comes) — and, when it replaced one, that it did. It prints no context size: the plugin measures none for that moment. A refusal starts forward_compaction refused, nothing stored: and names what to fix: every unknown field and every bad section argument, one line each, in one refusal; then an assembled handoff over the cap, with its total; then a bad load_files entry, or a file that is absent, not a regular file, too large or could not be checked; then load files over the room, each named with its size, with the room, and asking for lines ranges or fewer files. A refusal is a tool answer, not an error, so the model reads it and calls again.

Every answer, receipt and refusal alike, ends with one line saying the plugin is experimental and asking the model to append a short note of any trouble to issues.md in the level-log directory (the reply itself when no directory resolves).

The wake

The installed context is, oldest first: one facts message, one handoff message, then the messages after the call, returned as Claude Code holds them. The handoff message carries the call's own receipt, since the install replaces the message that held it, and, like a mechanical wake's message, ends with the experimental line and where to note any trouble.

The facts message holds, each section only when it has content:

  • what arrived after the handoff and the kept messages lack: every notification, message from the person or the coordinator, and message that resumed a sub-agent, verbatim, first;
  • the brief (sub-agent), verbatim, or the person's newest ten messages (main chat), oldest first;
  • the newest ten messages delivered to the sub-agent (its coordinator's, and the notifications that resumed it) or the notifications delivered (main chat);
  • the results of the tool calls made in the same response as the handoff;
  • the skills in force, as they were loaded. A skill Claude Code loaded without saying which party loaded it is named, not carried, in every party's wake, with the call that loads it again;
  • the agents, listed at install (at most 50 rows): for main every agent, for a sub-agent only the agents it spawned (parentId its own id);
  • the background shells and crons, from the newest Stop event, with its age. The list is session-wide, not per agent; before any Stop event it says the running shells are unknown;
  • the files the party touched, newest first, at most 15 paths, each tagged R read, E edited or W written (its strongest touch), from its finished Read, Edit, MultiEdit, Write and NotebookEdit calls; a denied or failed call touched nothing;
  • the task list as last set, from TodoWrite or TaskCreate/TaskUpdate, whichever moved last, carried whole within 5% of the budget or named as not carried;
  • the files the handoff asked to carry, and the ones not carried with why.

Its size is held to a budget of half Claude Code's ask point (the autoCompactThreshold that $.session.usage reports, held at or below the party's max context: 31,500 tokens at CLAUDE_CODE_AUTO_COMPACT_WINDOW=100000), turned into characters at the party's measured characters per token: the characters over the tokens it has grown since its base, its live window's characters (from $.session.messages({ as: 'api' }), the messages its next request is built from) and reading at its first reading since it started or was last compacted, so the system prompt, tools and skills every reading counts cancel. It is unmeasured until the reading has grown 5,000 tokens past the base, or outside 1 to 8 characters a token, and unmeasured nothing is fitted: a handoff's wake carries the facts, the files and every message after the call, and a mechanical wake keeps no earlier message. Each section has a share — brief or person's messages 25%, what arrived after the handoff 25%, delivered messages or notifications 5%, sibling results 10%, skills the whole budget (every skill and command the party invoked is carried whole), and the files their own room above — and is carried whole or named as not carried, never cut; the person's messages and skills are carried newest first while they fit. The room left goes to the messages after the call: the oldest are dropped whole until the rest fits, and the handoff message says how many were dropped.

The handoff message carries the handoff verbatim, tells the model it is its frontier and to continue from ## Next, and says how many later messages follow. A mechanical wake replaces it with a message saying no frontier was written, how many of the newest messages were kept and how many are gone, and to re-establish the objective from the person's messages or the brief.

Each install writes one line, measured: it waits for the party's first reading after the install. A handoff the model chose is routine, so its line is an info row in the logs, never on screen: main handoff landed — 164k → 46k context, measured; frontier 4,812 characters, 3 facts, 5 messages kept verbatim. A mechanical wake lost the model's frontier, so its line is also printed: Explore 3f2a9c had no handoff at 125k (ceiling 120k) — mechanical wake installed, 125k → 38k context, measured. Before is the party's last reading before the install, after its first one since. A party that ends before a request measures it gets its line at its end, with after said as unknown, never guessed.

What else happens at a compaction

  • A person's /compact runs Claude Code's own summary untouched; a stored handoff is dropped, and the screen says so.
  • A message the person sends after the call does not cancel a stored handoff: it is among the messages kept after the call.
  • A stored handoff whose call is no longer in the party's transcript (something else compacted it) is dropped and said on screen; the ask is then judged as if nothing were stored.
  • An engine loop that is not one of the session's sub-agents (a fork Claude Code runs itself) is left to Claude Code.
  • A precompute ask is skipped: it is never used.
  • An error inside the compaction hook never reaches Claude Code: the plugin installs a mechanical wake instead, built without any read, and if that fails too, Claude Code's own summary runs. Either way the error is on the screen.
  • When the main chat's turn-end install is skipped or rejected, the handoff stays stored for the next turn end or ask, and a skip is said on screen.
  • A party's context that falls to half or less with no compaction this plugin handled (another plugin or Claude Code compacted it) is said on screen.

Look back

look_back (mcp__forward-compaction__look_back) searches only the caller's own transcript before its latest compaction, for an exact detail the wake no longer holds. Supply query (case-insensitive words; double quotes keep a phrase whole) or entry: N to fetch one entry whole. Results rank by how many terms an entry holds, then newest first. Every call is logged as a compaction miss, and the person is told what was looked up.

⚙️ Configuration

Options

Every option is an environment variable, set in the shell that starts Claude Code or in the env block of settings.json. The plugin declares no userConfig and has no /config screen; an options object handed to it is named in one warning and not applied. An unset, empty or blank variable is absent and the default applies; a bad value is named by its variable on the screen and in the log, and the default applies.

A size is a token count (100000), a number with a k/m suffix (100k, 0.6m), or a percentage of the party's max context above 0 and at most 100 (60%). A max context is a token count, never a percentage. A flag is 1, 0, true or false in any case. A model list is comma-separated, case-insensitive substrings of the model id a step reports; set empty, it ignores no model (the one option whose empty value is a value).

VariableTypeDefaultMeaning
FORWARD_COMPACTION_MAIN_MAX_CONTEXTmax contextwindowThe main chat's max context, which its sizes are a percentage of: by default the window $.session.usage() reports, read at start and again after /model.
FORWARD_COMPACTION_MAIN_CEILINGsize60%With no handoff stored, the main chat's context at which the plugin installs a mechanical wake (1M window → 600k).
FORWARD_COMPACTION_MAIN_NUDGE_STARTsize15%The main chat's context at which it is first told its size and that it may hand off.
FORWARD_COMPACTION_MAIN_NUDGE_EVERYsize5%How much the main chat's context grows between nudges.
FORWARD_COMPACTION_SUBAGENT_MAX_CONTEXTmax context200kEvery sub-agent's max context, Haiku included: Claude Code's API reports no sub-agent's window (Max contexts).
FORWARD_COMPACTION_SUBAGENT_CEILINGsize80%With no handoff stored, a sub-agent's context at which the plugin installs a mechanical wake (160k on 200k).
FORWARD_COMPACTION_SUBAGENT_NUDGE_STARTsize40%A sub-agent's first nudge (80k on 200k), above the 55-60k a sub-agent lands at after an install, so a handoff never loops.
FORWARD_COMPACTION_SUBAGENT_NUDGE_EVERYsize10%How much a sub-agent's context grows between nudges (20k on 200k).
FORWARD_COMPACTION_CEILING_NATIVEflag01: at any party's ceiling with no handoff, Claude Code's own summary runs instead of the mechanical wake.
FORWARD_COMPACTION_IGNORE_MODELSmodel listhaikuModels that take no handoff: no nudge, the tool refused, every ask held to the ceiling and then compacted mechanically (Ignored models).
FORWARD_COMPACTION_KEEP_SKILLSname listnoneSkills and commands never dropped from a wake, comma-separated, as a file's forward-compaction-keep: true pins it (The tool).
FORWARD_COMPACTION_DEBUGflag10: no debug.jsonl, the row the plugin writes at every step.
FORWARD_COMPACTION_LOG_FILEpath~/.local/state/forward-compaction/logs/decisions/.jsonlWhere decision rows go; each session writes {session}.jsonl beside it. With PLUGIN_LOG_DIR set the default is $PLUGIN_LOG_DIR/forward-compaction/decisions/.jsonl. A relative path is refused and the default applies.

A nudge start at or past its party's ceiling in the same unit is kept and named once: no nudge goes out before the ceiling. A ceiling below Claude Code's ask point is named once at start, for the main chat and for sub-agents: a party with no handoff is then compacted mechanically at its first ask.

{
  "env": {
    "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1",
    "CLAUDE_CODE_AUTO_COMPACT_WINDOW": "100000",
    "FORWARD_COMPACTION_MAIN_CEILING": "60%",
    "FORWARD_COMPACTION_SUBAGENT_MAX_CONTEXT": "200k",
    "FORWARD_COMPACTION_IGNORE_MODELS": "haiku"
  }
}

The plugin also reads, and does not own, PLUGIN_LOG_DIR, HOME and CLAUDE_CONFIG_DIR (for look_back). It reads no CLAUDE_CODE_AUTO_COMPACT_WINDOW: Claude Code's own compaction window and ask point come from $.session.usage.

The window rule

A plugin can hold a compaction back, but it can't make Claude Code ask sooner. The plugin reads where Claude Code asks from $.session.usage: the breakdown's autoCompactThreshold (about 63k with CLAUDE_CODE_AUTO_COMPACT_WINDOW=100000), against its compaction window rawMaxTokens. When auto-compaction is off, or no breakdown is reported, a warning row says which, and the ask point is taken as rawMaxTokens, else the window. A sub-agent's handoff installs only at such an ask, so set CLAUDE_CODE_AUTO_COMPACT_WINDOW=100000: when the compaction window Claude Code reports is above 100000 the plugin says so once at start, with that window, its source and the ask point. Past that point Claude Code asks before every request, so a stored handoff installs at the party's next request. The low window is there so the plugin can install a handoff whenever the model writes one, never to force one: a held ask changes no nudge, and the model keeps picking its milestone until the ceiling. The main chat does not wait for an ask: its handoff installs at the end of its turn.

Letting a plugin start a compaction for any sub-agent from code would remove this rule. It is requested upstream in anthropics/claude-code#91870.

Max contexts

Sizes are percentages of each party's max context. The main chat's is the window Claude Code reports through $.session.usage(), read at session start, at the main chat's first request when not read yet, and again when its model changes (/model); FORWARD_COMPACTION_MAIN_MAX_CONTEXT sets it instead. Until it is known (set, or reported) the main chat gets no nudge and no ceiling. A sub-agent's is FORWARD_COMPACTION_SUBAGENT_MAX_CONTEXT, 200k by default, for every model: Claude Code's API reports only the main chat's window, so the plugin never g

Source 17 files
hooks/forward-compaction.ts 961 lines
1import type { EngineInterface, On, PluginOptions, Register, SessionCompactInput, SessionCompactResult, ToolCallResult, TurnStartInput, TurnStepInput, TurnStepResult, TurnCompleteInput, PromptSubmitInput, SessionReceiveInput, SkillPromptInput, AgentSpawnInput, AgentSpawnResult } from 'claude-code';
2import { bounded, unlessAborted } from '../src/bounded.ts';
3import { applyDrops, dropNote, pinnedBy, experimental, parseHandoff, loadFileRefusal, loadRoomRefusal, receipt, ignoredRefusal, parseLineRanges, sliceFile, toolInput, type FileCheck, type LoadFile } from '../src/handoff.ts';
4import { workOf, type CallAnswer } from '../src/work.ts';
5import { newLedger, markStart, addEntry, resolveSpawn, attributeSkill, shellsOf, factsFor, promptsKept, restorePrompts, skillsKept, restoreSkills, isPersonOrigin, keyOf, originKind, type Ledger, type PartyKey } from '../src/ledger.ts';
6import { splitAtCall, buildWake, mechanicalWake, landedLine, type WakeFile, type AgentRow, type Landing } from '../src/wake.ts';
7import { decide, turnEndDoor, readingTokens, knownTrigger } from '../src/decide.ts';
8import { k, nudgeLevel, nudgeText, gated, gateMark, gateText } from '../src/nudge.ts';
9import { Parties, type Judging } from '../src/parties.ts';
10import { askPointOf, charsPerToken, partyMarks, startWarnings, apiChars, type AskPoint, type Marks, type UsageBreakdown } from '../src/window.ts';
11import { unseenCompaction } from '../src/unseen.ts';
12import { parseLookBack, boundaryArgv, boundaryLine, searchArgv, entryArgv, namesArgv, processOutput, parseEntries, toolNames, resultIds, searchResults, entryResult, manualTranscriptCall, lookBackNotice, findTranscriptArgv, firstFound, type LookBackResult } from '../src/look-back.ts';
13import { DecisionWriteError, hookRow, sessionLogPath, runCwd, noticeOnScreen, decisionAppendArgv, retentionArgv, requeueRows, keptRowsText, endReport, SkipFold, pluginLogDir, levelRow, pluginLogEvent, PluginLogQueue, type PluginLogLevel, type PluginLogFields } from '../src/log.ts';
14import { resolveOptions, type OptionEnvironment, type ResolvedOptions } from '../src/options.ts';
15import { TOOLS, toolsToRegister, ENSURE_TOOLS_TIMEOUT_MS, END_CORE_RESERVE_MS, REGISTER_TRIES, PENDING_BEFORE_CONFIG, type ToolKind } from '../src/register.ts';
16
17// Thin adapter: every decision lives in src/. Each hook catches its own failures, logs them with context and lets the compaction through.
18
19type Row = Record<string, unknown>;
20
21const message = (error: unknown): string => (error instanceof Error ? error.message : String(error));
22
23type State = {
24  config: ResolvedOptions;
25  ignoredOptions: string[]; // The names of the options handed to `register`: none is read, the environment is the only home of an option.
26  pluginLog?: PluginLogQueue;
27  pluginInitialized: boolean;
28  pluginRows: Omit<PluginLogFields, 'plugin' | 'session'>[];
29  pluginLogDir?: string;
30  pluginWriting: Promise<void>;
31  pluginFailing: boolean;
32  started?: Promise<void>;
33  configured?: boolean; // True once `start` has applied the environment layer to `config`: before it, `config` holds the defaults alone.
34  home?: string; // The home directory, for the log directories.
35  toolNames: Partial<Record<ToolKind, string>>; // Each tool's full name once registered.
36  configDir?: string; // The Claude config directory, where transcripts are found.
37  sessionId?: Promise<string>; // Cached session id for the transcript lookup; no process runs.
38  restored: Set<PartyKey>; // Parties whose kept prompts and skills were put back this process (restoreKept).
39  toolTries: Record<ToolKind, number>; // How many times registering each tool was tried.
40  registering?: Promise<void>; // The registration in progress: hooks that need a tool wait on it, so none registers twice.
41  toolsAbandoned?: boolean; // Registering the tools has not finished within ENSURE_TOOLS_TIMEOUT_MS: no hook waits on it until it settles, and a late finish still names the tools.
42  toolsSettled?: boolean; // Every tool is registered or out of tries: no hook registers or sets a timer again.
43  warned: Set<string>; pending: string[];
44  logFailures: Set<string>; // The log-write failures already shown: a log that cannot be written fails every flush the same way, and the person hears each failure once.
45  rowsWriting: number; // Rows the decision append in flight carries; 0 when none is.
46  levelRowsWriting: number; // Level-log rows queued or being written: what a cut end flush may lose from the level log.
47  levelLinks: number; levelBatched: number; // Level-log rows whose write is chained, and rows queued for the burst's one append.
48  skips: SkipFold; // Routine skips fold into one `skips` row per streak.
49  levelSkips: SkipFold; // The same fold for the level log, which writes whether or not the decision log is on.
50  retained?: boolean; // Old sessions' logs were cleaned up once this session (or tried).
51  writeError?: string; // Why the last decision append failed, while its rows wait in `pending`; cleared by the next append that succeeds.
52  rowsDropped?: { rows: number; error: string }; // Rows the hold let go this session, and the failure that dropped the last of them: counted at session end.
53  logPath?: Promise<string | undefined>;
54  logOff?: boolean; // Reading the session id failed: no row is written this session, so a routine line goes to the screen.
55  writing: Promise<void>;
56  parties: Parties; ledger: Ledger;
57  inFlight: Map<string, { party: PartyKey; tool: string }>; // Skill and Agent calls running now, by tool_use_id: the party that made each, for skill attribution and a spawn's parent.
58  pendingCommand?: string; // The command main typed, until its first step: a skill it expands is main's.
59  pendingSpawns: Set<string>; // agent.spawn calls running now: a skill loaded meanwhile is a preload of the one spawn.
60  knownAgents: Map<string, string>; // Sub-agents spawned here, id to type: an ask for any other agent id is the engine's own (C6).
61  mainWindow?: number; // Main's window as `$.session.usage()` last reported it: main's max context unless FORWARD_COMPACTION_MAIN_MAX_CONTEXT is set.
62  apiAsk?: AskPoint; breakdown?: UsageBreakdown; // Claude Code's ask point and the breakdown it was read from, at session start and each main-window read.
63  installedSince: Set<string>; // Parties this plugin compacted since their last reading: a fall in that reading is no unseen compaction.
64};
65
66/** A line on the person's screen: Claude Code already names the plugin in front of it, so the line never names it again. */
67function show($: EngineInterface, line: string): void {
68  $.ui.log(screenLine(line));
69}
70
71const screenLine = (line: string): string => line.replace(/^forward-compaction: /, '');
72
73/** Every line said lands in the decision log as a `said` row; a routine one reaches the screen too while there is no log to hold it. A line said with its diagnostic row is one level-log entry: the row's, at the level said. */
74function say(st: State, $: EngineInterface, line: string, level: PluginLogLevel = 'error', row?: Row): void {
75  const shown = noticeOnScreen({ level, decisionLog: !!st.config.logFile, pluginLog: st.pluginLog !== undefined, logOff: st.logOff || st.writeError !== undefined || st.pluginFailing });
76  const diagnostic = row === undefined ? undefined : pluginLogEvent({ line: screenLine(line), ...row });
77  plog(st, $, diagnostic !== undefined ? { ...diagnostic, level } : { level, event: 'said', msg: screenLine(line), where: 'hooks/forward-compaction.ts#say' });
78  if (shown) show($, line);
79  record(st, $, { event: 'said', line: screenLine(line), shown });
80  if (row !== undefined) record(st, $, row, diagnostic === undefined);
81}
82
83/** Level logging is off the work path; failures report through show, never recursively through say. */
84function plog(st: State, $: EngineInterface, fields: Omit<PluginLogFields, 'plugin' | 'session'>): void {
85  const queue = st.pluginLog;
86  if (queue === undefined) { if (!st.pluginInitialized) st.pluginRows.push(fields); return; }
87  let rows = 1;
88  let held = false; // this row waits in the queue for a later row's append
89  st.levelRowsWriting += rows;
90  st.levelLinks++;
91  st.pluginWriting = st.pluginWriting.then(async () => {
92    let session: string | undefined;
93    let sessionError: string | undefined;
94    try { session = await $.session.id(); }
95    catch (error) { sessionError = `forward-compaction: reading the plugin log session id failed: ${message(error)}; row has no session`; }
96    const now = await $.clock.now();
97    if (sessionError !== undefined) {
98      rows++;
99      st.levelRowsWriting++;
100      queue.add(levelRow({ plugin: 'forward-compaction', level: 'error', event: 'plugin-log-session-error', msg: screenLine(sessionError), where: 'hooks/forward-compaction.ts#plog' }, now));
101      show($, sessionError);
102    }
103    queue.add(levelRow({ plugin: 'forward-compaction', ...fields, ...(session ? { session } : {}) }, now));
104    st.levelBatched += rows;
105    if (st.levelLinks > 1) return void (held = true); // a burst of rows is one append process: the burst's last row writes them all
106    [rows, st.levelBatched] = [st.levelBatched, 0];
107    const out = await queue.flush((argv) => $.process.run(argv, { ...runCwd(st.pluginLogDir ?? ''), timeoutMs: 15_000 }));
108    if (out.error !== undefined && !st.pluginFailing) pluginLogFailed(st, $, out.error, out.dropped);
109    st.pluginFailing = out.dropped !== 0;
110  }).catch((error) => {
111    if (!st.pluginFailing) pluginLogFailed(st, $, message(error), rows);
112    st.pluginFailing = true;
113  }).finally(() => { st.levelLinks--; if (!held) st.levelRowsWriting -= rows; });
114}
115
116/** Once per failing streak: on the screen and in the decision log, whose row maps to no level, so it never recurses into the failing log. */
117function pluginLogFailed(st: State, $: EngineInterface, error: string, dropped: number): void {
118  show($, `forward-compaction: plugin log ${st.pluginLogDir} failed: ${error}; dropped ${dropped} ${dropped === 1 ? 'row' : 'rows'}`);
119  record(st, $, { event: 'plugin-log-failed', dir: st.pluginLogDir ?? null, error, dropped });
120}
121
122// Rows queue in memory; each flush appends them to the session's file through the append script, under its lock, so a resumed session keeps its earlier rows, no row is rewritten and no other chat shares the file.
123function record(st: State, $: EngineInterface, row: Row, levelRow = true): void {
124  dbg(st, $, `row:${String(row.event)}`, row); // every decision row, whole, before any fold
125  const ts = new Date().toISOString(); // one stamp for both logs. Folded first: a held-ask streak reaches the level log as its first row and one `skips` row.
126  if (levelRow) for (const diagnostic of st.levelSkips.add({ ts, ...row }).map(pluginLogEvent)) if (diagnostic !== undefined) plog(st, $, diagnostic);
127  // The decision log's file may come from the environment, which `start` reads: a row before that waits, so the session's one file is chosen from the full config. `settleConfig` writes what waited.
128  if (!st.configured) {
129    for (const out of st.skips.add({ ts, ...row })) if (st.pending.length < PENDING_BEFORE_CONFIG) st.pending.push(JSON.stringify(out));
130    return;
131  }
132  if (!st.config.logFile) return;
133  for (const out of st.skips.add({ ts, ...row })) st.pending.push(JSON.stringify(out));
134  flushDecisions(st, $);
135}
136
137/** What every party is judged by now: main's max context known, and the ignore list. */
138const judging = (st: State): Judging => ({ mainKnown: st.config.main.maxContext !== undefined || st.mainWindow !== undefined, ignore: st.config.ignoreModels });
139
140/** A party's ignore judgement, written as an `ignore` row when it is new or has changed. */
141function judge(st: State, $: EngineInterface, key: string): void { const row = st.parties.judged(key, judging(st)); if (row) record(st, $, row); }
142
143/** A debug row (`debug.jsonl`) while FORWARD_COMPACTION_DEBUG is on: numbers as the API gave them. */
144function dbg(st: State, $: EngineInterface, event: string, detail: Row): void {
145  if (st.config.debug) plog(st, $, { level: 'debug', event, msg: event, where: 'hooks/forward-compaction.ts', detail });
146}
147
148/** Marks the config as final and writes the rows that waited for it, or drops them when no decision log is on. */
149function settleConfig(st: State, $: EngineInterface): void {
150  if (st.configured) return;
151  st.configured = true;
152  if (!st.config.logFile) {
153    st.pending.length = 0;
154    st.skips.drain();
155  } else {
156    for (const out of st.skips.drain()) if (st.pending.length < PENDING_BEFORE_CONFIG) st.pending.push(JSON.stringify(out));
157    if (st.pending.length > 0) flushDecisions(st, $);
158  }
159}
160
161/** `last`: the session-end flush, after which no write comes, so a failure promises none. */
162function flushDecisions(st: State, $: EngineInterface, last = false): void {
163  const file = st.config.logFile;
164  if (!file) return;
165  st.logPath ??= $.session.id().then(
166    (id) => sessionLogPath(file, id),
167    (error) => {
168      // The level log is independent of the decision log that is off.
169      st.logOff = true;
170      const line = `forward-compaction: reading the session id failed: ${message(error)}; the decision log is off for this session`;
171      plog(st, $, { level: 'error', event: 'decision-log-session-error', msg: screenLine(line), where: 'hooks/forward-compaction.ts#record', detail: { error: message(error) } });
172      show($, line);
173      return undefined;
174    },
175  );
176  // Taken now: rows queued before a /clear go to the ending session's file even when they are written after startOver.
177  const logPath = st.logPath;
178  const pending = st.pending;
179  // The failure line names the session's own file: the configured logFile is only its template (`decisions/.jsonl`).
180  let named: string = file;
181  st.writing = st.writing
182    .then(async () => {
183      const path = await logPath;
184      if (path) named = path;
185      const rows = pending.splice(0);
186      if (!path || rows.length === 0) return;
187      st.rowsWriting = rows.length;
188      try {
189        const out = await $.process.run(decisionAppendArgv(path), { ...runCwd(path), stdin: rows.map((line) => `${line}\n`).join(''), timeoutMs: 15_000 });
190        if (out.exitCode !== 0) throw new Error(out.stderr.split('\n')[0]?.trim() || `the append script exited ${out.exitCode}`);
191        st.writeError = undefined;
192      } catch (error) {
193        // The `$` of an abandoned dispatch (an interrupt, a rejected tool) aborts its run: the batch goes back to the front of the queue and the next flush, under a live dispatch, writes it. A run rejected after its child appended them writes them twice: a duplicate row, never a lost one; a child killed mid-row leaves a torn line, and the append script starts the retried rows on a line of their own.
194        st.writeError = message(error);
195        const dropped = requeueRows(pending, rows);
196        if (dropped > 0) st.rowsDropped = { rows: (st.rowsDropped?.rows ?? 0) + dropped, error: message(error) };
197        throw new DecisionWriteError(message(error), rows.length, dropped);
198      }
199      // Written: no longer in flight while the cleanup runs.
200      st.rowsWriting = 0;
201      // Once per session, after its first rows are written: they never wait behind the cleanup.
202      if (!st.retained) {
203        st.retained = true;
204        await retainLogs(st, $, file, path);
205      }
206    })
207    .finally(() => { st.rowsWriting = 0; })
208    // Screen and level log only, once per distinct failure: never queue another decision write. The key drops numbers, so one failure whose text carries a varying size or offset is still shown once.
209    .catch((error) => {
210      const kept = error instanceof DecisionWriteError ? keptRowsText(error.rows, error.dropped, last) : '';
211      const failed = `forward-compaction: writing the decision log for ${named} failed: ${message(error)}`;
212      const line = `${failed}${kept}`;
213      // The first drop past the hold is told even when its failure already was; the session-end line counts them all.
214      const key = `${failed}${error instanceof DecisionWriteError && error.dropped > 0 ? ' (hold)' : ''}`.replace(/\d+/g, '#');
215      if (st.logFailures.has(key)) return;
216      st.logFailures.add(key);
217      plog(st, $, { level: 'error', event: 'decision-log-write-error', msg: screenLine(line), where: 'hooks/forward-compaction.ts#record', detail: { error: message(error) } });
218      show($, line);
219    });
220}
221
222/** Once per session, after its first append: old sessions' logs beside this one go; a failure is told once, in both logs, and the log goes on. */
223async function retainLogs(st: State, $: EngineInterface, file: string, path: string): Promise<void> {
224  try {
225    const out = await $.process.run(retentionArgv(file, path), { ...runCwd(file, path), timeoutMs: 15_000 });
226    if (out.exitCode !== 0) throw new Error(out.stderr.split('\n')[0]?.trim() || `the retention script exited ${out.exitCode}`);
227  } catch (error) {
228    const line = `forward-compaction: cleaning up old decision logs beside ${path} failed: ${message(error)}; they stay`;
229    say(st, $, line, 'warning', { event: 'decision-retention-failed', file, error: message(error), line: screenLine(line) });
230  }
231}
232
233/** After a /clear or a resume: main's conversation is a new one, under a new session id and its own decision log file. */
234function startOver(st: State): void {
235  Object.assign(st, { sessionId: undefined, logPath: undefined, pending: [], retained: false, writeError: undefined, rowsDropped: undefined });
236}
237
238function warnOnce(st: State, $: EngineInterface, key: string, line: string, level: PluginLogLevel = 'warning'): void {
239  if (st.warned.has(key)) return;
240  st.warned.add(key);
241  say(st, $, line, level, { event: level === 'error' ? 'error' : 'warning', key, line });
242}
243
244/** The environment variable of each option, by name. Claude Code reads only a name spelled as a string literal, so each is written out twice here (the key and the call); a test holds them to `OPTION_ENV`. */
245async function readOptionEnv($: EngineInterface): Promise<Pick<OptionEnvironment, 'vars' | 'readErrors'>> {
246  const vars: Record<string, string> = {};
247  const readErrors: string[] = [];
248  const readOptionVar = async (name: string, get: () => Promise<string | undefined>): Promise<void> => {
249    try {
250      const value = await get();
251      if (value !== undefined) vars[name] = value;
252    } catch (error) {
253      readErrors.push(`forward-compaction: reading ${name} failed: ${message(error)}; using the default`);
254    }
255  };
256  await Promise.all([
257    readOptionVar('FORWARD_COMPACTION_MAIN_MAX_CONTEXT', () => $.env.get('FORWARD_COMPACTION_MAIN_MAX_CONTEXT')),
258    readOptionVar('FORWARD_COMPACTION_MAIN_CEILING', () => $.env.get('FORWARD_COMPACTION_MAIN_CEILING')),
259    readOptionVar('FORWARD_COMPACTION_MAIN_NUDGE_START', () => $.env.get('FORWARD_COMPACTION_MAIN_NUDGE_START')),
260    readOptionVar('FORWARD_COMPACTION_MAIN_NUDGE_EVERY', () => $.env.get('FORWARD_COMPACTION_MAIN_NUDGE_EVERY')),
261    readOptionVar('FORWARD_COMPACTION_SUBAGENT_MAX_CONTEXT', () => $.env.get('FORWARD_COMPACTION_SUBAGENT_MAX_CONTEXT')),
262    readOptionVar('FORWARD_COMPACTION_SUBAGENT_CEILING', () => $.env.get('FORWARD_COMPACTION_SUBAGENT_CEILING')),
263    readOptionVar('FORWARD_COMPACTION_SUBAGENT_NUDGE_START', () => $.env.get('FORWARD_COMPACTION_SUBAGENT_NUDGE_START')),
264    readOptionVar('FORWARD_COMPACTION_SUBAGENT_NUDGE_EVERY', () => $.env.get('FORWARD_COMPACTION_SUBAGENT_NUDGE_EVERY')),
265    readOptionVar('FORWARD_COMPACTION_CEILING_NATIVE', () => $.env.get('FORWARD_COMPACTION_CEILING_NATIVE')),
266    readOptionVar('FORWARD_COMPACTION_LOG_FILE', () => $.env.get('FORWARD_COMPACTION_LOG_FILE')),
267    readOptionVar('FORWARD_COMPACTION_IGNORE_MODELS', () => $.env.get('FORWARD_COMPACTION_IGNORE_MODELS')),
268    readOptionVar('FORWARD_COMPACTION_KEEP_SKILLS', () => $.env.get('FORWARD_COMPACTION_KEEP_SKILLS')),
269    readOptionVar('FORWARD_COMPACTION_DEBUG', () => $.env.get('FORWARD_COMPACTION_DEBUG')),
270  ]);
271  return { vars, readErrors };
272}
273
274// First hook call: report option errors, find the log directories, and resolve the options.
275function start(st: State, $: EngineInterface): Promise<void> {
276  st.started ??= (async () => {
277    const early: { line: string; level: PluginLogLevel }[] = [];
278    let home: string | undefined;
279    try {
280      home = await $.env.get('HOME');
281      st.home = home;
282    } catch (error) {
283      early.push({ line: `forward-compaction: reading HOME failed: ${message(error)}; the logs have no home directory`, level: 'error' });
284    }
285    let override: string | undefined;
286    try {
287      override = await $.env.get('PLUGIN_LOG_DIR');
288    } catch (error) {
289      early.push({ line: `forward-compaction: reading PLUGIN_LOG_DIR failed: ${message(error)}; using HOME fallback`, level: 'warning' });
290    }
291    st.pluginInitialized = true;
292    st.pluginLogDir = pluginLogDir(override, home, 'forward-compaction');
293    if (st.pluginLogDir !== undefined) st.pluginLog = new PluginLogQueue(st.pluginLogDir, 'forward-compaction');
294    else show($, 'plugin log off: no PLUGIN_LOG_DIR and no HOME');
295    const env = await readOptionEnv($);
296    st.config = resolveOptions({ pluginLogDir: override, home, ...env });
297    const earlyRows = st.pluginRows.splice(0); // rows held before the level log had a home: debug ones only while the debug log is on
298    if (st.pluginLog !== undefined) for (const fields of earlyRows) if (fields.level !== 'debug' || st.config.debug) plog(st, $, fields);
299    dbg(st, $, 'env', { HOME: home ?? null, PLUGIN_LOG_DIR: override ?? null, ...env.vars, readErrors: env.readErrors });
300    const { errors: _, ...options } = st.config;
301    dbg(st, $, 'options', options);
302    settleConfig(st, $);
303    for (const { line, level } of early) say(st, $, line, level);
304    for (const line of st.config.errors) {
305      say(st, $, line, 'error', { event: 'option-error', line });
306    }
307    if (st.ignoredOptions.length > 0) {
308      const line = `forward-compaction: options ${st.ignoredOptions.join(', ')} are not read; set FORWARD_COMPACTION_* environment variables instead (README, Options); their values are not applied`;
309      warnOnce(st, $, 'options-ignored', line, 'error');
310    }
311    try {
312      st.configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) || (home ? `${home}/.claude` : undefined);
313    } catch (error) {
314      say(st, $, `forward-compaction: reading CLAUDE_CONFIG_DIR failed: ${message(error)}; look_back reads ~/.claude only`, 'warning');
315      st.configDir = home ? `${home}/.claude` : undefined;
316    }
317    record(st, $, { event: 'start', main: st.config.main, subagent: st.config.subagent, ceilingNative: st.config.ceilingNative, ignoreModels: st.config.ignoreModels });
318  })().finally(() => settleConfig(st, $));
319  return st.started;
320}
321
322async function registerTool(st: State, $: EngineInterface, kind: ToolKind): Promise<void> {
323  const { lost, ...spec } = TOOLS[kind];
324  try {
325    const { tool } = await $.tool.register(spec);
326    st.toolNames[kind] = tool;
327    record(st, $, { event: 'tool-registered', tool });
328  } catch (error) {
329    warnOnce(st, $, `register-failed:${kind}`, `forward-compaction: registering the ${spec.name} tool failed: ${message(error)}; ${lost}`, 'error');
330    record(st, $, { event: 'tool-register-failed', tool: spec.name, error: message(error), tries: st.toolTries[kind] });
331  }
332}
333
334async function answerLookBackTool(st: State, $: EngineInterface, e: Record<string, unknown>): Promise<string> {
335  const agentId = typeof e.agentId === 'string' ? e.agentId : undefined;
336  const party = agentId ?? 'main';
337  const args = parseLookBack(toolInput(e));
338  const label = agentId ?? 'main';
339  let boundary: number | null = null;
340  let result: LookBackResult;
341  try {
342    await start(st, $);
343    if (st.configDir === undefined) throw new Error('no Claude config directory');
344    const session = await (st.sessionId ??= $.session.id());
345    const found = processOutput(await $.process.run(findTranscriptArgv({ configDir: st.configDir, session, ...(agentId !== undefined ? { agentId } : {}) }), { ...runCwd(st.configDir), timeoutMs: 15_000 }), 'transcript lookup');
346    const file = firstFound(found);
347    if (file === undefined) throw new Error(`no transcript file for ${agentId === undefined ? `session ${session}` : `agent ${agentId}`}`);
348    const bounds = processOutput(await $.process.run(boundaryArgv(file), { ...runCwd(file), timeoutMs: 15_000 }), 'compaction boundary', true);
349    boundary = boundaryLine(bounds) ?? null;
350    // Before any argument check: with nothing compacted there is no earlier transcript, whatever was asked.
351    if (boundary === null) result = { text: 'Nothing has been compacted yet, so everything is still in context.', hits: 0 };
352    else if (args.error) result = { text: `Look back failed: ${args.error}`, error: args.error, input: true };
353    else if (args.entry !== undefined && args.entry >= boundary) result = entryResult([], args.entry, boundary);
354    else {
355      const argv = args.entry !== undefined ? entryArgv(file, args.entry) : searchArgv(file, boundary, args.terms);
356      const raw = boundary === 1 ? '' : processOutput(await $.process.run(argv, { ...runCwd(file), timeoutMs: 30_000 }), args.entry !== undefined ? 'entry fetch' : 'transcript search', true);
357      const entries = parseEntries(raw).filter((entry) => entry.line < boundary!);
358      let names = toolNames(entries);
359      const ids = resultIds(entries).filter((id) => !Object.hasOwn(names, id));
360      if (ids.length > 0) {
361        const uses = processOutput(await $.process.run(namesArgv(file, boundary, ids), { ...runCwd(file), timeoutMs: 30_000 }), 'tool name lookup', true);
362        names = { ...names, ...toolNames(parseEntries(uses).filter((entry) => entry.line < boundary!)) };
363      }
364      result = args.entry !== undefined ? entryResult(entries, args.entry, boundary, names) : searchResults(entries, args.terms, boundary, names, st.toolNames.forward_compaction);
365    }
366  } catch (error) {
367    const reason = message(error);
368    result = { text: `Look back failed: ${reason}`, error: reason };
369  }
370  record(st, $, { event: 'look-back', party, ...(args.query !== undefined ? { query: args.query } : {}), ...(args.entry !== undefined ? { entry: args.entry } : {}), ...(result.hits !== undefined ? { hits: result.hits } : {}), boundary, ...(result.error !== undefined ? { error: result.error } : {}) });
371  // A failure the caller's input caused is a warning; only the plugin's own fault is an error.
372  say(st, $, lookBackNotice(label, args, result), result.error === undefined ? 'info' : result.input ? 'warning' : 'error');
373  return result.text;
374}
375
376/** Observation never rejects, rewrites or starts a process for an ordinary tool call. */
377async function observeManualLookBack(st: State, $: EngineInterface, e: Record<string, unknown>): Promise<void> {
378  const tool = typeof e.tool === 'string' ? e.tool : '';
379  if (!['Read', 'Grep', 'Bash'].includes(tool)) return;
380  const agentId = typeof e.agentId === 'string' ? e.agentId : undefined;
381  const party = agentId ?? 'main';
382  try {
383    const session = agentId === undefined ? await (st.sessionId ??= $.session.id()) : '';
384    if (!manualTranscriptCall(tool, toolInput(e), session, agentId)) return;
385    record(st, $, { event: 'look-back-manual', party, tool });
386    say(st, $, `look back by hand (${party}): ${tool} on the transcript`, 'info');
387  } catch (error) {
388    say(st, $, `forward-compaction: observing manual transcript lookup of ${party} via ${tool} failed: ${message(error)}`, 'error', { event: 'look-back-manual-error', party, tool, error: message(error) });
389  }
390}
391
392/** `$.clock.after`, or, where the engine has none, a timer that never fires and a warning once: the wait is then unbounded, as it was. A call a hook refuses without throwing never fires either and is not reported: that wait is unbounded too. */
393function afterOrNever(st: State, $: EngineInterface, ms: number, fn: () => void, what: string): { cancel: () => void } {
394  try {
395    return $.clock.after(ms, fn);
396  } catch (error) {
397    warnOnce(st, $, `no-clock:${what}`, `forward-compaction: $.clock.after is unavailable (${message(error)}); ${what} is not time-bounded`, 'error');
398    return { cancel: () => {} };
399  }
400}
401
402/** Registers each tool that is not registered yet, up to REGISTER_TRIES hook calls each: `session.start` may not fire (a plugin loaded mid-session) or may run before the session can take a tool. Hooks that need a tool wait on it. */
403async function ensureTools(st: State, $: EngineInterface, signal?: AbortSignal): Promise<void> {
404  if (st.toolsAbandoned || st.toolsSettled) return;
405  const left = () => toolsToRegister({ forward_compaction: true, look_back: true }, { forward_compaction: st.toolNames.forward_compaction !== undefined, look_back: st.toolNames.look_back !== undefined }, st.toolTries, REGISTER_TRIES);
406  const began = Date.now();
407  const names = () => Object.values(st.toolNames);
408  const had = new Set(names());
409  const registered = (async () => {
410    await start(st, $);
411    st.registering = (st.registering ?? Promise.resolve())
412      .then(async () => {
413        for (const kind of left()) {
414          st.toolTries[kind]++;
415          await registerTool(st, $, kind);
416        }
417      })
418      .catch((error) => say(st, $, `forward-compaction: registering the tools failed: ${message(error)}`));
419    await st.registering;
420    if (left().length === 0) st.toolsSettled = true;
421  })();
422  // A hang here would hold every turn of every chat: past the limit the turn goes on, and the person is told. The dispatch's abort ends the wait too: its timer may never fire, and a hook that waits on past its abort starves the hooks worker.
423  const outcome = await bounded(registered, ENSURE_TOOLS_TIMEOUT_MS, (ms, fn) => afterOrNever(st, $, ms, fn, 'registering the tools'), signal);
424  if (!outcome.timedOut) return;
425  st.toolsAbandoned = true;
426  // Once the slow registration settles, a later hook spends the tries left, and the late finish is told with the tools it named.
427  const resume = () => void (st.toolsAbandoned = false);
428  void registered.then(() => {
429    resume();
430    const ms = Date.now() - began;
431    // The tools this attempt registered, never an earlier one's; none is said as such, with the tries left still to come.
432    const tools = names().filter((name) => !had.has(name));
433    say(st, $, `forward-compaction: registering the tools finished after ${Math.round(ms / 1000)}s: ${tools.length > 0 ? tools.join(', ') : 'it registered none'}`, 'info', { event: 'tools-late', ms, tools });
434  }, resume);
435  // An interrupted wait is no slow registration: nothing to tell.
436  if (outcome.aborted) return;
437  warnOnce(st, $, 'tools-timeout', `forward-compaction: registering the tools did not finish within ${ENSURE_TOOLS_TIMEOUT_MS / 1000}s; it goes on, turns do not wait for it, and the tools come into use once it finishes`);
438}
439
440/** A line the person always sees (a landed compaction, a dropped handoff, a fault the plugin worked around), and its row. */
441function tell(st: State, $: EngineInterface, line: string, row: Row): void {
442  show($, line);
443  record(st, $, { ...row, line: screenLine(line) });
444}
445
446/** D9: a hook's own work never throws out of the plugin; a failure is said with the hook it came from, and the event goes on. */
447async function guard(st: State, $: EngineInterface, where: string, work: () => unknown, e?: unknown): Promise<void> {
448  dbg(st, $, 'hook', hookRow(where, e));
449  try {
450    await work();
451  } catch (error) {
452    say(st, $, `forward-compaction: ${where} failed: ${message(error)}`, 'error', { event: 'hook-error', where, error: message(error) });
453  }
454}
455
456/** A party's token marks against its max context (main's option or reported window, the sub-agent option); undefined while main's is not known. */
457function marks(st: State, key: string): Marks | undefined {
458  return key === 'main' ? partyMarks(st.config.main, st.config.main.maxContext ?? st.mainWindow, st.apiAsk?.tokens) : partyMarks(st.config.subagent, st.config.subagent.maxContext, st.apiAsk?.tokens);
459}
460
461/** After a /clear or a resume: no party, fact or call of the old conversation carries into the new one. */
462async function conversationStartsOver(st: State, $: EngineInterface, reason: string): Promise<void> {
463  st.parties = new Parties();
464  st.ledger = markStart(newLedger(), await $.clock.now(), reason);
465  st.inFlight.clear();
466  st.pendingSpawns.clear();
467  st.installedSince.clear();
468  st.restored.clear();
469  st.pendingCommand = undefined;
470}
471
472/** session.start: the ledger starts, main's window and Claude Code's ask point are read, and a compaction window that holds a handoff back is named once (R33). */
473async function sessionBegins(st: State, $: EngineInterface): Promise<void> {
474  st.ledger = markStart(st.ledger, await $.clock.now(), 'startup');
475  await readMainWindow(st, $);
476  judge(st, $, 'main');
477  for (const w of startWarnings(st.breakdown, st.apiAsk, marks(st, 'main'), marks(st, 'agent:')!)) if (!st.warned.has(w.key)) (st.warned.add(w.key), tell(st, $, w.line, w.row));
478}
479
480/** ENOENT is a missing file; any other failure to stat is said as the error it is. */
481async function statFile(st: State, $: EngineInterface, path: string): Promise<FileCheck> {
482  const check: FileCheck = await Promise.resolve().then(() => $.fs.stat(path)).then((stat) => ({ kind: stat.kind, size: stat.size }), (error: unknown) => ((error as { code?: unknown } | null)?.code === 'ENOENT' ? { absent: true } : { error: message(error) }));
483  dbg(st, $, 'fs.stat', { path, ...check });
484  return check;
485}
486
487/** The forward_compaction tool (R31): the handoff is checked, stored for the party, and answered with the receipt of its door. */
488async function answerForward(st: State, $: EngineInterface, e: Record<string, unknown>): Promise<string> {
489  const key = keyOf(e.agentId);
490  try {
491    await start(st, $);
492    const parsed = parseHandoff(toolInput(e));
493    const m = marks(st, key);
494    const matched = st.parties.ignored(key, st.config.ignoreModels);
495    let refusal = ignoredRefusal(matched, m?.ceiling) ?? ('refusal' in parsed ? parsed.refusal : undefined);
496    if (!('refusal' in parsed)) for (const [index, file] of parsed.loadFiles.entries()) refusal ??= loadFileRefusal(index, file.path, await statFile(st, $, file.path));
497    if (refusal === undefined && !('refusal' in parsed) && parsed.loadFiles.length > 0) {
498      const ratio = ratioOf(st, $, key, 'tool-call', await partyChars(st, $, key, e.agentId), m);
499      const files = (await readLoadFiles($, parsed.loadFiles)).map((f) => ({ path: f.path, chars: f.content?.length ?? 0 }));
500      if (ratio === undefined || m === undefined) say(st, $, `forward-compaction: ${key}'s load_files were accepted without a size check: ${m === undefined ? "main's max context is not known yet" : 'its characters per token are not measured yet'}; the wake names any file it cannot carry`, 'warning', { event: 'warning', key: 'load-files-unchecked', party: key, files: files.length });
501      else refusal = loadRoomRefusal(files, m.maxContext, ratio);
502    }
503    if (refusal === undefined && typeof e.tool_use_id !== 'string') refusal = 'forward_compaction refused, nothing stored: the call has no tool_use_id, so the wake cannot find it.';
504    if (refusal !== undefined || 'refusal' in parsed) {
505      record(st, $, { event: 'refused', party: key, refusal, ...(matched !== undefined ? { matched } : {}) }); // the refusal names the field that failed
506      return refusal!;
507    }
508    const tokens = st.parties.get(key).reading; // the last reading: the call's own context is measured only by the next request
509    const replaced = st.parties.store(key, { text: parsed.handoff, loadFiles: parsed.loadFiles, toolUseId: e.tool_use_id as string, storedAtTokens: tokens, since: st.ledger.nextSeq });
510    await restoreKept(st, $, key); // a skill kept across a plugin reload is in the ledger before a drop can take it out
511    const drop = applyDrops(st.ledger, key, parsed.dropSkills, st.parties.get(key).dropAsked); (st.ledger = drop.ledger), (st.parties.get(key).dropAsked = drop.outcome.pending); // a handoff naming nothing resets the count
512    if (parsed.dropSkills.length > 0) (record(st, $, { event: 'drop', party: key, ...drop.outcome }), await keepKept(st, $, key));
513    const files = parsed.loadFiles.length;
514    record(st, $, { event: 'stored', party: key, chars: parsed.handoff.length, files, tokens: tokens ?? null, ...(replaced !== undefined ? { replaced: replaced.storedAtTokens ?? null } : {}) });
515    return [key === 'main' ? receipt({ party: 'main', chars: parsed.handoff.length, files, tokens, askPoint: m?.askPoint, replaced: replaced !== undefined }) : receipt({ party: 'subagent', chars: parsed.handoff.length, files, tokens, askPoint: m!.askPoint, replaced: replaced !== undefined }), dropNote(drop.outcome)].filter(Boolean).join(' ');
516  } catch (error) {
517    say(st, $, `forward-compaction: storing ${key}'s handoff failed: ${message(error)}`, 'error', { event: 'store-error', party: key, error: message(error) });
518    return `forward_compaction failed, nothing stored: ${message(error)}`;
519  }
520}
521
522/** Past a nudge point with nothing stored, the tool result carries the nudge once per level (R37). */
523async function nudged(st: State, $: EngineInterface, key: string, result: ToolCallResult): Promise<ToolCallResult> {
524  if (result.deny !== undefined) return result;
525  try {
526    await start(st, $);
527    judge(st, $, key); const party = st.parties.get(key);
528    const tokens = party.reading; // measured only: a step late at worst, never an estimate
529    const m = marks(st, key);
530    const level = tokens === undefined || m === undefined ? 0 : nudgeLevel(tokens, m.start, m.every);
531    const withheld = tokens === undefined ? 'no-reading' : m === undefined ? 'window-unknown' : level === 0 ? 'below-start' : st.parties.ignored(key, st.config.ignoreModels) !== undefined ? 'ignored-model' : !st.parties.nudgeDue(key, level) ? 'not-due' : undefined;
532    if (withheld !== undefined) return (dbg(st, $, 'nudge-withheld', { party: key, why: withheld, tokens: tokens ?? null, level, start: m?.start ?? null, every: m?.every ?? null }), result);
533    record(st, $, { event: 'nudge', party: key, level, tokens, start: m!.start, every: m!.every });
534    return { ...result, context: [...(result.context ?? []), nudgeText({ tokens: tokens!, window: m!.maxContext, ceiling: m!.ceiling, tool: st.toolNames.forward_compaction, ...(key === 'main' || party.gateLevel === undefined ? {} : { gateAt: gateMark(m!.start, m!.every, party.gateLevel) }) })] };
535  } catch (error) {
536    say(st, $, `forward-compaction: nudging ${key} failed: ${message(error)}`, 'error', { event: 'nudge-error', party: key, error: message(error) });
537    return result;
538  }
539}
540
541/** The refusal for a call that waits on the party's handoff (src/nudge.ts `gated`); `opens` or `open` once the gate lets it run (Parties `gateAnswer`); undefined when not gated. */
542function gateRefusal(st: State, key: PartyKey, tool: string): string | undefined {
543  if (!key.startsWith('agent:') || !st.knownAgents.has(key.slice('agent:'.length))) return undefined; // main and Claude Code's own forks are never gated
544  const party = st.parties.get(key);
545  const m = marks(st, key);
546  const open = [st.toolNames.forward_compaction, st.toolNames.look_back, 'ToolSearch'].filter((t): t is string => t !== undefined);
547  const level = party.reading === undefined || m === undefined ? 0 : nudgeLevel(party.reading, m.start, m.every);
548  if (m === undefined || !gated({ party: 'subagent', level, gateLevel: party.gateLevel, stored: party.stored !== undefined, ignored: st.parties.ignored(key, st.config.ignoreModels) !== undefined, tool, open })) return undefined;
549  const answer = st.parties.gateAnswer(key);
550  return answer === 'refuse' ? gateText({ tokens: party.reading!, ceiling: m.ceiling, tool: st.toolNames.forward_compaction }) : answer;
551}
552
553/** A sub-agent spawned here is ours; any other agent id the engine's own run, unless `$.agent.list` knows it. */
554async function partyKind(st: State, $: EngineInterface, agentId: string): Promise<'subagent' | 'foreign'> {
555  if (st.knownAgents.has(agentId)) return 'subagent';
556  try {
557    const agents = await $.agent.list();
558    const agent = agents.find((a) => a.id === agentId);
559    dbg(st, $, 'agent.list', { count: agents.length, agentId, found: agent !== undefined });
560    if (agent === undefined) return 'foreign';
561    st.knownAgents.set(agentId, agent.type);
562    st.parties.describe(`agent:${agentId}`, { label: `${agent.type} ${agentId.slice(0, 6)}` });
563    return 'subagent';
564  } catch (error) {
565    warnOnce(st, $, 'agent-list', `forward-compaction: listing agents failed: ${message(error)}; agent ${agentId} is handled as a sub-agent`);
566    return 'subagent';
567  }
568}
569
570/** A rejected list is the wake's own agents error; a list that is no list throws, and the ask falls to C11. */
571async function agentRows($: EngineInterface): Promise<AgentRow[] | { error: string }> {
572  const list = await $.agent.list().then((agents) => agents, (error: unknown) => ({ error: message(error) }));
573  return 'error' in list ? list : list.map((a) => ({ id: a.id, type: a.type, status: a.status, description: a.description, ...(a.parentId !== undefined ? { parentId: a.parentId } : {}) }));
574}
575
576/** Each load file read now, whole or by its lines; one that cannot be read is carried as its problem. */
577async function readLoadFiles($: EngineInterface, files: readonly LoadFile[]): Promise<WakeFile[]> {
578  return Promise.all(files.map(async (file): Promise<WakeFile> => {
579    try {
580      const sliced = sliceFile(await $.fs.read(file.path), file.lines === undefined ? undefined : parseLineRanges(file.lines));
581      return 'body' in sliced ? { ...file, ...(sliced.shown ? { lines: sliced.shown } : {}), content: sliced.body } : { ...file, problem: sliced.problem };
582    } catch (error) {
583      return { ...file, problem: message(error) };
584    }
585  }));
586}
587
588/** A wake the engine took: no reading stands until the next request's, its next fall is no unseen compaction, and a landing still waiting from an earlier install is said now. */
589function installed(st: State, $: EngineInterface, key: string, landing: Landing | undefined, signal?: AbortSignal): void {
590  if (signal?.aborted) return;
591  land(st, $, key, st.parties.installed(key, landing));
592  st.installedSince.add(key);
593}
594
595/** M2: an installed wake's landed line and `landed` row, at the party's first reading after it (`after`), or at its end with `after` unknown. A chosen handoff's line is a routine info row in the logs; a mechanical wake lost the model's frontier, so the person sees it. */
596function land(st: State, $: EngineInterface, key: string, landing: Landing | undefined, after?: number): void {
597  if (landing !== undefined) { const line = landedLine(landing, after), row = { event: 'landed', party: key, kind: landing.kind, before: landing.before ?? null, after: after ?? null, line: screenLine(line) }; if (landing.kind === 'handoff') say(st, $, line, 'info', row); else tell(st, $, line, row); }
598}
599
600/** One session.compact ask, rows C1-C10: decide, then pass, hold, or install the handoff or a mechanical wake. */
601async function compactAsk(st: State, $: EngineInterface, e: SessionCompactInput, key: PartyKey, pass: () => Promise<SessionCompactResult>, signal?: AbortSignal): Promise<SessionCompactResult> {
602  await start(st, $);
603  for (const who of [key, 'unknown' as const]) await guard(st, $, 'kept restore', () => restoreKept(st, $, who));
604  let trigger = knownTrigger(e.trigger);
605  if (trigger === undefined) {
606    warnOnce(st, $, `trigger:${String(e.trigger)}`, `forward-compaction: session.compact trigger ${String(e.trigger)} is unknown; it is handled as an automatic ask`);
607    trigger = 'auto';
608  }
609  const kind = key === 'main' ? 'main' : await partyKind(st, $, e.agentId!);
610  judge(st, $, key); const party = st.parties.get(key);
611  const { label, stored: held } = party;
612  const found = held !== undefined && splitAtCall(e.messages, held.toolUseId) !== undefined;
613  const tokens = party.reading; // measured only; none since an install, or yet, is unknown
614  const m = marks(st, key);
615  const matched = st.parties.ignored(key, st.config.ignoreModels);
616  const action = decide({ trigger, party: kind, stored: found, storedMissing: held !== undefined && !found, tokens, ceiling: m?.ceiling, reactive: st.parties.reactive(key), ceilingNative: st.config.ceilingNative, label, ignored: matched !== undefined, windowUnknown: st.parties.windowUnknown(key, judging(st).mainKnown) });
617  const row: Row = { event: 'compact', party: key, trigger, decision: action.decision, why: action.why, tokens: tokens ?? null, ceiling: m?.ceiling ?? null, askPoint: m?.askPoint ?? null, ...(matched !== undefined ? { matched } : {}) };
618  if ('dropStored' in action && action.dropStored && held !== undefined) {
619    st.parties.takeStored(key);
620    tell(st, $, action.why === 'manual' ? `forward-compaction: /compact ran Claude Code's own summary; ${label}'s stored handoff was dropped.` : `forward-compaction: ${label}'s stored handoff is no longer in its transcript (compacted by something else); it was dropped.`, { event: 'dropped', party: key, why: action.why });
621  }
622  if (action.action === 'next') {
623    if (kind === 'foreign') st.parties.forget(key);
624    else (st.parties.asked(key, action.decision), st.installedSince.add(key)); // Claude Code's own summary: its fall is seen
625    record(st, $, row);
626    if (action.decision === 'native' && action.why !== 'manual') tell(st, $, `forward-compaction: ${label} ${action.why === 'ceiling' ? `reached its ceiling ${k(m!.ceiling)}` : 'was too long for its request'} with no handoff; Claude Code's own summary ran (FORWARD_COMPACTION_CEILING_NATIVE=1).`, { event: 'native', party: key, why: action.why });
627    return pass();
628  }
629  if (action.action === 'skip') {
630    // A precompute is no ask for a handoff: only a held one makes the next ask reactive (D8).
631    if (action.why !== 'precompute') st.parties.asked(key, 'skip');
632    record(st, $, { ...row, reason: action.reason });
633    return { skip: action.reason };
634  }
635  const shared = { messages: e.messages, party: kind === 'main' ? ('main' as const) : ('subagent' as const), tokens, askPoint: m?.askPoint ?? 0, charsPerToken: ratioOf(st, $, key, 'compact', await partyChars(st, $, key, e.agentId), m), now: await $.clock.now(), facts: (kept: readonly string[]) => factsFor(st.ledger, key, { keptTexts: kept, ...(action.action === 'install' ? { since: held!.since } : {}) }), agents: await agentRows($), logDir: st.pluginLogDir, ...(e.agentId !== undefined ? { self: e.agentId } : {}) };
636  const wake = action.action === 'install'
637    ? buildWake({ ...shared, toolUseId: held!.toolUseId, handoff: held!.text, maxContext: m?.maxContext, tool: st.toolNames.forward_compaction, files: await readLoadFiles($, held!.loadFiles) })
638    : mechanicalWake({ ...shared, tool: st.toolNames.forward_compaction, reason: action.why === 'ceiling' ? { kind: 'ceiling', ceiling: m!.ceiling } : { kind: 'reactive' } });
639  dbg(st, $, 'wake', { party: key, kind: action.action, ratio: shared.charsPerToken ?? null, askPoint: shared.askPoint, sizes: wake.sizes, headChars: wake.headChars, carried: wake.carried, notCarried: wake.notCarried, kept: wake.kept, dropped: wake.dropped });
640  installed(st, $, key, action.action === 'install' ? { kind: 'handoff', label, before: tokens, chars: held!.text.length, facts: wake.facts, kept: wake.kept, dropped: wake.dropped } : { kind: 'mechanical', label, before: tokens, tokens, ceiling: m?.ceiling ?? 0 }, signal);
641  record(st, $, { ...row, facts: wake.facts, kept: wake.kept, dropped: wake.dropped });
642  return { messages: wake.messages, ...(tokens !== undefined ? { tokensBefore: tokens } : {}) }; // tokensAfter: the next reading measures it (M2)
643}
644
645/** A party's live-window characters through `$.session.messages({ as: 'api' })` (main: no agentId), what its reading counts; a denied or failed read is said in its row and is no count. */
646async function partyChars(st: State, $: EngineInterface, key: string, agentId: unknown): Promise<number | undefined> {
647  return Promise.resolve().then(() => (typeof agentId !== 'string' ? $.session.messages({ as: 'api' }) : $.session.messages({ as: 'api', agentId }))).then((found) => ('deny' in found ? (dbg(st, $, 'session.messages-denied', { party: key, deny: found.deny }), undefined) : apiChars(found)), (error: unknown) => (dbg(st, $, 'session.messages-failed', { party: key, error: message(error) }), undefined));
648}
649
650/** A party's measured characters per token on its growth since its base, every count the live window `$.session.messages({ as: 'api' })` answers; unmeasured, or with no marks, nothing is converted, and its row says why. */
651function ratioOf(st: State, $: EngineInterface, key: string, at: 'compact' | 'tool-call', chars: number | undefined, m: Marks | undefined): number | undefined {
652  const { reading, base } = st.parties.get(key);
653  const r: { ratio?: number; why?: string; delta?: number } = m === undefined ? { why: 'no-marks' } : charsPerToken(base, chars, reading);
654  dbg(st, $, r.ratio === undefined ? 'ratio-unmeasured' : 'ratio', { party: key, at, baseChars: base?.chars ?? null, baseTokens: base?.tokens ?? null, chars: chars ?? null, reading: reading ?? null, ...r });
655  return r.ratio;
656}
657
658/** C11 (R35): an ask whose handling threw gets a mechanical wake, never the engine's summarizer by accident (A2); only when that throws too does Claude Code's own summary run. */
659async function compactFailed(st: State, $: EngineInterface, e: SessionCompactInput, key: PartyKey, error: unknown, pass: () => Promise<SessionCompactResult>, signal?: AbortSignal): Promise<SessionCompactResult> {
660  const party = st.parties.get(key);
661  const said = `forward-compaction: error in session.compact for ${party.label}: ${message(error)}`;
662  try {
663    const tokens = party.reading;
664    const m = marks(st, key);
665    const wake = mechanicalWake({ messages: e.messages, party: key === 'main' ? 'main' : 'subagent', tokens, askPoint: m?.askPoint ?? 0, charsPerToken: ratioOf(st, $, key, 'compact', await partyChars(st, $, key, e.agentId), m), now: Date.now(), facts: (kept) => factsFor(st.ledger, key, { keptTexts: kept }), tool: st.toolNames.forward_compaction, logDir: st.pluginLogDir, reason: m === undefined ? { kind: 'reactive' } : { kind: 'ceiling', ceiling: m.ceiling } });
666    installed(st, $, key, undefined, signal); // the error line below is said now; no landing waits
667    tell(st, $, `${said}; a mechanical wake was installed.`, { event: 'compact', party: key, decision: 'error', error: message(error), kept: wake.kept, dropped: wake.dropped });
668    return { messages: wake.messages, ...(tokens !== undefined ? { tokensBefore: tokens } : {}) };
669  } catch (again) {
670    tell(st, $, `${said}; Claude Code's own summary ran.`, { event: 'compact', party: key, decision: 'error', error: message(error), fallback: message(again) });
671    return pass();
672  }
673}
674
675/** Main's window and Claude Code's ask point, at session start, at main's first step when not read yet, and after its model changed (/model, `model`); a failed read keeps what is known. */
676async function readMainWindow(st: State, $: EngineInterface, model?: string): Promise<void> {
677  try {
678    const { context } = await $.session.usage({ breakdown: 'summary' });
679    const b = context.breakdown;
680    dbg(st, $, 'session.usage', { model: model ?? null, window: context.window ?? null, tokens: context.tokens ?? null, autoCompactThreshold: b?.autoCompactThreshold ?? null, rawMaxTokens: b?.rawMaxTokens ?? null, autocompactSource: b?.autocompactSource ?? null, isAutoCompactEnabled: b?.isAutoCompactEnabled ?? null, breakdownModel: b?.model ?? null });
681    const window = typeof context.window === 'number' && context.window > 0 ? context.window : st.mainWindow;
682    if (model !== undefined && st.mainWindow !== undefined && window !== st.mainWindow) record(st, $, { event: 'window-changed', party: 'main', model, from: st.mainWindow, to: window });
683    Object.assign(st, { mainWindow: window, breakdown: b, apiAsk: askPointOf(b, window) });
684    const missing = st.apiAsk?.missing;
685    if (missing !== undefined) warnOnce(st, $, `ask-point:${missing}`, `forward-compaction: $.session.usage reported no ${missing === 'breakdown' ? 'context breakdown' : 'autoCompactThreshold (auto-compaction is off)'}; Claude Code's ask point is taken as its ${st.apiAsk!.source} ${k(st.apiAsk!.tokens)}`);
686  } catch (error) {
687    warnOnce(st, $, 'usage', `forward-compaction: reading main's context window${model === undefined ? '' : ` after its model changed to ${model}`} failed: ${message(error)}; ${st.mainWindow === undefined ? "main's window stays unknown" : `keeping ${k(st.mainWindow)}`}`);
688  }
689}
690
691/** A step's usage is the party's reading; a sharp fall nobody here caused is an unseen compaction (R39). */
692async function readStep(st: State, $: EngineInterface, e: TurnStepInput, r: TurnStepResult): Promise<void> {
693  const key = keyOf(e.agentId);
694  const u = r?.usage;
695  dbg(st, $, 'turn.step', { party: key, model: e.model ?? null, input_tokens: u?.input_tokens ?? null, cache_read_input_tokens: u?.cache_read_input_tokens ?? null, cache_creation_input_tokens: u?.cache_creation_input_tokens ?? null, output_tokens: u?.output_tokens ?? null, usageModel: u?.model ?? null });
696  if (key === 'main' && e.model !== undefined && (st.mainWindow === undefined || (st.parties.get(key).model ?? e.model) !== e.model)) await readMainWindow(st, $, e.model); // not read yet, or after /model
697  st.parties.describe(key, { model: e.model }); judge(st, $, key);
698  if (key === 'main') st.pendingCommand = undefined;
699  const tokens = readingTokens(u);
700  if (tokens === undefined) return st.parties.answered(key);
701  const party = st.parties.get(key);
702  if (unseenCompaction(party.reading, tokens, st.installedSince.has(key))) {
703    tell(st, $, `forward-compaction: ${party.label}'s context fell from ${k(party.reading!)} to ${k(tokens)} with no compaction this plugin handled (another plugin or Claude Code compacted it).`, { event: 'unseen-compaction', party: key, before: party.reading, after: tokens });
704    st.parties.rebase(key, undefined);
705  }
706  const m = marks(st, key);
707  const level = m === undefined ? 0 : nudgeLevel(tokens, m.start, m.every);
708  dbg(st, $, 'reading', { party: key, tokens, level, from: party.reading ?? null, absorb: party.absorbNext });
709  land(st, $, key, st.parties.read(key, tokens, level), tokens);
710  st.installedSince.delete(key);
711  if (party.base !== undefined) return; // the first reading in its live window takes the ratio's base (window.ts `charsPerToken`)
712  const chars = await partyChars(st, $, key, e.agentId);
713  dbg(st, $, 'ratio-base', { party: key, chars: chars ?? null, tokens });
714  if (chars !== undefined) st.parties.rebase(key, { chars, tokens });
715}
716
717/** P1 (R32): main's answered turn with a handoff stored compacts once. A sub-agent's turn end is one run, never its finish (a child's notification resumes it): its party stays, only a waiting landing is said. */
718async function turnEnds(st: State, $: EngineInterface, e: TurnCompleteInput): Promise<void> {
719  const key = keyOf(e.agentId);
720  if (key !== 'main') return land(st, $, key, st.parties.takeLanding(key));
721  const party = st.parties.get('main');
722  const door = turnEndDoor({ main: true, reason: e.reason, stored: party.stored !== undefined, installedThisTurn: party.installedThisTurn });
723  st.parties.turnEnded('main');
724  if (!door.compact) return;
725  try {
726    const out = await $.session.compact();
727    if (out.skip !== undefined) tell(st, $, `forward-compaction: main's handoff did not install at turn end: ${out.skip}. It stays stored for the next turn end or ask.`, { event: 'turn-end-skipped', party: 'main', skip: out.skip });
728  } catch (error) {
729    record(st, $, { event: 'turn-end-rejected', party: 'main', error: message(error) });
730  }
731}
732
733/** What the person typed is a fact of main's; a message from anywhere else is a notice. */
734async function noteSubmit(st: State, $: EngineInterface, e: PromptSubmitInput): Promise<void> {
735  if (e.isMeta) return;
736  const at = await $.clock.now();
737  const origin = originKind(e.origin);
738  st.pendingCommand = e.command?.name;
739  st.ledger = isPersonOrigin(origin)
740    ? addEntry(st.ledger, { kind: 'person', party: 'main', at, text: e.text, ...(e.command ? { command: e.command.name } : {}) })
741    : addEntry(st.ledger, { kind: 'notice', party: 'main', at, origin, text: e.text });
742  if (isPersonOrigin(origin)) await keepKept(st, $, 'main');
743}
744
745/** The files a finished call touched and the task list it set, as the party's work (src/work.ts). */
746async function noteWork(st: State, $: EngineInterface, party: PartyKey, e: Record<string, unknown>, answer: ToolCallResult): Promise<void> {
747  for (const fact of workOf(String(e.tool), toolInput(e), answer as CallAnswer)) st.ledger = addEntry(st.ledger, { kind: 'work', party, at: await $.clock.now(), fact });
748}
749
750/** A party's newest prompts and its loaded skills, held by the host for the session (contract.d.ts), so a plugin reload does not lose them. */
751async function keepKept(st: State, $: EngineInterface, party: PartyKey): Promise<void> {
752  await restoreKept(st, $, party);
753  const id = `${await (st.sessionId ??= $.session.id())}/${party}`;
754  await $.state.set({ plugin: 'forward-compaction', key: 'prompts', id }, promptsKept(st.ledger, party));
755  await $.state.set({ plugin: 'forward-compaction', key: 'skills', id }, skillsKept(st.ledger, party));
756}
757
758/** What a party kept before a plugin reload, put back once per process: before its first write, so a write never drops it, and before its wake. */
759async function restoreKept(st: State, $: EngineInterface, party: PartyKey): Promise<void> {
760  if (st.restored.has(party)) return;
761  st.restored.add(party);
762  const id = `${await (st.sessionId ??= $.session.id())}/${party}`;
763  st.ledger = restorePrompts(st.ledger, party, (await $.state.get({ plugin: 'forward-compaction', key: 'prompts', id })).value);
764  st.ledger = restoreSkills(st.ledger, party, (await $.state.get({ plugin: 'forward-compaction', key: 'skills', id })).value);
765}
766
767/** A turn starts. A sub-agent's later run begins with what woke it (a background child's notification): recorded like a coordinator's message, once. */
768async function noteRun(st: State, $: EngineInterface, e: TurnStartInput): Promise<void> {
769  const key = keyOf(e.agentId);
770  const first = key !== 'main' && st.parties.get(key).requests === 0; // the first run begins with the brief, recorded at agent.spawn
771  st.parties.newTurn(key);
772  if (key === 'main' || first || e.text === '') return;
773  const last = [...st.ledger.entries].reverse().find((x) => (x.kind === 'brief' || x.kind === 'received') && x.party === key);
774  if (last !== undefined && 'text' in last && last.text === e.text) return; // session.receive recorded it already
775  st.ledger = addEntry(st.ledger, { kind: 'received', party: key, at: await $.clock.now(), origin: 'run-start', text: e.text });
776  await keepKept(st, $, key);
777}
778
779async function noteReceived(st: State, $: EngineInterface, e: SessionReceiveInput): Promise<void> {
780  if (e.agentId === undefined) return;
781  st.ledger = addEntry(st.ledger, { kind: 'received', party: keyOf(e.agentId), at: await $.clock.now(), origin: originKind(e.origin), text: e.text });
782  await keepKept(st, $, keyOf(e.agentId));
783}
784
785/** D5: the party a skill belongs to, from the calls in flight around it (the event names none). */
786async function noteSkill(st: State, $: EngineInterface, e: SkillPromptInput): Promise<void> {
787  const inFlightSkills = [...st.inFlight].filter(([, call]) => call.tool === 'Skill').map(([tool_use_id, call]) => ({ party: call.party, tool_use_id }));
788  const { party, how } = attributeSkill({ inFlightSkills, pendingCommand: st.pendingCommand, pendingSpawns: st.pendingSpawns, skill: e.skill });
789  st.ledger = addEntry(st.ledger, { kind: 'skill', party, at: await $.clock.now(), skill: e.skill, text: e.text, how, ...(pinnedBy(e.frontmatter) || st.config.keepSkills.includes(e.skill) ? { keep: true as const } : {}), ...(e.source !== undefined ? { source: e.source } : {}), ...(e.path !== undefined ? { path: e.path } : {}) });
790  record(st, $, { event: 'skill', party, skill: e.skill, how });
791  await keepKept(st, $, party);
792}
793
794/** A spawned agent's brief is its first fact, and the skills it preloaded become its own. */
795async function noteSpawn(st: State, $: EngineInterface, e: AgentSpawnInput, r: AgentSpawnResult): Promise<void> {
796  const id = r?.agentId;
797  if (typeof id !== 'string' || id === '') return;
798  const key: PartyKey = `agent:${id}`;
799  st.ledger = resolveSpawn(addEntry(st.ledger, { kind: 'brief', party: key, at: await $.clock.now(), text: e.prompt, description: e.description, subagentType: e.subagentType, spawnedBy: st.inFlight.get(e.tool_use_id)?.party ?? 'unknown' }), e.tool_use_id, id);
800  st.knownAgents.set(id, e.subagentType);
801  st.parties.describe(key, { label: `${e.subagentType} ${id.slice(0, 6)}` }); judge(st, $, key);
802  await keepKept(st, $, key); // its brief, and its preloaded skills filed under the spawn until now
803}
804
805/** The shells and crons running at a stop: the latest snapshot is a fact of every wake. */
806async function noteShells(st: State, $: EngineInterface, e: Record<string, unknown>): Promise<void> {
807  st.ledger = addEntry(st.ledger, { kind: 'shells', at: await $.clock.now(), ...shellsOf(e) });
808}
809
810export const register: Register = (on: On, options: PluginOptions) => {
811  const st: State = {
812    config: resolveOptions(),
813    ignoredOptions: options !== null && typeof options === 'object' ? Object.keys(options) : [],
814    pluginRows: [], pluginInitialized: false, pluginWriting: Promise.resolve(), pluginFailing: false,
815    toolNames: {}, toolTries: { forward_compaction: 0, look_back: 0 },
816    warned: new Set(), logFailures: new Set(),
817    pending: [], rowsWriting: 0, levelRowsWriting: 0, levelLinks: 0, levelBatched: 0, skips: new SkipFold(), levelSkips: new SkipFold(), writing: Promise.resolve(),
818    parties: new Parties(), ledger: newLedger(), restored: new Set(),
819    inFlight: new Map(), pendingSpawns: new Set(), knownAgents: new Map(), installedSince: new Set(),
820  };
821
822  on('session.start', async ($, e, next) => {
823    const result = await next(e);
824    await ensureTools(st, $, next.signal);
825    await guard(st, $, 'session.start', () => sessionBegins(st, $), e);
826    return result;
827  });
828
829  on('tool.call', async ($, e, next) => {
830    dbg(st, $, 'hook', hookRow('tool.call', e));
831    if (st.toolNames.look_back !== undefined && e.tool === st.toolNames.look_back) return { result: await answerLookBackTool(st, $, e as Record<string, unknown>) };
832    if (st.toolNames.forward_compaction !== undefined && e.tool === st.toolNames.forward_compaction) return { result: experimental(await answerForward(st, $, e as Record<string, unknown>), st.pluginLogDir) };
833    await unlessAborted(observeManualLookBack(st, $, e as Record<string, unknown>), next.signal);
834    const key = keyOf(e.agentId);
835    const refused = gateRefusal(st, key, String(e.tool));
836    if (refused === 'opens') record(st, $, { event: 'gate-opened', party: key, tool: e.tool, tokens: st.parties.get(key).reading ?? null });
837    else if (refused !== undefined && refused !== 'open') return (record(st, $, { event: 'gated', party: key, tool: e.tool, tokens: st.parties.get(key).reading ?? null }), { deny: refused });
838    const id = e.tool_use_id;
839    const tracked = typeof id === 'string' && (e.tool === 'Skill' || e.tool === 'Agent');
840    if (tracked) st.inFlight.set(id, { party: key, tool: e.tool });
841    let result: ToolCallResult;
842    try {
843      result = await next(e);
844    } finally {
845      if (tracked) st.inFlight.delete(id);
846    }
847    await guard(st, $, 'tool.call work', () => noteWork(st, $, key, e, result));
848    return nudged(st, $, key, result);
849  });
850
851  on('session.compact', async ($, e, next) => {
852    const key = keyOf(e.agentId);
853    dbg(st, $, 'hook', hookRow('session.compact', e));
854    try {
855      return await compactAsk(st, $, e, key, () => next(e), next.signal);
856    } catch (error) {
857      return compactFailed(st, $, e, key, error, () => next(e), next.signal);
858    }
859  });
860
861  on('turn.step', async function* ($, e, next) {
862    const r = yield* next(e);
863    await guard(st, $, 'turn.step', () => readStep(st, $, e, r), e);
864    return r;
865  });
866
867  on('turn.start', async ($, e, next) => {
868    await guard(st, $, 'turn.start', () => noteRun(st, $, e), e);
869    return next(e);
870  });
871
872  on('turn.complete', async ($, e, next) => {
873    const r = await next(e);
874    await guard(st, $, 'turn.complete', () => turnEnds(st, $, e), e);
875    return r;
876  });
877
878  on('prompt.submit', async ($, e, next) => {
879    await guard(st, $, 'prompt.submit', () => noteSubmit(st, $, e), e);
880    return next(e);
881  });
882
883  on('session.receive', async ($, e, next) => {
884    await guard(st, $, 'session.receive', () => noteReceived(st, $, e), e);
885    return next(e);
886  });
887
888  on('skill.prompt', async ($, e, next) => {
889    await guard(st, $, 'skill.prompt', () => noteSkill(st, $, e), e);
890    return next(e);
891  });
892
893  on('agent.spawn', async ($, e, next) => {
894    st.pendingSpawns.add(e.tool_use_id);
895    let r: AgentSpawnResult;
896    try {
897      r = await next(e);
898    } finally {
899      st.pendingSpawns.delete(e.tool_use_id);
900    }
901    await guard(st, $, 'agent.spawn', () => noteSpawn(st, $, e, r), e);
902    return r;
903  });
904
905  on('classic.Stop', async ($, e, next) => {
906    await guard(st, $, 'classic.Stop', () => noteShells(st, $, e as Record<string, unknown>), e);
907    return next(e);
908  });
909
910  on('classic.SubagentStop', async ($, e, next) => {
911    await guard(st, $, 'classic.SubagentStop', () => (land(st, $, keyOf(e.agent_id), st.parties.takeLanding(keyOf(e.agent_id))), noteShells(st, $, e as Record<string, unknown>)), e);
912    return next(e);
913  });
914
915  on('session.end', async ($, e, next) => {
916    // A /clear or a resume ends the conversation, and the process goes on under a new session id with no session.start.
917    const goesOn = e.reason === 'clear' || e.reason === 'resume';
918    dbg(st, $, 'hook', { ...hookRow('session.end', e), reason: e.reason });
919    if (goesOn) record(st, $, { event: 'cleared', party: 'main', reason: e.reason });
920    for (const [key, landing] of st.parties.takeLandings()) land(st, $, key, landing); // ended with no reading after its install
921    // The open skip streaks close now, and the session ends only once every queued row is written.
922    for (const diagnostic of st.levelSkips.drain().map(pluginLogEvent)) if (diagnostic !== undefined) plog(st, $, diagnostic);
923    if (st.config.logFile) {
924      for (const out of st.skips.drain()) st.pending.push(JSON.stringify(out));
925      flushDecisions(st, $, true);
926    }
927    // The whole end chain shares one short bound: the writes get what it leaves less core's share, and the conversation starts over either way. A write queued while the end waits (a cleanup failure said) is waited for too.
928    const settled = async (): Promise<void> => {
929      for (;;) {
930        const writing = st.writing, plugin = st.pluginWriting;
931        await Promise.all([writing, plugin]);
932        if (writing === st.writing && plugin === st.pluginWriting) return;
933      }
934    };
935    const left = next.budget?.remainingMs ?? Infinity;
936    const outcome = Number.isFinite(left) ? await bounded(settled(), Math.max(0, left - END_CORE_RESERVE_MS), (ms, fn) => afterOrNever(st, $, ms, fn, 'the session-end log flush'), next.signal) : (await settled(), { timedOut: false as const });
937    const report = endReport({
938      timedOut: outcome.timedOut, boundMs: next.budget?.ms ?? left, decisionRows: st.pending.length + st.rowsWriting, levelRows: st.levelRowsWriting,
939      held: st.config.logFile && st.writeError !== undefined ? st.pending.length : 0, dropped: st.config.logFile ? st.rowsDropped?.rows ?? 0 : 0,
940      error: st.writeError ?? st.rowsDropped?.error ?? 'unknown', // a cut chain may hold the path's own lookup: it is awaited only once settled, or once an append ran
941      path: !st.config.logFile ? '' : !outcome.timedOut || st.rowsDropped !== undefined ? (await st.logPath) ?? st.config.logFile : st.config.logFile,
942    });
943    if (report.cut !== undefined) show($, report.cut);
944    if (report.lost !== undefined) {
945      // The rows a failing append kept have no later flush, and the hold's drops were told only once: they are lost, and said so, never silently.
946      const { line, rows, dropped, error } = report.lost;
947      plog(st, $, { level: 'error', event: 'decision-log-rows-lost', msg: screenLine(line), where: 'hooks/forward-compaction.ts#session.end', detail: { rows, dropped, error } });
948      show($, line);
949      // The level row is the record of the loss: the end waits for it, within what the bound leaves.
950      const rest = next.budget?.remainingMs ?? Infinity;
951      if (Number.isFinite(rest)) await bounded(st.pluginWriting, Math.max(0, rest - END_CORE_RESERVE_MS), (ms, fn) => afterOrNever(st, $, ms, fn, 'the session-end log flush'), next.signal);
952      else await st.pluginWriting;
953    }
954    if (goesOn) {
955      startOver(st);
956      await guard(st, $, 'session.end', () => conversationStartsOver(st, $, e.reason));
957    }
958    return next(e);
959  });
960};
961
src/bounded.ts 53 lines
1/** `$.clock.after`: the hooks' environment has no `setTimeout`, so the wait is passed in. */
2export type After = (ms: number, fn: () => void) => { cancel: () => void };
3
4/** `aborted`: the wait ended because the hook's signal aborted, not because the time ran out. */
5export type Bounded<T> = { timedOut: true; aborted?: true } | { timedOut: false; value: T };
6
7/**
8 * The promise's value if it settles within `ms`, else `{ timedOut: true }`.
9 * A rejection passes on. The timer is cancelled whichever way it ends; the
10 * promise itself is not cancelled and may settle later.
11 *
12 * With a `signal`, its abort ends the wait at once as `{ timedOut: true, aborted: true }`:
13 * the timer belongs to the dispatch that was aborted and may never fire, and a
14 * hook that waits on past its abort is a runaway to the hooks worker.
15 */
16export async function bounded<T>(promise: Promise<T>, ms: number, after: After, signal?: AbortSignal): Promise<Bounded<T>> {
17  let timer: { cancel: () => void } | undefined;
18  let onAbort: (() => void) | undefined;
19  const limit = new Promise<{ timedOut: true; aborted?: true }>((resolve) => {
20    if (signal !== undefined) {
21      onAbort = () => resolve({ timedOut: true, aborted: true });
22      if (signal.aborted) onAbort();
23      else signal.addEventListener('abort', onAbort, { once: true });
24    }
25    if (signal?.aborted !== true) timer = after(ms, () => resolve({ timedOut: true }));
26  });
27  try {
28    return await Promise.race([promise.then((value) => ({ timedOut: false as const, value })), limit]);
29  } finally {
30    timer?.cancel();
31    if (onAbort !== undefined) signal?.removeEventListener('abort', onAbort);
32  }
33}
34
35/**
36 * The promise's value, or `undefined` once `signal` aborts first (at once when it already has). A rejection passes on;
37 * the promise itself is not cancelled and may settle later. No timer: this is for a hook's hot path, where a wait
38 * past the dispatch's abort is a runaway to the hooks worker.
39 */
40export async function unlessAborted<T>(promise: Promise<T>, signal: AbortSignal): Promise<{ value: T } | undefined> {
41  let onAbort: (() => void) | undefined;
42  const aborted = new Promise<undefined>((resolve) => {
43    onAbort = () => resolve(undefined);
44    if (signal.aborted) onAbort();
45    else signal.addEventListener('abort', onAbort, { once: true });
46  });
47  try {
48    return await Promise.race([promise.then((value) => ({ value })), aborted]);
49  } finally {
50    if (onAbort !== undefined) signal.removeEventListener('abort', onAbort);
51  }
52}
53
src/handoff.ts 256 lines
1// The forward_compaction tool contract: what the model may send, what is refused (never trimmed), and what the receipt says.
2
3import { k } from './nudge.ts';
4import { loadFilesRoom } from './window.ts';
5import { DROP_MAX } from './drop.ts';
6export { applyDrops, dropNote, pinnedBy } from './drop.ts';
7
8/** The tool's name as registered; the model sees `mcp__forward-compaction__forward_compaction`. */
9export const FORWARD_TOOL = 'forward_compaction';
10
11export const FORWARD_TOOL_DESCRIPTION =
12  'Hands off your context — at a clean milestone once context is large. Stores your handoff, one argument per section (20,000 characters in all), written for yourself after a reset; at your next compaction everything before this call is replaced by it plus recorded facts (the person\'s messages or your brief, your skills), and every later message stays verbatim. Returns a receipt of when it installs; a refusal names what to fix and stores nothing. Example: forward_compaction({"objective": "…", "done": "…", "remaining": "…", "next": "…", "load_files": []}). Exact old detail later → look_back.';
13
14/** The handoff's sections in the order they are assembled: the argument, its heading, whether it is required, and what it holds. */
15const SECTIONS = [
16  { key: 'objective', heading: 'Objective', required: true, description: 'What the work is for and what done means.' },
17  { key: 'done', heading: 'Done', required: true, description: 'Finished pieces, each with how it was verified.' },
18  { key: 'remaining', heading: 'Remaining', required: true, description: 'Every step left, in order.' },
19  { key: 'next', heading: 'Next', required: true, description: 'The first concrete action after the wake.' },
20  { key: 'blocked', heading: 'Blocked', required: false, description: 'Optional: what waits on whom.' },
21  { key: 'findings', heading: 'Findings', required: false, description: 'Optional: decisions with why, errors verbatim.' },
22] as const;
23
24export const FORWARD_INPUT_SCHEMA = {
25  type: 'object',
26  additionalProperties: false,
27  required: ['objective', 'done', 'remaining', 'next'],
28  properties: {
29    objective: { type: 'string', description: SECTIONS[0].description },
30    done: { type: 'string', description: SECTIONS[1].description },
31    remaining: { type: 'string', description: SECTIONS[2].description },
32    next: { type: 'string', description: SECTIONS[3].description },
33    blocked: { type: 'string', description: SECTIONS[4].description },
34    findings: { type: 'string', description: SECTIONS[5].description },
35    load_files: {
36      type: 'array',
37      maxItems: 8,
38      description: 'Files carried whole into the new context, read when it installs. Files that come to more than 5% of your window are refused at the call: pass lines ranges or fewer files.',
39      items: {
40        type: 'object',
41        additionalProperties: false,
42        required: ['path', 'why'],
43        properties: {
44          path: { type: 'string', description: 'Absolute path.' },
45          lines: { type: 'string', description: 'Line ranges, like "10-40" or "1-60,160-185".' },
46          why: { type: 'string', maxLength: 200, description: 'What you need it for.' },
47        },
48      },
49    },
50    drop_skills: {
51      type: 'array',
52      maxItems: DROP_MAX,
53      items: { type: 'string' },
54      description: 'Optional: names of skills or commands in your context you no longer need. Name one on two handoffs in a row and the second wake leaves it out; one its file pins stays.',
55    },
56  },
57} as const;
58
59// Format caps: design budgets on what the model may send, never a measured size.
60/** The assembled handoff's cap: a frontier stays a page or two the woken model reads whole, well inside any wake budget. */
61export const HANDOFF_MAX_CHARS = 20_000;
62/** Load files per call: enough for a plan, a spec and the files in hand, few enough that each is named and chosen. */
63export const LOAD_FILES_MAX = 8;
64/** A load file's `why`: one line saying what it is for. */
65export const WHY_MAX_CHARS = 200;
66/** A load file's size on disk (1 MiB): a file past it is a dump to slice with `lines`, not a file to carry whole. */
67export const LOAD_FILE_MAX_BYTES = 1_048_576;
68
69/** One file the model asked to carry whole into the wake; every field is the model's own text, kept verbatim. */
70export type LoadFile = { path: string; lines?: string; why: string };
71
72export type ParsedHandoff = { handoff: string; loadFiles: LoadFile[]; dropSkills: string[] };
73export type Refusal = { refusal: string };
74
75const FIELDS = [...SECTIONS.map((section) => section.key), 'load_files', 'drop_skills'];
76const PREFIX = 'forward_compaction refused, nothing stored: ';
77const refuse = (text: string): Refusal => ({ refusal: PREFIX + text });
78const num = (n: number): string => n.toLocaleString('en-US');
79const kind = (v: unknown): string => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v);
80const isAbsolutePath = (p: string): boolean => /^(\/|[A-Za-z]:[\\/]|\\\\)/.test(p);
81const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
82
83/** One line per bad argument: an unknown field, a required section missing, not a string or blank, an optional one given but not a string. */
84function argumentFaults(fields: Record<string, unknown>): string[] {
85  const unknown = Object.keys(fields).filter((key) => !FIELDS.includes(key)).map((key) => `unknown field "${key}". The fields are ${FIELDS.slice(0, -1).join(', ')} and drop_skills.`);
86  const sections = SECTIONS.flatMap(({ key, required }) => {
87    const value = fields[key];
88    if (required && (typeof value !== 'string' || value.trim() === '')) return [`${key} must be a non-blank string; got ${typeof value === 'string' ? 'a blank string' : kind(value)}.`];
89    if (!required && value !== undefined && typeof value !== 'string') return [`${key} must be a string when given; got ${kind(value)}.`];
90    return [];
91  });
92  return [...unknown, ...sections];
93}
94
95/**
96 * Validates the model's tool input before anything is stored (V1-V11 of the spec table), every bad argument named in
97 * one refusal, then assembles the handoff from its sections in order, an absent or blank optional one left out. Each
98 * section's text and the files come back exactly as sent: nothing is trimmed, a fault is refused for the model to fix.
99 * `$.fs.stat` checks (V12-V15) are the adapter's, through `loadFileRefusal`.
100 */
101export function parseHandoff(input: unknown): ParsedHandoff | Refusal {
102  const fields = isObject(input) ? input : {};
103  const faults = argumentFaults(fields);
104  if (faults.length > 0) return refuse(faults.join('\n'));
105  const handoff = SECTIONS.flatMap(({ key, heading }) => {
106    const text = fields[key];
107    return typeof text === 'string' && text.trim() !== '' ? [`## ${heading}\n${text}`] : [];
108  }).join('\n\n');
109  if (handoff.length > HANDOFF_MAX_CHARS) return refuse(`the handoff assembled from your sections is ${num(handoff.length)} characters, over the ${num(HANDOFF_MAX_CHARS)} cap. Shorten it (detail can go in load_files, or stay in the transcript for look_back) and call again.`);
110  const drops = fields.drop_skills;
111  if (drops !== undefined && (!Array.isArray(drops) || drops.length > DROP_MAX || drops.some((d) => typeof d !== 'string' || d.trim() === ''))) return refuse(`drop_skills must be an array of at most ${DROP_MAX} skill or command names, each a non-blank string.`);
112  const dropSkills = (drops ?? []) as string[];
113  const files = fields.load_files;
114
115  if (files === undefined) return { handoff, loadFiles: [], dropSkills };
116  if (!Array.isArray(files)) return refuse('load_files must be an array of {path, lines?, why}.');
117  if (files.length > LOAD_FILES_MAX) return refuse(`load_files has ${files.length} entries, over the ${LOAD_FILES_MAX} cap.`);
118  const loadFiles: LoadFile[] = [];
119  for (const [i, entry] of files.entries()) {
120    const { path, why, lines: ranges } = isObject(entry) ? entry : ({} as Record<string, unknown>);
121    if (typeof path !== 'string' || !isAbsolutePath(path)) return refuse(`load_files[${i}].path must be an absolute path; got "${String(path)}".`);
122    const whyLength = typeof why === 'string' ? why.length : 0;
123    if (typeof why !== 'string' || why.trim() === '' || whyLength > WHY_MAX_CHARS) return refuse(`load_files[${i}].why must be 1\u2013${WHY_MAX_CHARS} characters; it is ${whyLength}.`);
124    if (ranges !== undefined && (typeof ranges !== 'string' || parseLineRanges(ranges) === undefined)) return refuse(`load_files[${i}].lines "${String(ranges)}" is not a range like "10-40" or "1-60,160-185".`);
125    loadFiles.push({ path, why, ...(ranges !== undefined ? { lines: ranges as string } : {}) });
126  }
127  return { handoff, loadFiles, dropSkills };
128}
129
130/** What the adapter learned from `$.fs.stat(path)` on one load file: its stat, or `absent` (ENOENT), or the error text of any other throw. */
131export type FileCheck = { kind: 'file' | 'dir' | 'other'; size: number } | { absent: true } | { error: string };
132
133/** V12-V15: the refusal for one load file (`index` is its place in `load_files`, from 0), or undefined when it can be carried. */
134export function loadFileRefusal(index: number, path: string, check: FileCheck): string | undefined {
135  const at = `load_files[${index}] ${path}`;
136  if ('error' in check) return PREFIX + `${at} could not be checked: ${check.error}.`;
137  if ('absent' in check) return PREFIX + `${at}: no such file.`;
138  if (check.kind !== 'file') return PREFIX + `${at} is not a regular file.`;
139  if (check.size > LOAD_FILE_MAX_BYTES) return PREFIX + `${at} is ${num(check.size)} bytes, over the ${num(LOAD_FILE_MAX_BYTES)} cap for a carried file.`;
140  return undefined;
141}
142
143/** `tokens`: the party's last reading, absent when it has none; it picks the sentence and is never printed, being a step behind the call. */
144export type ReceiptInput =
145  | { party: 'main'; chars: number; files: number; tokens?: number; askPoint?: number; replaced?: boolean }
146  | { party: 'subagent'; chars: number; files: number; tokens?: number; askPoint: number; replaced?: boolean };
147
148/** The tool result of a stored handoff: when it installs, true to the party's door (a sub-agent's only door is Claude Code's ask). */
149export function receipt(input: ReceiptInput): string {
150  const stored = `Handoff stored (${num(input.chars)} characters, ${input.files} files).`;
151  const behind = 'Everything after this call is kept verbatim behind it';
152  const replaced = input.replaced ? ' It replaces the handoff you stored before.' : '';
153  if (input.party === 'main') return `${stored} It installs at the end of this turn, or at Claude Code's next compaction ask if one comes first. ${behind}, so finish the step in hand; you need not stop.${replaced}`;
154  if (input.tokens !== undefined && input.tokens >= input.askPoint) return `${stored} It installs at Claude Code's next compaction ask, before your next request. ${behind}.${replaced}`;
155  return `${stored} Claude Code has not asked to compact you yet: it installs at Claude Code's next ask, from about ${k(input.askPoint)} context. ${behind}. Carry on with your work.${replaced}`;
156}
157
158/**
159 * The refusal for load files whose content comes to more than `loadFilesRoom` of the party's max context at its measured
160 * characters per token (`ratio`), each file named with its size (`chars`, as read for the wake: a `lines` range counts its
161 * lines alone); undefined when they fit, and with no ratio (unmeasured yet), when nothing can be measured. Refused, never trimmed.
162 */
163export function loadRoomRefusal(files: readonly { path: string; chars: number }[], maxContext: number, ratio: number | undefined): string | undefined {
164  const room = loadFilesRoom(maxContext, ratio);
165  const total = files.reduce((sum, f) => sum + f.chars, 0);
166  if (total <= room) return undefined;
167  return [
168    PREFIX + `load_files come to ${num(total)} characters, over the ${num(room)}-character room for carried files (5% of your ${k(maxContext)} max context):`,
169    ...files.map((f) => `- ${f.path}: ${num(f.chars)} characters`),
170    'Pass lines ranges (like "10-40" or "1-60,160-185") to carry only what you need, or fewer files, and call again.',
171  ].join('\n');
172}
173
174/** The answer to a forward_compaction call from a party whose model is on the ignore list (`Parties.ignored`, the entry it matched): refused, nothing stored. Undefined for a party the plugin manages. */
175export function ignoredRefusal(matched: string | undefined, ceiling: number | undefined): string | undefined {
176  if (matched === undefined) return undefined;
177  return `forward_compaction refused, nothing stored: your model is on forward-compaction's ignore list ("${matched}"), so it takes no handoff from you; carry on with your work, and at ${ceiling === undefined ? 'your ceiling' : k(ceiling)} the plugin compacts you mechanically.`;
178}
179
180/** Keys the tool.call event carries beside the tool's arguments. */
181const EVENT_KEYS = new Set(['tool', 'tool_use_id', 'agentId']);
182
183/** The arguments of a tool call: every key of the event but its own (a key that is not a field included, so the check names it) or, when none is there, the event's `input`. */
184export function toolInput(e: Record<string, unknown>): Record<string, unknown> {
185  const top: Record<string, unknown> = {};
186  for (const [key, value] of Object.entries(e)) if (!EVENT_KEYS.has(key) && key !== 'input' && value !== undefined) top[key] = value;
187  if (Object.keys(top).length > 0) return top;
188  const nested = e.input;
189  return nested !== null && typeof nested === 'object' && !Array.isArray(nested) ? (nested as Record<string, unknown>) : {};
190}
191
192/** Every forward_compaction answer, receipt or refusal, ends with this line; `logDir` is the level-log directory, where the issues file lives. */
193export const EXPERIMENTAL_OPENING = 'forward-compaction is experimental.';
194export function experimental(text: string, logDir: string | undefined): string {
195  const where = logDir === undefined ? 'your reply.' : `${logDir.replace(/\/+$/, '')}/issues.md; it will be taken care of later.`;
196  return `${text}\n${EXPERIMENTAL_OPENING} If it gave you any trouble, append a short note of what happened to ${where}`;
197}
198
199export function parseLineRanges(text: string): { from: number; to: number }[] | undefined {
200  const out: { from: number; to: number }[] = [];
201  for (const part of text.split(',')) {
202    const m = /^\s*(\d+)\s*(?:[-:]\s*(\d+))?\s*$/.exec(part);
203    if (!m) return undefined;
204    const from = Number(m[1]);
205    const to = m[2] === undefined ? from : Number(m[2]);
206    if (from < 1 || to < from) return undefined;
207    out.push({ from, to });
208  }
209  return out;
210}
211
212const span = (r: { from: number; to: number }): string => (r.from === r.to ? `${r.from}` : `${r.from}-${r.to}`);
213
214/**
215 * A file's text as `load_files` asked for it: whole without ranges, else its ranges in file order, overlapping or
216 * touching ones merged, the lines between two of them named by one `[lines a-b not loaded]` line. `shown` is the ranges that loaded, clipped to the file. A range
217 * past the file's end, or clipped by it, is a `problem`, and an empty load is one too: a slice of nothing never
218 * reaches the wake as a Load block that says nothing.
219 */
220export function sliceFile(text: string, ranges: readonly { from: number; to: number }[] | undefined): { body: string; shown: string; problem?: string } | { problem: string } {
221  if (ranges === undefined) return text.trim() === '' ? { problem: 'the file is empty' } : { body: text, shown: '' };
222  const lines = text.split('\n');
223  if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
224  const count = lines.length;
225  const parts: string[] = [];
226  const shown: { from: number; to: number }[] = [];
227  const problems: string[] = [];
228  // In file order and merged: an overlap never loads a line twice, and a reversed list never reads as one stretch.
229  const ordered: { from: number; to: number }[] = [];
230  for (const r of [...ranges].sort((a, b) => a.from - b.from || a.to - b.to)) {
231    const prev = ordered[ordered.length - 1];
232    if (prev !== undefined && r.from <= prev.to + 1) prev.to = Math.max(prev.to, r.to);
233    else ordered.push({ ...r });
234  }
235  let held = false;
236  for (const r of ordered) {
237    if (r.from > count) {
238      problems.push(`lines ${span(r)}: past the end of the file, which has ${count} ${count === 1 ? 'line' : 'lines'}`);
239      continue;
240    }
241    const to = Math.min(r.to, count);
242    if (to < r.to) problems.push(`lines ${span(r)}: the file ends at line ${count}`);
243    const last = shown[shown.length - 1];
244    if (last !== undefined && r.from > last.to + 1) parts.push(`[lines ${span({ from: last.to + 1, to: r.from - 1 })} not loaded]`);
245    const taken = lines.slice(r.from - 1, to);
246    if (taken.some((line) => line.trim() !== '')) held = true;
247    parts.push(...taken);
248    shown.push({ from: r.from, to });
249  }
250  const problem = problems.length > 0 ? problems.join('; ') : undefined;
251  if (shown.length === 0) return { problem: problem ?? 'no lines asked for' };
252  if (!held) return { problem: [problem, `lines ${shown.map(span).join(',')}: only blank lines`].filter((p) => p !== undefined).join('; ') };
253  return { body: parts.join('\n'), shown: shown.map(span).join(','), ...(problem !== undefined ? { problem } : {}) };
254}
255
256
src/work.ts 77 lines
1// The work a party's tool calls record as each one finishes: the files touched and the task list. Read from the call's
2// own arguments and answer, never from rendered text, so the wake restores what Claude Code's own compaction restores
3// (the recent files, the todo list).
4
5/** R read, E edited, W written whole (Write). A file keeps the strongest touch it had: W over E over R. */
6export type FileTag = 'R' | 'E' | 'W';
7export type WorkFact =
8  | { kind: 'file'; path: string; tag: FileTag }
9  | { kind: 'todos'; lines: string[] }
10  | { kind: 'task-create'; id?: string; subject: string }
11  | { kind: 'task-update'; id: string; status?: string; subject?: string };
12
13/** The answer of a finished call, as `next(e)` resolved it: a deny or an error touched nothing. */
14export type CallAnswer = { deny?: string; isError?: boolean; text?: string };
15
16const str = (value: unknown): string | undefined => (typeof value === 'string' && value !== '' ? value : undefined);
17const MARK: Record<string, string> = { completed: '[x]', in_progress: '[~]' };
18const mark = (status: unknown): string => MARK[String(status)] ?? '[ ]';
19
20/** The facts a finished call records, none for a call that was denied, errored or touches no file or task. */
21export function workOf(tool: string, input: Record<string, unknown>, answer: CallAnswer): WorkFact[] {
22  if (answer.deny !== undefined || answer.isError === true) return [];
23  const file = (path: string | undefined, tag: FileTag): WorkFact[] => (path === undefined ? [] : [{ kind: 'file', path, tag }]);
24  switch (tool) {
25    case 'Read':
26      return file(str(input.file_path), 'R');
27    case 'Edit':
28    case 'MultiEdit':
29    case 'Write':
30      return file(str(input.file_path), tool === 'Write' ? 'W' : 'E');
31    case 'NotebookEdit':
32      return file(str(input.notebook_path), 'E');
33    case 'TodoWrite':
34      return Array.isArray(input.todos) ? [{ kind: 'todos', lines: (input.todos as Record<string, unknown>[]).map((t) => `${mark(t?.status)} ${String(t?.content ?? '')}`) }] : [];
35    case 'TaskCreate': {
36      const id = /#(\d+)/.exec(answer.text ?? '')?.[1];
37      return [{ kind: 'task-create', ...(id !== undefined ? { id } : {}), subject: String(input.subject ?? '') }];
38    }
39    case 'TaskUpdate': {
40      const id = str(input.taskId);
41      return id === undefined ? [] : [{ kind: 'task-update', id, ...(str(input.status) !== undefined ? { status: str(input.status) } : {}), ...(str(input.subject) !== undefined ? { subject: str(input.subject) } : {}) }];
42    }
43    default:
44      return [];
45  }
46}
47
48const RANK: Record<FileTag, number> = { R: 0, E: 1, W: 2 };
49
50export type WorkSummary = { files: { path: string; tag: FileTag }[]; tasks: string[] };
51
52/** A party's work, from its facts in the order recorded: every file newest touch first, and the task list as last set (TodoWrite's, or the TaskCreate/TaskUpdate list, whichever moved last). */
53export function workSummary(facts: readonly WorkFact[]): WorkSummary {
54  const files = new Map<string, FileTag>();
55  const tasks = new Map<string, { subject: string; status: string }>();
56  let todos: string[] = [];
57  let todosLast = false;
58  for (const f of facts) {
59    if (f.kind === 'file') {
60      const before = files.get(f.path);
61      files.delete(f.path); // re-inserted last: the map's order is the touch order
62      files.set(f.path, before !== undefined && RANK[before] > RANK[f.tag] ? before : f.tag);
63    } else if (f.kind === 'todos') (todos = f.lines), (todosLast = true);
64    else if (f.kind === 'task-create') (tasks.set(f.id ?? String(tasks.size + 1), { subject: f.subject, status: 'pending' }), (todosLast = false));
65    else {
66      const task = tasks.get(f.id);
67      if (task !== undefined && f.status === 'deleted') tasks.delete(f.id);
68      else if (task !== undefined) Object.assign(task, f.status !== undefined ? { status: f.status } : {}, f.subject !== undefined ? { subject: f.subject } : {});
69      todosLast = false;
70    }
71  }
72  return {
73    files: [...files].reverse().map(([path, tag]) => ({ path, tag })),
74    tasks: todosLast ? todos : [...tasks.values()].map((t) => `${mark(t.status)} ${t.subject}`),
75  };
76}
77
src/ledger.ts 194 lines
1// The ledger: typed facts recorded as events happen, reduced by pure functions. Nothing is read from rendered text.
2
3import { workSummary, type WorkFact, type WorkSummary } from './work.ts';
4
5export type PartyKey = 'main' | 'unknown' | `agent:${string}` | `spawn:${string}`;
6
7/** The party an event's `agentId` names: main when there is none. */
8export const keyOf = (agentId: unknown): PartyKey => (typeof agentId === 'string' && agentId !== '' ? `agent:${agentId}` : 'main');
9
10/** An event origin's kind, `unknown` when it carries none. */
11export const originKind = (origin: unknown): string => (origin !== null && typeof origin === 'object' && typeof (origin as { kind?: unknown }).kind === 'string' ? (origin as { kind: string }).kind : 'unknown');
12export type SkillHow = 'skill-tool' | 'typed-command' | 'preload' | 'ambiguous';
13export type ShellTask = { id: string; type: string; status: string; description: string; command?: string };
14export type CronJob = { id: string; schedule: string; recurring: boolean; prompt: string };
15
16type Base = { seq: number; at: number };
17export type PersonEntry = Base & { kind: 'person'; party: 'main'; text: string; command?: string };
18export type NoticeEntry = Base & { kind: 'notice'; party: 'main'; origin: string; text: string };
19export type ReceivedEntry = Base & { kind: 'received'; party: PartyKey; origin: string; text: string };
20export type BriefEntry = Base & {
21  kind: 'brief'; party: PartyKey; text: string; description?: string; subagentType?: string; spawnedBy: PartyKey;
22};
23export type SkillEntry = Base & {
24  kind: 'skill'; party: PartyKey; skill: string; text: string; source?: string; path?: string; how: SkillHow; keep?: true;
25};
26export type ShellsEntry = Base & { kind: 'shells'; tasks: ShellTask[]; crons: CronJob[] };
27export type WorkEntry = Base & { kind: 'work'; party: PartyKey; fact: WorkFact };
28export type LedgerEntry = PersonEntry | NoticeEntry | ReceivedEntry | BriefEntry | SkillEntry | ShellsEntry | WorkEntry;
29type Without<T, K extends string> = T extends unknown ? Omit<T, K> : never;
30export type EntryInput = Without<LedgerEntry, 'seq'>;
31
32export type Ledger = { startedAt?: number; startReason?: string; nextSeq: number; entries: readonly LedgerEntry[] };
33
34/** Prompt origins that are the person typing (composer, a bridge client, the SDK); any other origin is a notice. */
35export const PERSON_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk']);
36export function isPersonOrigin(kind: string | undefined): boolean {
37  return kind !== undefined && PERSON_ORIGINS.has(kind);
38}
39
40/** The shells and crons of a Stop event's `background_tasks` and `session_crons`; anything that is not a list is none. */
41export function shellsOf(e: Record<string, unknown>): { tasks: ShellTask[]; crons: CronJob[] } {
42  const list = (value: unknown): Record<string, unknown>[] => (Array.isArray(value) ? value : []);
43  const tasks = list(e.background_tasks).map((t) => ({ id: String(t.id), type: String(t.type), status: String(t.status), description: String(t.description ?? ''), ...(typeof t.command === 'string' ? { command: t.command } : {}) }));
44  const crons = list(e.session_crons).map((c) => ({ id: String(c.id), schedule: String(c.schedule), recurring: c.recurring === true, prompt: String(c.prompt ?? '') }));
45  return { tasks, crons };
46}
47
48export function newLedger(): Ledger {
49  return { nextSeq: 1, entries: [] };
50}
51
52/** Records when and why the process's ledger started (session.start); entries are kept. */
53export function markStart(ledger: Ledger, at: number, reason: string): Ledger {
54  return { ...ledger, startedAt: at, startReason: reason };
55}
56
57/** Appends an entry numbered in ledger order; a shells entry replaces the previous one. Returns a new ledger. */
58export function addEntry(ledger: Ledger, entry: EntryInput): Ledger {
59  const kept = entry.kind === 'shells' ? ledger.entries.filter((e) => e.kind !== 'shells') : ledger.entries;
60  return { ...ledger, nextSeq: ledger.nextSeq + 1, entries: [...kept, { ...entry, seq: ledger.nextSeq } as LedgerEntry] };
61}
62
63/** The most prompts a party keeps, for its wake and across a plugin reload: the person's messages (main) or its coordinator's (a sub-agent, its brief besides), the newest ten, the owner's ruling. */
64export const PROMPTS_KEPT = 10;
65
66/** A prompt as `$.state` keeps it: a ledger entry without its seq and party. */
67export type KeptPrompt = { kind: 'person' | 'received' | 'brief'; at: number; text: string; command?: string; origin?: string; description?: string; subagentType?: string; spawnedBy?: string };
68type PromptEntry = PersonEntry | ReceivedEntry | BriefEntry;
69const isPrompt = (e: LedgerEntry, party: PartyKey): e is PromptEntry => (e.kind === 'person' || e.kind === 'received' || e.kind === 'brief') && e.party === party;
70const promptKey = (p: { kind: string; at: number; text: string }): string => `${p.kind}\u0000${p.at}\u0000${p.text}`;
71
72/** A party's prompts to keep, oldest first: main's newest PROMPTS_KEPT messages; a sub-agent's brief and its coordinator's newest PROMPTS_KEPT. */
73export function promptsKept(ledger: Ledger, party: PartyKey): KeptPrompt[] {
74  const mine = ledger.entries.filter((e): e is PromptEntry => isPrompt(e, party));
75  const brief = mine.filter((e) => e.kind === 'brief').slice(-1);
76  const said = mine.filter((e) => e.kind !== 'brief').slice(-PROMPTS_KEPT);
77  return [...brief, ...said].map(({ seq: _seq, party: _party, ...kept }) => kept as KeptPrompt);
78}
79
80/** The ledger with a party's kept prompts put back ahead of its own, each only once and a brief only when it has none; anything not a prompt of that party's kind is left out. */
81export function restorePrompts(ledger: Ledger, party: PartyKey, kept: unknown): Ledger {
82  if (!Array.isArray(kept)) return ledger;
83  const own = ledger.entries.filter((e): e is PromptEntry => isPrompt(e, party));
84  const seen = new Set(own.map(promptKey));
85  const kinds = party === 'main' ? ['person'] : ['brief', 'received'];
86  const hasBrief = own.some((e) => e.kind === 'brief');
87  const back = kept.filter((p): p is KeptPrompt => p !== null && typeof p === 'object' && kinds.includes(p.kind) && typeof p.at === 'number' && typeof p.text === 'string')
88    .filter((p) => !seen.has(promptKey(p)) && !(p.kind === 'brief' && hasBrief));
89  if (back.length === 0) return ledger;
90  const restored = back.map((p, i) => ({ ...p, party, seq: ledger.nextSeq + i, ...(p.kind === 'brief' ? { spawnedBy: (p.spawnedBy ?? 'unknown') as PartyKey } : {}), ...(p.kind === 'received' ? { origin: p.origin ?? 'unknown' } : {}) }) as LedgerEntry);
91  return { ...ledger, nextSeq: ledger.nextSeq + back.length, entries: [...restored, ...ledger.entries] };
92}
93
94const latestPerSkill = (skills: SkillEntry[]): SkillEntry[] => {
95  const seen = new Set<string>();
96  return skills.filter((s) => (seen.has(s.skill) ? false : (seen.add(s.skill), true)));
97};
98
99/** A skill as `$.state` keeps it: a skill entry without its seq and party. */
100export type KeptSkill = { at: number; skill: string; text: string; how: SkillHow; source?: string; path?: string; keep?: true };
101
102/** A party's skills to keep: the latest text of each skill it loaded, oldest first. */
103export function skillsKept(ledger: Ledger, party: PartyKey): KeptSkill[] {
104  const mine = ledger.entries.filter((e): e is SkillEntry => e.kind === 'skill' && e.party === party);
105  return latestPerSkill(mine.reverse()).reverse().map(({ seq: _seq, party: _party, kind: _kind, ...kept }) => kept);
106}
107
108/** The ledger with a party's kept skills put back ahead of its own, each only when the party has not loaded it since; anything not a skill is left out. */
109export function restoreSkills(ledger: Ledger, party: PartyKey, kept: unknown): Ledger {
110  if (!Array.isArray(kept)) return ledger;
111  const own = new Set(ledger.entries.filter((e): e is SkillEntry => e.kind === 'skill' && e.party === party).map((e) => e.skill));
112  const back = kept.filter((s): s is KeptSkill => s !== null && typeof s === 'object' && typeof s.at === 'number' && typeof s.skill === 'string' && typeof s.text === 'string' && !own.has(s.skill));
113  if (back.length === 0) return ledger;
114  const restored = back.map((s, i) => ({ ...s, how: typeof s.how === 'string' ? s.how : 'ambiguous', kind: 'skill', party, seq: ledger.nextSeq + i }) as SkillEntry);
115  return { ...ledger, nextSeq: ledger.nextSeq + back.length, entries: [...restored, ...ledger.entries] };
116}
117
118/** A preload skill is first filed under `spawn:{tool_use_id}`; once the spawn returns, it belongs to the child. */
119export function resolveSpawn(ledger: Ledger, toolUseId: string, agentId: string): Ledger {
120  const from = `spawn:${toolUseId}`;
121  const entries = ledger.entries.map((e) => (e.kind === 'skill' && e.party === from ? { ...e, party: `agent:${agentId}` as const } : e));
122  return { ...ledger, entries };
123}
124
125export type SkillAttribution = { party: PartyKey; how: SkillHow };
126
127/** D5: which party loaded a skill, from the typed events around skill.prompt (the event carries no agentId). */
128export function attributeSkill(input: {
129  inFlightSkills: readonly { party: PartyKey; tool_use_id: string }[];
130  pendingCommand: string | undefined;
131  pendingSpawns: ReadonlySet<string>;
132  skill: string;
133}): SkillAttribution {
134  const { inFlightSkills, pendingCommand, pendingSpawns, skill } = input;
135  const [only] = inFlightSkills;
136  if (inFlightSkills.length === 1 && only) return { party: only.party, how: 'skill-tool' };
137  if (inFlightSkills.length === 0) {
138    const command = pendingCommand?.replace(/^\//, '');
139    if (command !== undefined && (command === skill || command.endsWith(`:${skill}`))) return { party: 'main', how: 'typed-command' };
140    const [spawn] = pendingSpawns;
141    if (pendingSpawns.size === 1 && spawn !== undefined) return { party: `spawn:${spawn}`, how: 'preload' };
142  }
143  return { party: 'unknown', how: 'ambiguous' };
144}
145
146export type Facts = {
147  person: PersonEntry[];
148  /** Person messages left out because the kept tail already holds them. */
149  personInTail: number;
150  notices: NoticeEntry[];
151  /** Every delivery to the party recorded from seq `since` (its handoff stored) that the kept tail does not hold, oldest first: the model wrote its handoff without them. Left out of person, notices and received. */
152  arrived: { at: number; origin: string; text: string }[];
153  brief?: BriefEntry;
154  received: ReceivedEntry[];
155  skills: SkillEntry[];
156  unattributedSkills: SkillEntry[];
157  shells?: ShellsEntry;
158  /** The files the party's calls touched and its task list, from its finished tool calls. */
159  work: WorkSummary;
160  startedAt?: number;
161  startReason?: string;
162};
163
164/** What the wake records for a party. Text already in a kept tail message (equal or contained) is left out. */
165export function factsFor(ledger: Ledger, party: PartyKey, opts: { keptTexts: readonly string[]; /** A ledger seq: deliveries from it on are `arrived`. */ since?: number }): Facts {
166  const fresh = <T extends { text: string }>(e: T): boolean =>
167    e.text === '' || !opts.keptTexts.some((kept) => kept.includes(e.text));
168  const before = (e: { seq: number }): boolean => opts.since === undefined || e.seq < opts.since;
169  const entries = ledger.entries;
170  const mine = <K extends LedgerEntry['kind']>(kind: K, who: PartyKey): Extract<LedgerEntry, { kind: K }>[] =>
171    entries.filter((e): e is Extract<LedgerEntry, { kind: K }> => e.kind === kind && 'party' in e && e.party === who);
172  // Latest text per skill name wins before the kept filter: an older version is stale, not "already there".
173  const newestFirst = (who: PartyKey) => latestPerSkill(mine('skill', who).reverse());
174  const carried = new Set(newestFirst(party).map((s) => s.skill));
175  const delivered = entries.filter((e): e is PersonEntry | NoticeEntry | ReceivedEntry => (e.kind === 'person' || e.kind === 'notice' || e.kind === 'received') && e.party === party);
176  const arrived = delivered.filter((e) => !before(e) && e.text !== '' && fresh(e)).map((e) => ({ at: e.at, origin: e.kind === 'person' ? 'person' : e.origin, text: e.text }));
177  const person = mine('person', party).filter(before);
178  const kept = person.filter(fresh);
179  return {
180    person: kept,
181    personInTail: person.length - kept.length,
182    notices: mine('notice', party).filter(before).filter(fresh),
183    arrived,
184    brief: mine('brief', party).filter(fresh).at(-1),
185    received: mine('received', party).filter(before).filter(fresh),
186    skills: newestFirst(party).filter(fresh),
187    unattributedSkills: newestFirst('unknown').filter((s) => !carried.has(s.skill)).filter(fresh),
188    shells: entries.find((e): e is ShellsEntry => e.kind === 'shells'),
189    work: workSummary(mine('work', party).map((e) => e.fact)),
190    startedAt: ledger.startedAt,
191    startReason: ledger.startReason,
192  };
193}
194
src/wake.ts 414 lines
1// The wake: what a compaction installs. [facts] [handoff] [tail…] — the facts and the handoff are built here from typed values,
2// the tail is the engine's own messages returned untouched. Pure: files, agents and the clock arrive as inputs.
3import { DEFAULT_TOOL, FREE, k as kilo } from './nudge.ts';
4import { loadFilesRoom } from './window.ts';
5import { PROMPTS_KEPT } from './ledger.ts';
6import { experimental, EXPERIMENTAL_OPENING } from './handoff.ts';
7
8/** A message in `session.compact`'s shape; a SessionMessage fits. */
9export type WakeMessage = {
10  role: 'user' | 'assistant';
11  text: string;
12  toolUses: readonly { tool_use_id: string; tool?: string; input?: unknown; text?: string }[];
13  toolResults?: readonly { tool_use_id: string; text?: string }[];
14  handle?: string;
15};
16
17/** A message this module builds; no handle, so the engine reads it as built. */
18export type BuiltMessage = { role: 'user'; text: string; toolUses: [] };
19
20export type Sibling = { tool: string; input: unknown; text: string };
21
22// Characters and tokens convert only at the party's measured ratio (`charsPerToken` in window.ts), never at a fixed one.
23const tokensOf = (chars: number, ratio: number): number => Math.ceil(chars / ratio);
24const charsIn = (tokens: number, ratio: number | undefined): number => (ratio === undefined ? Number.POSITIVE_INFINITY : Math.floor(tokens * ratio));
25const num = (value: number): string => value.toLocaleString('en-US');
26const iso = (at: number): string => new Date(at).toISOString();
27
28/**
29 * Per-section shares of the budget B (D18), design budgets and never measured sizes: the person's words or the brief and the
30 * skills in force are what a woken model most needs (a quarter each), sibling results a tenth, coordinator notices a
31 * twentieth; the rest of B goes to the messages kept after the call. Load files have their own room, `loadFilesRoom`.
32 */
33const SHARE = { people: 0.25, arrived: 0.25, notices: 0.05, siblings: 0.1, skills: 1, tasks: 0.05 } as const;
34/** The most files the Files section lists, newest touch first: a path list stays a glance, as the old carry's trail did. */
35export const FILES_MAX = 15;
36/** The agents section's row cap: a design budget, enough for a coordinator's fleet, never the size of one. */
37export const AGENTS_MAX_ROWS = 50;
38
39/** B = half the ask point, a design budget: a wake above it would draw the next ask at once, and half leaves the woken party room to work before it (D18). */
40export function wakeBudget(askPoint: number): number {
41  return Math.floor(0.5 * askPoint);
42}
43
44/**
45 * The anchor is the last assistant message whose tool uses hold `toolUseId`; the response is the run of assistant
46 * messages around it; the user messages right after it whose results all answer that response's calls are its results.
47 * `tail` is everything after them, the same objects (handles kept); `siblings` are the response's other calls with
48 * their result text; `own` is the call's own result (its receipt and experimental line), which the tail no longer holds.
49 * Undefined when the anchor is not in `messages`.
50 */
51export function splitAtCall<M extends WakeMessage>(messages: readonly M[], toolUseId: string): { tail: M[]; siblings: Sibling[]; own?: string } | undefined {
52  let at = -1;
53  for (let i = messages.length - 1; i >= 0; i--) {
54    const m = messages[i]!;
55    if (m.role === 'assistant' && m.toolUses.some((u) => u.tool_use_id === toolUseId)) {
56      at = i;
57      break;
58    }
59  }
60  if (at < 0) return undefined;
61  let first = at;
62  while (first > 0 && messages[first - 1]!.role === 'assistant') first--;
63  let last = at;
64  while (last + 1 < messages.length && messages[last + 1]!.role === 'assistant') last++;
65  const uses = messages.slice(first, last + 1).flatMap((m) => m.toolUses);
66  const ids = new Set(uses.map((u) => u.tool_use_id));
67  const results = new Map<string, string>();
68  let next = last + 1;
69  while (next < messages.length) {
70    const m = messages[next]!;
71    const answers = m.toolResults ?? [];
72    if (m.role !== 'user' || answers.length === 0 || !answers.every((r) => ids.has(r.tool_use_id))) break;
73    for (const r of answers) results.set(r.tool_use_id, r.text ?? '');
74    next++;
75  }
76  const siblings = uses
77    .filter((u) => u.tool_use_id !== toolUseId)
78    .map((u) => ({ tool: u.tool ?? 'tool', input: u.input, text: results.get(u.tool_use_id) ?? u.text ?? '(no result recorded)' }));
79  const own = results.get(toolUseId);
80  return { tail: messages.slice(next), siblings, ...(own !== undefined ? { own } : {}) };
81}
82
83/** A message's size at `ratio` characters a token: its text, its tool inputs and its tool results (a call's own result text is the answer's, counted there). */
84function messageTokens(m: WakeMessage, ratio: number): number {
85  let chars = m.text.length;
86  for (const u of m.toolUses) chars += JSON.stringify(u.input ?? {}).length;
87  for (const r of m.toolResults ?? []) chars += (r.text ?? '').length;
88  return tokensOf(chars, ratio);
89}
90
91/**
92 * The newest whole messages that fit `budgetTokens`: the oldest are dropped until the rest fits, then any leading user
93 * message carrying tool results too, so no result stands without its call. The kept messages are the input objects.
94 */
95export function fitTail<M extends WakeMessage>(messages: readonly M[], budgetTokens: number, ratio: number): { messages: M[]; dropped: number } {
96  let total = messages.reduce((sum, m) => sum + messageTokens(m, ratio), 0);
97  let from = 0;
98  while (from < messages.length && total > budgetTokens) total -= messageTokens(messages[from++]!, ratio);
99  while (from < messages.length && messages[from]!.role === 'user' && (messages[from]!.toolResults?.length ?? 0) > 0) from++;
100  return { messages: messages.slice(from), dropped: from };
101}
102
103/** What the ledger gives for a party (ledger.ts `Facts` fits); only what the wake renders. */
104export type WakeFacts = {
105  person: readonly { at: number; text: string }[];
106  /** Person messages the ledger left out because the tail holds them. */
107  personInTail?: number;
108  notices: readonly { at: number; origin: string; text: string }[];
109  /** What reached the party after it stored its handoff and the kept tail lacks (ledger.ts `Facts`); absent on a mechanical wake. */
110  arrived?: readonly { at: number; origin: string; text: string }[];
111  /** The `agent.spawn` prompt; absent when the agent was spawned before the ledger started. */
112  brief?: { text: string };
113  received: readonly { at: number; origin: string; text: string }[];
114  /** Latest text per skill, newest first. */
115  skills: readonly { skill: string; text: string; source?: string; path?: string; keep?: true }[];
116  unattributedSkills: readonly { skill: string }[];
117  shells?: {
118    at: number;
119    tasks: readonly { id: string; type: string; status: string; description: string; command?: string }[];
120    crons: readonly { id: string; schedule: string; recurring: boolean; prompt: string }[];
121  };
122  /** The files the party's finished calls touched, newest first, and its task list as last set. */
123  work?: { files: readonly { path: string; tag: 'R' | 'E' | 'W' }[]; tasks: readonly string[] };
124  startedAt?: number;
125  startReason?: string;
126};
127
128/** A load file, already read by the adapter: its content, or why it could not be read. */
129export type WakeFile = { path: string; lines?: string; why: string } & ({ content: string; problem?: undefined } | { problem: string; content?: undefined });
130export type AgentRow = { id: string; type: string; status: string; description: string; parentId?: string };
131
132type Shared<M> = {
133  /** The party's current `e.messages`. */
134  messages: readonly M[];
135  party: 'main' | 'subagent';
136  /** The party's context reading, for the wake's own wording; undefined when it has none. */
137  tokens?: number;
138  askPoint: number;
139  /** The party's measured characters per token (window.ts `charsPerToken`); undefined while unmeasured: then nothing is fitted, see `assemble`. */
140  charsPerToken?: number;
141  /** `$.clock.now()`, for the age of the shells snapshot. */
142  now: number;
143  /** The party's facts, leaving out entries already inside `keptTexts` (the kept tail's texts): the tail is fitted around the facts and the facts around the tail. */
144  facts: (keptTexts: readonly string[]) => WakeFacts;
145  /** The woken sub-agent's id: its wake lists only the agents it spawned, and leaves it out of the background tasks. Absent for main, whose wake lists every agent. */
146  self?: string;
147  /** `$.agent.list()` rows, or its error; absent, no section. */
148  agents?: readonly AgentRow[] | { error: string };
149  /** The registered `forward_compaction` name, DEFAULT_TOOL when absent. */
150  tool?: string;
151  /** The level-log directory, where the issues file the wake's last line names lives. */
152  logDir?: string;
153};
154/** `maxContext`: the party's max context, whose `loadFilesRoom` the files are carried in. */
155export type BuildWakeInput<M> = Shared<M> & { toolUseId: string; handoff: string; files?: readonly WakeFile[]; maxContext?: number };
156export type MechanicalWakeInput<M> = Shared<M> & { reason: { kind: 'ceiling'; ceiling: number } | { kind: 'reactive' } };
157
158export type Wake<M> = {
159  /** [F, H, ...tail]: H is the last message built here; the tail is the input objects. */
160  messages: (BuiltMessage | M)[];
161  /** F sections that hold content. */
162  facts: number;
163  kept: number;
164  dropped: number;
165  /** For the debug row: each F section's heading and characters, the head's characters, and the load files carried and not. */
166  sizes: { section: string; chars: number }[];
167  headChars: number;
168  carried: string[];
169  notCarried: string[];
170};
171
172type Section = { text: string; content: boolean };
173const section = (text: string): Section => ({ text, content: true });
174
175/** Entries newest first while they fit, rendered oldest first; the older ones left are counted. */
176function newestFitting<T>(entries: readonly T[], render: (entry: T) => string, room: number): { shown: string[]; left: number } {
177  const shown: string[] = [];
178  let used = 0;
179  for (let i = entries.length - 1; i >= 0; i--) {
180    const block = render(entries[i]!);
181    if (used + block.length + 1 > room) return { shown: shown.reverse(), left: i + 1 };
182    shown.push(block);
183    used += block.length + 1;
184  }
185  return { shown: shown.reverse(), left: 0 };
186}
187
188function age(ms: number): string {
189  const s = Math.max(0, Math.round(ms / 1000));
190  if (s < 60) return `${s}s`;
191  if (s < 3600) return `${Math.round(s / 60)}m`;
192  return `${Math.round(s / 3600)}h`;
193}
194
195type Extras = { siblings: readonly Sibling[]; files: readonly WakeFile[]; fileRoom: number };
196
197type Rendered = { text: string; sections: number; sizes: Wake<never>['sizes']; carried: string[]; notCarried: string[] };
198
199/** F: every section with its cap; whole or not, never cut. With no measured ratio no section is capped. */
200function renderFacts<M>(input: Shared<M>, facts: WakeFacts, { siblings, files, fileRoom }: Extras): Rendered {
201  const B = wakeBudget(input.askPoint);
202  const room = (share: number): number => charsIn(B * share, input.charsPerToken);
203  const filesCarried: string[] = [];
204  const filesNotCarried: string[] = [];
205  const sections: Section[] = [];
206  const sub = input.party === 'subagent';
207
208  // First: what the model has not seen, which a notification-driven party would otherwise wait on forever.
209  const arrived = facts.arrived ?? [];
210  if (arrived.length > 0) {
211    const { shown, left } = newestFitting(arrived, (m) => `### ${iso(m.at)} (${m.origin})\n${m.text}`, room(SHARE.arrived));
212    sections.push(section(['## Arrived after you wrote your handoff: you have not read these yet', ...(left > 0 ? [`(${left} older not carried; look_back finds them)`] : []), ...shown].join('\n')));
213  }
214
215  if (sub) {
216    const cap = room(SHARE.people);
217    if (facts.brief === undefined) sections.push({ text: '## Your brief\n(not recorded: this agent was spawned before forward-compaction started in this process)', content: false });
218    else if (facts.brief.text.length > cap) sections.push({ text: `## Your brief\n(not carried: ${num(facts.brief.text.length)} characters, over the ${num(cap)}-character room; look_back finds it)`, content: false });
219    else sections.push(section(`## Your brief\n${facts.brief.text}`));
220  } else if (facts.person.length === 0 && (facts.personInTail ?? 0) > 0) {
221    sections.push({ text: `## The person's messages\n(all ${facts.personInTail} are in the messages below)`, content: false });
222  } else if (facts.person.length === 0) {
223    sections.push({ text: `## The person's messages\n(none recorded${facts.startedAt === undefined ? '' : ` since ${iso(facts.startedAt)}`})`, content: false });
224  } else {
225    const { shown } = newestFitting(facts.person.slice(-PROMPTS_KEPT), (p) => `### ${iso(p.at)}\n${p.text}`, room(SHARE.people));
226    const count = facts.person.length;
227    const left = count - shown.length;
228    const heading = left === 0 ? `(${shown.length} of ${count})` : `(${shown.length} of ${count}; the ${left} oldest are not carried: look_back finds them)`;
229    sections.push(section([`## The person's messages, oldest first ${heading}`, ...shown].join('\n')));
230  }
231
232  const inbox = sub ? { entries: facts.received, title: 'Messages delivered to you' } : { entries: facts.notices, title: 'Notifications delivered to you' };
233  if (inbox.entries.length > 0) {
234    const { shown } = newestFitting(sub ? inbox.entries.slice(-PROMPTS_KEPT) : inbox.entries, (m) => `### ${iso(m.at)} (${m.origin})\n${m.text}`, room(SHARE.notices));
235    const left = inbox.entries.length - shown.length;
236    sections.push(section([`## ${inbox.title}`, ...(left > 0 ? [`(${left} older not carried)`] : []), ...shown].join('\n')));
237  }
238
239  if (siblings.length > 0) {
240    let left = room(SHARE.siblings);
241    const rows = siblings.map((s) => {
242      const block = `### ${s.tool} ${JSON.stringify(s.input ?? {})}\n${s.text}`;
243      if (block.length + 1 > left) return `- ${s.tool}: result not carried (${num(s.text.length)} characters)`;
244      left -= block.length + 1;
245      return block;
246    });
247    sections.push(section(['## Results of the calls you made beside the handoff', ...rows].join('\n')));
248  }
249
250  const notCarried: string[] = [];
251  const carriedSkills: string[] = [];
252  let skillRoom = room(SHARE.skills); // every skill the party invoked is carried whole: the whole budget, the tail fitted after it
253  for (const s of facts.skills) {
254    const block = `### ${s.skill} (${s.source ?? 'unknown source'}${s.path === undefined ? '' : `, ${s.path}`}${s.keep ? ', pinned: never dropped' : ''})\n${s.text}`;
255    if (block.length + 1 > skillRoom) notCarried.push(`- ${s.skill}: over the skill room (${num(s.text.length)} characters); call Skill ${s.skill} to load it again.`);
256    else {
257      skillRoom -= block.length + 1;
258      carriedSkills.push(block);
259    }
260  }
261  if (carriedSkills.length > 0) sections.push(section(['## Skills in force, as loaded (one you no longer need: name it in drop_skills on two handoffs in a row)', ...carriedSkills].join('\n')));
262  for (const s of facts.unattributedSkills) notCarried.push(`- ${s.skill}: loaded in this session by an unidentified party; call Skill ${s.skill} if it applies to you.`);
263  if (notCarried.length > 0) sections.push({ text: ['## Skills not carried', ...notCarried].join('\n'), content: false });
264
265  const work = facts.work;
266  if (work !== undefined && work.files.length > 0) {
267    const more = work.files.length - FILES_MAX;
268    sections.push(section(['## Files touched, newest first (R read, E edited, W written)', ...work.files.slice(0, FILES_MAX).map((f) => `${f.tag} ${f.path}`), ...(more > 0 ? [`(${more} older not listed)`] : [])].join('\n')));
269  }
270  if (work !== undefined && work.tasks.length > 0) {
271    const text = ['## Tasks, as last set', ...work.tasks].join('\n');
272    const cap = room(SHARE.tasks);
273    sections.push(text.length <= cap ? section(text) : { text: `## Tasks\n(not carried: ${num(text.length)} characters, over the ${num(cap)}-character room; look_back finds them)`, content: false });
274  }
275
276  if (input.agents !== undefined) {
277    if (!Array.isArray(input.agents)) sections.push({ text: `## Agents\n(could not be listed: ${(input.agents as { error: string }).error})`, content: false });
278    else if (input.agents.some((a) => input.self === undefined || a.parentId === input.self)) {
279      const others = input.agents.filter((a) => input.self === undefined || a.parentId === input.self);
280      const rows = others.slice(0, AGENTS_MAX_ROWS).map((a) => `- ${a.id} ${a.type} ${a.status}: ${a.description}`);
281      const more = others.length - AGENTS_MAX_ROWS;
282      sections.push(section(['## Agents, listed at install', ...rows, ...(more > 0 ? [`(${more} more not listed)`] : [])].join('\n')));
283    }
284  }
285
286  if (facts.shells === undefined) sections.push({ text: '## Background shells and crons\n(unknown: no Stop event yet in this process)', content: false });
287  else {
288    const rows = [
289      ...facts.shells.tasks.filter((t) => t.id !== input.self).map((t) => `- ${t.type} ${t.id} ${t.status}: ${t.command ?? t.description}`),
290      ...facts.shells.crons.map((c) => `- cron ${c.id} ${c.schedule}${c.recurring ? ' recurring' : ''}: ${c.prompt}`),
291    ];
292    const header = `## Background shells and crons (session-wide, from the Stop event ${age(input.now - facts.shells.at)} before install)`;
293    sections.push({ text: [header, ...(rows.length > 0 ? rows : ['(none running)'])].join('\n'), content: rows.length > 0 });
294  }
295
296  if (files.length > 0) {
297    let left = fileRoom; // the content alone counts, as the store-time check (`loadRoomRefusal`) counts it
298    const carried: string[] = [];
299    const dropped: string[] = [];
300    for (const f of files) {
301      if (f.problem !== undefined) {
302        dropped.push(`- ${f.path}: ${f.problem}`);
303        filesNotCarried.push(f.path);
304        continue;
305      }
306      if (f.content.length > left) (dropped.push(`- ${f.path}: ${num(f.content.length)} characters, over the room left`), filesNotCarried.push(f.path));
307      else {
308        left -= f.content.length;
309        filesCarried.push(f.path);
310        carried.push(`### ${f.path}${f.lines === undefined ? '' : ` lines ${f.lines}`} — ${f.why}\n${f.content}`);
311      }
312    }
313    if (carried.length > 0) sections.push(section(['## Files you asked to carry', ...carried].join('\n')));
314    if (dropped.length > 0) sections.push({ text: ['## Files not carried', ...dropped].join('\n'), content: false });
315  }
316
317  const resume = facts.startReason === 'resume' && facts.startedAt !== undefined ? [`The ledger started at ${iso(facts.startedAt)} when this process resumed; anything earlier is not recorded.`] : [];
318  const head = '[forward-compaction: facts] Your context was compacted by forward-compaction. These facts were recorded as they happened, never reconstructed. Your own handoff follows in the next message.';
319  return {
320    text: [[head, ...resume].join('\n'), ...sections.map((s) => s.text)].join('\n\n'),
321    sections: sections.filter((s) => s.content).length,
322    sizes: sections.map((s) => ({ section: s.text.split('\n', 1)[0]!, chars: s.text.length })),
323    carried: filesCarried,
324    notCarried: filesNotCarried,
325  };
326}
327
328/** Every text a kept message holds, for the facts to leave out what the tail already says. */
329function keptTexts(kept: readonly WakeMessage[]): string[] {
330  return kept.flatMap((m) => [m.text, ...m.toolUses.map((u) => u.text ?? ''), ...(m.toolResults ?? []).map((r) => r.text ?? '')]).filter((t) => t !== '');
331}
332
333/**
334 * [F, ...H, ...tail] around a head text: the tail is fitted to what the facts and the head leave of the budget, and the
335 * facts are rendered against the tail kept, so each is sized for the other (the tail is only ever shrunk, so this ends).
336 * With no measured ratio nothing is fitted: `unmeasured` says whether every candidate is kept (the messages after a
337 * handoff's call) or none is (a mechanical wake's earlier tail).
338 */
339function assemble<M extends WakeMessage>(
340  input: Shared<M>,
341  candidates: readonly M[],
342  extras: Extras,
343  head: (counts: { kept: number; dropped: number }) => string,
344  unmeasured: 'all' | 'none',
345): Wake<M> {
346  const B = wakeBudget(input.askPoint);
347  const ratio = input.charsPerToken;
348  const longest = head({ kept: Math.max(1, candidates.length), dropped: Math.max(1, candidates.length) }).length;
349  let kept: readonly M[] = candidates;
350  for (;;) {
351    const f = renderFacts(input, input.facts(keptTexts(kept)), extras);
352    const fit = ratio === undefined ? { messages: unmeasured === 'all' ? [...candidates] : [] } : fitTail(candidates, B - tokensOf(f.text.length, ratio) - tokensOf(longest, ratio), ratio);
353    // only a smaller fit is taken: facts can shrink with the tail, and a fit growing back would loop forever
354    if (fit.messages.length < kept.length) {
355      kept = fit.messages;
356      continue;
357    }
358    const counts = { kept: kept.length, dropped: candidates.length - kept.length };
359    const text = head(counts);
360    return {
361      messages: [{ role: 'user', text: f.text, toolUses: [] }, { role: 'user', text, toolUses: [] }, ...kept],
362      facts: f.sections,
363      ...counts,
364      sizes: f.sizes,
365      headChars: text.length,
366      carried: f.carried,
367      notCarried: f.notCarried,
368    };
369  }
370}
371
372/** The wake for a stored handoff: facts, the handoff verbatim, then every message after the `forward_compaction` call. Throws when the call is not in `messages`. */
373export function buildWake<M extends WakeMessage>(input: BuildWakeInput<M>): Wake<M> {
374  const split = splitAtCall(input.messages, input.toolUseId);
375  if (split === undefined) throw new Error(`forward-compaction: call ${input.toolUseId} is not in the transcript to compact`);
376  return assemble(input, split.tail, { siblings: split.siblings, files: input.files ?? [], fileRoom: loadFilesRoom(input.maxContext ?? 0, input.charsPerToken) }, ({ kept, dropped }) => {
377    const lines = [
378      '[forward-compaction: handoff] Below is the handoff you wrote with forward_compaction. It is your frontier: continue from its ## Next.',
379      `=== handoff ===\n${input.handoff}\n=== end of handoff ===`,
380    ];
381    // The receipt's own experimental line moves to the wake's end, where it is read last.
382    if (split.own !== undefined) lines.push(`Your forward_compaction call returned:\n${split.own.split(`\n${EXPERIMENTAL_OPENING}`)[0]}`);
383    if (kept > 0) lines.push(`The ${kept} messages after this one are what happened after you wrote it, kept verbatim. Continue from where they leave off.`);
384    if (dropped > 0) lines.push(`${dropped} older messages after your handoff did not fit and are gone (look_back finds an exact detail); call ${input.tool ?? DEFAULT_TOOL} again at your next milestone.`);
385    if (kept === 0) lines.push('Nothing happened after you wrote it. Continue from ## Next.');
386    lines.push(FREE); // a woken party budgets what still fits before the next handoff and cuts the task short: say it is free
387    return experimental(lines.join('\n\n'), input.logDir);
388  }, 'all');
389}
390
391/** The wake for a party with no handoff: the same facts, then the newest messages of `messages` that fit. */
392export function mechanicalWake<M extends WakeMessage>(input: MechanicalWakeInput<M>): Wake<M> {
393  const why = input.reason.kind === 'ceiling' ? `, past the ${kilo(input.reason.ceiling)} ceiling` : ', and Claude Code reported your request too long';
394  const source = input.party === 'main' ? "the person's messages" : 'your brief';
395  return assemble(input, input.messages, { siblings: [], files: [], fileRoom: 0 }, ({ kept, dropped }) => experimental(
396    `[forward-compaction: no frontier written by the model] Your context reached ${input.tokens === undefined ? 'a size not measured yet' : `${kilo(input.tokens)} tokens`}${why}, and you had not called forward_compaction, so the plugin compacted it mechanically: the facts above and your newest ${kept} messages below, kept verbatim; ${dropped} older messages are gone (look_back finds an exact detail). No objective or next step was written. Re-establish them from ${source} and the messages below, then continue; call ${input.tool ?? DEFAULT_TOOL} at your next milestone. ${FREE}`, input.logDir),
397  'none');
398}
399
400/** An installed wake whose landed line waits for the party's first reading after it: what the line says, `before` the last reading before the install. */
401export type Landing = { label: string; before?: number } & ({ kind: 'handoff'; chars: number; facts: number; kept: number; dropped: number } | { kind: 'mechanical'; tokens?: number; ceiling: number });
402
403/** The measured sizes: the last reading before the install and the first after it; one never read is said as unknown, never estimated. */
404function measured(before: number | undefined, after: number | undefined): string {
405  const unknown = after === undefined ? ' (it ended before a request after the install measured it)' : ', measured';
406  return `${before === undefined ? 'unknown' : kilo(before)} → ${after === undefined ? 'unknown' : kilo(after)} context${before === undefined && after !== undefined ? ' (no reading before the install), measured after' : unknown}`;
407}
408
409/** The screen line for an installed wake (always shown), at the party's first reading after it (`after`), or at its end with `after` unknown. */
410export function landedLine(l: Landing, after?: number): string {
411  if (l.kind === 'mechanical') return `forward-compaction: ${l.label} had no handoff at ${l.tokens === undefined ? 'an unmeasured size (no reading yet)' : kilo(l.tokens)} (ceiling ${kilo(l.ceiling)}) — mechanical wake installed, ${measured(l.before, after)}.`;
412  return `forward-compaction: ${l.label} handoff landed — ${measured(l.before, after)}; frontier ${num(l.chars)} characters, ${l.facts} facts, ${l.kept} messages kept verbatim${l.dropped > 0 ? `, ${l.dropped} dropped` : ''}.`;
413}
414
src/decide.ts 109 lines
1export type Trigger = 'manual' | 'auto' | 'plugin' | 'precompute';
2
3const TRIGGERS: readonly Trigger[] = ['auto', 'manual', 'plugin', 'precompute'];
4
5/** The trigger when the plugin knows it; undefined for any other value a newer Claude Code may send. */
6export function knownTrigger(trigger: unknown): Trigger | undefined {
7  return TRIGGERS.find((known) => known === trigger);
8}
9
10/** The context a response was answered over: uncached input plus cache reads and writes. */
11export function contextTokens(usage: {
12  input_tokens?: number;
13  cache_read_input_tokens?: number;
14  cache_creation_input_tokens?: number;
15} | null | undefined): number | undefined {
16  if (!usage || typeof usage.input_tokens !== 'number') return undefined;
17  return usage.input_tokens + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
18}
19
20/** A party's reading: the context its response was answered over plus that response's own output, which the next request carries too. */
21export function readingTokens(usage: ({ output_tokens?: number } & NonNullable<Parameters<typeof contextTokens>[0]>) | null | undefined): number | undefined {
22  const context = contextTokens(usage);
23  return context === undefined ? undefined : context + (typeof usage?.output_tokens === 'number' ? usage.output_tokens : 0);
24}
25
26export type PartyKind = 'main' | 'subagent' | 'foreign';
27
28export type DecideInput = {
29  trigger: Trigger;
30  party: PartyKind;
31  /** A handoff is stored and its `forward_compaction` call is in `e.messages`. */
32  stored?: boolean;
33  /** A handoff is stored but its call is not in `e.messages`: something else compacted the party since. */
34  storedMissing?: boolean;
35  /** The party's last reading; undefined when it has none (none yet, or none since an install): no ceiling is reached then. */
36  tokens?: number;
37  /** The context at which a party with no handoff gets a mechanical wake. Left out, it is never reached. */
38  ceiling?: number;
39  /** `parties.reactive`: the ask follows a skip with no completed request, so Claude Code found the request too long (D8). */
40  reactive?: boolean;
41  /** `FORWARD_COMPACTION_CEILING_NATIVE`: at the ceiling, Claude Code's own summary runs. */
42  ceilingNative?: boolean;
43  /** Screen name of the party: "main", or "{subagentType} {agentId first 6}". */
44  label?: string;
45  /** `Parties.ignored`: the party's model is on the ignore list: it takes no handoff, and its asks are held until its ceiling. */
46  ignored?: boolean;
47  /** `Parties.windowUnknown`: the party's ceiling is not known, so it is never reached; a reactive ask stays mechanical. */
48  windowUnknown?: boolean;
49};
50
51/** The decision row's `decision` for the action. */
52export type Action =
53  | { action: 'next'; decision: 'native' | 'pass' | 'foreign'; why: 'manual' | 'plugin' | 'foreign' | 'ceiling' | 'reactive'; dropStored?: true }
54  | { action: 'skip'; decision: 'skip'; why: 'precompute' | 'waiting' | 'no-reading' | 'ignored-model'; reason: string; dropStored?: true }
55  | { action: 'install'; decision: 'install'; why: 'stored' }
56  | { action: 'mechanical'; decision: 'mechanical'; why: 'ceiling' | 'reactive'; dropStored?: true };
57
58/**
59 * One `session.compact` ask (rows C1-C10 of the decision table). A manual ask runs Claude Code's own summary and
60 * drops the stored handoff; a precompute is skipped; a handoff stored for main installs at a plugin request
61 * (the turn-end door) and for any party at an automatic ask. With nothing stored an automatic ask is held until
62 * the ceiling, or at once when it follows a held ask unanswered (the request was too long), and then the party
63 * gets a mechanical wake, or Claude Code's own summary when the ceiling is native. A foreign party (the engine's own
64 * fork) is never ours: its automatic ask passes. A party whose model is on the ignore list stores nothing, so its ask is
65 * held as `ignored-model` until the ceiling. An automatic ask from any other party with no reading and nothing stored is
66 * held as `no-reading`: the next reading decides. A stored handoff whose call left the transcript is dropped, and the ask goes on as if none were stored.
67 */
68export function decide({ trigger, party, stored = false, storedMissing = false, tokens, ceiling = Number.POSITIVE_INFINITY, reactive = false, ceilingNative = false, label = 'main', ignored = false, windowUnknown = false }: DecideInput): Action {
69  const drop = storedMissing ? ({ dropStored: true } as const) : {};
70  if (trigger === 'manual') return { action: 'next', decision: 'native', why: 'manual', dropStored: true };
71  if (trigger === 'precompute') return { action: 'skip', decision: 'skip', why: 'precompute', reason: 'forward-compaction: precompute is never used' };
72  if (trigger === 'plugin') {
73    if (party === 'main' && stored) return { action: 'install', decision: 'install', why: 'stored' };
74    return { action: 'next', decision: 'pass', why: 'plugin', ...(party === 'main' ? drop : {}) };
75  }
76  if (party === 'foreign') return { action: 'next', decision: 'foreign', why: 'foreign' };
77  if (stored && !ignored) return { action: 'install', decision: 'install', why: 'stored' };
78  const atCeiling = !windowUnknown && tokens !== undefined && tokens >= ceiling;
79  if (atCeiling || reactive) {
80    const why = atCeiling ? 'ceiling' : 'reactive';
81    if (ceilingNative) return { action: 'next', decision: 'native', why, ...drop };
82    return { action: 'mechanical', decision: 'mechanical', why, ...drop };
83  }
84  if (ignored) return { action: 'skip', decision: 'skip', why: 'ignored-model', reason: `forward-compaction: holding Claude Code's compaction for ${label}: its model is on the ignore list, so it is compacted mechanically at its ceiling`, ...drop };
85  if (tokens === undefined) return { action: 'skip', decision: 'skip', why: 'no-reading', reason: `forward-compaction: holding Claude Code's compaction for ${label}: it has no reading yet, and its next reading decides`, ...drop };
86  return { action: 'skip', decision: 'skip', why: 'waiting', reason: `forward-compaction: holding Claude Code's compaction for ${label} until its handoff is stored or the ceiling is reached`, ...drop };
87}
88
89export type TurnEndInput = {
90  main: boolean;
91  /** `turn.complete`'s reason. */
92  reason: string;
93  /** A handoff is stored for the party. */
94  stored: boolean;
95  /** `parties.get(key).installedThisTurn`. */
96  installedThisTurn: boolean;
97};
98
99export type TurnEnd = { compact: true } | { compact: false; why: 'not-main' | 'not-answered' | 'nothing-stored' | 'installed-this-turn' };
100
101/** Row P1: whether main's turn end calls `$.session.compact()`. Everything else keeps the handoff stored for the next door. */
102export function turnEndDoor({ main, reason, stored, installedThisTurn }: TurnEndInput): TurnEnd {
103  if (!main) return { compact: false, why: 'not-main' };
104  if (reason !== 'answer') return { compact: false, why: 'not-answered' };
105  if (!stored) return { compact: false, why: 'nothing-stored' };
106  if (installedThisTurn) return { compact: false, why: 'installed-this-turn' };
107  return { compact: true };
108}
109
src/nudge.ts 60 lines
1/** The tool's name as the installed copy registered it; a `-dev` copy's differs. */
2export const DEFAULT_TOOL = 'mcp__forward-compaction__forward_compaction';
3
4/** A token size for model-facing and screen text: thousands as `51k`, a million or more as `1M` or `1.5M`. */
5export function k(tokens: number): string {
6  const thousands = Math.round(tokens / 1000);
7  return thousands < 1000 ? `${thousands}k` : `${Math.round(tokens / 100_000) / 10}M`;
8}
9
10/** 0 below `start`, 1 at it, and one more per `every` tokens past it. */
11export function nudgeLevel(tokens: number, start: number, every: number): number {
12  if (tokens < start) return 0;
13  return 1 + Math.floor((tokens - start) / Math.max(1, every));
14}
15
16export type NudgeInput = {
17  /** The party's last measured reading. */
18  tokens: number;
19  /** The party's model window, which the nudge percentages are taken against. */
20  window: number;
21  /** The context at which the plugin compacts the party mechanically. */
22  ceiling: number;
23  /** The registered `forward_compaction` name; DEFAULT_TOOL when absent. */
24  tool?: string;
25  /** A sub-agent's gate mark (`gateMark`): the nudge says that from there every other call is refused until it hands off. */
26  gateAt?: number;
27};
28
29/** What the model reads after a tool result once its context passes a nudge point: hand off at the next clean milestone; the model picks the moment, not whether. */
30/** Said in every nudge and refusal: a model that reads the handoff as a cost cuts its task short to avoid one. */
31export const FREE = 'A handoff costs you nothing: you continue from it with every message after it, as many times as the work needs, so carry the task through to its end; never cut it short to avoid one.';
32
33export function nudgeText({ tokens, window, ceiling, tool = DEFAULT_TOOL, gateAt }: NudgeInput): string {
34  const percent = Math.round((tokens / Math.max(1, window)) * 100);
35  return `forward-compaction: your context is ${k(tokens)} tokens (${percent}% of your ${k(window)} max context), and every call re-sends all of it. Hand off at your next clean milestone: once the step in hand is finished and its results are written down, and before you start the next step, call ${tool} with objective, done, remaining and next. You pick the milestone, not whether: skip it only if nothing is left but writing your result and your final answer. ${gateAt !== undefined && tokens < gateAt ? `From ${k(gateAt)} your other calls wait until you have handed off. ` : ''}At ${k(ceiling)} the plugin compacts you mechanically, with no frontier from you. ${FREE} If this tool is not in your tool list, carry on.`;
36}
37
38/** How many nudge levels past its first nudge a sub-agent's calls start waiting for its handoff: one, so the first nudge leaves it one step to pick its milestone. */
39export const GATE_STEPS = 1;
40
41/** The context at which a gate level closes. */
42export const gateMark = (start: number, every: number, gateLevel: number): number => start + (gateLevel - 1) * every;
43
44export type GateInput = { party: 'main' | 'subagent'; level: number; gateLevel?: number; stored: boolean; ignored: boolean; tool: string; open: readonly string[] };
45
46/**
47 * Whether a call waits for the party's handoff: a managed sub-agent at or past its gate level (GATE_STEPS past its first
48 * nudge since the last install) with nothing stored, calling anything but the tools that lead to the handoff (`open`).
49 * A fast reader passes its milestones between two nudges; the gate makes the handoff its next call instead of the
50 * mechanical wake. Main is never gated: its person steers it.
51 */
52export function gated({ party, level, gateLevel, stored, ignored, tool, open }: GateInput): boolean {
53  return party === 'subagent' && !ignored && !stored && gateLevel !== undefined && level >= gateLevel && !open.includes(tool);
54}
55
56/** The refusal a gated call answers with: nothing ran, hand off now, then repeat the call. */
57export function gateText({ tokens, ceiling, tool = DEFAULT_TOOL }: { tokens: number; ceiling: number; tool?: string }): string {
58  return `forward-compaction: this call did not run. Your context is ${k(tokens)} tokens and you have not handed off; at ${k(ceiling)} the plugin compacts you mechanically and your work so far is lost. Call ${tool} now, with objective, done, remaining and next: what you have read and concluded so far goes in done and findings, what is left in remaining. Then repeat this call; every call runs again once the handoff is stored. If ${tool} is not in your tool list, repeat this call: it runs. ${FREE}`;
59}
60
src/parties.ts 262 lines
1import type { Landing } from './wake.ts';
2import { ignoredBy, type RatioBase } from './window.ts';
3import { GATE_STEPS } from './nudge.ts';
4
5/** A file the model asked the wake to carry (the tool's `load_files` entry). */
6export type LoadFile = { path: string; lines?: string; why: string };
7
8/** A handoff the model wrote, held in memory until a door installs it. */
9export type StoredHandoff = {
10  text: string;
11  loadFiles: LoadFile[];
12  /** The `forward_compaction` call that stored it: the anchor `splitAtCall` finds in the messages. */
13  toolUseId: string;
14  /** The party's last reading when it stored the handoff; absent when it had none (none since an install). */
15  storedAtTokens?: number;
16  /** The ledger's next seq when it was stored: what the ledger records for the party from it, the model never saw (ledger.ts `factsFor` `since`). */
17  since: number;
18  /** Store order across all parties. */
19  seq: number;
20};
21
22/** What the party's last `session.compact` ask ended in (the decision row's `decision`). */
23export type AskDecision = 'skip' | 'install' | 'mechanical' | 'native' | 'pass' | 'foreign' | 'dropped' | 'error';
24
25/** What the plugin does with a party: manage it, leave it to Claude Code (its model is on the ignore list), or not yet know. */
26export type Judgement = 'managed' | 'ignored' | 'unknown';
27
28/** What a party is judged by: whether main's max context is known (set, or its window read), and the ignore list (`FORWARD_COMPACTION_IGNORE_MODELS`). */
29export type Judging = { mainKnown: boolean; ignore: readonly string[] };
30
31/** The decision row `ignore`: a party's ignore judgement, written when first made and each time it changes, with the list entry its model matched. */
32export type IgnoreRow = { event: 'ignore'; party: string; label: string; model: string | null; judged: Judgement; matched: string | null };
33
34export type PartyState = {
35  /** "main" or "agent:{id}". */
36  key: string;
37  /** What screen lines call the party: "main", or "{subagentType} {agentId first 6}". */
38  label: string;
39  model?: string;
40  /** The measured context of the party's last completed request; none from an install until its next reading. */
41  reading?: number;
42  /** The ratio base (window.ts `charsPerToken`), taken at the party's first reading in its live window; none from any compaction of it until its next reading. */
43  base?: RatioBase;
44  /** An installed wake whose landed line waits for the party's first reading after it. */
45  landing?: Landing;
46  stored?: StoredHandoff;
47  lastDecision?: AskDecision;
48  /** False from a skip until a request completes: the next ask is then a prompt too long (D8). */
49  answeredSinceSkip: boolean;
50  /** The highest nudge level delivered, or absorbed after an install, since the last install. */
51  nudgeLevel: number;
52  /** True from an install until the party's next reading, whose nudge level is then absorbed silently. */
53  absorbNext: boolean;
54  /** The nudge level from which the party's calls wait for its handoff (nudge.ts `gated`): GATE_STEPS past its first nudge since the last install; absent until that nudge. */
55  gateLevel?: number;
56  /** The party's completed requests: a gated call's request is told from a later one by it. */
57  requests: number;
58  /** The request whose calls the gate refused since the last install (`gateAnswer`). */
59  gateRequest?: number;
60  /** The gate let the party's calls run since the last install: it answered a refused request with no handoff. */
61  gateOpen?: boolean;
62  installs: number;
63  /** An install happened in the party's current turn: the turn-end door (P1) does not fire again. */
64  installedThisTurn: boolean;
65  /** The loaded, unpinned names the last stored handoff's drop_skills asked once: the next handoff naming one again drops it. */
66  dropAsked: string[];
67  /** The judgement last returned by `judged`. */
68  judged?: Judgement;
69};
70
71/** Per-party state, memory only, keyed "main" or "agent:{id}". A party is created on first touch. */
72export class Parties {
73  private readonly states = new Map<string, PartyState>();
74  private seq = 0;
75
76  /** The party's state, created with defaults when new. */
77  get(key: string): PartyState {
78    let state = this.states.get(key);
79    if (!state) {
80      state = { key, label: key, answeredSinceSkip: true, nudgeLevel: 0, absorbNext: false, requests: 0, installs: 0, installedThisTurn: false, dropAsked: [] };
81      this.states.set(key, state);
82    }
83    return state;
84  }
85
86  describe(key: string, patch: { label?: string; model?: string }): void {
87    Object.assign(this.get(key), patch);
88  }
89
90  /** The ignore-list entry the party's model matches: the plugin leaves it to Claude Code. Undefined while its model is not known. */
91  ignored(key: string, ignore: readonly string[]): string | undefined {
92    return ignoredBy(this.get(key).model, ignore);
93  }
94
95  /**
96   * Main's max context is not known: `FORWARD_COMPACTION_MAIN_MAX_CONTEXT` is unset and Claude Code has not reported its
97   * window yet. Main then gets no nudge and no ceiling. A sub-agent's is always known: `FORWARD_COMPACTION_SUBAGENT_MAX_CONTEXT`.
98   */
99  windowUnknown(key: string, mainKnown: boolean): boolean {
100    return key === 'main' && !mainKnown;
101  }
102
103  /** The party's judgement as known now: `ignored`, a sub-agent whose model no step has named, and `windowUnknown`, in one word. */
104  judgement(key: string, { mainKnown, ignore }: Judging): Judgement {
105    if (this.ignored(key, ignore) !== undefined) return 'ignored';
106    if (key !== 'main' && this.get(key).model === undefined) return 'unknown'; // no step has named its model: the list cannot judge it yet
107    return this.windowUnknown(key, mainKnown) ? 'unknown' : 'managed';
108  }
109
110  /** The ignore row when the judgement is new or has changed since the last row returned for the party; undefined while it holds. */
111  judged(key: string, judging: Judging): IgnoreRow | undefined {
112    const state = this.get(key);
113    const judged = this.judgement(key, judging);
114    if (state.judged === judged) return undefined;
115    state.judged = judged;
116    return { event: 'ignore', party: key, label: state.label, model: state.model ?? null, judged, matched: this.ignored(key, judging.ignore) ?? null };
117  }
118
119  /** Stores the party's handoff, the latest winning; returns the one it replaced. */
120  store(key: string, handoff: Omit<StoredHandoff, 'seq'>): StoredHandoff | undefined {
121    const state = this.get(key);
122    const replaced = state.stored;
123    state.stored = { ...handoff, seq: ++this.seq };
124    return replaced;
125  }
126
127  /** Removes the party's stored handoff and returns it; a party never seen gains no state. */
128  takeStored(key: string): StoredHandoff | undefined {
129    const state = this.states.get(key);
130    const stored = state?.stored;
131    if (state) state.stored = undefined;
132    return stored;
133  }
134
135  /**
136   * A completed request's reading and its nudge level; that request also answers a held ask. The first reading after
137   * an install is the party's floor plus what it kept: its level is absorbed, so the next nudge goes only past it.
138   * Returns the landing that reading measures, taken: its line is due now.
139   */
140  read(key: string, tokens: number, level = 0): Landing | undefined {
141    const state = this.get(key);
142    const landing = state.landing;
143    state.reading = tokens;
144    state.landing = undefined;
145    if (state.absorbNext) {
146      state.absorbNext = false;
147      state.nudgeLevel = Math.max(state.nudgeLevel, level);
148    }
149    this.answered(key);
150    return landing;
151  }
152
153  /** Takes the landing still waiting for a reading (the party ends), or undefined; a party never seen gains no state. */
154  takeLanding(key: string): Landing | undefined {
155    const state = this.states.get(key);
156    const landing = state?.landing;
157    if (state) state.landing = undefined;
158    return landing;
159  }
160
161  /** Every party's waiting landing, taken (the session ends). */
162  takeLandings(): [string, Landing][] {
163    return [...this.states.keys()].flatMap((key) => {
164      const landing = this.takeLanding(key);
165      return landing === undefined ? [] : [[key, landing] as [string, Landing]];
166    });
167  }
168
169  /** What the party's ask ended in. A skip makes the next ask reactive until a request completes; a compaction left to Claude Code (native, pass) ends the ratio base. */
170  asked(key: string, decision: AskDecision): void {
171    const state = this.get(key);
172    state.lastDecision = decision;
173    if (decision === 'skip') state.answeredSinceSkip = false;
174    if (decision === 'native' || decision === 'pass') state.base = undefined;
175  }
176
177  /** Sets the party's ratio base, or ends it (an unseen compaction): the next reading takes a new one. A party forgotten meanwhile gains no state. */
178  rebase(key: string, base: RatioBase | undefined): void {
179    const state = this.states.get(key);
180    if (state) state.base = base;
181  }
182
183  /** A new turn of the party: an ask held before it ended with the old turn, so the next ask is a fresh one, never a request too long. */
184  newTurn(key: string): void {
185    const state = this.states.get(key);
186    if (state) state.answeredSinceSkip = true;
187  }
188
189  /** A request completed (with or without a usage reading). */
190  answered(key: string): void {
191    const state = this.get(key);
192    state.answeredSinceSkip = true;
193    state.requests += 1;
194  }
195
196  /**
197   * What a gated call gets: refused while it belongs to the request that met the gate (its parallel calls too), let
198   * run from any later one, `opens` the first time. No API names a sub-agent's tools: one whose list lacks the handoff
199   * tool answers the refusal with another call, and would otherwise wait forever; its ceiling's mechanical wake stays.
200   */
201  gateAnswer(key: string): 'refuse' | 'opens' | 'open' {
202    const state = this.get(key);
203    state.gateRequest ??= state.requests;
204    if (state.gateRequest === state.requests) return 'refuse';
205    if (state.gateOpen) return 'open';
206    state.gateOpen = true;
207    return 'opens';
208  }
209
210  /** The ask follows a skip with no completed request between: Claude Code reports the request too long, not a fresh ask (D8). */
211  reactive(key: string): boolean {
212    const state = this.states.get(key);
213    return state?.lastDecision === 'skip' && !state.answeredSinceSkip;
214  }
215
216  /**
217   * Whether a nudge goes out now, recorded as sent: once per level while nothing is stored. None goes between an
218   * install and the party's next reading: until then no size is measured.
219   */
220  nudgeDue(key: string, level: number): boolean {
221    const state = this.get(key);
222    if (state.stored || state.absorbNext || level <= state.nudgeLevel) return false;
223    state.nudgeLevel = level;
224    state.gateLevel ??= level + GATE_STEPS;
225    return true;
226  }
227
228  /**
229   * A compaction installed: the handoff goes, the nudges start over from the next reading's level, and no reading
230   * stands until the next request measures one, whose landing line then goes out. Returns a landing still waiting from an earlier install.
231   */
232  installed(key: string, landing?: Landing): Landing | undefined {
233    const earlier = this.get(key).landing;
234    Object.assign(this.get(key), {
235      stored: undefined,
236      lastDecision: 'install',
237      answeredSinceSkip: true,
238      nudgeLevel: 0,
239      absorbNext: true,
240      gateLevel: undefined,
241      gateRequest: undefined,
242      gateOpen: undefined,
243      reading: undefined,
244      base: undefined,
245      landing,
246      installedThisTurn: true,
247    });
248    this.get(key).installs += 1;
249    return earlier;
250  }
251
252  /** The party's turn ended. */
253  turnEnded(key: string): void {
254    this.get(key).installedThisTurn = false;
255  }
256
257  /** The party starts over as if never seen: its agent finished, or the conversation was cleared. */
258  forget(key: string): void {
259    this.states.delete(key);
260  }
261}
262
src/window.ts 131 lines
1import { k } from './nudge.ts';
2import { sizeTokens, type PartySizes, type Size } from './options.ts';
3
4/** `$.session.usage({ breakdown: 'summary' })`'s breakdown, the fields the ask point is read from. */
5export type UsageBreakdown = { autoCompactThreshold?: number; rawMaxTokens?: number; autocompactSource?: string };
6
7/** Where the ask point came from, and what Claude Code left out when it is not its auto-compact threshold. */
8export type AskPoint = { tokens: number; source: 'autoCompactThreshold' | 'rawMaxTokens' | 'window'; missing?: 'breakdown' | 'autoCompactThreshold' };
9
10const positive = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value > 0;
11
12/**
13 * The context at which Claude Code asks to compact a loop: the breakdown's `autoCompactThreshold`. Absent (auto-compaction
14 * off) or with no breakdown at all, the compaction window `rawMaxTokens`, else main's `context.window`; `missing` names what was absent.
15 * Undefined while not even a window was reported.
16 */
17export function askPointOf(breakdown: UsageBreakdown | undefined, window: number | undefined): AskPoint | undefined {
18  if (breakdown !== undefined && positive(breakdown.autoCompactThreshold)) return { tokens: breakdown.autoCompactThreshold, source: 'autoCompactThreshold' };
19  const missing = breakdown === undefined ? 'breakdown' : 'autoCompactThreshold';
20  if (breakdown !== undefined && positive(breakdown.rawMaxTokens)) return { tokens: breakdown.rawMaxTokens, source: 'rawMaxTokens', missing };
21  return positive(window) ? { tokens: window, source: 'window', missing } : undefined;
22}
23
24/** The ignore-list entry a model id matches, case-insensitive substring; undefined when none does or the model is not known. */
25export function ignoredBy(model: string | undefined, list: readonly string[]): string | undefined {
26  if (model === undefined) return undefined;
27  const id = model.toLowerCase();
28  return list.find((entry) => entry !== '' && id.includes(entry.toLowerCase()));
29}
30
31/**
32 * The context at which a party with no handoff stored is compacted mechanically: the size against the party's max context.
33 * A party past it is still asked to compact before every request, so the ceiling is where the plugin gives up waiting for a handoff.
34 */
35export function ceilingTokens(size: Size, maxContext: number): number {
36  return sizeTokens(size, maxContext);
37}
38
39/** A party's token marks: its max context, Claude Code's ask point (held at or below the max context), the ceiling, the nudge start and step. */
40export type Marks = { maxContext: number; askPoint: number; ceiling: number; start: number; every: number };
41
42/** The marks of a party with these sizes against its max context; undefined while that is not known (main's, before Claude Code reports its window). */
43export function partyMarks(sizes: PartySizes, maxContext: number | undefined, apiAsk: number | undefined): Marks | undefined {
44  if (maxContext === undefined) return undefined;
45  return { maxContext, askPoint: Math.min(apiAsk ?? maxContext, maxContext), ceiling: ceilingTokens(sizes.ceiling, maxContext), start: sizeTokens(sizes.nudgeStart, maxContext), every: sizeTokens(sizes.nudgeEvery, maxContext) };
46}
47
48/** A live window as `$.session.messages({ as: 'api' })` answers it: each message's content blocks. */
49export type ApiWindow = readonly { role?: string; content: readonly { type: string; [field: string]: unknown }[] }[];
50
51/** One block's characters: its text and thinking, a tool_use's input as JSON, a tool_result's content text; media counts none (its tokens are no characters). */
52function blockChars(block: { type: string; [field: string]: unknown }): number {
53  const str = (value: unknown): number => (typeof value === 'string' ? value.length : 0);
54  if (block.type === 'tool_use') return JSON.stringify(block.input ?? {}).length;
55  if (block.type === 'tool_result') return typeof block.content === 'string' ? block.content.length : Array.isArray(block.content) ? block.content.reduce((n: number, inner) => n + (inner !== null && typeof inner === 'object' ? blockChars(inner) : 0), 0) : 0;
56  return str(block.text) + str(block.thinking);
57}
58
59/** A live window's characters: every block's text, thinking, tool inputs and tool results, the text whose tokens the reading counts. */
60export function apiChars(window: ApiWindow): number {
61  return window.reduce((n, message) => n + message.content.reduce((m, block) => m + blockChars(block), 0), 0);
62}
63
64/** A party's ratio base: its live window's characters (`apiChars`) and its reading at its first reading in its live window (first ever, or first after a compaction). */
65export type RatioBase = { chars: number; tokens: number };
66
67/** The token growth past the base a ratio is measured over, a design budget: below it one tool result's tokenization dominates the ratio. */
68export const RATIO_MIN_GROWTH = 5_000;
69/** The characters per token a measured ratio must fall in, a design budget: text tokenizes inside it, so a ratio outside is a stale base or a cut transcript, never text. */
70export const RATIO_RANGE = { min: 1, max: 8 } as const;
71
72/** Why a party's ratio is unmeasured; `delta` is the growth ratio that fell outside RATIO_RANGE. */
73export type Unmeasured = { ratio?: undefined; why: 'no-base' | 'no-reading' | 'no-chars' | 'below-min-growth' | 'out-of-range'; delta?: number };
74
75/**
76 * A party's measured characters per token, on its growth since its base: `(chars - base.chars) / (reading - base.tokens)`,
77 * so the system prompt, tools and skills in every reading, never in the transcript, cancel. Unmeasured with no base, no
78 * reading, no characters, growth under RATIO_MIN_GROWTH or a ratio outside RATIO_RANGE: then nothing is converted.
79 */
80export function charsPerToken(base: RatioBase | undefined, chars: number | undefined, reading: number | undefined): { ratio: number } | Unmeasured {
81  if (base === undefined) return { why: 'no-base' };
82  if (reading === undefined) return { why: 'no-reading' };
83  if (chars === undefined) return { why: 'no-chars' };
84  if (reading - base.tokens < RATIO_MIN_GROWTH) return { why: 'below-min-growth' };
85  const delta = (chars - base.chars) / (reading - base.tokens);
86  return delta >= RATIO_RANGE.min && delta <= RATIO_RANGE.max ? { ratio: delta } : { why: 'out-of-range', delta };
87}
88
89/** The share of a party's max context its carried load files may take, a design budget the owner reads, never a measured size: a twentieth leaves the wake budget to the facts and the kept messages. */
90export const LOAD_FILES_SHARE = 0.05;
91
92/** The characters of load-file content a party's wake carries, at its measured ratio; unbounded with no ratio. */
93export function loadFilesRoom(maxContext: number, ratio: number | undefined): number {
94  return ratio === undefined ? Number.POSITIVE_INFINITY : Math.floor(maxContext * LOAD_FILES_SHARE * ratio);
95}
96
97/**
98 * The `CLAUDE_CODE_AUTO_COMPACT_WINDOW` the README asks for: Claude Code then asks about every request from about 63k, early
99 * enough for a stored handoff to install (CLAUDE.md, Spine). A recommendation named to the owner, never a measured size.
100 */
101export const RECOMMENDED_COMPACT_WINDOW = 100_000;
102
103export type StartWarning = { key: string; line: string; row: Record<string, unknown> };
104
105/**
106 * What session.start names once, from Claude Code's own numbers: a compaction window (`rawMaxTokens`) past
107 * RECOMMENDED_COMPACT_WINDOW, which holds every handoff back to Claude Code's late ask, and a party whose ceiling is below
108 * its ask point. Nothing it cannot read is named.
109 */
110export function startWarnings(breakdown: UsageBreakdown | undefined, apiAsk: AskPoint | undefined, main: Marks | undefined, subagent: Marks): StartWarning[] {
111  const out: StartWarning[] = [];
112  const raw = breakdown?.rawMaxTokens;
113  if (raw !== undefined && raw > RECOMMENDED_COMPACT_WINDOW && apiAsk !== undefined) {
114    const source = breakdown?.autocompactSource ?? 'unknown';
115    out.push({
116      key: 'compact-window',
117      line: `forward-compaction: Claude Code compacts against a ${k(raw)} window (${source}) and asks to compact from about ${k(apiAsk.tokens)}, so a handoff installs no earlier. Set CLAUDE_CODE_AUTO_COMPACT_WINDOW to ${RECOMMENDED_COMPACT_WINDOW} in your settings env.`,
118      row: { event: 'warning', key: 'compact-window', rawMaxTokens: raw, autocompactSource: source, askPoint: apiAsk.tokens },
119    });
120  }
121  for (const [kind, key, m] of [['main', 'ceiling-below-ask', main], ['sub-agent', 'ceiling-below-ask:subagent', subagent]] as const) {
122    if (m === undefined || m.ceiling >= m.askPoint) continue;
123    out.push({
124      key,
125      line: `forward-compaction: the ${kind} ceiling ${k(m.ceiling)} is below Claude Code's ask point ${k(m.askPoint)}; a ${kind} with no handoff is compacted mechanically at its first ask.`,
126      row: { event: 'warning', key, ceiling: m.ceiling, askPoint: m.askPoint },
127    });
128  }
129  return out;
130}
131
src/unseen.ts 13 lines
1// A compaction the plugin never heard of: Claude Code can compact the main chat without
2// dispatching session.compact to user plugins (2.1.299 passes it over on a machine with managed
3// settings). The next token reading is then the only trace, so a sharp fall is logged.
4
5/** The share of the previous reading a reading falls to, or below, for the fall to count as a compaction. */
6export const UNSEEN_DROP = 0.5;
7
8/** True when main's reading fell to half the one before or less, and no session.compact was handled in between. */
9export function unseenCompaction(before: number | undefined, after: number | undefined, seen: boolean): boolean {
10  if (seen || before === undefined || after === undefined) return false;
11  return after <= before * UNSEEN_DROP;
12}
13
src/look-back.ts 329 lines
1// Recovery is bounded to one party's pre-compaction prefix. Only the adapter does I/O.
2export const LOOK_BACK_TOOL = 'look_back';
3export const LOOK_BACK_DESCRIPTION = 'Recover an exact detail — when you need an error text, code snippet, value, or what the user said from before your latest compaction. Searches only your own transcript’s part you no longer hold. Each call tells the person what you looked up. Returns numbered snippets or a whole entry; failures say why, no matches suggests other words. Example: {query:"connection refused"}.';
4export const LOOK_BACK_HINT = 'Need an exact detail from before this handoff? look_back finds it.';
5export const LOOK_BACK_INPUT_SCHEMA = {
6  type: 'object',
7  properties: {
8    query: { type: 'string', description: 'Case-insensitive words; entries holding the most of them rank first, then newest. Double quotes keep a phrase whole; \\" is a literal quote.' },
9    entry: { type: 'integer', minimum: 1, description: 'Line number from a look_back result to fetch whole, before the latest compaction.' },
10  },
11  // Query-or-entry is parseLookBack's check, not the schema's: a client sending `entry: null` beside a query is not refused.
12  additionalProperties: false,
13};
14export type LookBackArgs = { query?: string; entry?: number; terms: string[]; error?: string };
15/** `input`: the error is the caller's input (a bad argument, an entry it cannot fetch), not a plugin fault. */
16export type LookBackResult = { text: string; hits?: number; error?: string; input?: true };
17const oneLine = (s: string): string => s.replace(/\s+/g, ' ').trim();
18/**
19 * The search terms of a query's pieces: a quoted phrase as typed; a bare word without the punctuation prose wraps
20 * around it (`(KEY` → `KEY`, `review.md)` → `review.md`), its inner punctuation kept; each term once, case aside.
21 */
22function queryTerms(pieces: readonly string[]): string[] {
23  const out: string[] = [];
24  for (const piece of pieces) {
25    const term = piece.startsWith('"') ? piece.slice(1, -1).replace(/\\"/g, '"') : piece.replace(/\\"/g, '"').replace(/^[([{<'`,;]+/, '').replace(/[)\]}>'`,;:!?.]+$/, '');
26    if (term !== '' && !out.some((t) => t.toLowerCase() === term.toLowerCase())) out.push(term);
27  }
28  return out;
29}
30
31export function parseLookBack(raw: Record<string, unknown>): LookBackArgs {
32  // A null query or entry is absent: clients send unset optional fields as null.
33  const input = Object.fromEntries(Object.entries(raw).filter(([key, value]) => !(value === null && (key === 'query' || key === 'entry'))));
34  const args: LookBackArgs = { terms: [] };
35  if (typeof input.query === 'string') args.query = input.query.trim();
36  if (typeof input.entry === 'number') args.entry = input.entry;
37  const bad = Object.keys(input).find((key) => !['query', 'entry'].includes(key));
38  if (bad) args.error = `unknown argument ${bad}`;
39  else if (input.entry !== undefined && (!Number.isSafeInteger(input.entry) || Number(input.entry) < 1)) args.error = 'entry must be a positive integer';
40  else if (input.query !== undefined && typeof input.query !== 'string') args.error = 'query must be a string';
41  else if (args.entry === undefined) {
42    if (args.query === undefined) args.error = 'query or entry is required';
43    else {
44      // A backslash-escaped quote is a literal quote character, never a phrase delimiter; any other backslash is literal.
45      // A newline inside a phrase would turn grep -e into OR patterns; refuse it.
46      const pieces = args.query.match(/"(?:\\"|\\(?!")|[^"\\\r\n])*"|(?:\\"|\\(?!")|[^\s"\\])+/g) ?? [];
47      const delimiters = args.query.replace(/\\"/g, '');
48      if ((delimiters.match(/"/g)?.length ?? 0) % 2 !== 0) args.error = 'query has an unclosed quote';
49      else if (args.query.includes('\0') || /"[^"\n]*[\r\n]/.test(delimiters)) args.error = 'query terms must not contain NUL or newlines';
50      else args.terms = queryTerms(pieces);
51      if (!args.error && args.terms.length === 0) args.error = 'query must contain at least one word or quoted phrase';
52    }
53  }
54  return args;
55}
56
57const COMPLETE = '\n[look-back-complete]\n';
58const finish = `; printf '\\n[look-back-complete]\\n'`;
59// POSIX pipelines lose upstream status. Report each failure even without stderr;
60// grep's rc=1 alone means no matches, not an error.
61const checked = `checked() { empty="$1"; shift; "$@"; rc=$?; if [ "$rc" -ne 0 ] && ! { [ "$empty" = 1 ] && [ "$rc" -eq 1 ]; }; then printf 'look-back subprocess failed (exit %s): %s\\n' "$rc" "$*" >&2; return "$rc"; fi; }; `;
62const prefix = 'checked 0 sed -n "1,$(($2 - 1))p" "$1"';
63/**
64 * The script contains only fixed code and argument indexes; values stay in argv. grep reads the raw JSONL line, where a
65 * term sits as typed (a tool input's own JSON structure) or in the form JSON writes a string value (a quote as \", a
66 * backslash as \\): a term whose two forms differ goes to grep as both; searchResults matches the decoded text.
67 */
68export function searchArgv(file: string, boundary: number, terms: readonly string[]): string[] {
69  const forms = terms.map((term) => [...new Set([term, JSON.stringify(term).slice(1, -1)])]);
70  const refs = forms.flat().map((_, i) => `"\${${i + 3}}"`);
71  // The term each form belongs to, as fixed numbers: awk counts a line's terms, not its forms.
72  const groups = forms.flatMap((f, i) => f.map(() => i + 1)).join(' ');
73  return ['sh', '-c', `${checked}${prefix} | ${STRIP_IMAGES} | checked 1 grep -a -n -i -F ${refs.map((r) => `-e ${r}`).join(' ')} | checked 0 awk -v groups='${groups}' '${RANK}' ${refs.join(' ')} | checked 0 env LC_ALL=C sort -t: -k1,1nr -k2,2nr | ${BUDGET} | checked 0 env LC_ALL=C sort -t: -k2,2n | checked 0 cut -d: -f2-${finish}`, 'sh', file, String(boundary), ...forms.flat()];
74}
75/**
76 * `$.process.run` keeps 4,194,304 characters of output (Claude Code 2.1.307) and cuts the rest, the completion marker with it.
77 * An image's base64 holds nothing to search: emptied, its entry still reads as an image block. The best 200 lines go on
78 * while they fit 3,000,000 bytes; every line is read, so no stage upstream is cut off mid-write.
79 */
80const STRIP_IMAGES = `checked 0 sed -E 's|"data":"[A-Za-z0-9+/=]{200,}"|"data":""|g'`;
81const BUDGET = `checked 0 env LC_ALL=C awk '{ n += length($0) + 1; if (NR > 200 || (NR > 1 && n > 3000000)) next; print }'`;
82/**
83 * Prefixes each `grep -n` line with how many terms it holds, case aside; the forms come as operands, taken in BEGIN and
84 * never read as files, so a term like `a=b` is never an assignment. The most-matching 200, newest first among equals, go on.
85 */
86const RANK = String.raw`BEGIN { n = split(groups, g, " "); for (i = 1; i <= n; i++) { f[i] = tolower(ARGV[i]); delete ARGV[i] } }
87{ line = tolower($0); c = 0; split("", seen); for (i = 1; i <= n; i++) if (!(g[i] in seen) && index(line, f[i]) > 0) { seen[g[i]] = 1; c++ } print c ":" $0 }`;
88// Filter candidate lines while streaming: nested tool input is not a boundary.
89// Track JSON depth outside strings; escaped quotes/braces must not change it.
90const topLevelBoundary = String.raw`checked 0 awk '
91  {
92    depth = 0; quoted = 0; escaped = 0
93    for (i = 1; i <= length($0); i++) {
94      c = substr($0, i, 1)
95      if (quoted) {
96        if (escaped) escaped = 0
97        else if (c == "\\") escaped = 1
98        else if (c == "\"") quoted = 0
99      } else {
100        if (depth == 1 && substr($0, i) ~ /^"subtype":"compact_boundary"/) { print; break }
101        if (c == "\"") quoted = 1
102        else if (c == "{" || c == "[") depth++
103        else if (c == "}" || c == "]") depth--
104      }
105    }
106  }'`;
107export function boundaryArgv(file: string): string[] {
108  return ['sh', '-c', `${checked}checked 1 grep -a -n '"subtype":"compact_boundary"' "$1" | ${topLevelBoundary} | checked 0 tail -n 1${finish}`, 'sh', file];
109}
110export function entryArgv(file: string, entry: number): string[] {
111  return ['sh', '-c', `${checked}checked 0 sed -n "\${2}{s/^/\${2}:/;p;q;}" "$1"${finish}`, 'sh', file, String(entry)];
112}
113export function namesArgv(file: string, boundary: number, ids: readonly string[]): string[] {
114  if (ids.some((id) => /[\r\n\0]/.test(id))) throw new Error('tool result id contains NUL or newline');
115  const filters = ids.map((_, i) => `-e "\${${i + 3}}"`).join(' ');
116  return ['sh', '-c', `${checked}${prefix} | checked 1 grep -a -n '"tool_use"' | checked 1 grep -a -F ${filters} | checked 0 tail -n 200${finish}`, 'sh', file, String(boundary), ...ids];
117}
118export function processOutput(out: { exitCode: number; stdout: string; stderr: string }, stage: string, framed = false): string {
119  // POSIX sh reports tail's status, not upstream grep/sed: stderr is also a failure.
120  if (out.exitCode !== 0 || out.stderr.trim()) throw new Error(`${stage}: ${out.stderr.trim() || `exit ${out.exitCode}`}`);
121  if (!framed) return out.stdout;
122  if (!out.stdout.endsWith(COMPLETE)) throw new Error(`${stage}: missing completion marker; output may be truncated`);
123  return out.stdout.slice(0, -COMPLETE.length);
124}
125export function boundaryLine(stdout: string): number | undefined {
126  for (const line of stdout.trim().split('\n').reverse()) {
127    if (!line) continue;
128    const match = line.match(/^(\d+):([\s\S]*)$/);
129    const n = Number(match?.[1]);
130    if (!Number.isSafeInteger(n) || n < 1) throw new Error('invalid compaction boundary output');
131    let data: unknown;
132    try { data = JSON.parse(match![2]!); }
133    catch { throw new Error('invalid compaction boundary JSON'); }
134    if (object(data)?.subtype === 'compact_boundary') return n;
135  }
136  return undefined;
137}
138
139type ToolUse = { id: string; name: string };
140export type LookBackEntry = { line: number; time: string; kind: string; text: string; uses: ToolUse[]; results: string[]; warnings: string[] };
141const object = (v: unknown): Record<string, unknown> | undefined => v !== null && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : undefined;
142function readable(content: unknown, uses: ToolUse[], results: string[], warnings: string[]): string {
143  if (typeof content === 'string') return content;
144  if (!Array.isArray(content)) { warnings.push('unsupported content shape'); return '[unsupported content shape]'; }
145  return content.flatMap((value) => {
146    const b = object(value);
147    if (!b) { warnings.push('unsupported content block'); return ['[unsupported content block]']; }
148    if (b.type === 'thinking' || b.type === 'redacted_thinking') return [];
149    if (b.type === 'text' && typeof b.text === 'string') return [b.text];
150    if (b.type === 'tool_use') {
151      const name = typeof b.name === 'string' ? b.name : '[unknown tool]';
152      if (typeof b.id === 'string') uses.push({ id: b.id, name });
153      return [`${name} ${JSON.stringify(b.input ?? {})}`];
154    }
155    if (b.type === 'tool_result') {
156      if (typeof b.tool_use_id === 'string') results.push(b.tool_use_id);
157      else { results.push('[missing id]'); warnings.push('tool result has no tool_use_id'); }
158      return [readable(b.content, uses, [], warnings)];
159    }
160    // A tool search's result names the tools it loaded.
161    if (b.type === 'tool_reference') {
162      const name = typeof b.tool_name === 'string' ? b.tool_name : typeof b.name === 'string' ? b.name : undefined;
163      if (name !== undefined) return [`[tool ${name}]`];
164    }
165    const kind = typeof b.type === 'string' ? b.type : 'unknown';
166    // An image or a document is known and holds no text to search: a note, not a failure.
167    const note = kind === 'image' || kind === 'document' ? `${kind} block` : `unsupported ${kind} block`;
168    warnings.push(note); return [`[${note}]`];
169  }).join('\n');
170}
171export function parseEntries(stdout: string): LookBackEntry[] {
172  return stdout.split('\n').filter(Boolean).map((line) => {
173    const m = line.match(/^(\d+):([\s\S]*)$/);
174    if (!m || !Number.isSafeInteger(Number(m[1])) || Number(m[1]) < 1) throw new Error('invalid numbered transcript output');
175    const e: LookBackEntry = { line: Number(m[1]), time: 'unknown time', kind: 'raw', text: m[2]!, uses: [], results: [], warnings: [] };
176    let data: unknown;
177    try { data = JSON.parse(m[2]!); }
178    catch { return e; } // Malformed JSON is deliberately readable raw text (R4).
179    const r = object(data);
180    const msg = object(r?.message);
181    const role = r?.type ?? msg?.role;
182    if (typeof r?.timestamp === 'string' && Number.isFinite(Date.parse(r.timestamp))) e.time = new Date(r.timestamp).toISOString().slice(0, 16).replace('T', ' ');
183    if (r?.type === 'attachment') return attachmentEntry(e, object(r.attachment));
184    if (r?.type === 'queue-operation' && r.operation === 'enqueue' && typeof r.content === 'string') { e.kind = 'queued prompt'; e.text = r.content; return e; }
185    if (typeof r?.type === 'string' && (BOOKKEEPING.has(r.type) || (r.type === 'system' && typeof r.content !== 'string'))) {
186      e.kind = 'bookkeeping'; e.text = r.type; return e;
187    }
188    if (r?.type === 'system' && typeof r.content === 'string') { e.kind = 'system'; e.text = r.content; return e; }
189    if (r?.type === 'summary' && typeof r.summary === 'string') { e.kind = 'summary'; e.text = r.summary; return e; }
190    if (!msg || !['user', 'assistant'].includes(String(role))) {
191      e.kind = 'unknown'; e.text = `[unsupported entry type ${String(role)}]`; e.warnings.push(e.text.slice(1, -1)); return e;
192    }
193    e.kind = String(role);
194    e.text = readable(msg.content, e.uses, e.results, e.warnings);
195    if (e.uses.length) e.kind = `tool ${e.uses.map((u) => u.name).join(', ')}`;
196    return e;
197  });
198}
199/**
200 * Entry types Claude Code writes for its own bookkeeping, never part of the conversation (read off 2.1.300 transcripts):
201 * a queue operation other than an enqueued prompt (a dequeue, removal or pop repeats or empties it), a copy of a request
202 * sent, titles, modes, links, file history and cost. Skipped without a note; a `system` entry without text is too.
203 */
204const BOOKKEEPING = new Set([
205  'queue-operation', 'api-request', 'api-request-blob', 'api-request-shape', 'last-prompt', 'mode', 'permission-mode',
206  'atis-latch', 'ai-title', 'custom-title', 'agent-name', 'bridge-session', 'history-suppression', 'frame-link', 'pr-link',
207  'file-history-snapshot', 'file-history-delta', 'cost-state', 'artifact-comment-monitor', 'artifact-autoreact-ledger',
208]);
209/**
210 * An `attachment` entry: what Claude Code put into the conversation beside the messages. A task notification (a child's
211 * result) is a `queued_command` whose `commandMode` is `task-notification`; its text is `prompt`. Others carry `content`
212 * (a string or a list of strings) or `text`. One with none of these (engine records such as a prompt snapshot, or a
213 * file whose text is nested) is shown raw, its JSON verbatim, so nothing it holds is hidden.
214 */
215function attachmentEntry(e: LookBackEntry, a: Record<string, unknown> | undefined): LookBackEntry {
216  const type = typeof a?.type === 'string' ? a.type : 'unknown';
217  const content = a?.content;
218  const text = typeof a?.prompt === 'string' ? a.prompt
219    : typeof content === 'string' ? content
220    : Array.isArray(content) && content.every((item) => typeof item === 'string') ? content.join('\n')
221    : typeof a?.text === 'string' ? a.text
222    : undefined;
223  if (text === undefined) {
224    e.kind = `attachment ${type} raw`; e.text = JSON.stringify(a ?? null); return e;
225  }
226  e.kind = a?.commandMode === 'task-notification' ? 'task-notification' : `attachment ${type}`;
227  e.text = text;
228  return e;
229}
230/** A warning that leaves an entry, or part of one, unread: a failure the caller sees, not a routine note. */
231const unread = (warning: string): boolean => warning.startsWith('unsupported');
232export function toolNames(entries: readonly LookBackEntry[]): Record<string, string> {
233  return Object.fromEntries(entries.flatMap((e) => e.uses.map((u) => [u.id, u.name])));
234}
235export function resultIds(entries: readonly LookBackEntry[]): string[] {
236  return [...new Set(entries.flatMap((e) => e.results))];
237}
238function header(e: LookBackEntry, names: Record<string, string>): string {
239  const kind = e.results.length ? `result of ${e.results.map((id) => Object.hasOwn(names, id) ? names[id] : `unknown tool ${id}`).join(', ')}` : e.kind;
240  return `#${e.line}  ${e.time}  ${oneLine(kind)}`;
241}
242export function snippet(text: string, terms: readonly string[]): string {
243  const lower = text.toLowerCase();
244  const first = terms.map((t) => lower.indexOf(t.toLowerCase())).find((at) => at >= 0) ?? -1;
245  const start = Math.max(0, Math.min(Math.max(0, text.length - 400), first - 200));
246  const end = start + 400;
247  return `${start > 0 ? '…' : ''}${text.slice(start, end)}${end < text.length ? '…' : ''}`;
248}
249// A tool call's text shows its input as JSON, a quote in a value as \": a term holding the quote itself matches it there too.
250const unescaped = (text: string): string => text.replace(/\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4})/g, (m) => JSON.parse(`"${m}"`) as string);
251/**
252 * The matches, those holding the most terms first, then newest; the party's own calls of `demote` (its forward_compaction call, whose fields restate what it
253 * searches for) and their results rank below every other hit. An entry left unread is the result's error, hits still shown.
254 */
255export function searchResults(entries: readonly LookBackEntry[], terms: readonly string[], boundary: number, names: Record<string, string> = {}, demote?: string): LookBackResult {
256  const candidates = entries.filter((e) => e.line < boundary && e.kind !== 'bookkeeping');
257  const own = (e: LookBackEntry): boolean => demote !== undefined && (e.uses.some((u) => u.name === demote) || e.results.some((id) => names[id] === demote));
258  // Ranked by how many terms an entry holds, so a query of many words finds the entry that holds most of them.
259  const held = new Map(candidates.map((e) => { const text = e.text.toLowerCase(), plain = unescaped(text); return [e, terms.filter((term) => text.includes(term.toLowerCase()) || plain.includes(term.toLowerCase())).length] as const; }));
260  const count = (e: LookBackEntry): number => held.get(e) ?? 0;
261  const matches = candidates
262    .filter((e) => count(e) > 0)
263    .sort((a, b) => Number(own(a)) - Number(own(b)) || count(b) - count(a) || b.line - a.line);
264  const notes = [...new Set(candidates.flatMap((e) => e.warnings.map((w) => `#${e.line}: ${w}`)))].join('\n');
265  const out: string[] = [];
266  for (const e of matches) {
267    if (out.length === 20) break;
268    const part = count(e) < terms.length ? `  (${count(e)} of ${terms.length} terms)` : '';
269    const hit = `${header(e, names)}${part}\n${snippet(e.text, terms.filter((t) => e.text.toLowerCase().includes(t.toLowerCase()) || unescaped(e.text.toLowerCase()).includes(t.toLowerCase())))}`;
270    // Reserve room for the footer and unsupported-shape report within the total cap.
271    if ([...out, hit].join('\n\n').length + Math.min(notes.length, 1000) + 120 > 12_000) break;
272    out.push(hit);
273  }
274  const more = matches.length - out.length;
275  let rendered = out.join('\n\n');
276  if (matches.length === 0) {
277    const query = terms.join(', ');
278    const echo = query.length > 10_000 ? query.slice(0, 10_000) + '[query cut]' : query;
279    rendered = `No entry before the compaction at line ${boundary} matches: ${echo}\nTry other words.`;
280  } else if (terms.length > 1 && matches.every((e) => count(e) < terms.length)) rendered = `No entry holds every term (${terms.join(', ')}); these hold the most:\n\n${rendered}`;
281  if (notes) rendered += `\n${notes.slice(0, 1000)}${notes.length > 1000 ? '\n[unsupported-shape report cut]' : ''}`;
282  if (more) rendered += `\n${more} more matches: quote a phrase or use rarer words to narrow it, or fetch one with entry`;
283  const failed = candidates.flatMap((e) => e.warnings.filter(unread).map((w) => `#${e.line}: ${w}`));
284  const unreadEntries = new Set(failed.map((f) => f.split(':')[0])).size;
285  const error = failed.length === 0 ? undefined : `${unreadEntries} ${unreadEntries === 1 ? 'entry' : 'entries'} could not be read: ${[...new Set(failed)].slice(0, 5).join('; ')}${failed.length > 5 ? '; …' : ''}`;
286  return { text: rendered, hits: out.length, ...(error ? { error } : {}) };
287}
288export function entryResult(entries: readonly LookBackEntry[], n: number, boundary: number, names: Record<string, string> = {}): LookBackResult {
289  const error = n >= boundary ? `entry ${n} is at or after the compaction at line ${boundary}, so already in context` : undefined;
290  if (error) return { text: error, error, input: true };
291  const e = entries.find((e) => e.line === n);
292  if (!e) { const error = `entry ${n} was not found in the transcript`; return { text: error, error, input: true }; }
293  if (e.kind === 'bookkeeping') {
294    const error = `entry ${n} is Claude Code bookkeeping (${e.text}), not part of the conversation`;
295    return { text: error, error, input: true };
296  }
297  if (e.kind === 'unknown') {
298    const error = `entry ${n} could not be read: ${e.warnings.join(', ')}`;
299    return { text: error, error };
300  }
301  const text = e.text.slice(0, 24_000) + (e.text.length > 24_000 ? `\n[cut at 24k of ${e.text.length} chars]` : '');
302  return { text: `${header(e, names)}\n${text}` };
303}
304export function manualTranscriptCall(tool: string, input: Record<string, unknown>, session: string, agentId?: string): boolean {
305  if (!['Read', 'Grep', 'Bash'].includes(tool)) return false;
306  const name = agentId === undefined ? `${session}.jsonl` : `agent-${agentId}.jsonl`;
307  const literal = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
308  return new RegExp(`(^|[^A-Za-z0-9_.-])${literal}($|[^A-Za-z0-9_.-])`).test(JSON.stringify(input));
309}
310export function lookBackNotice(label: string, args: Pick<LookBackArgs, 'query' | 'entry'>, result: { hits?: number; error?: string }): string {
311  const query = oneLine(args.query ?? '');
312  const asked = args.entry !== undefined ? `entry ${args.entry}` : args.query !== undefined ? `query ${query.length > 120 ? query.slice(0, 115) + '[cut]' : query}` : 'no query or entry';
313  const outcome = result.error ? `failed: ${oneLine(result.error)}` : args.entry !== undefined ? '' : `${result.hits ?? 0} hits`;
314  return `look back (${oneLine(label)}): ${asked}${outcome ? ` — ${outcome}` : ''}`;
315}
316
317/** The command that finds a transcript by its session id, the way Claude Code placed it: the project directory is whatever Claude Code named it. `-H`: a `projects` that is a link to the real directory is searched through. */
318export function findTranscriptArgv({ configDir, session, agentId }: { configDir: string; session: string; agentId?: string }): string[] {
319  const projects = `${configDir}/projects`;
320  return agentId === undefined
321    ? ['find', '-H', projects, '-maxdepth', '2', '-type', 'f', '-name', `${session}.jsonl`]
322    : ['find', '-H', projects, '-maxdepth', '4', '-type', 'f', '-path', `*/${session}/subagents/agent-${agentId}.jsonl`];
323}
324
325/** The path `find` printed first, or undefined when it printed none. */
326export function firstFound(stdout: string): string | undefined {
327  return stdout.split('\n').find((line) => line.startsWith('/'));
328}
329