SLOPSHOPPER

session-owl

Session memory as a background-maintained append-only knowledge graph; its roots become the compaction summary; the agent traverses and regex-searches it to…

newpanebandspinnerrowsguard
★ 7v1.3.5MITupdated 2026-10-23avtc/avtc-pi-session-owl/claude
A shopper browsing a rack in a slop shop
README

session-owl — Claude Code port

Session memory as a background-maintained append-only knowledge graph; its roots become the compaction summary; the agent traverses and regex-searches it to recall verbatim messages, tool calls and results.

session-owl

Features

  • Persistent memory — observations (immutable, source-backed) are captured continuously and organized into a semantic node graph that lasts across every compaction.
  • Compaction summary — at compaction, the graph's root level is refined toward a token budget and rendered into the summary injected at compaction; the full detail remains available to the agent via recall.
  • Task-relative — a Selector picks the nodes that matter to the current work and the planned next tasks; superseded and stale context drops out.
  • Browse session memory — /owl-ls, /owl-cat, /owl-find let you list, inspect, and search your memory graph; the owl_recall tool lets the agent recall on demand.
  • Live status widget — a footer line shows the active maintenance stage, its progress, and its token cost.

Installation

Requires Claude Code 2.1.302 or later.

claude plugin install session-owl --marketplace avtc/avtc-pi-session-owl

Compatibility

Claude Code CLI — tested.

VS Code extension — the plugin loads and memory capture works, but plugin UI does not render there: no status widget, and the /owl-* commands produce no visible output (the engine draws plugin UI on the terminal surface only). Stage-agent output appears in the main chat window instead. Not thoroughly tested.

Claude Desktop — tested.

Other surfaces — not tested.

After finishing, session-owl's background agents are shown as stopped (a red circle in the CLI for ~30s) — normal behavior, working around anthropics/claude-code#99417.

How it works

session-owl runs three maintenance stages:

  1. Observer — watches the session turn-by-turn and captures observations: condensed, source-backed facts. Each captured fact becomes a node in the graph.
  2. Builder — maintains the graph: groups, merges, supersedes, and re-rates nodes so the structure stays coherent and bounded.
  3. Selector — curates a task-relative view (the active set) from the maintained graph.

At compaction, the active set is rendered into the compaction summary that is injected afterward — the agent's post-compaction memory:

# Memory
Your session memory — the top level of a tree; each id opens deeper detail via owl_recall.
Legend: 📁 n.. node · 📄 o.. observation (obs) · importance crit high med low (how much it matters if lost) · 🆕new (not yet processed) 📦archived 🪦obsolete · 2nodes 3obs (direct children) · 34lines 412tokens (direct children observations full details size)

## Memory use
The root view is a navigation index into retained session memory: the lines name what exists; the detail, evidence, rationale, and results live in the tree behind them, across the whole session (see the totals). Recall before relying on session-derived understanding: search memory, or expand a visible related id — a complete-looking label still summarizes only the surface, and relevant context often sits deeper than the root. Use {"ids":["n23"]} to expand, {"query":"…"} to search, and add "fullDetails":true when exact messages, tool output, or rationale matter.

## Initial prompt
Redesign the auth flow: move JWT validation to middleware and drop the legacy login form.

## Active set
📁 nGoal · crit · Redesign auth flow (JWT middleware, drop legacy login) · 3nodes 1obs · 2lines 45tokens · Jul 28 14:30 — Jul 29 09:15
📁 n12 · high · Auth flow redesign · 1obs · 2lines 15tokens · Jul 28 14:30 — Jul 29 09:15
📁 n8 · high · Decisions · 5obs · 214lines 5100tokens · Jul 28 14:30 — Jul 29 09:15
📁 n6 · 📦low · Old login form · 2obs · 96lines 2300tokens · Jul 27 09:00 — Jul 27 18:00
📁 nIrrelevant · med · Irrelevant · 4obs · 12lines 260tokens · Jul 28 14:30 — Jul 29 09:15
---
Source tree total: 237 nodes (4 levels) · 273 observations · 10k lines 576k tokens of details · 3 compactions

## Recently touched
Jul 29 09:10 read src/auth/middleware.ts:1-40,120-180
Jul 29 09:12 edit src/auth/jwt.ts
Jul 29 09:15 write src/auth/index.ts

After compaction the agent continues with this summary alongside Claude Code's own recent-context tail.

Benchmarks

Self-run benchmarks (one authored scenario, N=3 continuations per arm, LLM-judge scored) against bare pi and pi-observational-memory, driven by our compaction benchmark harness. Agent glm-5.3; session-owl-builder is the default configuration:

Recall accuracybare piom-defaultsession-owl-buildersession-owl-selectorom-full
Facts the user asked to remember (direct probes)100%87%100%100%90%
Topics cued from summary, agent has to call tools to recall details21%23%78%77%43%
No trace in the summary or kept tail, agent has to search through session memory or deduct11%22%100%56%22%

Arms: bare pi — native compaction summary, no memory extension; om-default — pi-observational-memory (v2 fork) at the default threshold, observations fit — Reflector/Pruner stay idle; om-full — observations exceed the threshold, Reflector + Pruner run.

Full reports: agent glm-5.3 · background qwen3.8-27B · agent qwen3.8-27B fp16 · xhigh

The memory graph

The graph is a containment tree of nodes (folders) holding observations (leaves). It renders the same way everywhere — the agent's owl_recall, the Builder and Selector tools, and the /owl-ls//owl-cat//owl-find commands:

📁 nGoal · crit · The session goal · 4nodes 1obs · 2lines 45tokens · Jul 28 14:30
  📁 n12 · high · Auth flow redesign · 1obs · 2lines 15tokens · Jul 28 14:30 — Jul 29 09:15
    📄 o31 · med · JWT validation moved to middleware · 2lines 15tokens · Jul 28 14:30
  📁 n8 · high · Decisions · 5obs · 214lines 5100tokens · Jul 28 14:30 — Jul 29 09:15
  📁 n6 · 📦low · Old login form · 2obs · 96lines 2300tokens · Jul 27 09:00 — Jul 27 18:00
  📁 n3 · 🪦med · YAML config · → n8 · 1obs · 1line 12tokens · Jul 27 09:00
📁 n5 · med · Scratch · 2obs · 38lines 900tokens · Jul 28 14:30 — Jul 29 09:15

(📁 n.. node · 📄 o.. observation (obs); importance *crit*/*high*/*med*/*low* (how much it matters if lost); state glyphs 🆕 new (not yet processed) · 📦 archived · 🪦 obsolete; 2nodes 3obs (direct children) · 34lines 412tokens (direct children observations full details size).)

How the agent operates it. The Builder and Selector navigate and edit the graph with filesystem-style tools — ls, cat, find to read; mkdir, mv, merge, supersede (Builder-only), set_meta to reorganize; try_finish to converge the root view on its budget. The Builder maintains the source graph; the Selector builds a curated copy (the active set) for the summary. The agent itself uses the read-only owl_recall to fetch and search on demand.

Status widget

While session-owl works, a footer line shows the active stage, its progress, and its token cost:

🦉 1005(+11) obs 150/371 → 95 roots 35k/40k · 8.0k/262k · 3.4k tok · +3 obs

(observation/node counts with deltas since the stage started · chunk batch N/M during an observe run · root-view tokens vs budget · context-window usage · streamed output tokens · observations accepted in the current chunk but not yet persisted — appears only while a chunk is actively recording).

Tools

ToolDescription
owl_recallRecall from memory — fetch by id, filter by time range, or regex search. Targets the rendered tree (selected-root or observations-root).

Tool visibility

Observer/builder/selector stage tools are hidden from the main agent. With the default base URL the engine defers them on its own; when the base URL is overridden, set ENABLE_TOOL_SEARCH=true to keep that deferral. Running /context in the main session surfaces the hidden session-owl tool names to the main agent — not critical: a direct call is denied with an instruction to use owl_recall.

Commands

CommandDescription
/owl-statusShow memory stats and per-phase token/cost usage (since last compaction and since session start)
/owl-ls [nodeId]List root nodes, or a node's children
/owl-cat <id>Show a node (with its observations) or a single observation in full
/owl-find <query>Search memory (regex; current items)
/owl-find-all <query>Search memory (regex; everything, including superseded)
/owl-rescanDiscard the current memory graph and re-observe the entire session from the start (asks confirmation). With --reuse-observations: rebuild the graph structure from the collected observations without re-observing
/owl-reobserve-0-obs-chunksRe-observe session ranges that were skipped with zero observations (repair after a degraded model run)
/owl-settingsOpen the settings UI

/owl-status output:

owl:status

/owl-settings — the settings pane:

owl:settings

The value picker:

owl:settings-dropdown

Configuration

Five independent mode axes, all live-toggleable mid-session:

SettingOptionsDefault
observerModeon-threshold · on-compactionon-threshold
builderModeon-compaction · each-N-observations · on-session-context-threshold · on-root-view-thresholdeach-N-observations
selectorModeon-compaction · on-session-context-thresholdon-compaction
renderModeselected-root (Selector curates) · observations-root (the Builder's root view)observations-root
subagentModeinherited (same as the main conversation) · on-compaction (captured only when the subagent itself compacts) · disabledon-compaction

The defaults keep the observations graph in shape, so when a compaction is triggered the summary is immediately provided — tune the modes and thresholds to your model and workload. Two shape knobs — rootViewTargetNodes (how many roots to aim for; no target = the agent shapes the root view on its own) and rootViewStrategy (task · category · recency · importance · topic) — advise the Builder and Selector on the root view's organization. See CONFIGURATION.md for the full schema reference (every knob, defaults, per-component model presets).

Plugin-spawned agents (session-owl's own included) are ignored, and their compaction is left to Claude Code. subagentMode covers all other subagents.

Conflicts with other mods

Claude Code runs every plugin's session.compact hook; when two mods both answer compaction, the session's summary is contested. Session Owl detects this at every start — it fingerprints the loaded plugins' declared events (the same surface claude plugin validate prints), watches for foreign compaction answers and compaction-shaped session rows, and when it finds a conflict it stays dormant: no observations are collected, compaction falls through to the native or foreign handler, and the widget shows a paused line:

🦉 ⚠ paused — other-mod@x also handles compaction (/owl-status)

The pause is runtime-only (nothing is written to your settings): remove the other mod and restart, or toggle ignoreConflicts in /owl-settings — session-owl resumes immediately, even mid-session.

Forcing session-owl on

If you deliberately run session-owl alongside another compaction handler (e.g. for benchmarking), set ignoreConflicts: true in /owl-settings. You are choosing that fight knowingly.

Development

The port's development notes, gates, and release runbook live in DEVELOPMENT.md.

Full suite

Check out the full suite of related extensions, avtc-pi — deterministic feature development, subagent delegation, working-memory, behavioral learning, parallel-work guardrails, durable decisions, notifications, and more.

Developed with Z.ai — get 10% off your subscription via this referral link.

License

MIT

Source 90 files
hooks/register.ts 2273 lines
1// SPDX-License-Identifier: MIT
2// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
4// The plugin's single hooks entry (hooks.json names exactly this module):
5// the event wiring for the whole port. `$` and every `$.noun.method(...)`
6// call site are spelled literally INSIDE handler bodies — the engine's
7// scanner (2.1.291+) refuses `$` itself, its nouns, and non-literal hook
8// arguments crossing call boundaries, and `claude plugin validate ./claude`
9// checks it offline. Each handler ARMS the bridge with capsule closures
10// (created in the handler, literal calls in their bodies) so deep host code
11// reaches the engine without ever seeing `$`; the handlers otherwise stay
12// thin, delegating decisions and path math to the data-only claude/host/*
13// modules.
14import type { Register } from "claude-code";
15import { log } from "../core/src/log.js";
16import { captureAppend, getCapture, releaseAllCaptures, releaseCapture } from "../host/capture/append-handler.js";
17import { flushTurn } from "../host/capture/flush.js";
18import type { ClaudeRow } from "../host/capture/grouper.js";
19// FIRST IMPORT — installs the claude-logger default backend the vendored
20// core's log.ts needs at ITS module load (createLogger at top level). This
21// module's own imports pull nothing but the logger library, so its body
22// (setDefaultBackend) runs before any later import can evaluate log.ts.
23import {
24  type OwlCommandDeps,
25  runOwlCat,
26  runOwlFind,
27  runOwlFindAll,
28  runOwlLs,
29  runOwlReobserve,
30  runOwlRescan,
31  runOwlStatus,
32} from "../host/commands/handlers.js";
33import { buildViewerTree, setViewerContent, VIEWER_PANE_ID } from "../host/commands/viewer.js";
34import {
35  AWAIT_STAGE_TOOL,
36  joinStageRunSliced,
37  raceTakeoverWithSignal,
38  STAGE_SLICE_MS,
39  withBudgetFreeWait,
40} from "../host/compaction/budget-free.js";
41import { type CompactEvent, compactionTakeover } from "../host/compaction/hook.js";
42import { recomputePauseSet, runConflictActivation } from "../host/conflicts/activation.js";
43import { recordBWitness, recordFoldList } from "../host/conflicts/channel-a-prime.js";
44import { handlePluginRegisterEnvelope, recordLiveOutcome, resetWaveLiveHits } from "../host/conflicts/channel-b.js";
45import { observeCompactionRow, recordCompactAnswer } from "../host/conflicts/channel-c.js";
46import {
47  conflictPauseLogLine,
48  conflictsDetectedLogLine,
49  getConflictHits,
50  isConflictPaused,
51} from "../host/conflicts/pause.js";
52import type { TimelineEvent } from "../host/conflicts/timeline.js";
53import { newestCompactionCut, parseTimelines } from "../host/conflicts/timeline.js";
54import { evictResidentConversation, reloadCoreFor } from "../host/core-instances.js";
55import * as bridge from "../host/engine-bridge.js";
56import { mergePaneKeybindings } from "../host/keys/pane-bindings.js";
57import { initLogConsumer } from "../host/log/consumer.js";
58import { errDetail, guarded, sayError } from "../host/log/err-detail.js";
59import { installUnhandledRejectionLogging } from "../host/log/unhandled.js";
60import { sessionOwlNoun } from "../host/noun.js";
61import { handleOwlRecallCall, type OwlRecallCallEvent } from "../host/recall/handler.js";
62import { restyleToolResultProps } from "../host/recall/render.js";
63import { OWL_RECALL_FULL_NAME } from "../host/recall/spec.js";
64import { runPruneHelper } from "../host/retention/prune-caller.js";
65import { type Fingerprint, fingerprintBlocks } from "../host/rewind/canonical-check.js";
66import { checkpointService } from "../host/rewind/checkpoint-service.js";
67import { isCapRefusal, runVerifyPass } from "../host/rewind/helper-pass.js";
68import { clearWatermarks, runCheckpoint } from "../host/rewind/reconcile.js";
69import { parseTranscript, type TranscriptPass } from "../host/rewind/verifier.js";
70import { getBinding, releaseAllBindings, releaseBinding, setSessionCwd } from "../host/runs/bind.js";
71import { installRunsLogSink } from "../host/runs/seams.js";
72import { getInstalledSettingsHandle, initSettingsConsumer } from "../host/settings/consumer.js";
73import {
74  ensureSettingsPane,
75  getSettingsPaneController,
76  manualJsonNoticeTree,
77  SETTINGS_PANE_ID,
78  takeSettingsPaneFirstRingOffer,
79} from "../host/settings/pane.js";
80import { noStageModelSettings, STAGE_AGENT_NAMES, stageAgentSpecs } from "../host/stage/agents.js";
81import { shouldDropPeerMessage } from "../host/stage/delivery.js";
82import { recordDumpOutputRow } from "../host/stage/dumps.js";
83import { createLaunchBroker, STAGE_LAUNCHED_TOOL } from "../host/stage/launch.js";
84import { getStagePrompts } from "../host/stage/prompts.js";
85import { relayTurnStep } from "../host/stage/relay.js";
86import { clearAllStageRuns, getStageRun } from "../host/stage/runs.js";
87import { handleStageToolCall, resetStageToolExecutors, stageToolShortName } from "../host/stage/tools.js";
88import { completionUsageOf } from "../host/stage/usage.js";
89import { runBackfill } from "../host/start/backfill.js";
90import { manualBootstrapCommand, runBootstrapHelper } from "../host/start/bootstrap-caller.js";
91import { copyBranchLineageStore } from "../host/start/branch-copy.js";
92import { launchGoalExtract, registerGoalExtractLauncher } from "../host/start/goal-extract.js";
93import { corePendingEvaluator } from "../host/start/pending.js";
94import * as state from "../host/state.js";
95import { type MirroredRow, sweepMirror } from "../host/storage/mirror.js";
96import {
97  flushAllSessionStorage,
98  flushSessionStorage,
99  getSessionStorage,
100  releaseAllSessionStorage,
101  releaseSessionStorage,
102} from "../host/storage/session-storage.js";
103import { newestAssistantUuid, shouldFireBoundary } from "../host/triggers/boundary-gate.js";
104import { recordMeasure, seedWindow } from "../host/triggers/context.js";
105import { onStepBoundary, onTurnCompleteBoundary } from "../host/triggers/eval.js";
106import { abortActiveFor, abortAllForSessionEnd, awaitIdle } from "../host/triggers/run-locks.js";
107import { composeBand, decideBand } from "../host/widget/band.js";
108import { consumeIdleRefresh } from "../host/widget/line-store.js";
109import { formatPauseLine } from "../host/widget/pause-line.js";
110import { buildPaneTree } from "../node_modules/avtc-claude-settings-ui/dist/index.js";
111
112// runsLog → the file logger: one sink swap at load, so trigger/chain/run
113// diagnostics land in the session-owl log instead of engine --debug only.
114installRunsLogSink((message, cause) => {
115  if (cause === undefined) {
116    log.info(message);
117  } else {
118    log.warn(`${message}: ${cause instanceof Error ? cause.message : String(cause)}`);
119  }
120});
121
122// The process-level net FIRST (before any wiring can float a promise): a
123// rejection nobody catches lands in the file log with its stack instead of
124// the engine's transient "mod .catch" warning, with the stack attached.
125installUnhandledRejectionLogging();
126
127// Event inputs are structural (the engine's envelopes); handler params stay
128// permissive where the shapes are wide — the validator + kit specs pin them.
129type AnyEvent = Record<string, any>;
130type Next = any;
131
132function report(kind: string, err: unknown): void {
133  // The one error transport: the FILE log AND the
134  // engine's $.ui.log (a dim transcript row the user sees — the engine's
135  // debug log carries it either way), console only when neither channel
136  // exists yet (module-load / pre-arm). The stack rides along via errDetail.
137  // The ui copy drops the manual prefix — the engine's row attribution
138  // already names the plugin (no "session-owl: session-owl: " doubling).
139  sayError(`session-owl: ${kind}: ${errDetail(err)}`, { uiText: `${kind}: ${errDetail(err)}` });
140}
141
142/** A diagnostic line: the file log carries it (info — the live-session
143 *  evidence surface), the engine's debug log duplicates it via uiLog.
144 *  Degrades to the file alone before the first arm — and NEVER throws:
145 *  a throw at the top of a hook makes the engine skip the whole hook. */
146function diag(message: string): void {
147  try {
148    log.info(message);
149  } catch (err) {
150    // diagnostics never throw — the file logger itself failed: sayError
151    // retries the transports, console only when both refuse
152    try {
153      sayError(`session-owl: file log failed: ${errDetail(err)}`);
154    } catch {
155      // nothing exists to say it with
156    }
157  }
158  try {
159    const svcs = bridge.currentServices();
160    if (typeof svcs.uiLog === "function") svcs.uiLog(message, { to: "debug" });
161  } catch (err) {
162    // never let diagnostics throw — but never lose the reason either:
163    // sayError retries both transports and consoles only when both refuse
164    try {
165      sayError(`session-owl: diagnostics failed: ${errDetail(err)}`);
166    } catch {
167      // nothing exists to say it with
168    }
169  }
170}
171
172// --- engine + plugin lifecycle ------------------------------------------------
173
174async function onEngineCreate(_$: any, e: AnyEvent, next: Next) {
175  // `$` here is the EMPTY table — build first, then fold the noun in.
176  const built = await next(e);
177  // Channel A-prime input: the COMPLETE module list of the load in chain
178  // order (mods that loaded before owl are invisible to plugin.register).
179  // engine.create refires on any reload when a module hooks it (owl does),
180  // so this doubles as the Channel-B wave reset: the envelopes that follow
181  // repopulate the live hit set (the pause auto-recovers on reload).
182  const plugins = (e as { plugins?: unknown }).plugins;
183  if (Array.isArray(plugins)) {
184    recordFoldList(
185      plugins
186        .map((p) =>
187          typeof p === "string"
188            ? p
189            : typeof (p as { name?: unknown })?.name === "string"
190              ? (p as { name: string }).name
191              : "",
192        )
193        .filter((n) => n !== ""),
194    );
195  }
196  resetWaveLiveHits();
197  return { ...built, sessionOwl: sessionOwlNoun() };
198}
199
200/** The last pause state mirrored into the timeline (transition detection:
201 *  recompute sites call syncPauseIntoTimeline so the Channel C windows
202 *  close exactly when a pause lifts, not on stale evidence). */
203let timelinePausedMirror = false;
204
205/** Recompute the effective pause set and mirror any FLIP into the timeline
206 *  (pause opens the not-active boundary; resume closes it). Fire-and-forget
207 *  like every timeline append — the timeline is best-effort evidence. */
208function syncPauseIntoTimeline(): void {
209  const paused = isConflictPaused(() => ({
210    enabled: state.isOwlEnabled(),
211    ignoreConflicts: state.isOwlIgnoreConflicts(),
212  }));
213  if (paused !== timelinePausedMirror) {
214    timelinePausedMirror = paused;
215    guarded(appendTimelineEvent({ t: paused ? "pause" : "resume", at: Date.now() }), "timeline append");
216  }
217}
218
219async function onPluginRegister($: any, e: AnyEvent, next: Next) {
220  // Channel B — the live envelope listener: session.compact matched against
221  // uses.events as patterns, then the bounded scan over the envelope's root
222  // separates overriders from passive registrants. A hit pauses immediately
223  // (the live predicate reads the merged set); builtin tier is witnessed but
224  // never scanned (inner — cannot override).
225  try {
226    const name = typeof (e as { name?: unknown }).name === "string" ? (e as { name: string }).name : null;
227    if (name !== null) {
228      const tier = typeof (e as { tier?: unknown }).tier === "string" ? (e as { tier: string }).tier : "user";
229      const root = typeof (e as { root?: unknown }).root === "string" ? (e as { root: string }).root : "";
230      const uses = (e as { uses?: { events?: unknown } }).uses;
231      const events =
232        uses && Array.isArray(uses.events) ? uses.events.filter((x): x is string => typeof x === "string") : [];
233      const result = await handlePluginRegisterEnvelope(
234        { name, tier, root, events },
235        {
236          list: (d) => $.fs.list(d),
237          read: (p) => $.fs.read(p),
238          stat: (p) => $.fs.stat(p),
239        },
240        { selfName: "session-owl", familyPrefix: "avtc-claude-" },
241      );
242      recordBWitness(result.witness);
243      recordLiveOutcome(result.hit, result.advisory);
244      if (result.hit !== null) {
245        log.warn(`conflict: ${result.hit.name} registers session.compact (${result.hit.evidence}) — pausing`);
246      } else if (result.advisory !== null) {
247        log.info(
248          `advisory: ${result.advisory.name} registers only classic compaction hooks (${result.advisory.evidence}) — no pause`,
249        );
250      }
251      // EVERY envelope recomputes the effective set (hit or not): the wave
252      // started at engine.create with a cleared live set, so envelopes that
253      // never re-fire (a removed mod) drop out and the pause auto-recovers
254      // as the wave settles — strictly before any compaction can run (the
255      // envelope IS the registration). Channel A's STATIC hits re-enumerate
256      // at process start only: their recovery stays restart-only — pi's
257      // parity, honestly bounded.
258      recomputePauseSet();
259      syncPauseIntoTimeline();
260    }
261  } catch (err) {
262    report("conflict channel B failed", err);
263  }
264  return next(e);
265}
266
267// --- session lifecycle ----------------------------------------------------------
268
269/** Register owl's surfaces that need `$` capsules: the tools, the commands,
270 *  the three stage agent types. Shared by both
271 *  session-start arms (idempotence lives in state's pending* queues). */
272async function registerSurfaces($: any): Promise<void> {
273  // One queue at a time: a source the engine REFUSES (the startup classic
274  // start fires before the session binds, so $.tool.register is unavailable
275  // there) must not burn the queue's read-once gate — a queue that served
276  // NOTHING reopens for the bound source that follows — and must never
277  // abort the caller's remaining bootstrap work (a warning replaces the
278  // throw; a partially served queue stays consumed, no duplicate service).
279  await serveQueue(
280    "tools",
281    () => state.pendingToolSpecs(),
282    (tool) => $.tool.register(tool),
283    state.reopenToolRegistrationGate,
284  );
285  await serveQueue(
286    "stage tools",
287    () => state.pendingStageToolSpecs(),
288    (tool) => $.tool.register(tool),
289    state.reopenStageToolRegistrationGate,
290  );
291  await serveQueue(
292    "commands",
293    () => state.pendingCommandSpecs(),
294    (command) => $.command.register(command),
295    state.reopenCommandRegistrationGate,
296  );
297  await serveQueue(
298    "stage agents",
299    () => state.pendingAgentSpecs(stageAgentSpecs({ ...getStagePrompts(), settings: noStageModelSettings() })),
300    (spec) => $.agent.register(spec),
301    state.reopenAgentRegistrationGate,
302  );
303}
304
305/** Serve one registration queue: item by item; the first REFUSAL (with
306 *  nothing served yet) reopens the queue's gate so the next start source
307 *  retries, then resolves — registration is best-effort and bootstraps
308 *  depend on it never throwing. The gate read itself runs through the same
309 *  promise: whatever it raises is a defer, never an abort. */
310async function serveQueue<T>(
311  label: string,
312  gate: () => T[],
313  serve: (item: T) => Promise<void>,
314  reopen: () => void,
315): Promise<void> {
316  let items: T[] = [];
317  try {
318    items = gate();
319  } catch (err) {
320    log.warn(`${label}: registration deferred (gate read failed): ${err instanceof Error ? err.message : String(err)}`);
321    return;
322  }
323  let served = 0;
324  for (const item of items) {
325    try {
326      await serve(item);
327      served += 1;
328    } catch (err) {
329      if (served === 0) reopen();
330      log.warn(
331        `${label}: registration deferred to the next start source: ${err instanceof Error ? err.message : String(err)}`,
332      );
333      return;
334    }
335  }
336}
337
338/** The ONE bridge arm table, shared by every handler. arm() REPLACES the
339 *  whole service table, so any handler arming a subset would strip services
340 *  other consumers reach through currentServices() at call time (the
341 *  settings after-change capsules, the stage spawn path) — the full
342 *  superset from every handler keeps them alive across re-arms. */
343/** The spawn answer as one log line: the full raw shape, whatever it is. */
344function describeSpawnAnswer(handle: unknown): string {
345  try {
346    return JSON.stringify(handle) ?? String(handle);
347  } catch {
348    return String(handle);
349  }
350}
351
352function armAll(dollar: any) {
353  // the launch broker's capsules (re-armed with the fresh dollar, the same
354  // replace-on-arm rule as the bridge table below)
355  stageLaunchBroker.arm({
356    launchViaCompanion: (spec: Record<string, unknown>) =>
357      dollar.tool.call({ tool: "mcp__session-owl-subagent-support__launch", ...spec }),
358    spawnDirect: async (input: Record<string, unknown>) => {
359      const handle = (await dollar.agent.spawn(input as never)) as { model?: unknown; agentId?: unknown };
360      const agentId = (handle as { agentId?: unknown }).agentId;
361      if (typeof agentId !== "string" || agentId.length === 0) {
362        // the full answer, verbatim: an id-less resolve differs per surface
363        // (a permission deny resolves rather than rejecting), and the raw
364        // shape tells them apart
365        log.warn(`stage spawn answer without an agentId: ${describeSpawnAnswer(handle)}`);
366        throw new Error("stage spawn resolved without an agentId");
367      }
368      return { model: typeof handle.model === "string" ? handle.model : "", agentId };
369    },
370    warn: (message: string) => log.warn(message),
371  });
372  bridge.arm({
373    envGetConfigDir: () => dollar.env.get("CLAUDE_CONFIG_DIR"),
374    envGetHome: () => dollar.env.get("HOME"),
375    envGetSessionOwlSettings: () => dollar.env.get("CLAUDE_SETTINGS_SESSION_OWL"),
376    envSetSessionOwlSettings: (v: string) => dollar.env.set("CLAUDE_SETTINGS_SESSION_OWL", v),
377    fsWrite: (p: string, t: string) => dollar.fs.write(p, t),
378    fsRead: (p: string) => dollar.fs.read(p),
379    fsList: (d: string) => dollar.fs.list(d),
380    fsStat: (p: string) => dollar.fs.stat(p),
381    uiLog: (text: string, opts?: { to?: string }) => dollar.ui.log(text, opts),
382    uiOpen: (args: unknown) => dollar.ui.open(args),
383    uiClose: () => dollar.ui.close({ id: SETTINGS_PANE_ID }),
384    uiInvalidate: () => dollar.ui.invalidate("ui.render"),
385    toolRegister: (s: unknown) => dollar.tool.register(s),
386    commandRegister: (s: unknown) => dollar.command.register(s),
387    agentRegister: (spec: unknown) => dollar.agent.register(spec),
388    // stage launches route through the companion (a refused launch or a
389    // missing callback degrades to this direct spawn — the broker's
390    // capsules are re-armed per surface arm like every service here)
391    agentSpawn: (input: unknown) => stageLaunchBroker.spawn(input as Record<string, unknown>),
392    sessionSurfaces: () => dollar.session.surfaces(),
393    agentList: () => dollar.agent.list(),
394    configList: () => dollar.config.list(),
395    widgetLog: (message: string) => log.info(message),
396    pluginRoot: () => dollar.plugin.root,
397    processRun: (argv: string[]) => dollar.process.run(argv),
398    sessionAppend: (input: unknown) => dollar.session.append(input as never),
399    modelComplete: (req: unknown, opts: unknown) => dollar.model.complete(req, opts),
400    uiStatus: (text: string | undefined) => dollar.ui.status(text),
401    avtcTodoGetItems: () => dollar.avtcTodo.getItems(),
402    // $.tool.call takes ONE input object ({tool, ...args}) — a (name, args)
403    // pair is rejected ("input must be an object")
404    toolCall: (name: string, args?: unknown) => dollar.tool.call({ tool: name, ...(args as Record<string, unknown>) }),
405    clockAfter: (ms: number, fn: () => void) => dollar.clock.after(ms, fn),
406    clockEvery: (ms: number, fn: () => void) => dollar.clock.every(ms, fn),
407    log: (m: string) => log.warn(m),
408  });
409}
410
411// The last engine session.start surface (null = CLI -p): the retention
412// sweep runs only where processes can (CLI); the desktop defers.
413let lastStartSurface: string | null = null;
414
415async function onSessionStart($: any, e: AnyEvent, next: Next) {
416  armAll($);
417  lastStartSurface =
418    typeof (e as { surface?: unknown }).surface === "string" ? (e as { surface: string }).surface : null;
419  // lifecycle trace: the modern start's identity — its
420  // absence next to a classic pulse is itself the finding
421  diag(
422    `session.start surface=${lastStartSurface ?? "-"} session=${String((e as { session_id?: unknown } | null)?.session_id ?? "?")} cwd=${e != null && typeof e.cwd === "string" ? e.cwd : "<absent>"}`,
423  );
424  try {
425    // Registration first: surfaces never depend on the config dir, and a
426    // missing-dir environment must not silently unregister the mod.
427    await registerSurfaces($);
428    const cfg = await $.env.get("CLAUDE_CONFIG_DIR");
429    const home = await $.env.get("HOME");
430    const profile = await $.env.get("USERPROFILE");
431    const configDir = state.resolveConfigDir(cfg, home ?? profile);
432    // the pane's shipped keybindings (pi's Tab-pages / Ctrl+S save): a
433    // merge-only-if-absent install into the person's keybindings.json —
434    // their own bindings always win; failures log and never block. The
435    // RESOLVED dir (the env var is unset in normal terminals — the home
436    // fallback is the real ~/.claude)
437    try {
438      await mergePaneKeybindings(
439        {
440          readTextFile: (path) => $.fs.read(path),
441          writeTextFile: (path, text) => $.fs.write(path, text),
442          // the merge's own outcomes are INFORMATIONAL (a success trace or
443          // "current (no changes)") — the diag channel (file INFO + the
444          // debug-only ui copy), never the user-visible error transport
445          log: (message) => diag(`pane keybindings: ${message}`),
446        },
447        configDir,
448      );
449    } catch (err) {
450      report("pane keybindings merge failed", err);
451    }
452  } catch (err) {
453    // registration failures must never block the session — the engine log
454    // names the plugin; the session continues without our surfaces
455    report("registration failed", err);
456  }
457  return next(e);
458}
459
460// --- the conflict activation (once per process) ---------------------------------------
461
462let conflictActivationDone = false;
463
464/** Channels A + A-prime over the live B merge, once per process, with pi's
465 *  log ladder. Every $ touch is spelled literally here (the scanner's
466 *  rule); the five settings reads enumerate the sources the engine itself
467 *  applies (policy > flag > local > project > user). */
468async function runConflictActivationOnce($: any, configDir: string, selfRoot: string): Promise<void> {
469  if (conflictActivationDone) return;
470  conflictActivationDone = true;
471  try {
472    const root = configDir.replace(/[\\/]+$/, "");
473    const readInstalled = async (): Promise<unknown> => {
474      try {
475        const text = await $.fs.read(`${root}/plugins/installed_plugins.json`);
476        return JSON.parse(text) as unknown;
477      } catch (err) {
478        log.info(`installed-plugins registry unreadable — degrade to empty: ${errText(err)}`);
479        return null; // missing/malformed registry — degrade to empty
480      }
481    };
482    const pluck = (v: unknown): Record<string, boolean> | null => {
483      const map = (v as { enabledPlugins?: unknown }).enabledPlugins;
484      return typeof map === "object" && map !== null ? (map as Record<string, boolean>) : null;
485    };
486    const userSettings = await $.settings.read({ source: "user" });
487    const projectSettings = await $.settings.read({ source: "project" });
488    const localSettings = await $.settings.read({ source: "local" });
489    const flagSettings = await $.settings.read({ source: "flag" });
490    const policySettings = await $.settings.read({ source: "policy" });
491    const maps = {
492      user: pluck(userSettings),
493      project: pluck(projectSettings),
494      local: pluck(localSettings),
495      flag: pluck(flagSettings),
496      policy: pluck(policySettings),
497    };
498    const out = await runConflictActivation(
499      {
500        readInstalledPlugins: () => readInstalled(),
501        readEnabledPlugins: (source) => Promise.resolve(maps[source]),
502        list: (d) => $.fs.list(d),
503        read: (p) => $.fs.read(p),
504        stat: (p) => $.fs.stat(p),
505      },
506      {
507        selfName: "session-owl",
508        selfRoot,
509        familyPrefix: "avtc-claude-",
510        enabled: state.isOwlEnabled(),
511        ignoreConflicts: state.isOwlIgnoreConflicts(),
512      },
513    );
514    if (out.log === "pause") {
515      log.info(conflictPauseLogLine());
516    } else if (out.log === "ignore-conflicts") {
517      log.warn(conflictsDetectedLogLine());
518    }
519  } catch (err) {
520    report("conflict activation failed", err);
521  }
522}
523
524async function onClassicSessionStart($: any, e: AnyEvent, next: Next) {
525  armAll($);
526  // lifecycle trace: which start pulse actually fires, with
527  // what source, and whether the envelope carries its cwd — every settings,
528  // backfill and boundary decision below keys off these three facts
529  diag(
530    `classic SessionStart source=${String((e as { source?: unknown } | null)?.source ?? "?")} session=${String(
531      (e as { session_id?: unknown } | null)?.session_id ?? "?",
532    )} cwd=${e != null && typeof e.cwd === "string" ? e.cwd : "<absent>"}`,
533  );
534  try {
535    // The {compact} source takes NO action (the union's fifth start
536    // source): a native compaction re-fires classic.SessionStart after the
537    // summary row — re-running the bootstrap would rebuild the settings
538    // library, re-anchor the timeline, and append a redundant CHECK.
539    if (e && (e as { source?: unknown }).source === "compact") {
540      return next(e);
541    }
542    if (e && typeof e.session_id === "string") {
543      // The session cwd rides the envelope: the dumps' per-project dir stem
544      // + the goal-extract launcher's conversation lookup.
545      if (typeof e.cwd === "string") setSessionCwd(e.cwd);
546      // The goal-extract launcher (the boot backfill's fresh-capture hook):
547      // the real extraction over the conversation binding (the one-shot
548      // model capsule + the don't-clobber run; a missing model skips with a
549      // log — the Builder is the backstop, pi's degraded behavior).
550      registerGoalExtractLauncher((verbatim: string, sessionId: string) => {
551        void getBinding(sessionId, sessionId, null)
552          .launchGoalExtract(verbatim)
553          .catch((cause: unknown) => {
554            log.warn(`goal extract failed: ${cause instanceof Error ? cause.message : String(cause)}`);
555          });
556      });
557      // Registration first (same rule as session.start).
558      const cfg = await $.env.get("CLAUDE_CONFIG_DIR");
559      const home = await $.env.get("HOME");
560      const profile = await $.env.get("USERPROFILE");
561      state.resolveConfigDir(cfg, home ?? profile);
562      // The family-library runtimes land BEFORE the surfaces register (the
563      // enable gate reads live settings; the surfaces consult it).
564      const configDir = state.getCachedConfigDir() ?? "";
565      initLogConsumer({
566        configDir,
567        fs: {
568          read: (p) => bridge.currentServices().fsRead(p),
569          write: (p, t) => bridge.currentServices().fsWrite(p, t),
570          list: (d) => bridge.currentServices().fsList(d),
571        },
572      });
573      await initSettingsConsumer({
574        configDir,
575        cwd: typeof e.cwd === "string" ? e.cwd : "",
576        fs: {
577          read: async (p) => {
578            try {
579              const text = await bridge.currentServices().fsRead(p);
580              if (p.includes("avtc-session-owl-settings")) {
581                log.info(
582                  `settings fs.read probe path=${p} bytes=${text === null || text === undefined ? "null" : String(text).length}`,
583                );
584              }
585              return text;
586            } catch (err) {
587              log.info(`settings pane fs read failed (${p}): ${errText(err)}`);
588              return null;
589            }
590          },
591          write: (p, c) => bridge.currentServices().fsWrite(p, c),
592          exists: async (p) => {
593            try {
594              await bridge.currentServices().fsRead(p);
595              return true;
596            } catch (err) {
597              log.info(`settings pane fs exists=false (${p}): ${errText(err)}`);
598              return false;
599            }
600          },
601        },
602        env: {
603          // EnvStore contract: get(name) / set(name, value). The NAME the
604          // library passes is IGNORED on purpose — the bridge capsules pin
605          // the literal (the env validator enumerates per-variable sites),
606          // so only the VALUE crosses this seam. A set that passed the NAME
607          // as the value would write a variable name where the value
608          // belongs: every later load then fails its JSON.parse and falls
609          // back to full defaults (the resume/branch settings reset).
610          get: async (name: string) => {
611            void name; // pinned literal inside the capsule, never the argument
612            let raw: string | undefined;
613            try {
614              const value = await bridge.currentServices().envGetSessionOwlSettings();
615              raw = typeof value === "string" || value === undefined ? value : String(value);
616            } catch (err) {
617              log.info(`settings env.get probe THREW: ${errText(err)}`);
618              throw err;
619            }
620            log.info(
621              `settings env.get probe = ${raw === undefined ? "undefined" : JSON.stringify(String(raw).slice(0, 200))}`,
622            );
623            return raw;
624          },
625          set: (name: string, value: string) => {
626            void name; // pinned literal inside the capsule, never the argument
627            log.info(`settings env.set probe name=${name} valueHead=${JSON.stringify(String(value).slice(0, 80))}`);
628            return bridge.currentServices().envSetSessionOwlSettings(value);
629          },
630        },
631        capsules: {
632          toolRegister: (s) => bridge.currentServices().toolRegister(s),
633          uiInvalidate: () => bridge.currentServices().uiInvalidate(),
634          uiStatus: (text) => bridge.currentServices().uiStatus(text),
635          appendTimeline: (t) => guarded(appendTimelineEvent({ t, at: Date.now() }), "timeline append"),
636        },
637      });
638      await registerSurfaces($);
639      // Prime the main conversation's binding here: the band's idle line
640      // gains its provider and the store's counts before any frame that
641      // outlives the opening one (turns, compaction and recall would each
642      // have created this same instance on first use).
643      void getBinding(e.session_id, e.session_id, null);
644      // Branch/fork lineage: under this new id the engine
645      // copied a /branch or /fork's TRANSCRIPT (the first row's
646      // forkedFrom.sessionId names the origin) — copy the origin's owl
647      // store too, BEFORE the marker write and any storage assembly touch
648      // this directory, so derived observations, checkpoints and watermarks
649      // survive the branch. Source-gated to fork; every guard inside keeps
650      // the helper inert on a normal start or a populated target.
651      if ((e as { source?: unknown }).source === "fork") {
652        await copyBranchLineageStore({
653          sessionId: e.session_id,
654          transcriptPath: typeof e.transcript_path === "string" ? e.transcript_path : "",
655          targetDir: state.sessionDirPath(state.getCachedConfigDir() ?? "", e.session_id, null),
656          originDirPathFor: (id) => state.sessionDirPath(state.getCachedConfigDir() ?? "", id, null),
657          io: {
658            read: async (path) => await $.fs.read(path),
659            list: async (dir) => await $.fs.list(dir),
660            write: async (path, text) => await $.fs.write(path, text),
661          },
662          log: (line) => log.info(line),
663        });
664      }
665      // The .session.json marker: facts from the envelope, never derived
666      // from cwd (a /cd mid-session must not move it). An absent or
667      // corrupt marker is simply replaced (tolerant read).
668      await $.fs.write(state.sessionMarkerPath(e.session_id), state.sessionMarkerJson(e.session_id, e.transcript_path));
669      state.setCurrentSession(e.session_id);
670      state.setCurrentTranscriptPath(typeof e.transcript_path === "string" ? e.transcript_path : null);
671      state.ensureConversation(e.session_id, null);
672      // The engine's live session model — `$.session.model()` answers the
673      // main loop's model with a CONCRETE id at start, before any message
674      // (the /config row would carry the default-model display label, which
675      // no API request accepts). Best-effort: a rejected read leaves the
676      // slot empty (goalModelSetting skips with the Builder backstop), and
677      // the setter discards any label-shaped value regardless.
678      try {
679        state.setSessionModel(await $.session.model());
680      } catch (err) {
681        log.info(`session model read failed — the slot stays unset: ${errText(err)}`);
682        // the read is an optimization, never a gate
683      }
684      // The widget's context-window denominator: pi resolves the slot's
685      // window from its model registry the moment a stage's first message
686      // lands; the port has no registry, so read the engine's own window
687      // here — before any turn produces a `session.measure` row — and a
688      // rescan started at startup already shows {fill}/{window}. Best-effort
689      // like the model read: a failed pull leaves the map as it was.
690      try {
691        const usage = await $.session.usage();
692        const session = state.getCurrentSession();
693        if (session !== null && typeof usage.context.window === "number") {
694          seedWindow(session, usage.context.window);
695        }
696      } catch (err) {
697        log.info(`session usage lookup failed — the widget window stays unseeded: ${errText(err)}`);
698        // the read is an optimization, never a gate
699      }
700      // Restart persistence for the rewind alignment's compaction exemption
701      // + the resume exemption — BOTH must precede the process-start
702      // checkpoint below: that check adjudicates the resumed view against
703      // the mirror, and these boundaries change what it expects.
704      try {
705        const seededCut = newestCompactionCut(await readTimelineUnion());
706        if (seededCut !== null) {
707          checkpointService().noteCompactionBoundary(
708            state.conversationKey(e.session_id, null),
709            seededCut.uuid,
710            seededCut.at,
711          );
712        }
713      } catch (err) {
714        log.info(`compaction cut re-seed skipped: ${errText(err)}`);
715      }
716      // The resume/clear/fork re-attach: owl's capture runs AHEAD of the engine's
717      // own persistence — the command/exit ceremony and tail batches still
718      // in the engine's flush window are mirrored live but never reach the
719      // transcript, so the un-exempted first check adjudicates exactly those
720      // rows dead and re-anchors. Seed a ONE-SHOT boundary at the pre-start
721      // mirror tip: rows at-or-below it are capture-ahead residue expected
722      // absent at the first check, rows after it are judged normally; the
723      // first check that KNOWS our rows clears it (a knowledge-less
724      // process-start clean validates nothing and keeps the boundary armed).
725      // In every real resume the assembly is fresh — session.end released
726      // the old one — so an empty writer falls back to the PERSISTED mirror
727      // (a directory sweep); a genuinely new conversation has no mirror rows
728      // and stays untouched.
729      {
730        const startSource = (e as { source?: unknown }).source;
731        const convKeyEarly = state.conversationKey(e.session_id, null);
732        if (startSource === "resume" || startSource === "clear" || startSource === "fork") {
733          const storageEarly = getSessionStorage(convKeyEarly, e.session_id, null);
734          let tip = storageEarly.mirror.tipUuid();
735          let tipAt = Number.NaN;
736          let tipOrigin = "resident";
737          if (tip !== null) {
738            const tipRow = storageEarly.mirror.rowByUuid(tip);
739            const tsRaw = (tipRow?.row as { timestamp?: unknown } | undefined)?.timestamp;
740            tipAt = typeof tsRaw === "string" ? Date.parse(tsRaw) : Date.now();
741          } else {
742            // the released-assembly shape: this writer has captured nothing
743            // yet — the previous process's rows sit in the persisted batches
744            const swept = await sweepMirror(storageEarly.fs, storageEarly.dir);
745            let newest: { uuid: string; at: number } | null = null;
746            for (const r of swept.rows as readonly MirroredRow[]) {
747              const raw = (r.row as { timestamp?: unknown } | undefined)?.timestamp;
748              const ts = typeof raw === "string" ? Date.parse(raw) : Number.NaN;
749              const at = Number.isNaN(ts) ? Number.NEGATIVE_INFINITY : ts;
750              if (newest === null || at >= newest.at) newest = { uuid: r.uuid, at };
751            }
752            if (newest !== null) {
753              tip = newest.uuid;
754              tipAt = newest.at;
755              tipOrigin = "persisted";
756            }
757          }
758          if (tip !== null) {
759            checkpointService().noteResumeBoundary(convKeyEarly, tip, Number.isNaN(tipAt) ? Date.now() : tipAt);
760            diag(
761              `start boundary: resume exemption seeded conv=${convKeyEarly} source=${String(startSource)} tip=${tip} from=${tipOrigin}`,
762            );
763          } else {
764            diag(
765              `start boundary: no resume exemption (no mirror tip yet) conv=${convKeyEarly} source=${String(startSource)}`,
766            );
767          }
768        } else {
769          // the trace the /branch shape needs: did this start source get the
770          // exemption at all, or was it armed only for resume/clear/fork?
771          diag(`start boundary: no resume exemption (source=${String(startSource ?? "?")}) conv=${convKeyEarly}`);
772        }
773      }
774      // The process-start checkpoint (a rewind that happened while owl was
775      // absent reconciles here, before any observation of this session) — a
776      // CHECK-appending point.
777      await runRewindCheckpoint($, e.session_id, null, true);
778      // The conflict activation (Channels A + A-prime over the live B merge):
779      // deterministic, activation-time, precedes-all-LLM-cost self-restraint.
780      // Runs once per process (the installed set cannot change mid-process;
781      // reload waves recompute through Channel B).
782      await runConflictActivationOnce($, configDir, $.plugin.root);
783      // The per-process timeline anchor: an enabled owl records its enable
784      // transition (and a pause mirror when the activation found conflicts),
785      // so Channel C's owl-active windows cover this process from the start.
786      if (state.isOwlEnabled()) {
787        guarded(appendTimelineEvent({ t: "enable", at: Date.now() }), "timeline append");
788        if (
789          isConflictPaused(() => ({
790            enabled: state.isOwlEnabled(),
791            ignoreConflicts: state.isOwlIgnoreConflicts(),
792          }))
793        ) {
794          guarded(appendTimelineEvent({ t: "pause", at: Date.now() }), "timeline append");
795        }
796      }
797      // Restart persistence for the rewind alignment's compaction exemption
798      // was moved ABOVE the process-start checkpoint (the start check
799      // adjudicates against these boundaries). Loop keys re-arm through
800      // their own answers.
801      // The start sequence (runBackfill): sweep + transcript read +
802      // crash-window re-mirror + store reconstruction + initial prompt
803      // capture + the pending-backlog derivation. The storage assembly
804      // owns the conversation's live writers — appends after this start
805      // flow into the same mirror/ledger. The enabled gate folds the
806      // conflict pause (a paused owl persists no observations; the graph
807      // still reconstructs read-only — pi's dormant parity).
808      const owlActive =
809        state.isOwlEnabled() &&
810        !isConflictPaused(() => ({
811          enabled: state.isOwlEnabled(),
812          ignoreConflicts: state.isOwlIgnoreConflicts(),
813        }));
814      const convKey = state.conversationKey(e.session_id, null);
815      const storage = getSessionStorage(convKey, e.session_id, null);
816      await runBackfill(storage.mirror, storage.ledger, {
817        key: convKey,
818        sessionId: e.session_id,
819        agentId: null,
820        fs: storage.fs,
821        transcriptPath: state.getCurrentTranscriptPath(),
822        enabled: owlActive,
823        fireGoalExtract: (verbatim: string) => launchGoalExtract(verbatim, e.session_id),
824        forkParentSessionId: null,
825        computePending: corePendingEvaluator,
826        bootstrapOverCap:
827          state.getCurrentTranscriptPath() !== null
828            ? async (transcriptPath) => {
829                // the ladder's automatic rung: CLI surfaces stream the
830                // over-cap transcript through the node helper before the
831                // sweep; anything else defers and /owl-status documents
832                // the manual command
833                const result = await runBootstrapHelper(
834                  {
835                    runProcess: (argv) => bridge.currentServices().processRun(argv),
836                    log: (m, err) =>
837                      log.warn(
838                        `${m}${err instanceof Error ? `: ${err.message}` : err !== undefined ? `: ${String(err)}` : ""}`,
839                      ),
840                  },
841                  {
842                    surface: lastStartSurface,
843                    transcriptPath,
844                    configDir,
845                    sessionId: e.session_id,
846                    pluginRoot: bridge.currentServices().pluginRoot(),
847                  },
848                );
849                return result.ok;
850              }
851            : undefined,
852      }).then((backfillResult) => {
853        // the blocked-backlog note for /owl-status (the ladder's rung 2 hint
854        // " + EM + " the exact manual command, cleared on every clean start)
855        state.setBootstrapBlockedCommand(
856          backfillResult.bootstrapBlocked && state.getCurrentTranscriptPath() !== null
857            ? manualBootstrapCommand({
858                surface: "terminal",
859                transcriptPath: state.getCurrentTranscriptPath() ?? "",
860                configDir,
861                sessionId: e.session_id,
862                pluginRoot: bridge.currentServices().pluginRoot(),
863              })
864            : null,
865        );
866        return backfillResult;
867      });
868      // The mirror-retention sweep (the S5 read path): planned from the LIVE
869      // settings (mirrorRetentionDays — keep-forever by default), run on CLI
870      // surfaces via $.process.run, and never blocking the start sequence.
871      try {
872        const live = getInstalledSettingsHandle()?.getSettings() as { mirrorRetentionDays?: unknown } | undefined;
873        const retentionDays = typeof live?.mirrorRetentionDays === "number" ? live.mirrorRetentionDays : null;
874        await runPruneHelper(
875          {
876            runProcess: (argv) => bridge.currentServices().processRun(argv),
877            log: (message, err) =>
878              log.warn(
879                `${message}${err instanceof Error ? `: ${err.message}` : err !== undefined ? `: ${String(err)}` : ""}`,
880              ),
881          },
882          {
883            surface: lastStartSurface,
884            retentionDays,
885            followCleanupPeriodDays: false,
886            configDir,
887            pluginRoot: bridge.currentServices().pluginRoot(),
888          },
889        );
890      } catch (err) {
891        report("mirror retention sweep failed", err);
892      }
893    }
894  } catch (err) {
895    report("session bootstrap failed", err);
896  }
897  return next(e);
898}
899
900// --- the conflict timeline (Channel C's substrate) -------------------------------------
901//
902// Per-process timeline-<tag>.jsonl INSIDE the main session directory
903// (<configDir>/session-owl/sessions/<sessionId>/) — enable/disable and
904// pause/resume transitions plus compaction answers/skips with coordinates;
905// the union read across processes feeds the owl-active windows and the
906// adjudicated-row set. Living in the session dir means the retention prune
907// collects the timeline with the session's own data.
908
909const TIMELINE_TAG = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
910
911function timelineDir(): string | null {
912  const cfg = state.getCachedConfigDir();
913  const sid = state.getCurrentSession();
914  if (cfg === null || sid === null) return null;
915  // the storage layout: the per-process timeline files live
916  // INSIDE the main session directory — the retention sweep collects them
917  // with the session's own data, and the id sanitizes exactly like the dir
918  // itself
919  return state.sessionDirPath(cfg, sid, null);
920}
921
922async function readTimelineUnion(): Promise<TimelineEvent[]> {
923  const dir = timelineDir();
924  if (dir === null) return [];
925  try {
926    const svcs = bridge.currentServices();
927    const entries = (await svcs.fsList(dir)) as Array<{ name?: unknown; kind?: unknown }>;
928    const events: TimelineEvent[] = [];
929    for (const ent of entries) {
930      if (typeof ent?.name !== "string" || !ent.name.startsWith("timeline-") || !ent.name.endsWith(".jsonl")) continue;
931      try {
932        const text = await svcs.fsRead(`${dir}/${ent.name}`);
933        events.push(...parseTimelines(text.split("\n").filter((l: string) => l !== "")));
934      } catch (err) {
935        log.info(`timeline file unreadable, skipped (${ent.name}): ${errText(err)}`);
936        // unreadable timeline file — skip
937      }
938    }
939    return events;
940  } catch (err) {
941    log.info(`timeline dir unreadable — empty union: ${errText(err)}`);
942    return []; // no timeline dir yet — empty union
943  }
944}
945
946// Appends serialize through one chain per process (the timeline is ONE
947// file per process): two fire-and-forget appends that interleave at the
948// fsRead await would both observe the same previous content and the later
949// write would drop the earlier event (a deterministic in-process RMW race
950// — the enable→pause pair at session start hits it).
951let timelineAppendChain: Promise<void> = Promise.resolve();
952
953function appendTimelineEvent(event: TimelineEvent): Promise<void> {
954  const run = timelineAppendChain.then(() => appendTimelineEventNow(event));
955  timelineAppendChain = run.then(
956    () => undefined,
957    () => undefined, // the body catches its own errors; the chain never breaks
958  );
959  return run;
960}
961
962async function appendTimelineEventNow(event: TimelineEvent): Promise<void> {
963  try {
964    const dir = timelineDir();
965    if (dir === null) return;
966    const svcs = bridge.currentServices();
967    const own = `${dir}/timeline-${TIMELINE_TAG}.jsonl`;
968    let prev = "";
969    try {
970      prev = await svcs.fsRead(own);
971    } catch (err) {
972      log.info(`timeline own file not readable yet (${own}): ${errText(err)}`);
973      prev = ""; // not created yet
974    }
975    await svcs.fsWrite(own, `${prev}${prev.endsWith("\n") || prev === "" ? "" : "\n"}${JSON.stringify(event)}\n`);
976  } catch (err) {
977    report("conflict timeline append failed", err);
978  }
979}
980
981/** The machinery probe: one roster look-up per unknown agent id. Machinery
982 *  is a plugin-typed loop — the roster shows a plugin-namespaced type
983 *  (`<plugin>:<name>`, present from the loop's first row — ours are
984 *  `session-owl:observer` & co). A spawn through a builtin or anonymous type
985 *  (a mod passing `general-purpose`, or no name → `teammate`) is treated as
986 *  a legitimate subagent under subagentMode; a model/person spawn mirrors as
987 *  before. Fails OPEN — a probe that errors or misses must never drop a real
988 *  subagent's rows. */
989async function isPluginSpawnedLoop(agentId: string): Promise<boolean> {
990  if (bridge.isKnownPluginSpawned(agentId)) return true;
991  if (bridge.isRosterProbed(agentId)) return false;
992  bridge.markRosterProbed(agentId);
993  try {
994    const roster = (await bridge.currentServices().agentList()) as Array<{ id?: unknown; type?: unknown }>;
995    const entry = Array.isArray(roster)
996      ? roster.find((candidate) => candidate != null && candidate.id === agentId)
997      : undefined;
998    const pluginNamespaced =
999      entry !== undefined &&
1000      typeof entry.type === "string" &&
1001      entry.type.includes(":") &&
1002      entry.type.length > ":".length;
1003    diag(
1004      `machinery probe agent=${agentId} found=${String(entry !== undefined)} type=${String(entry?.type ?? "<absent>")}`,
1005    );
1006    if (entry !== undefined && pluginNamespaced) {
1007      bridge.markPluginSpawned(agentId);
1008      return true;
1009    }
1010    return false;
1011  } catch (err) {
1012    report("agent roster lookup failed", err);
1013    return false;
1014  }
1015}
1016
1017async function onSessionAppend($: any, e: AnyEvent, next: Next) {
1018  armAll($);
1019  try {
1020    // Channel C — the integrity net: a door-"compaction" row inside an
1021    // owl-active window of this session is a foreign override (the hit
1022    // adjudicates its row; re-ingest is inert). Runs even while paused
1023    // (paused windows are closed — the check is inert there by construction).
1024    if (e && (e as { door?: unknown }).door === "compaction" && typeof (e as { uuid?: unknown }).uuid === "string") {
1025      checkpointService()
1026        .tripwires()
1027        .noteCompactionRow((e as { uuid: string }).uuid);
1028      // the alignment's prefix-exemption boundary (belt-and-suspenders: the
1029      // structural root-side rule already exempts windowed/summarized rows).
1030      // A LOOP's compaction row feeds its OWN key, never the main boundary.
1031      const boundarySession = state.getCurrentSession();
1032      const compactionAgentId =
1033        e && typeof (e as { agentId?: unknown }).agentId === "string" ? (e as { agentId: string }).agentId : null;
1034      if (boundarySession !== null) {
1035        checkpointService().noteCompactionBoundary(
1036          state.conversationKey(boundarySession, compactionAgentId),
1037          (e as { uuid: string }).uuid,
1038          Date.now(),
1039        );
1040      }
1041      const rowUuid = (e as { uuid: string }).uuid;
1042      const rowAt =
1043        typeof (e as { timestamp?: unknown }).timestamp === "number"
1044          ? (e as { timestamp: number }).timestamp
1045          : Date.now();
1046      // owl's own compaction rows: every row inside the claim window is
1047      // owl-known (skip = owl was consulted; answer = owl's own verdict) —
1048      // claim its coordinate BEFORE the net reads the union, so the row
1049      // adjudicates instead of hitting as foreign. The claim is
1050      // PER-CONVERSATION (the compact event and the rows both carry
1051      // agentId): main + subagent loops compacting concurrently each
1052      // consume their own claim. NON-one-shot: the platform persists one
1053      // compaction as up to TWO rows — each adjudicates on its own
1054      // coordinate and the claim itself stays until it expires.
1055      const claimKey = compactClaimKey(boundarySession, compactionAgentId);
1056      const claim = compactClaims.get(claimKey);
1057      if (claim !== undefined && rowAt >= claim.at && Date.now() - claim.at <= COMPACT_SKIP_CLAIM_MS) {
1058        if (claim.kind === "answer") {
1059          await recordCompactAnswer(
1060            rowUuid,
1061            {
1062              readTimelines: () => readTimelineUnion(),
1063              appendEvent: (event) => appendTimelineEvent(event),
1064            },
1065            Date.now(),
1066          );
1067        } else {
1068          await appendTimelineEvent({ t: "compact-skip", at: Date.now(), uuid: rowUuid });
1069        }
1070      }
1071      const cHit = await observeCompactionRow(
1072        {
1073          uuid: rowUuid,
1074          at: rowAt,
1075          door: "compaction",
1076        },
1077        {
1078          readTimelines: () => readTimelineUnion(),
1079          appendEvent: (event) => appendTimelineEvent(event),
1080        },
1081        Date.now(),
1082      );
1083      if (cHit !== null) {
1084        log.warn(`conflict: foreign compaction row observed (${cHit.evidence}) — pausing`);
1085        recordLiveOutcome(cHit, null);
1086        // the safety net actually pauses: the live set recomputes from the
1087        // recorded C hit (the predicate reads the merged set) and any flip
1088        // mirrors into the timeline
1089        recomputePauseSet();
1090        syncPauseIntoTimeline();
1091      }
1092    }
1093    // The capture body: a row from a subagent loop bootstraps that loop's
1094    // key; the verbatim event payload files into the loop's row cache +
1095    // pending list + mirror buffer. Owl's own stage loops (spawn registry)
1096    // are skipped — the stage dump is their record. Conflict-paused: the
1097    // pause is dormant-not-absent — no observations collected (pi parity).
1098    // Bootstrap-paused: an old session without an owl mirror pauses EVERYTHING
1099    // — no silent mirroring either (the transcript is the source of truth;
1100    // the later bootstrap recovers the whole conversation from it).
1101    const sessionId = state.getCurrentSession();
1102    if (
1103      sessionId !== null &&
1104      e &&
1105      typeof e.uuid === "string" &&
1106      !state.isBootstrapBlocked() &&
1107      !isConflictPaused(() => ({ enabled: state.isOwlEnabled(), ignoreConflicts: state.isOwlIgnoreConflicts() }))
1108    ) {
1109      const agentId = typeof e.agentId === "string" ? e.agentId : null;
1110      if (agentId !== null && bridge.spawnRegistry.has(agentId)) {
1111        // A stage run's rows are its dump's <output>, never the mirror
1112        // (the stage dump is their record).
1113        const run = getStageRun(agentId);
1114        if (run !== undefined) recordDumpOutputRow(run, e);
1115      } else if (agentId !== null && (await isPluginSpawnedLoop(agentId))) {
1116        // Machinery (any mod's roster-attributed spawn): no conversation is
1117        // ensured, no rows mirror, no store dir is ever created. The roster
1118        // is engine-side, so the verdict holds from the loop's first row —
1119        // the window our own registry can't cover (it fills only after the
1120        // spawn promise resolves).
1121      } else if (agentId !== null && state.subagentsFullyDisabled()) {
1122        // subagentMode "disabled": a foreign subagent loop collects NOTHING —
1123        // no conversation is ensured, no rows mirror, no store churn. The
1124        // loop's compaction is declined the same way (onSessionCompact).
1125        // Owl's own stage runs were handled above; the main loop has no agentId.
1126      } else {
1127        const conv = state.ensureConversation(sessionId, agentId);
1128        captureAppend(
1129          conv.key,
1130          e as unknown as ClaudeRow,
1131          (key, row) => getSessionStorage(key, sessionId, row?.agentId ?? null).mirror,
1132        );
1133      }
1134    }
1135  } catch (err) {
1136    report("append registry failed", err);
1137  }
1138  return next(e);
1139}
1140
1141async function onSessionEnd(_$: any, _e: AnyEvent, next: Next) {
1142  // lifecycle trace: why the session ended decides whether
1143  // the resident-state teardown follows a resume, a clear, an exit, …
1144  diag(
1145    `session.end reason=${String((_e as { reason?: unknown } | null)?.reason ?? "?")} session=${String(
1146      (_e as { sessionId?: unknown } | null)?.sessionId ?? (_e as { session_id?: unknown } | null)?.session_id ?? "?",
1147    )}`,
1148  );
1149  try {
1150    // The user's exit cancels every live gate: an in-flight compaction wait
1151    // (the looped await_stage) unwinds at its next signal check and settles
1152    // — its takeover degrades to a skip, never a stale answer.
1153    abortAllForSessionEnd();
1154    await state.sessionEndBody();
1155    // durability before teardown: any still-buffered ledger records (a run
1156    // that outlived its last turn flush) are written, THEN the assemblies go
1157    await flushAllSessionStorage();
1158    releaseAllCaptures();
1159    releaseAllSessionStorage();
1160    releaseAllBindings();
1161    clearAllStageRuns();
1162    resetStageToolExecutors();
1163    bridge.clearAllForSessionEnd();
1164    // the checkpoint maps follow the session out (main-key dead sets,
1165    // boundaries, prepared registrations, watermarks, skip marks — never
1166    // leak across sessions)
1167    checkpointService().releaseAll();
1168    clearWatermarks();
1169    lastCheckpointSkipped.clear();
1170  } catch (err) {
1171    report("teardown failed", err);
1172  }
1173  return next(_e);
1174}
1175
1176// --- the layered rewind checkpoint ---------------------------------------------------
1177//
1178// Every $ literal of the rewind check lives in this helper's closures; the
1179// modules under host/rewind/ stay platform-free. The api-fingerprint thunk
1180// reads the canonical view; the transcript thunk reads the file when the
1181// path is armed and readable (the verifier layer).
1182
1183function errText(err: unknown): string {
1184  // the shared detail shape: message + stack when one exists (never a
1185  // message-only log line for a thrown Error)
1186  return errDetail(err);
1187}
1188
1189async function rewindApiFingerprints($: any, agentId: string | null): Promise<Fingerprint[]> {
1190  // A subagent loop aligns against ITS OWN canonical view (the agentId
1191  // scopes the read); without it every loop would compare its rows against
1192  // the main conversation's form — a guaranteed false divergence.
1193  const msgs = (await $.session.messages({
1194    as: "api",
1195    ...(agentId !== null ? { agentId } : {}),
1196  })) as unknown[];
1197  return msgs.map((m) => fingerprintBlocks((m as { content?: unknown }).content ?? m));
1198}
1199
1200async function rewindTranscriptPass(agentId: string | null): Promise<TranscriptPass | null> {
core/src/log.ts 76 lines
1// SPDX-License-Identifier: MIT
2// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
4/**
5 * Root session-owl logger.
6 *
7 * Thin wrapper over the shared `avtc-pi-logger` library. The implementation
8 * (file backend, rotation, retention, level formatting) lives in the library;
9 * this module only owns the session-owl logger singleton.
10 *
11 * Logs land at `~/.pi/logs/avtc-pi-session-owl/<YYYY-MM-DD>.log` (date-partitioned,
12 * with size roll-over + age-based retention — all handled by the library).
13 * Best-effort: a logging failure never throws to the host.
14 *
15 * All levels always write — nothing gates `.debug` (the dump limit
16 * governs dump files only). The library logger is created with debug
17 * enabled.
18 */
19
20import { createLogger } from "../../host/log/backend.js";
21
22/** Library logger created with debug-level enabled. */
23const LOGGER_OPTIONS: Parameters<typeof createLogger>[1] = { debug: true };
24
25const baseLogger = createLogger("avtc-pi-session-owl", LOGGER_OPTIONS);
26
27/** The methods session-owl's logger wrapper actually uses. */
28type LogSink = Pick<typeof baseLogger, "info" | "warn" | "error" | "debug">;
29
30// `current` lets a test seam redirect calls (observe `.debug` without writing to
31// the real log file); defaults to the real baseLogger.
32let current: LogSink = baseLogger;
33
34// --- session scope ----------------------------------------------------------
35
36/** The id prefix length that tags log lines to one pi session. Six hex chars
37 *  keep same-day sessions distinguishable while the line stays short. */
38const SESSION_TAG_LENGTH = 6;
39
40/** The active session's short tag, or null outside an active session. */
41let sessionTag: string | null = null;
42
43/** Tag every session-owl log line with the active session (`[s-xxxxxx] `) so lines
44 *  from parallel pi terminals sharing one log file attribute to their session
45 *  without timestamp forensics. Called on session_start (the session id comes
46 *  from ctx.sessionManager.getSessionId()); cleared on session_shutdown. */
47export function setLogSessionScope(sessionId: string): void {
48  sessionTag = sessionId.length <= SESSION_TAG_LENGTH ? sessionId : sessionId.slice(0, SESSION_TAG_LENGTH);
49}
50
51/** Clear the session tag (called on session_shutdown). */
52export function clearLogSessionScope(): void {
53  sessionTag = null;
54}
55
56/** Prefix a message with the active session tag (no-op outside a session). */
57function scoped(message: string): string {
58  return sessionTag === null ? message : `[s-${sessionTag}] ${message}`;
59}
60
61/** Test-only: swap the underlying logger sink (pass `null` to restore the real
62 *  one). Returns the previous sink so a test can restore it. */
63export function _setBaseLoggerForTest(replacement: LogSink | null): LogSink {
64  const prev = current;
65  current = replacement ?? baseLogger;
66  return prev;
67}
68
69/** Root session-owl logger. Trace logs are always on. */
70export const log = {
71  info: (msg: string) => current.info(scoped(msg)),
72  warn: (msg: string) => current.warn(scoped(msg)),
73  error: (msg: string, err?: unknown) => current.error(scoped(msg), err),
74  debug: (msg: string) => current.debug(scoped(msg)),
75};
76
host/capture/append-handler.ts 124 lines
1// SPDX-License-Identifier: MIT
2// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
4import type { SessionEntry } from "@earendil-works/pi-coding-agent";
5import type { MirroredRow, MirrorWriter } from "../storage/mirror.js";
6// The session.append capture body: parse the row, file it into its
7// conversation's row cache + pending list, and hand it to the mirror buffer.
8// Owl's own stage-agent loops are SKIPPED (their record is the stage dump,
9// matching pi's outside-the-log stage runs) — decided by the spawn registry,
10// injected as a predicate so this module stays engine-free.
11//
12// UNCAPPED: the row cache and the mirror hold full raw content — caps apply
13// downstream in chunk rendering only.
14import { type ClaudeRow, groupRows } from "./grouper.js";
15
16/** One conversation's capture state (lazily created at its first row). */
17export interface ConversationCapture {
18  /** the ConversationState key — derived ONLY by state.conversationKey
19   *  (sessionId or sessionId|agentId); this module never builds keys */
20  key: string;
21  /** uuid -> verbatim row (the live process's view). */
22  rowCache: Map<string, ClaudeRow>;
23  /** uuids only, in arrival order — the pending-observation list. */
24  pending: string[];
25  mirror: MirrorWriter;
26}
27
28const captures = new Map<string, ConversationCapture>();
29
30/** The row as the mirror stores it (verbatim content + chain fields). */
31function toMirroredRow(row: ClaudeRow): MirroredRow {
32  // The platform append envelope omits parentUuid (the edges live in the
33  // transcript file) — a missing edge is null (inferred until a readable
34  // pass), never undefined (it would poison the byte accounting).
35  return { uuid: row.uuid, parentUuid: row.parentUuid ?? null, row };
36}
37
38/** Get-or-create a conversation's capture state. The mirror factory also
39 *  receives the creating row (its agentId picks the loop's storage set). */
40export function ensureCapture(
41  key: string,
42  mirrorFor: (key: string, row?: ClaudeRow) => MirrorWriter,
43  row?: ClaudeRow,
44): ConversationCapture {
45  let cap = captures.get(key);
46  if (cap === undefined) {
47    cap = { key, rowCache: new Map(), pending: [], mirror: mirrorFor(key, row) };
48    captures.set(key, cap);
49  }
50  return cap;
51}
52
53export function getCapture(key: string): ConversationCapture | undefined {
54  return captures.get(key);
55}
56
57/**
58 * The session.append body. `isOwlLoop(agentId)` decides the stage-spawn skip
59 * (the spawn registry predicate, injected). Returns false when the row was
60 * skipped (owl loop or unparseable), true when captured.
61 */
62export function captureAppend(
63  key: string,
64  row: ClaudeRow,
65  mirrorFor: (key: string, row?: ClaudeRow) => MirrorWriter,
66  isOwlLoop: (agentId: string | undefined) => boolean = () => false,
67): boolean {
68  if (isOwlLoop(row.agentId)) return false;
69  if (typeof row.uuid !== "string" || row.uuid.length === 0) return false;
70  // The engine's META rows (message.type "system": turn_duration,
71  // informational, away_summary, …) are session chrome: chained in the
72  // transcript file but NEVER part of messages({as:"api"}). Mirroring them
73  // would make the rewind check see a dead row at every turn boundary — a
74  // false rewind aborting in-flight runs and quarantining their records.
75  if (row.message.type === "system") return false;
76  const cap = ensureCapture(key, mirrorFor, row);
77  const known = cap.rowCache.has(row.uuid);
78  // The envelope carries no timestamp either — a row without one is stamped
79  // with its ARRIVAL time (rows arrive ~live; an absent stamp rendered every
80  // observation range as the epoch — "Jan 01 03:00").
81  const stamped =
82    typeof row.timestamp === "string" && row.timestamp.length > 0
83      ? row
84      : ({ ...row, timestamp: new Date().toISOString() } as typeof row);
85  cap.rowCache.set(row.uuid, stamped);
86  // a re-delivered uuid refreshes the cache but never re-buffers (one line,
87  // one row — the mirror must stay deduplicated)
88  if (!known) {
89    // The append envelope carries NO parentUuid (pinned fields: message,
90    // door, origin, uuid, agentId) — a live row's parent is INFERRED from
91    // the arrival order (the write order IS the chain for a live loop; the
92    // transcript pass replaces inferences with the file's exact edges).
93    // Without it every mirror row chains to null, chainTo() answers a
94    // one-row branch, and every fold drops every record not marked at the
95    // current leaf — the whole graph vanishing on each core switch.
96    cap.mirror.bufferRow(toMirroredRow({ ...stamped, parentUuid: stamped.parentUuid ?? cap.mirror.tipUuid() }));
97    cap.pending.push(row.uuid);
98  }
99  return true;
100}
101
102/** Render a conversation's cached rows into pi-shaped entries (the grouper
103 *  over the row cache, in arrival order). */
104export function entriesForCache(key: string): SessionEntry[] {
105  const cap = captures.get(key);
106  if (cap === undefined) return [];
107  return groupRows([...cap.rowCache.values()]);
108}
109
110/** Drop one conversation's capture state (loop teardown). */
111export function releaseCapture(key: string): boolean {
112  return captures.delete(key);
113}
114
115/** Drop every conversation's capture state (session.end teardown). */
116export function releaseAllCaptures(): void {
117  captures.clear();
118}
119
120/** Test-only: drop all capture state. */
121export function _resetCapture(): void {
122  captures.clear();
123}
124
host/capture/flush.ts 42 lines
1// SPDX-License-Identifier: MIT
2// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
4import type { LedgerWriter } from "../storage/ledger.js";
5// The turn.complete flush: mirror flush scoped to the loop + the CHECK-record
6// append point (the clean-check mark that makes the blind-window quarantine
7// evaluable at any process start). The mid-turn split guard lives inside the
8// mirror's flush (roll margin); this module sequences flush -> CHECK.
9import type { MirrorWriter } from "../storage/mirror.js";
10
11export interface FlushResult {
12  /** Mirror batch files written this turn (empty when nothing buffered). */
13  files: string[];
14  /** The CHECK record's ledger id (the turn boundary mark); null when this
15   *  flush skipped the boundary record (an unverified turn). */
16  checkId: string | null;
17}
18
19/**
20 * Flush one loop's turn: mirror first (rows durable), then the CHECK record
21 * (the mark lands after the rows it covers — quarantine boundaries stay
22 * sound even if the process dies between the two writes: unmarked rows are
23 * simply the widened window).
24 */
25export async function flushTurn(
26  mirror: MirrorWriter,
27  ledger: LedgerWriter,
28  tipUuid: string | null,
29  opts: { check?: boolean } = {},
30): Promise<FlushResult> {
31  const files = await mirror.flush();
32  // `check: false` skips the boundary record — a turn whose rewind check
33  // skipped (api form unavailable, tripwires silent) or errored must not
34  // advance the quarantine boundary over its unverified rows.
35  let checkId: string | null = null;
36  if (opts.check !== false) {
37    checkId = ledger.append("CHECK", null, tipUuid ?? "seed");
38  }
39  await ledger.flush();
40  return { files, checkId };
41}
42
host/capture/grouper.ts 351 lines
1// SPDX-License-Identifier: MIT
2// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
4import type { AssistantMessage, ToolResultMessage, Usage, UserMessage } from "@earendil-works/pi-ai";
5// PURE row-to-entry grouper: Claude rows -> pi-shaped SessionEntry[] by a
6// fixed mapping table. No fs, no $, no clock — a pure function of the rows.
7// The produced entries feed the vendored renderGroups/chunking unchanged.
8//
9// Shapes (pi-coding-agent/pi-ai): every entry carries {type, id, parentId,
10// timestamp}; message entries wrap AgentMessage (user/assistant/toolResult);
11// plugin user appends become custom_message entries.
12//
13// Grouping rule for assistant rows: consecutive rows of ONE API response
14// (same message.id) merge into one assistant entry, items in row order
15// (apiBlockIndex when present, arrival order otherwise). A missing id
16// degrades to one-entry-per-row (never merges unrelated responses).
17import type { CompactionEntry, CustomMessageEntry, SessionEntry } from "@earendil-works/pi-coding-agent";
18
19/** The verbatim row payload (session.append input + derived link fields). */
20export interface ClaudeRow {
21  uuid: string;
22  parentUuid: string | null;
23  agentId?: string;
24  door: string;
25  origin: { kind: string; model?: string; tool?: string; plugin?: string };
26  message: {
27    type: "user" | "assistant" | "attachment" | "system";
28    role?: "user" | "assistant";
29    isMeta?: boolean;
30    id?: string;
31    apiBlockIndex?: number;
32    text?: string;
33    /** the engine's per-response accounting fields (assistant rows). */
34    usage?: {
35      input_tokens?: number;
36      output_tokens?: number;
37      cache_read_input_tokens?: number;
38      cache_creation_input_tokens?: number;
39    };
40    stop_reason?: string;
41    content: Array<{
42      type: string;
43      text?: string;
44      thinking?: string;
45      id?: string;
46      name?: string;
47      input?: unknown;
48      tool_use_id?: string;
49      content?: unknown;
50      is_error?: boolean;
51    }>;
52  };
53  timestamp?: string;
54}
55
56const SKIPPED_DOORS = new Set(["attachment", "hook-context", "notice"]);
57
58/** The content blocks of a row; a non-array content degrades to []. */
59function blocksOf(row: ClaudeRow): ClaudeRow["message"]["content"] {
60  return Array.isArray(row.message.content) ? row.message.content : [];
61}
62
63function textOfBlocks(content: unknown): string {
64  if (typeof content === "string") return content;
65  if (!Array.isArray(content)) return "";
66  const parts: string[] = [];
67  for (const block of content) {
68    if (
69      typeof block === "object" &&
70      block !== null &&
71      (block as { type?: string }).type === "text" &&
72      typeof (block as { text?: unknown }).text === "string"
73    ) {
74      parts.push((block as { text: string }).text);
75    }
76  }
77  return parts.join("\n");
78}
79
80function ts(row: ClaudeRow): string {
81  return row.timestamp ?? "1970-01-01T00:00:00.000Z";
82}
83
84function base(row: ClaudeRow, id?: string) {
85  return { id: id ?? row.uuid, parentId: row.parentUuid, timestamp: ts(row) };
86}
87
88function userEntry(row: ClaudeRow, text: string): SessionEntry {
89  const message: UserMessage = { role: "user", content: text, timestamp: Date.parse(ts(row)) || 0 };
90  return { ...base(row), type: "message", message };
91}
92
93function customMessageEntry(row: ClaudeRow, text: string, plugin: string): SessionEntry {
94  const entry: CustomMessageEntry = {
95    ...base(row),
96    type: "custom_message",
97    customType: `claude.plugin.${plugin}`,
98    content: text,
99    display: true,
100  };
101  return entry;
102}
103
104/** Group consecutive assistant rows of one API response into one entry. */
105function assistantEntries(rows: readonly ClaudeRow[]): SessionEntry[] {
106  const out: SessionEntry[] = [];
107  let group: ClaudeRow[] = [];
108  let groupId: string | undefined;
109  const flushGroup = () => {
110    if (group.length === 0) return;
111    const ordered = [...group].sort((a, b) => (a.message.apiBlockIndex ?? 0) - (b.message.apiBlockIndex ?? 0));
112    const first = ordered[0];
113    if (first === undefined) return;
114    const items: unknown[] = [];
115    for (const row of ordered) {
116      for (const block of blocksOf(row)) {
117        if (block.type === "thinking" && typeof block.thinking === "string") {
118          items.push({ type: "thinking", thinking: block.thinking });
119        } else if (block.type === "text" && typeof block.text === "string") {
120          items.push({ type: "text", text: block.text });
121        } else if (block.type === "tool_use" && typeof block.id === "string" && typeof block.name === "string") {
122          items.push({
123            type: "toolCall",
124            id: block.id,
125            name: block.name,
126            arguments: (block.input ?? {}) as Record<string, unknown>,
127          });
128        }
129      }
130    }
131    const usageRow = ordered.find((r) => r.message.usage !== undefined);
132    const hasToolUse = ordered.some((r) => blocksOf(r).some((b) => b.type === "tool_use"));
133    const stopOverride = mapStopReason(first.message.stop_reason);
134    const message: AssistantMessage = {
135      role: "assistant",
136      content: items as AssistantMessage["content"],
137      api: "anthropic",
138      provider: "anthropic",
139      model: first.origin.model ?? "",
140      usage: usageRow !== undefined ? mapUsage(usageRow.message.usage) : emptyUsage(),
141      stopReason: stopOverride ?? (hasToolUse ? "toolUse" : "stop"),
142      timestamp: Date.parse(ts(first)) || 0,
143    };
144    out.push({ ...base(first), type: "message", message });
145    group = [];
146  };
147  for (const row of rows) {
148    const id = row.message.id;
149    if (row.message.type !== "assistant") {
150      flushGroup();
151      continue;
152    }
153    if (id !== undefined && id === groupId) {
154      group.push(row);
155    } else {
156      flushGroup();
157      groupId = id;
158      group.push(row);
159    }
160  }
161  flushGroup();
162  return out;
163}
164
165function emptyUsage(): Usage {
166  // the pi-ai Usage shape — structural defaults when the row carries no
167  // accounting fields; rendering never reads them (cost is zero-filled)
168  return {
169    input: 0,
170    output: 0,
171    cacheRead: 0,
172    cacheWrite: 0,
173    totalTokens: 0,
174    cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
175  };
176}
177
178/** Map the engine's per-response token fields onto the pi-ai Usage shape
179 *  (totalTokens includes the cache tokens — pi's own usage computation). */
180function mapUsage(u: ClaudeRow["message"]["usage"]): Usage {
181  const input = u?.input_tokens ?? 0;
182  const output = u?.output_tokens ?? 0;
183  const cacheRead = u?.cache_read_input_tokens ?? 0;
184  const cacheWrite = u?.cache_creation_input_tokens ?? 0;
185  return {
186    input,
187    output,
188    cacheRead,
189    cacheWrite,
190    totalTokens: input + output + cacheRead + cacheWrite,
191    cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
192  };
193}
194
195/** Engine stop_reason vocabulary -> pi-ai StopReason. A pi-native value
196 *  passes through; an unknown engine value degrades to "stop" (never an
197 *  unsound cross-vocabulary passthrough). */
198function mapStopReason(raw: string | undefined): AssistantMessage["stopReason"] | undefined {
199  if (raw === undefined) return undefined;
200  switch (raw) {
201    case "stop":
202    case "length":
203    case "toolUse":
204    case "error":
205    case "aborted":
206    case "deferred":
207      return raw;
208    case "end_turn":
209    case "stop_sequence":
210    case "pause_turn":
211      return "stop";
212    case "max_tokens":
213      return "length";
214    case "tool_use":
215      return "toolUse";
216    default:
217      return "stop";
218  }
219}
220
221/** toolName resolution: prefer the paired call in the same batch. */
222function buildCallIndex(rows: readonly ClaudeRow[]): Map<string, string> {
223  const byId = new Map<string, string>();
224  for (const row of rows) {
225    if (row.message === undefined || row.message.type !== "assistant") continue;
226    for (const block of blocksOf(row)) {
227      if (block.type === "tool_use" && typeof block.id === "string" && typeof block.name === "string") {
228        byId.set(block.id, block.name);
229      }
230    }
231  }
232  return byId;
233}
234
235function toolResultEntries(rows: readonly ClaudeRow[], calls: Map<string, string>): SessionEntry[] {
236  const out: SessionEntry[] = [];
237  for (const row of rows) {
238    // ONE entry per row (the entry-id uniqueness invariant): all blocks of
239    // the row join into one content array keyed by the first block's call
240    const parts: Array<{ type: "text"; text: string }> = [];
241    let toolCallId: string | null = null;
242    let isError = false;
243    for (const block of blocksOf(row)) {
244      if (block.type !== "tool_result" || typeof block.tool_use_id !== "string") continue;
245      if (toolCallId === null) toolCallId = block.tool_use_id;
246      parts.push({ type: "text", text: textOfBlocks(block.content) });
247      if (block.is_error === true) isError = true;
248    }
249    if (toolCallId === null) continue;
250    const message: ToolResultMessage = {
251      role: "toolResult",
252      toolCallId,
253      toolName: calls.get(toolCallId) ?? "",
254      content: parts,
255      isError,
256      timestamp: Date.parse(ts(row)) || 0,
257    };
258    out.push({ ...base(row), type: "message", message });
259  }
260  return out;
261}
262
263/**
264 * The full table. Rows are consumed in order; assistant groups absorb their
265 * member rows, tool-result rows produce toolResult entries, and the output
266 * preserves row order (grouped assistant entries at their first row's spot).
267 *
268 * ID-NAMESPACE INVARIANT: an entry's id is its FIRST row's uuid — the other
269 * rows' uuids (pending-list entries, ledger CHECK tips, mirror anchors) are
270 * row ids, never entry ids; the two namespaces must not be mixed. A
271 * toolResult entry's parentId points at its call's row uuid, which is not
272 * an entry id (nothing in the vendored core walks entry parentId today).
273 */
274export function groupRows(rows: readonly ClaudeRow[]): SessionEntry[] {
275  const calls = buildCallIndex(rows);
276  const out: SessionEntry[] = [];
277  let group: ClaudeRow[] = [];
278  const flushAssistant = () => {
279    out.push(...assistantEntries(group));
280    group = [];
281  };
282  for (const row of rows) {
283    // malformed rows degrade to a skip (never a throw): one bad row must not
284    // kill the conversation's trigger evaluation or details resolution
285    if (row === null || typeof row !== "object" || row.origin === undefined || row.message === undefined) {
286      continue;
287    }
288    const m = row.message;
289    if (m.type === "assistant") {
290      group.push(row);
291      continue;
292    }
293    flushAssistant();
294    if (SKIPPED_DOORS.has(row.door)) continue;
295    if (row.door === "compaction") {
296      // treated as a pi CompactionEntry: present in the entry list (the
297      // boundary is model-context, not observer scope), skipped as an
298      // observer source by renderGroups itself
299      const summary = textOfBlocks(m.content) || (typeof m.text === "string" ? m.text : "");
300      const entry: CompactionEntry = {
301        ...base(row),
302        type: "compaction",
303        summary,
304        firstKeptEntryId: "",
305        tokensBefore: 0,
306        details: { summary },
307      };
308      out.push(entry);
309      continue;
310    }
311    if (row.door === "command") {
312      // the command door has its own rule (the {text} prints and Claude
313      // reads it) — a plugin's command output is a USER message, not a
314      // custom_message; checked before the generic plugin-origin split
315      if (m.type === "user" && typeof m.text === "string") {
316        out.push(userEntry(row, m.text));
317      }
318      continue;
319    }
320    if (row.origin.kind === "plugin") {
321      // the plugin split: user appends -> custom_message; system -> skipped;
322      // isMeta does NOT gate (engine-stamped meta user appends are model-read)
323      if (m.type === "user") {
324        out.push(customMessageEntry(row, textOfBlocks(m.content) || (m.text ?? ""), row.origin.plugin ?? "unknown"));
325      }
326      continue;
327    }
328    if (row.door === "note") {
329      // engine notes skipped unless plugin-origin user (handled above)
330      continue;
331    }
332    if (row.door === "tool-result" || row.door === "tool-message") {
333      const hasToolResult = blocksOf(row).some((b) => b.type === "tool_result");
334      if (hasToolResult) {
335        out.push(...toolResultEntries([row], calls));
336      } else if (m.type === "user") {
337        out.push(userEntry(row, textOfBlocks(m.content)));
338      }
339      continue;
340    }
341    // prompt / response / delivery: user-visible text. Unknown doors have
342    // no rule in the table — fail closed (skip) rather than fabricate
343    // model-visibility for a door the port never verified
344    if ((row.door === "prompt" || row.door === "response" || row.door === "delivery") && m.type === "user") {
345      out.push(userEntry(row, textOfBlocks(m.content)));
346    }
347  }
348  flushAssistant();
349  return out;
350}
351
host/commands/handlers.ts 494 lines
1// SPDX-License-Identifier: MIT
2// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
4// The eight `/owl-*` command bodies — pi's src/commands/user.ts, reobserve.ts
5// and status/command.ts arms, ported. Every handler runs the disabled guard
6// with pi's exact notice (the SETTINGS shell's guard is interim-only — see
7// its own note), the browse arms run the canonical-view check BEFORE serving
8// (the same layered-check entry the recall handler uses) and pin the
9// conversation's core before the vendored singleton reads, and every
10// user-visible line flows through the notify adapter (the ui.log mapping:
11// split-in-order, level dropped). Texts are pi's verbatim except the
12// adapter-authored interim degrades (status/settings enabled-path, recorded
13// in the known-issues file); only the command names swap `/owl:` for
14// `/owl-` (the platform refuses colons in names). A handler throw propagates
15// to the dispatcher's caller — the wiring logs it and answers {} (the user
16// never sees a raw stack; the command surface degrades, the session lives).
17import type { SessionEntry } from "@earendil-works/pi-coding-agent";
18import type { SessionOwlConfig } from "../../core/src/config/schema.js";
19import { directObsSizeHint, formatNodeLine, formatObservationLine, indent } from "../../core/src/format/render.js";
20import type { IncludeSuperseded } from "../../core/src/graph/read-tools.js";
21import {
22  buildCatUnits,
23  collectFindMatches,
24  directChildren,
25  nonObsoleteRoots,
26  orderActiveSetRoots,
27  renderCatUnit,
28  tryCompileFindRegex,
29} from "../../core/src/graph/read-tools.js";
30import type { GraphStore } from "../../core/src/store/graph-store.js";
31import { computeUncoveredRanges, makeMaybeBuilder } from "../../core/src/triggers.js";
32import { type NodeId, O_INITIAL_PROMPT, type ObsId } from "../../core/src/types.js";
33import type { ConfirmFn, NotifyFn } from "../notify/adapter.js";
34import { runsLog } from "../runs/seams.js";
35import { makeTriggerCtx } from "../triggers/context.js";
36import type { RunHandle } from "../triggers/run-locks.js";
37import { routeOutput } from "./viewer.js";
38
39// --- shared input types -------------------------------------------------------
40
41/** The commands' injected services — everything but the command event,
42 *  bound by the register wiring (tests supply fakes). */
43export interface OwlCommandDeps {
44  /** The bootstrap pause predicate (optional — kit contexts leave it
45   *  unset; production wires state.isBootstrapBlocked). */
46  isBootstrapBlocked?: () => boolean;
47  /** The conversation key (sessionId — commands run in the main loop). */
48  key: string;
49  /** The notify adapter (every user-visible line — the ui.log mapping). */
50  notify: NotifyFn;
51  /** The viewer-pane opener (eligible multi-line output; optional — the
52   *  unwired kit contexts keep everything on the notify transport). */
53  openViewer?: (title: string, text: string) => void;
54  /** The confirm port (pi's ui.confirm → $.ui.ask mapping). */
55  confirm: ConfirmFn;
56  /** The live master switch. */
57  isEnabled: () => boolean;
58  /** Live settings (the render cap, the stage models). */
59  getSettings: () => SessionOwlConfig;
60  /** The canonical-view check awaited BEFORE every browse serve (the
61   *  layered rewind check's interactive entry — latency-tolerant). */
62  checkThenServe: (key: string) => Promise<void>;
63  /** Pins the conversation's core before the vendored singleton reads. */
64  pinCore: () => Promise<void>;
65  /** The conversation's graph store (post-pin). */
66  getGraphStore: () => GraphStore;
67  /** The conversation's branch view (the reobserve range scan). The MIXED
68   *  pi-shaped view: message entries AND owl's coverage customs interleaved
69   *  (the wiring unwraps the store's session-owl.row markers to their
70   *  message entries and keeps the observation customs — pi's
71   *  sessionManager.getBranch shape; the raw store branch does NOT fit). */
72  getLeafId: () => string | null;
73  getBranch: (leafId: string | null) => SessionEntry[];
74  /** Void the graph + observations for a full re-observe (plain rescan). */
75  resetGraphForRescan: () => void;
76  /** Fire-and-forget the turn-boundary trigger chain (plain rescan's
77   *  catch-up launch — the evaluateAndLaunch entry, async like pi's
78   *  onTurnEnd).
79   */
80  relaunchMaintenance: () => void | Promise<void>;
81  /** Abort + await in-flight runs, hold until release (rescan reuse mode). */
82  acquireForCompaction: () => Promise<RunHandle>;
83  /** Try to acquire the observe lock — null means a run is in flight. */
84  acquireOrSkipObserve: () => RunHandle | null;
85  /** Resolves once every in-flight goal extraction settles (the goal-first
86   *  stage sequencing — awaited BEFORE acquiring, never while holding). */
87  goalExtractSettled?: () => Promise<void>;
88  /** The reuse-rebuild run body (the caller owns the lock + signal). */
89  runReuseRebuild: (signal: AbortSignal) => Promise<void>;
90  /** The raw Observer run body (the reobserve repair drives per range). */
91  runObserver: (args: {
92    unobserved: SessionEntry[];
93    signal: AbortSignal;
94    maybeBuild: () => Promise<boolean>;
95    /** Optional per-persist ledger flush (durability seam — same shape as
96     *  ObserverRunInput.flushLedger; the wiring passes it per conversation). */
97    flushLedger?: () => Promise<void>;
98  }) => Promise<void>;
99  /** The Builder stage run (the repair's mid-run folding + the reuse
100   *  rebuild's cadence gate — pi's scope:null turn-boundary shape). */
101  runBuilder: (args: { signal: AbortSignal }) => Promise<void>;
102  /** The status report body; absent ⇒ the interim degrade. */
103  serveStatusReport?: () => Promise<string>;
104  /** The settings pane body; absent ⇒ the interim degrade. */
105  serveSettings?: () => Promise<void>;
106  /** Test seam: receives each fire-and-forget launch promise. */
107  onLaunched?: (promise: Promise<void>) => void;
108}
109
110// --- named constants (pi's, kebab-swapped where the name appears) -------------
111
112const CHILD_DEPTH = 1;
113const EXCLUDE_SUPERSEDED: IncludeSuperseded = false;
114const INCLUDE_SUPERSEDED: IncludeSuperseded = true;
115const NO_OBSERVATIONS = 0;
116const BROWSE_DISABLED_MESSAGE = "session-owl is disabled.";
117const MAINTENANCE_DISABLED_MESSAGE = "session-owl is disabled (enable it first).";
118const BOOTSTRAP_PAUSED_MESSAGE =
119  "session-owl is paused — history bootstrap pending (see /owl-status; or turn it off in /owl-settings)";
120const OWL_CAT_USAGE = "Usage: /owl-cat <id> — give a node id or an observation id.";
121const OWL_FIND_USAGE = "Usage: /owl-find <query> — give a JS regex to search node summaries and observation content.";
122const NO_MEMORY_YET = "No memory yet.";
123const NO_MATCHES = "No matches.";
124const REUSE_FLAG = "--reuse-observations";
125const REBUILD_TITLE = "Rebuild memory";
126const RESCAN_TITLE = "Rescan memory";
127const NOTHING_TO_REBUILD = "Nothing to rebuild — no collected observations yet.";
128const REBUILD_FAILED_LOG = "rescan --reuse-observations: rebuild failed";
129const RESCAN_FAILED_LOG = "rescan: observer catch-up failed";
130const REOBSERVE_NOTHING_TO_DO = "No skipped or unobserved ranges — nothing to re-observe.";
131const REOBSERVE_IN_FLIGHT = "A maintenance run is in flight — try again once it finishes.";
132const REOBSERVE_FAILED = "Re-observe failed — see the session-owl log for the error.";
133const REOBSERVE_FAILED_LOG = "reobserve: run failed";
134const STATUS_DISABLED_MESSAGE = "session-owl is disabled.";
135const STATUS_FAILED_LOG = "status: report failed";
136/** The status/settings degrade answers when their deps are unwired —
137 *  visible answers, never silent no-ops. */
138const STATUS_INTERIM = "session-owl status failed: status report unavailable in this build";
139const SETTINGS_INTERIM = "session-owl settings: the pane is unavailable in this build.";
140const TRUNCATION_FOOTER = "… +{count} more — drill with /owl-ls <id> or narrow with /owl-find";
141
142// --- pi's pure helpers, ported -------------------------------------------------
143
144/** Trim a raw positional-args string into the single token (or null when
145 *  blank/whitespace) — pi's trimArg, shared by every argument command. */
146function trimArg(args: string): string | null {
147  const trimmed = args.trim();
148  return trimmed === "" ? null : trimmed;
149}
150
151/** Apply the user-commands render cap: keep the first `cap` lines, and when
152 *  truncated append the drill footer (pi's formatList; `cap === null` means
153 *  no limit). Pure. */
154function formatList(lines: string[], cap: number | null): { text: string; truncated: number } {
155  if (lines.length === 0) return { text: "", truncated: 0 };
156  if (cap === null || lines.length <= cap) {
157    return { text: lines.join("\n"), truncated: 0 };
158  }
159  const truncated = lines.length - cap;
160  const kept = lines.slice(0, cap);
161  kept.push(TRUNCATION_FOOTER.replace("{count}", String(truncated)));
162  return { text: kept.join("\n"), truncated };
163}
164
165/** The disabled guard for the browse arms (pi's replyIfDisabled — info). */
166function replyIfDisabled(deps: OwlCommandDeps): boolean {
167  if (!deps.isEnabled()) {
168    deps.notify(BROWSE_DISABLED_MESSAGE, "info");
169    return true;
170  }
171  return false;
172}
173
174// --- /owl-ls --------------------------------------------------------------------
175
176/** `/owl-ls [nodeId]` — list roots (no arg) or a node's direct children. */
177export async function runOwlLs(args: string, deps: OwlCommandDeps): Promise<void> {
178  if (replyIfDisabled(deps)) return;
179  await deps.checkThenServe(deps.key);
180  await deps.pinCore();
181  const graph = deps.getGraphStore().graph;
182  const idArg = trimArg(args);
183
184  const lines: string[] = [];
185  if (idArg === null) {
186    // roots: non-obsolete only (obsolete hidden by default — use /owl-find-all).
187    for (const node of orderActiveSetRoots(nonObsoleteRoots(graph))) {
188      lines.push(formatNodeLine(node, { obsSize: directObsSizeHint(node, graph.observations) }));
189    }
190    const { text } = formatList(lines, deps.getSettings().commandResultCap);
191    if (text === "") {
192      deps.notify(NO_MEMORY_YET, "info");
193      return;
194    }
195    routeOutput(deps, "owl-ls — roots", text);
196    return;
197  }
198
199  const parent = graph.nodes.get(idArg as NodeId);
200  if (parent === undefined) {
201    deps.notify(`No node '${idArg}'.`, "error");
202    return;
203  }
204  // parent header at depth 0; children indented at depth 1.
205  lines.push(formatNodeLine(parent, { obsSize: directObsSizeHint(parent, graph.observations) }));
206  const { nodes, observations } = directChildren(graph, parent);
207  for (const node of nodes) {
208    lines.push(indent(formatNodeLine(node, { obsSize: directObsSizeHint(node, graph.observations) }), CHILD_DEPTH));
209  }
210  for (const obs of observations) {
211    lines.push(indent(formatObservationLine(obs, {}), CHILD_DEPTH));
212  }
213  const { text } = formatList(lines, deps.getSettings().commandResultCap);
214  routeOutput(deps, `owl-ls — ${idArg}`, text);
215}
216
217// --- /owl-cat ---------------------------------------------------------------------
218
219/** `/owl-cat <id>` — a node's header + its direct observations' full text, or
220 *  an observation's full content + header (pi's cat over the vendored
221 *  buildCatUnits/renderCatUnit). */
222export async function runOwlCat(args: string, deps: OwlCommandDeps): Promise<void> {
223  if (replyIfDisabled(deps)) return;
224  const idArg = trimArg(args);
225  if (idArg === null) {
226    deps.notify(OWL_CAT_USAGE, "error");
227    return;
228  }
229  await deps.checkThenServe(deps.key);
230  await deps.pinCore();
231  const graph = deps.getGraphStore().graph;
232  // Resolve the id directly: an unknown node OR observation id → the error
233  // notify naming the id (pi's wording).
234  const node = graph.nodes.get(idArg as NodeId);
235  const obs = graph.observations.get(idArg as ObsId);
236  if (node === undefined && obs === undefined) {
237    deps.notify(`No node or observation with id '${idArg}'.`, "error");
238    return;
239  }
240  const units = buildCatUnits(graph, [idArg]);
241  const lines = units.map(renderCatUnit);
242  const { text } = formatList(lines, deps.getSettings().commandResultCap);
243  routeOutput(deps, `owl-cat — ${idArg}`, text);
244}
245
246// --- /owl-rescan ------------------------------------------------------------------
247
248/** Count the collected observation records (everything but the verbatim
249 *  oInitialPrompt — that one re-seeds under nGoal mechanically; pi's rule). */
250function collectedCount(deps: OwlCommandDeps): number {
251  let count = 0;
252  for (const id of deps.getGraphStore().graph.observations.keys()) {
253    if (id !== O_INITIAL_PROMPT) count += 1;
254  }
255  return count;
256}
257
258/** `/owl-rescan [--reuse-observations]` — discard the memory graph. Plain:
259 *  void everything and re-observe the entire session (the maintenance
260 *  relaunch; frontier is now null ⇒ the whole branch is unobserved). With
261 *  the reuse flag: void the STRUCTURE only and rebuild it from the collected
262 *  records in the background under the compaction lock (aborts + awaits any
263 *  in-flight run; the ledger, frontier, and usage are kept). */
264export async function runOwlRescan(args: string, deps: OwlCommandDeps): Promise<void> {
265  if (!deps.isEnabled()) {
266    deps.notify(MAINTENANCE_DISABLED_MESSAGE, "warning");
267    return;
268  }
269  if (deps.isBootstrapBlocked?.()) {
270    deps.notify(BOOTSTRAP_PAUSED_MESSAGE, "warning");
271    return;
272  }
273  const reuse = args.trim().includes(REUSE_FLAG);
274  // pin before the singleton reads (the count below and the reset later);
275  // the confirm awaits between them can swap the singleton mid-command
276  await deps.pinCore();
277
278  if (reuse) {
279    const collected = collectedCount(deps);
280    if (collected === NO_OBSERVATIONS) {
281      deps.notify(NOTHING_TO_REBUILD, "info");
282      return;
283    }
284    const ok = await deps.confirm(
285      REBUILD_TITLE,
286      `Discard the current graph structure and rebuild it from the ${collected} collected observations without re-observing the session? The observation ledger, frontier, and usage are kept. This cannot be undone.`,
287    );
288    if (!ok) return;
289    deps.notify(`Rebuilding — re-wrapping ${collected} collected observations into a fresh graph…`, "info");
290    // fire-and-forget the rebuild under the compaction lock (aborts + awaits
291    // any in-flight maintenance run, holds until done)
292    const launch = (async () => {
293      const handle = await deps.acquireForCompaction();
294      const signal = handle.abortController.signal;
295      try {
296        await deps.pinCore(); // the lock acquire awaited — re-pin for the run
297        await deps.runReuseRebuild(signal);
298      } catch (err) {
299        runsLog(REBUILD_FAILED_LOG, err);
300      } finally {
301        handle.release();
302      }
303    })();
304    deps.onLaunched?.(launch);
305    return;
306  }
307
308  const ok = await deps.confirm(
309    RESCAN_TITLE,
310    "Discard the current memory graph (observations, nodes, selected tree) and re-observe the entire session from the start? This cannot be undone.",
311  );
312  if (!ok) return;
313  await deps.pinCore(); // the confirm await may have swapped the singleton
314  // goal-first stage sequencing: settle any in-flight goal extraction BEFORE
315  // the reset wipes the graph it writes into and the catch-up chain acquires
316  // the run-lock (await before acquiring — never while holding).
317  await deps.goalExtractSettled?.();
318  deps.resetGraphForRescan();
319  deps.notify("Rescanning — observing the session from the start…", "info");
320  // fire-and-forget the Observer catch-up (frontier is now null → the whole
321  // branch is unobserved); the chained Builder/Selector fire after per mode.
322  const launch = (async () => {
323    try {
324      await deps.relaunchMaintenance();
325    } catch (err) {
326      runsLog(RESCAN_FAILED_LOG, err);
327    }
328  })();
329  deps.onLaunched?.(launch);
330}
331
332// --- /owl-reobserve-0-obs-chunks ---------------------------------------------------
333
334/** `/owl-reobserve-0-obs-chunks` — compute the zero-observation ranges on
335 *  the active branch and re-observe each oldest-first under one observe-lock
336 *  acquire. Additive only: nothing is discarded; recovered observations
337 *  persist per-chunk as usual. The mid-repair Builder folding follows the
338 *  catch-up semantics (compaction-scope root-view safeguard on top of the
339 *  mode-aware cadence) — the same maybeBuild the compaction gate builds. */
340export async function runOwlReobserve(_args: string, deps: OwlCommandDeps): Promise<void> {
341  if (!deps.isEnabled()) {
342    deps.notify(MAINTENANCE_DISABLED_MESSAGE, "warning");
343    return;
344  }
345  if (deps.isBootstrapBlocked?.()) {
346    deps.notify(BOOTSTRAP_PAUSED_MESSAGE, "warning");
347    return;
348  }
349  await deps.pinCore();
350  const branch = deps.getBranch(deps.getLeafId());
351  const ranges = computeUncoveredRanges(branch, deps.getGraphStore().observerFrontier);
352  if (ranges.length === 0) {
353    deps.notify(REOBSERVE_NOTHING_TO_DO, "info");
354    return;
355  }
356  // goal-first stage sequencing: an in-flight goal extraction settles FIRST
357  // (await before acquiring — never while holding the lock)
358  await deps.goalExtractSettled?.();
359  const handle = deps.acquireOrSkipObserve();
360  if (handle === null) {
361    deps.notify(REOBSERVE_IN_FLIGHT, "warning");
362    return;
363  }
364  const entryCount = ranges.reduce((sum, range) => sum + range.entries.length, 0);
365  deps.notify(`Re-observing ${ranges.length} skipped range(s) (${entryCount} entries)…`, "info");
366
367  const signal = handle.abortController.signal;
368  // Mid-repair Builder folding, catch-up semantics (pi's scope shape):
369  // each check re-pins first — the Observer's long awaits let other
370  // conversations' swaps re-point the singletons in between
371  const bareMaybeBuild = makeMaybeBuilder({
372    ctx: makeTriggerCtx({
373      key: deps.key,
374      getLeafId: () => deps.getLeafId(),
375      getBranch: () => deps.getBranch(deps.getLeafId()),
376      thinkingLevel: null,
377    }) as unknown as Parameters<typeof makeMaybeBuilder>[0]["ctx"],
378    settings: deps.getSettings(),
379    signal,
380    scope: { firstKeptEntryId: null },
381    runBuilder: () => deps.runBuilder({ signal }),
382  });
383  const maybeBuild = async (): Promise<boolean> => {
384    await deps.pinCore();
385    return bareMaybeBuild();
386  };
387  const launch = (async () => {
388    let recovered = 0;
389    try {
390      for (const range of ranges) {
391        if (signal.aborted) break;
392        // the previous run's awaits let other conversations' swaps re-point
393        // the singletons — re-pin before each range's reads and run
394        await deps.pinCore();
395        const before = deps.getGraphStore().graph.observations.size;
396        await deps.runObserver({ unobserved: range.entries, signal, maybeBuild });
397        // the run's long awaits may have swapped the singleton — re-pin
398        // before the recovered-delta read (the count is user-visible)
399        await deps.pinCore();
400        recovered += deps.getGraphStore().graph.observations.size - before;
401      }
402      deps.notify(`Re-observe done — ${recovered} observation(s) recovered into new roots.`, "info");
403    } catch (cause) {
404      runsLog(REOBSERVE_FAILED_LOG, cause);
405      deps.notify(REOBSERVE_FAILED, "error");
406    } finally {
407      handle.release();
408    }
409  })();
410  deps.onLaunched?.(launch);
411}
412
413// --- /owl-status + /owl-settings (shells) ------------------------------------------
414
415/** `/owl-status` — the report shell. Disabled ⇒ pi's exact notice; the
416 *  report body (`serveStatusReport` — pi's runOwlStatus gathers +
417 *  notifies) answers when wired; the interim degrade keeps the command
418 *  answered otherwise (never a silent no-op). */
419export async function runOwlStatus(_args: string, deps: OwlCommandDeps): Promise<void> {
420  if (!deps.isEnabled()) {
421    deps.notify(STATUS_DISABLED_MESSAGE, "info");
422    return;
423  }
424  if (deps.serveStatusReport === undefined) {
425    deps.notify(STATUS_INTERIM, "error");
426    return;
427  }
428  try {
429    const report = await deps.serveStatusReport();
430    routeOutput(deps, "owl-status — report", report);
431  } catch (err) {
432    const reason = err instanceof Error ? err.message : String(err);
433    runsLog(STATUS_FAILED_LOG, err);
434    deps.notify(`session-owl status failed: ${reason}`, "error");
435  }
436}
437
438/** `/owl-settings` — the pane shell. The pane binds via `serveSettings`;
439 *  unwired, the interim degrade answers. When bound, the shell
440 *  delegates and stays silent itself (the pane speaks for itself — pi
441 *  parity). NO disabled guard — pi's settings command opens the pane
442 *  unconditionally (it is the re-enable path); the live /owl-settings
443 *  dispatch uses the guardless inline pane branch for the same reason. */
444export async function runOwlSettings(_args: string, deps: OwlCommandDeps): Promise<void> {
445  if (deps.serveSettings === undefined) {
446    deps.notify(SETTINGS_INTERIM, "info");
447    return;
448  }
449  await deps.serveSettings();
450}
451
452// --- /owl-find + /owl-find-all ------------------------------------------------------
453
454/** Shared body for /owl-find (non-obsolete) and /owl-find-all (all statuses). */
455async function runFind(args: string, deps: OwlCommandDeps, includeSuperseded: IncludeSuperseded): Promise<void> {
456  if (replyIfDisabled(deps)) return;
457  const query = trimArg(args);
458  if (query === null) {
459    deps.notify(OWL_FIND_USAGE, "error");
460    return;
461  }
462  const compiled = tryCompileFindRegex(query);
463  if ("error" in compiled) {
464    deps.notify(compiled.error, "error");
465    return;
466  }
467  await deps.checkThenServe(deps.key);
468  await deps.pinCore();
469  const graph = deps.getGraphStore().graph;
470  const collected = await collectFindMatches(graph, [compiled.regex], includeSuperseded);
471  if ("error" in collected) {
472    deps.notify(collected.error, "error");
473    return;
474  }
475  if (collected.matches.length === 0) {
476    deps.notify(collected.note ?? NO_MATCHES, "info");
477    return;
478  }
479  const lines = collected.matches.map((m) => m.render);
480  if (collected.note !== undefined) lines.push(collected.note);
481  const { text } = formatList(lines, deps.getSettings().commandResultCap);
482  deps.notify(text, "info");
483}
484
485/** `/owl-find <query>` — JS regex search, default NON-obsolete. */
486export async function runOwlFind(args: string, deps: OwlCommandDeps): Promise<void> {
487  await runFind(args, deps, EXCLUDE_SUPERSEDED);
488}
489
490/** `/owl-find-all <query>` — same as find but ALL statuses incl. obsolete. */
491export async function runOwlFindAll(args: string, deps: OwlCommandDeps): Promise<void> {
492  await runFind(args, deps, INCLUDE_SUPERSEDED);
493}
494
host/commands/viewer.ts 87 lines
1// SPDX-License-Identifier: MIT
2// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
4// The command-output viewer pane. The notify transport (ui.log) is a dim
5// transcript line the engine draws up to a per-surface character budget —
6// long listings (the root view, a node's children, the status report)
7// arrive truncated or delimiter-joined. The viewer pane carries the SAME
8// text whole: one Text element per line inside the pane's scrollable body,
9// no engine drawing cap. The pane is read-only (browse surfaces only) and
10// shares the settings pane's lifecycle (opened under a plugin id, Escape
11// closes, the ui.render hook below the prompt draws it).
12
13/** The switch: `true` routes eligible command output into the viewer pane;
14 *  `false` keeps every message on the notify transport (the ui.log mapping)
15 *  — flip it back if the engine's ui.log drawing caps ever lift. */
16export const OUTPUT_PANE_VIEWER = true;
17
18/** The pane's plugin id (one pane at a time; a later listing retitles). */
19export const VIEWER_PANE_ID = "session-owl-output";
20
21/** Output eligible for the pane: ANY non-empty routed listing. A one-root
22 *  root view is still a whole browse surface — the notify transcript line
23 *  would draw it as one dim, budget-capped row. Empty text (nothing routed)
24 *  keeps the notify transport. */
25export function wantsViewer(text: string): boolean {
26  return text.trim().length > 0;
27}
28
29/** The pane's content (title + exact text), set at open time and read by
30 *  the render hook. Module-scoped — the pane outlives the command call. */
31let content: { title: string; text: string } | null = null;
32
33/** Set the pane's content (called by the open capsule before ui.open). */
34export function setViewerContent(title: string, text: string): void {
35  content = { title, text };
36}
37
38/** The stored content (the render hook's source); null with nothing set. */
39export function getViewerContent(): { title: string; text: string } | null {
40  return content;
41}
42
43/** Clear the stored content (a closed pane never shows stale text). */
44export function clearViewerContent(): void {
45  content = null;
46}
47
48/** The pane's element tree over the resolved element table: one Text per
49 *  line (an empty line draws as a single space — a zero-width text element
50 *  is skipped by the surface), the whole listing inside one Box. */
51export function buildViewerTree(els: {
52  Box: (props: Record<string, unknown>) => unknown;
53  Text: (props: Record<string, unknown>) => unknown;
54}): unknown {
55  const stored = content;
56  if (stored === null) {
57    return els.Text({ key: "owl-viewer-empty", children: "Nothing to show." });
58  }
59  const lines = stored.text.split("\n");
60  return els.Box({
61    key: "owl-viewer",
62    flexDirection: "column",
63    children: lines.map((line, i) =>
64      els.Text({ key: `owl-viewer-l${i}`, mono: true, children: line === "" ? " " : line }),
65    ),
66  });
67}
68
69/** Route one command output: the viewer pane when enabled, eligible, and
70 *  wired; the notify transport otherwise (short notices, the unwired kit
71 *  contexts, or the switch flipped off). */
72export function routeOutput(
73  deps: {
74    notify: (text: string, level: "info" | "warning" | "error") => void;
75    openViewer?: (title: string, text: string) => void;
76  },
77  title: string,
78  text: string,
79  enabled: boolean = OUTPUT_PANE_VIEWER,
80): void {
81  if (enabled && deps.openViewer !== undefined && wantsViewer(text)) {
82    deps.openViewer(title, text);
83    return;
84  }
85  deps.notify(text, "info");
86}
87
host/compaction/budget-free.ts 325 lines
1// SPDX-License-Identifier: MIT
2// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
4// The compaction gate's budget-free wait:
5// the engine's per-dispatch hook budget is 10s and the clock stops only
6// while a $ call of the hook is in flight (HookBudget's documented rule —
7// "a minute-long $.model.complete costs it nothing"). Our ensure-ready
8// phases wait for SPAWNED stage agents, and that wait is our own promise
9// time: a long catch-up exceeds the 10s budget and the engine discards
10// our answer (the native summary runs instead). Wrapping the phases in a $.tool.call to our own in-process
11// tool keeps the clock paused for the entire stage wait.
12//
13// The tool (mcp__session-owl__await_stage): plugin-hosted tools are served
14// as in-process "sdk" MCP servers — NO engine-side duration limit applies
15// (MCP_TOOL_TIMEOUT defaults to ~28 hours; the idle timeout is disabled
16// for sdk-type servers; the plugin $.tool.call schema carries no timeout
17// parameter at all). A failed dispatch falls back to running the work
18// inline — the budget risk returns, but the takeover is never broken.
19import type { CompactionHookDeps } from "./hook.js";
20
21/** The engine-facing tool name (the server prefix the dispatcher adds). */
22export const AWAIT_STAGE_TOOL = "mcp__session-owl__await_stage";
23
24/** The tool's registration spec — deferred (off the main prompt; only our
25 *  own compact hook ever calls it, by full name). */
26export function awaitStageToolSpec(): Record<string, unknown> {
27  return {
28    name: "await_stage",
29    isDeferred: true,
30    description:
31      "Internal compaction gate: runs this conversation's prepared ensure-ready phases (observer catch-up + builder) to completion. Not for the model.",
32    inputSchema: {
33      type: "object",
34      required: ["key"],
35      properties: {
36        key: {
37          type: "string",
38          description: "The conversation key the prepared work belongs to.",
39        },
40      },
41    },
42  };
43}
44
45/** The prepared ensure-ready work by conversation key (one-shot: the tool
46 *  side takes it; a fallback dispatch re-takes nothing). */
47const pendingWork = new Map<string, () => Promise<unknown>>();
48
49/** Park the ensure-ready phases for the tool side to run. */
50export function stageWorkCall<T>(key: string, work: () => Promise<T>): void {
51  pendingWork.set(key, work as () => Promise<unknown>);
52}
53
54/** Take (and remove) the parked work; undefined when none is prepared. */
55export function takeStageWork(key: string): (() => Promise<unknown>) | undefined {
56  const work = pendingWork.get(key);
57  pendingWork.delete(key);
58  return work;
59}
60
61/** The STARTED run by conversation key: the first serving dispatch starts
62 *  the parked work; later dispatches — the engine cut an earlier one at its
63 *  ~10s per-call ceiling — JOIN the same in-flight run, so the work executes
64 *  exactly once however many slices the wait takes. The record drops once
65 *  the run settles. */
66const liveRuns = new Map<string, { promise: Promise<void> }>();
67
68/** The serving side of await_stage: start the parked work or join the run
69 *  already in flight; resolves when the run settles (never rejects — the
70 *  wrapper's own closure carries the verdict and the failure). Undefined
71 *  when nothing is parked NOR running (a cold dispatch). */
72export function joinStageRun(key: string): Promise<void> | undefined {
73  const existing = liveRuns.get(key);
74  if (existing !== undefined) return existing.promise;
75  const work = takeStageWork(key);
76  if (work === undefined) return undefined;
77  const record: { promise: Promise<void> } = { promise: null as unknown as Promise<void> };
78  record.promise = work().then(
79    () => undefined,
80    () => undefined, // the failure rides the wrapper's closure, never this channel
81  );
82  liveRuns.set(key, record);
83  void record.promise.then(() => {
84    if (liveRuns.get(key) === record) liveRuns.delete(key);
85  });
86  return record.promise;
87}
88
89/** The serving-side slice ceiling: the engine
90 *  cuts a dispatch at its ~10s per-call ceiling, and a hook that keeps
91 *  running after the cut's abort signal is reported as lingering after 5s
92 *  — two consecutive reports name the plugin a runaway and the worker is
93 *  respawned (session-owl unloaded in the hard severity: every armed
94 *  structure dies mid-session). Serving a slice that RETURNS at 9s, before
95 *  any signal fires, removes the trigger outright: the signal channel stays
96 *  reserved for genuine termination (session end, an interrupt), which the
97 *  gate's own abort plumbing handles. The run itself continues in-process
98 *  (joinStageRun — single execution); the gate's loop re-dispatches exactly
99 *  as it does for engine cuts. */
100export const STAGE_SLICE_MS = 9000;
101
102/** The serving side of await_stage, sliced: answers {done:false} at the
103 *  slice boundary while the run continues detached, or {done:true} when the
104 *  run settles inside the slice. An ABORT SIGNAL (the turn was interrupted
105 *  — the engine grants 5s) answers {done:false, result:"aborted"} at once:
106 *  the in-flight slice never holds the grace. Undefined for a cold key
107 *  (the deny case). */
108export function joinStageRunSliced(
109  key: string,
110  sliceMs: number = STAGE_SLICE_MS,
111  signal?: AbortSignal,
112): Promise<{ result: string; done: boolean }> | undefined {
113  const run = joinStageRun(key);
114  if (run === undefined) return undefined;
115  return new Promise((resolve) => {
116    let settledSlice = false;
117    const finish = (answer: { result: string; done: boolean }) => {
118      if (settledSlice) return;
119      settledSlice = true;
120      clearTimeout(timer);
121      if (signal !== undefined && typeof signal.removeEventListener === "function") {
122        signal.removeEventListener("abort", onAbort);
123      }
124      resolve(answer);
125    };
126    const timer = setTimeout(() => finish({ result: "slice", done: false }), sliceMs);
127    const onAbort = () => finish({ result: "aborted", done: false });
128    if (signal !== undefined && !signal.aborted && typeof signal.addEventListener === "function") {
129      signal.addEventListener("abort", onAbort, { once: true });
130    } else if (signal?.aborted === true) {
131      onAbort();
132      return;
133    }
134    void run.then(() => finish({ result: "done", done: true }));
135  });
136}
137
138/** The compact hook's turn-interrupt grace: the
139 *  engine aborts the turn on Esc and grants the hook 5 seconds to stop
140 *  ("did not stop within 5s of the turn being interrupted" — then the hook
141 *  is skipped and what is below runs). A gate whose granularity is one
142 *  whole stage call can never unwind that fast, so the HOOK races the
143 *  signal: on abort it fires the cancel wire (the gate's controller — the
144 *  stages unwind detached, at their own signal checks) and returns pass
145 *  immediately (the engine is skipping the hook anyway; its answer would
146 *  be discarded). A takeover that settles or rejects before any abort
147 *  passes through untouched (a rejection becomes pass — never surface a
148 *  broken hook to a dispatch that already moved on). */
149export async function raceTakeoverWithSignal<T extends { kind: string }>(
150  takeover: Promise<T>,
151  signal: AbortSignal | undefined,
152  onCancel: (key: string) => void,
153  key: string,
154): Promise<T | { kind: "pass" }> {
155  if (signal === undefined || signal.aborted) {
156    if (signal?.aborted === true) onCancel(key);
157    return takeover.then(
158      (outcome) => outcome,
159      () => ({ kind: "pass" }) as { kind: "pass" },
160    );
161  }
162  return new Promise<T | { kind: "pass" }>((resolve) => {
163    let settled = false;
164    const onAbort = () => {
165      if (settled) return;
166      settled = true;
167      try {
168        onCancel(key);
169      } catch {
170        // the cancel wire is best-effort — the hook MUST return regardless
171      }
172      resolve({ kind: "pass" });
173    };
174    signal.addEventListener("abort", onAbort, { once: true });
175    takeover.then(
176      (outcome) => {
177        if (settled) return;
178        settled = true;
179        signal.removeEventListener("abort", onAbort);
180        resolve(outcome);
181      },
182      () => {
183        if (settled) return;
184        settled = true;
185        signal.removeEventListener("abort", onAbort);
186        resolve({ kind: "pass" });
187      },
188    );
189  });
190}
191
192/** The wrapper's named inputs (the register wiring binds them). */
193export interface BudgetFreeDeps {
194  /** The $.tool.call capsule (already validated at its literal call site —
195   *  this helper never spells a $ call itself). */
196  call: (tool: string, args: Record<string, unknown>) => Promise<unknown>;
197  /** Says a failure (the no-silent-catch rule) before the inline fallback. */
198  onError: (message: string, err: unknown) => void;
199  /** The compaction dispatch's abort signal (next.signal): the user
200   *  cancelled the compaction (Esc) — stop re-dispatching and settle the
201   *  run in-process; the gate's own controller (wired to the same signal
202   *  by the register) unwinds the stages on their next signal check. */
203  signal?: AbortSignal;
204  /** The queued-interrupt detector: a dispatch stalled past stallMs means
205   *  the engine paused dispatching behind a queued user gesture (Esc
206   *  serializes behind an in-flight hook on this engine build — the signal
207   *  never aborts). Called with the conversation key so the wiring can
208   *  abort the gate's controller and unwind the stages. */
209  onStall?: (key: string) => void;
210  /** The stall bail window (default 15s — 1.5 slices; a dispatch that
211   *  cannot be serviced in that window is treated as a queued interrupt). */
212  stallMs?: number;
213}
214
215/** Extend compaction deps with the budget-free wait: `viaToolCall` parks
216 *  the work under the conversation key and rides the await_stage tool
217 *  call — the hook-budget clock stays paused for the whole duration — and
218 *  LOOPS the dispatch: the engine cuts a $.tool.call at its ~10s per-call
219 *  ceiling (kind=timeout — the call resolves, the served run keeps going
220 *  in-process), so one dispatch can never cover a long catch-up. Each cut
221 *  slice re-dispatches (the run executes exactly once — joinStageRun);
222 *  there is NO total cap (pi's gate waits however long the stages take;
223 *  the user's exit cancels: session.end aborts the gate's controller and
224 *  the stages unwind at their next signal check). A REFUSED dispatch (a
225 *  rejection, not a cut) falls back to running the work inline — never
226 *  broken. */
227export function withBudgetFreeWait(
228  deps: CompactionHookDeps,
229  wiring: BudgetFreeDeps,
230): CompactionHookDeps & { viaToolCall: <T>(work: () => Promise<T>) => Promise<T> } {
231  return {
232    ...deps,
233    viaToolCall: async <T>(work: () => Promise<T>): Promise<T> => {
234      // The work's verdict and failure are CAPTURED here (the tool result
235      // channel is generic tool content — the closure assigns these,
236      // whichever side ends up running it)
237      let verdict: T | undefined;
238      let failure: unknown;
239      let settled = false;
240      stageWorkCall(deps.key, async () => {
241        try {
242          verdict = await work();
243        } catch (err) {
244          failure = err;
245        } finally {
246          settled = true;
247        }
248      });
249      const stallMs = wiring.stallMs ?? 15_000;
250      for (;;) {
251        if (wiring.signal?.aborted) {
252          // the user cancelled the compaction: NO further dispatches — the
253          // run settles in-process (started, or started now — the refusal
254          // path's semantics; the gate's controller unwinds its stages)
255          await joinStageRun(deps.key);
256          break;
257        }
258        // the queued-interrupt race: a dispatch the engine will not service
259        // (a user gesture queued behind the in-flight hook) must not hold
260        // the gate hostage — bail, unwind, and let the hook return so the
261        // engine processes the gesture. A REJECTION (sync throw or async
262        // refusal) keeps its original semantics: the inline fallback.
263        let refused: { refusal: unknown } | undefined;
264        let dispatch: Promise<unknown>;
265        try {
266          dispatch = wiring.call(AWAIT_STAGE_TOOL, { key: deps.key });
267        } catch (err) {
268          refused = { refusal: err };
269          dispatch = Promise.resolve();
270        }
271        let bailTimer: ReturnType<typeof setTimeout> | undefined;
272        const raced = await Promise.race([
273          dispatch.then(
274            () => "answered" as const,
275            (err: unknown) => ({ refusal: err }),
276          ),
277          new Promise<"stalled">((resolveStall) => {
278            bailTimer = setTimeout(() => resolveStall("stalled"), stallMs);
279          }),
280        ]);
281        if (bailTimer !== undefined) clearTimeout(bailTimer);
282        const refusal =
283          refused !== undefined
284            ? refused.refusal
285            : typeof raced === "object" && raced !== null
286              ? (raced as { refusal: unknown }).refusal
287              : undefined;
288        if (raced === "stalled") {
289          dispatch.catch((err: unknown) => {
290            // the abandoned dispatch may reject after the bail — say it (the
291            // stall already unwound the gate; this only explains the corpse)
292            wiring.onError(
293              "await_stage dispatch rejected after the stall bail (abandoned slice — the gate already unwound)",
294              err,
295            );
296          });
297          wiring.onError(
298            `await_stage dispatch stalled past ${stallMs}ms — treating it as a queued user interrupt (Esc serializes behind the in-flight hook); unwinding the gate`,
299            new Error("stalled dispatch"),
300          );
301          wiring.onStall?.(deps.key);
302          await joinStageRun(deps.key); // start-or-join; the aborted controller unwinds the stages
303          break;
304        }
305        if (refusal !== undefined && (typeof raced === "object" || refused !== undefined)) {
306          wiring.onError(
307            `await_stage tool call failed — running the compaction's prepare inline (the 10s hook-budget risk returns for this dispatch)`,
308            refusal,
309          );
310          await joinStageRun(deps.key); // start it here (the refusal path) or join the live run
311          break;
312        }
313        if (settled) break;
314        // the engine cut this slice at its ~10s ceiling — the run continues
315        // in-process; dispatch the next slice to keep the clock paused
316      }
317      if (!settled) {
318        throw new Error("the ensure-ready run never settled (dispatch refused, no run started)");
319      }
320      if (failure !== undefined) throw failure; // the gate's catch turns this into skipFailed(reason)
321      return verdict as T;
322    },
323  };
324}
325
host/compaction/hook.ts 506 lines
1// SPDX-License-Identifier: MIT
2// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
4import type { SessionEntry } from "@earendil-works/pi-coding-agent";
5// The compaction takeover — the session.compact gate, pi's hook ported to
6// the Claude event shape. Gate order (fixed): live pause predicate (conflict
7// self-restraint — hooks pass through) → enabled → the precompute branch →
8// cut computation at gate entry → acquireForCompaction → ensure-ready phases
9// (Observer catch-up over the frontier→cut gap with mid-catch-up Builder
10// folding, the Builder with its fast-path intact, the Selector when
11// selected-root; the live enabled switch re-checked between phases — a
12// disable leaves with a PASS so the engine's own compaction runs) → render →
13// snapshot persist BEFORE the answer → the answer. Failure anywhere ⇒ {skip}
14// + notify: the conversation stays exactly as it was and the next trigger
15// retries. Never throws; the lock releases in the finally.
16//
17// Port notes: the engine's compact input carries no abort signal (pi's hook
18// links one — here the compaction's own controller from the keyed lock is
19// the only signal). The kept tail is computed over the event's own messages
20// (the engine's cut exists only for its own compactions); the entry-space
21// cut aligns by message-bearing branch position, falling back to the whole
22// branch when the views disagree (pi's cut-not-found rule).
23import type { SessionCompacted, SessionCompactSkipped, SessionMessage } from "claude-code";
24import { renderSummary } from "../../core/src/compaction/summary.js";
25import { extractTouchedFiles } from "../../core/src/compaction/touched-files.js";
26import { ledgerToStageUsages } from "../../core/src/compaction/usage-map.js";
27import type { SessionOwlConfig } from "../../core/src/config/schema.js";
28import { isRenderableEntry } from "../../core/src/format/chunk.js";
29import { assertGraphStructure } from "../../core/src/graph/mutations.js";
30import { measureRootViewTokens } from "../../core/src/graph/read-tools.js";
31import { sinceLastCompaction, snapshotAtCompaction } from "../../core/src/status/usage-ledger.js";
32import { cloneLedger, encodeDetails } from "../../core/src/store/codecs.js";
33import type { GraphStore } from "../../core/src/store/graph-store.js";
34import { computeUnobserved, makeMaybeBuilder } from "../../core/src/triggers.js";
35import { O_INITIAL_PROMPT } from "../../core/src/types.js";
36import { ModelUnavailableError } from "../runs/seams.js";
37import { normalizeSummaryText, ownSummariesFor } from "../storage/summary-identity.js";
38import { contextTokensFor, makeTriggerCtx } from "../triggers/context.js";
39import { acquireForCompaction, acquireOrSkip } from "../triggers/run-locks.js";
40import { buildSkip, buildTakeover, estimateTokensAfter, summaryMessage } from "./answer.js";
41import { computeKeptTail } from "./cut.js";
42import { ownCompactionFloorIndex, ownSummaryFloorIndex } from "./floor.js";
43import { collectTailThinking, renderThinkingSection } from "./thinking.js";
44
45/** The compact event slice the takeover reads (the engine's input shape). */
46export interface CompactEvent {
47  trigger: "manual" | "auto" | "plugin" | "precompute";
48  agentId?: string;
49  instructions?: string;
50  messages: readonly SessionMessage[];
51}
52
53/** The stage runs, pre-bound by the wiring to the conversation's core,
54 *  storage, and widget (the stage-run host modules; the hook passes only the
55 *  per-compaction fields). */
56export interface CompactionRuns {
57  runObserver: (input: {
58    unobserved: SessionEntry[];
59    signal: AbortSignal;
60    maybeBuild: () => Promise<boolean>;
61  }) => Promise<void>;
62  runBuilder: (input: {
63    signal: AbortSignal;
64    scope: { firstKeptEntryId: string | null } | null;
65    /** True only for the final post-catch-up call (the sole call eligible for
66     *  the builderSkipWithinBudget fast-path); the mid-catch-up closure passes
67     *  false so folding always runs. */
68    ensureReady: boolean;
69  }) => Promise<void>;
70  runSelector: (input: { signal: AbortSignal; scope: { firstKeptEntryId: string | null } | null }) => Promise<void>;
71}
72
73/** The takeover's named inputs — everything the gate touches, injected. */
74export interface CompactionHookDeps {
75  /** The conversation key (agentId-scoped) — the run-lock's key. */
76  key: string;
77  /** The gate-entry settings snapshot. */
78  settings: SessionOwlConfig;
79  /** The live settings (re-read between phases — the mid-gate disable). */
80  getSettings: () => SessionOwlConfig;
81  /** The layered rewind check's compaction entry, awaited BEFORE the gate
82   *  reads the store: a rewind that happened since the last check-point
83   *  re-folds here, so the takeover renders from the rewound state instead
84   *  of the abandoned line's records. A throw degrades to a logged
85   *  skip-and-continue (the check's own last-resort rule). */
86  rewindCheck: () => Promise<void>;
87  /** The conflict-pause live predicate (true ⇒ hooks pass through). */
88  isPaused: () => boolean;
89  /** The kept-tail budget (the ported keepRecentTokens setting). */
90  keepRecentTokens: number;
91  /** Whether the kept tail's thinking folds into the '# Memory' summary
92   *  row as the attributed-reasoning section (the engine serves user rows;
93   *  a trailing assistant row persists as model="<synthetic>" and never
94   *  reaches the model). */
95  keptTailAggregatedThinking: boolean;
96  /** Whether the injected summary echoes as ONE visible assistant row at
97   *  the tail's end (a display mirror for the user; the model never sees
98   *  that row). Default off. */
99  injectedSummaryRow: boolean;
100  /** The conversation's entries view (the branch the gap + touched files read). */
101  getBranch: () => SessionEntry[];
102  getLeafId: () => string | null;
103  /** The conversation's observer frontier. */
104  getObserverFrontier: () => string | null;
105  /** Pins the conversation's core before each store-reading cluster. */
106  pinCore: () => Promise<void>;
107  /** The pinned conversation's graph store (read + the compaction-baseline write). */
108  getGraphStore: () => GraphStore;
109  runs: CompactionRuns;
110  /** Persists the encoded snapshot BEFORE the answer (the storage layer's
111   *  snapshot writer, bound by the wiring) and records the answered
112   *  summary's exact text as owl's own (branch record identity). */
113  persistSnapshot: (details: unknown, compactionId: string, summaryText: string) => Promise<void>;
114  /** Appends the transcript display row for the summary (the engine writes
115   *  none of its own for a hook-answered compaction — the row lands
116   *  model-invisible and renders in the transcript view). A failure is
117   *  logged and never fails the answer. */
118  /** The notify adapter (the commands/notify task wires the engine surface). */
119  notify: (message: string, level: "info" | "warning" | "error") => void;
120  log?: (message: string) => void;
121  /** One compaction id per takeover (the snapshot record's id). */
122  newCompactionId: () => string;
123  /** The budget-free wait: when present,
124   *  the ensure-ready phases (catch-up + builder + selector) run INSIDE
125   *  this wrapper — the wiring rides a $.tool.call so the engine's 10s
126   *  hook-budget clock stays paused for the whole stage wait (the clock
127   *  stops while any $ call of the hook is in flight; our own stage waits
128   *  otherwise burn it — a long stage wait trips "ran past its 10s
129   *  budget"). Absent ⇒
130   *  the phases run inline (today's behavior). */
131  viaToolCall?: <T>(work: () => Promise<T>) => Promise<T>;
132}
133
134/** What the takeover decided: answer, veto, or pass through to the engine. */
135export type TakeoverOutcome =
136  | { kind: "answer"; result: SessionCompacted; cut?: string | null }
137  | { kind: "skip"; result: SessionCompactSkipped }
138  | { kind: "pass" };
139
140const COMPACT_EVENT_ENTRY_TYPES = new Set(["message", "custom_message"]);
141
142/** The run hook: the session.compact takeover. Never throws. */
143export async function compactionTakeover(e: CompactEvent, deps: CompactionHookDeps): Promise<TakeoverOutcome> {
144  // the default is the informational channel — the wiring supplies the real log seam
145  const log = deps.log ?? ((message: string) => console.log(message));
146  try {
147    // (1) the live pause predicate gates at entry (conflict self-restraint)
148    if (deps.isPaused()) return { kind: "pass" };
149    // (2) disabled ⇒ transparent (the engine's own compaction runs)
150    if (!deps.settings.enabled) return { kind: "pass" };
151    // (2b) the layered rewind check: a rewind since the last check-point
152    // re-folds BEFORE the gate reads the store — the cut, the scope and the
153    // ensure-ready phases then work on the rewound conversation's own
154    // records, never the abandoned line's. A failed check degrades to a
155    // log line (the compaction still runs; the next check-point retries).
156    try {
157      await deps.rewindCheck();
158    } catch (cause) {
159      log(
160        `rewind check before compaction failed (continuing): ${cause instanceof Error ? cause.message : String(cause)}`,
161      );
162    }
163    // typed /compact instructions are ignored exactly as pi ignores them —
164    // the summary is built from the graph, never the focus text
165    if (typeof e.instructions === "string" && e.instructions.length > 0) {
166      log(`compaction instructions ignored: ${e.instructions}`);
167    }
168    // (3) the precompute branch: a held result must be a pure mechanical
169    // render — any backlog hands back to the real compaction (the full gate)
170    if (e.trigger === "precompute") return precomputeTakeover(deps);
171
172    // (4) the cut at gate entry — the kept tail over the event's own messages
173    const cut = computeKeptTail(e.messages, deps.keepRecentTokens);
174    // an empty swallowed prefix (the whole conversation fits the keep window)
175    // is declined: the summary would add a row while keeping every message,
176    // so the session stays as-is and the skip reason says why
177    if (cut.firstKeptIndex === 0) {
178      log("compaction declined: nothing to compact (session too small)");
179      return { kind: "skip", result: { skip: "session-owl: nothing to compact (session too small)" } };
180    }
181    await deps.pinCore();
182    const firstKeptEntryId = entryIdForCut(deps.getBranch(), cut.firstKeptIndex, e.messages.length);
183    const scope = { firstKeptEntryId };
184
185    // (5) the compaction-exclusive acquire: abort in-flight runs, await them
186    const handle = await acquireForCompaction(deps.key);
187    const signal = handle.abortController.signal;
188    try {
189      // a prior owl summary row never rides the tail — the fresh summary
190      // re-renders the same graph, so exactly one summary row exists at a time
191      const keptTail = cut.kept.filter((m) => !isPriorSummaryRow(m, ownSummariesFor(deps.getGraphStore())));
192      return await runGate(deps, log, { kept: keptTail, scope, handle, signal });
193    } finally {
194      handle.release();
195    }
196  } catch (cause) {
197    const reason = cause instanceof Error ? cause.message : String(cause);
198    if (cause instanceof ModelUnavailableError) {
199      // the session stays uncompacted (a skip answer never falls back to the
200      // engine's own compaction); the reason is surfaced as the skip notice
201      const notice = `session-owl: model unavailable during compaction capture (${reason}) — compaction skipped; fix the model and run /compact again`;
202      (deps.log ?? ((m: string) => console.log(m)))(notice);
203      return { kind: "skip", result: { skip: notice } };
204    }
205    return skipFailed(deps, reason);
206  }
207}
208
209/** The ensure-ready gate body under the held lock: catch-up → Builder →
210 *  Selector → render → snapshot persist → the answer. */
211async function runGate(
212  deps: CompactionHookDeps,
213  log: (m: string) => void,
214  held: {
215    kept: SessionMessage[];
216    scope: { firstKeptEntryId: string | null };
217    handle: { release(): void; abortController: AbortController };
218    signal: AbortSignal;
219  },
220): Promise<TakeoverOutcome> {
221  // the compaction's stage-cost baseline (cloned deep — the stages mutate in place)
222  await deps.pinCore();
223  const preStageLedger = cloneLedger(deps.getGraphStore().usageLedger);
224
225  // (a)+(b)+(c) run as ONE unit so the whole ensure-ready phase can ride
226  // the budget-free tool call (deps.viaToolCall): the engine's 10s hook
227  // budget stops while a $ call is in flight, and waiting for our spawned
228  // stages is otherwise our own promise time (the budget
229  // overrun). The verdict ("ok" | "pass" | "cancel") crosses the wrapper
230  // through the captured local — whichever side runs the phases.
231  const readyPhases = async (): Promise<"ok" | "pass" | "cancel"> => {
232    // (a) Observer catch-up — GAP-DRIVEN regardless of observerMode: observe
233    // the renderable entries between the frontier and the cut (the block this
234    // compaction is about to swallow)
235    const gap = observerCatchUpGap(deps, held.scope.firstKeptEntryId);
236    if (gap.length > 0) {
237      const bareMaybeBuild = makeMaybeBuilder({
238        ctx: makeTriggerCtx({
239          key: deps.key,
240          getLeafId: deps.getLeafId,
241          getBranch: () => deps.getBranch(),
242          thinkingLevel: null,
243        }) as unknown as Parameters<typeof makeMaybeBuilder>[0]["ctx"],
244        settings: deps.settings,
245        signal: held.signal,
246        scope: held.scope,
247        runBuilder: () => deps.runs.runBuilder({ signal: held.signal, scope: held.scope, ensureReady: false }),
248      });
249      // the vendored decision reads the store singletons mid-Observer — after
250      // the Observer's long awaits another conversation's swap may hold them,
251      // so every mid-catch-up check re-pins first (the boundary evaluator's
252      // cluster discipline)
253      const maybeBuild = async (): Promise<boolean> => {
254        await deps.pinCore();
255        return bareMaybeBuild();
256      };
257      await deps.runs.runObserver({ unobserved: gap, signal: held.signal, maybeBuild });
258    }
259    if (held.signal.aborted) return "cancel";
260    // live master switch between phases: a disable leaves with a PASS — the
261    // engine's native summary runs (NOT a skip)
262    if (!deps.getSettings().enabled) {
263      log("compaction: session-owl disabled mid-gate — leaving (native compaction)");
264      return "pass";
265    }
266
267    // (b) the Builder — always invoked; it owns its fast-path (the final
268    // post-catch-up call — ensureReady — may skip within budget; the
269    // mid-catch-up folding above never does)
270    await deps.runs.runBuilder({ signal: held.signal, scope: held.scope, ensureReady: true });
271    if (held.signal.aborted) return "cancel";
272    if (!deps.getSettings().enabled) {
273      log("compaction: session-owl disabled mid-gate — leaving (native compaction)");
274      return "pass";
275    }
276
277    // (c) the Selector — selected-root only (it owns its cached-tree fast-path)
278    if (deps.settings.renderMode === "selected-root") {
279      await deps.runs.runSelector({ signal: held.signal, scope: held.scope });
280    }
281    if (held.signal.aborted) return "cancel";
282    return "ok";
283  };
284  const verdict = deps.viaToolCall !== undefined ? await deps.viaToolCall(readyPhases) : await readyPhases();
285  if (verdict === "cancel") return cancelAborted(deps);
286  if (verdict === "pass") return { kind: "pass" };
287  if (verdict !== "ok") {
288    // The wrapper's contract says this is unreachable (it returns the true
289    // verdict or throws) — belt-and-braces: an unsettled gate NEVER renders.
290    // pi's degrade: a failed gate cancels (no-memory), never a half-built
291    // summary from a graph whose stages are still running.
292    return skipFailed(deps, "ensure-ready verdict missing (the gate never settled)");
293  }
294
295  // render + snapshot — the pin holds the store steady across the cluster
296  await deps.pinCore();
297  const store = deps.getGraphStore();
298  assertGraphStructure(store.graph, "encode snapshot"); // invalid ⇒ skip + notify, the last-good snapshot stays authoritative
299  const touchedFiles = extractTouchedFiles(
300    { getLeafId: deps.getLeafId, getBranch: deps.getBranch },
301    held.scope.firstKeptEntryId,
302  );
303  const oInitialPrompt = store.graph.observations.get(O_INITIAL_PROMPT) ?? null;
304  const summary = renderSummary({
305    graph: store.graph,
306    selectedTree: store.selectedTree,
307    oInitialPrompt,
308    renderMode: deps.settings.renderMode,
309    touchedFiles,
310    compactionCount: countCompactions(deps.getBranch()),
311  });
312  // the compaction baseline for the status report's since-last arithmetic
313  store.lastCompactionLedger = snapshotAtCompaction(store.usageLedger).lastCompactionLedger;
314
315  // the per-stage usage delta isolates THIS compaction's stage cost
316  const compactionDelta = sinceLastCompaction(store.usageLedger, preStageLedger);
317  const details = encodeDetails(store.graph, store.selectedTree, store.usageLedger) as { compactionStages?: unknown };
318  details.compactionStages = ledgerToStageUsages(compactionDelta);
319
320  // The kept tail's aggregated thinking rides INSIDE the summary row (the
321  // engine serves user rows; a trailing assistant row persists as
322  // model="<synthetic>" and never reaches the model). Off or empty — the
323  // summary stands alone.
324  let summaryText = summary;
325  if (deps.keptTailAggregatedThinking) {
326    const collected = collectTailThinking(deps.getBranch(), held.scope.firstKeptEntryId);
327    log(
328      `kept-tail thinking: ${collected.length} block(s) from cut ${held.scope.firstKeptEntryId ?? "whole-branch"} ` +
329        `(branch ${deps.getBranch().length} entr${deps.getBranch().length === 1 ? "y" : "ies"})`,
330    );
331    const section = renderThinkingSection(collected);
332    if (section !== null) summaryText = `${summary}\n\n${section}`;
333  } else {
334    log("kept-tail thinking: knob off — the tail rides without the attributed section");
335  }
336  // The display echo: the injected SUMMARY as one visible assistant row at
337  // the tail's end — the tree render alone; the reasoning section is
338  // AGENT-ONLY content (it rides the model-visible summary row, never the
339  // user-facing echo). The model never sees the echo row (the synthetic-row
340  // skip), so nothing duplicates.
341  let trailing: SessionMessage | undefined;
342  if (deps.injectedSummaryRow) trailing = { role: "assistant", text: summary, toolUses: [] };
343
344  // the snapshot + the own-summary record persist BEFORE the answer — a
345  // crash in between keeps the previous snapshot authoritative and the
346  // compaction skipped
347  await deps.persistSnapshot(details, deps.newCompactionId(), summaryText);
348
349  const summaryMsg = summaryMessage(summaryText);
350  return {
351    kind: "answer",
352    // the kept tail's first row id: the alignment's exemption boundary —
353    // the summarized prefix legitimately vanishes from the api view, and
354    // the engine writes no row a later observer could re-derive it from
355    cut: held.scope.firstKeptEntryId,
356    result: buildTakeover({
357      summary: summaryText,
358      kept: held.kept,
359      ...(trailing !== undefined ? { trailing } : {}),
360      tokensBefore: contextTokensFor(deps.key) ?? undefined,
361      tokensAfter: estimateTokensAfter([summaryMsg, ...held.kept, ...(trailing !== undefined ? [trailing] : [])]),
362    }),
363  };
364}
365
366/** The precompute branch (the ready-render rule): answer only from an
367 *  already-ready state — no unobserved backlog, root view within threshold —
368 *  a pure mechanical render costing zero LLM calls; anything else hands back
369 *  with a skip (the real compaction runs the full gate). Nothing persists:
370 *  the engine HOLDS the result and installs it at the next real compaction
371 *  only if the conversation still holds what it ran over. */
372async function precomputeTakeover(deps: CompactionHookDeps): Promise<TakeoverOutcome> {
373  const handle = acquireOrSkip(deps.key, "select");
374  if (handle === null) {
375    return { kind: "skip", result: buildSkip("session-owl precompute skipped: a background run is in flight") };
376  }
377  try {
378    await deps.pinCore();
379    const store = deps.getGraphStore();
380    const backlog = observerCatchUpGap(deps, null).length > 0;
381    const withinThreshold = measureRootViewTokens(store.graph) < deps.settings.builderRootViewThreshold;
382    if (backlog || !withinThreshold) {
383      return { kind: "skip", result: buildSkip("session-owl precompute skipped: memory graph not render-ready") };
384    }
385    const summary = renderSummary({
386      graph: store.graph,
387      selectedTree: store.selectedTree,
388      oInitialPrompt: store.graph.observations.get(O_INITIAL_PROMPT) ?? null,
389      renderMode: deps.settings.renderMode,
390      touchedFiles: extractTouchedFiles({ getLeafId: deps.getLeafId, getBranch: deps.getBranch }, null),
391      compactionCount: countCompactions(deps.getBranch()),
392    });
393    return { kind: "answer", result: buildTakeover({ summary, kept: [] }) };
394  } finally {
395    handle.release();
396  }
397}
398
399/** The cancel path when the compaction was aborted mid-gate. */
400function cancelAborted(deps: CompactionHookDeps): TakeoverOutcome {
401  const reason = "session-owl compaction canceled: compaction aborted";
402  deps.notify(reason, "warning");
403  return { kind: "skip", result: buildSkip(reason) };
404}
405
406/** The failure veto: notify + skip — the conversation stays as it was and
407 *  the next trigger retries. */
408function skipFailed(deps: CompactionHookDeps, cause: string): TakeoverOutcome {
409  const reason = `session-owl compaction failed: ${cause}; retry`;
410  deps.notify(reason, "error");
411  return { kind: "skip", result: buildSkip(reason) };
412}
413
414/** The renderable entries strictly between the frontier and the cut (the
415 *  block this compaction swallows). A null/unknown cut ⇒ the whole branch
416 *  (pi's cut-not-found rule). */
417
418/** A summary row owl itself rendered at an earlier compaction: the engine
419 *  hands it back as a plain user message, so the row is owl's own exactly
420 *  when its text matches one of the recorded answered summaries (an exact
421 *  identity — a user message that merely opens like a summary never
422 *  matches). The fresh answer carries the current render; keeping the old
423 *  row would duplicate the memory view in the conversation. */
424function isPriorSummaryRow(m: SessionMessage, ownTexts: ReadonlySet<string>): boolean {
425  return m.role === "user" && typeof m.text === "string" && ownTexts.has(normalizeSummaryText(m.text));
426}
427
428export function observerCatchUpGap(deps: CompactionHookDeps, firstKeptEntryId: string | null): SessionEntry[] {
429  const branch = deps.getBranch();
430  const cutIndex = firstKeptEntryId === null ? -1 : branch.findIndex((entry) => entry.id === firstKeptEntryId);
431  const compactedBlock = cutIndex === -1 ? branch : branch.slice(0, cutIndex);
432  // the observation floor: the newest OWL-OWN summary on the block bounds
433  // the re-observation scope (everything before it is condensed into that
434  // row; a lost coverage state rebuilds from the boundary, and native or
435  // foreign-mod summaries are never boundaries — their rows may still be
436  // observed, which is the full-session re-observation they require).
437  // Two witnesses of the same fact, the newer position winning: the store's
438  // recorded summary texts (exact normalized identity) and the block's own
439  // compaction entries — chrome rows carrying owl's rendered summary, which
440  // survive a store loss and keep the condensed history below them from
441  // ever re-entering the scope.
442  const recordedFloor = ownSummaryFloorIndex(compactedBlock, ownSummariesFor(deps.getGraphStore()));
443  const fileFloor = ownCompactionFloorIndex(compactedBlock);
444  const floor = Math.max(recordedFloor, fileFloor);
445  if (floor > recordedFloor) {
446    (deps.log ?? ((m: string) => console.log(m)))(
447      `catch-up floor: on-file own summary at index ${String(floor)} bounds the scope (no recorded match)`,
448    );
449  }
450  const scoped = floor === -1 ? compactedBlock : compactedBlock.slice(floor + 1);
451  return computeUnobserved(scoped, deps.getObserverFrontier()).filter(isRenderableEntry);
452}
453
454/** Map the message-space cut onto the branch's entry space by TAIL
455 *  CORRESPONDENCE: after a compaction the engine's conversation is
456 *  `[summary, ...kept, ...new]` while the branch keeps the append-only
457 *  originals (the boundary rides as a `compaction` entry, summaries are
458 *  those entries' texts), so whole-list positions structurally drift from
459 *  a conversation's second compaction on. What holds instead: the newest
460 *  `keptCount` messages (the kept tail — never a summary, which sits at
461 *  the head region) correspond one-to-one to the newest `keptCount`
462 *  MESSAGE-BEARING branch entries — head-side extras (rows the engine
463 *  compacted away) sit before the tail region and cannot shift it.
464 *  Distrust (null — the whole branch becomes the catch-up block, never a
465 *  cut swallowed unobserved) when the cut precedes the summary region, or
466 *  the branch is behind even counting its head extras. */
467function entryIdForCut(branch: SessionEntry[], firstKeptIndex: number, messageCount: number): string | null {
468  const messageBearing = branch.filter((entry) =>
469    COMPACT_EVENT_ENTRY_TYPES.has((entry as { type?: string }).type ?? ""),
470  );
471  // each live compaction rides the branch as TWO door-compaction rows. The
472  // current mapping rule (which row is the summary) is the recorded
473  // non-empty-summary heuristic — see isCompactionSummaryEntry
474  const summaries = branch.reduce((n, entry) => (isCompactionSummaryEntry(entry) ? n + 1 : n), 0);
475  const liveTail = messageCount - summaries; // the kept tail + new rows
476  const keptCount = messageCount - firstKeptIndex;
477  // the cut precedes the summary region (the tail includes a summary), or
478  // the branch is behind what the transcript holds — no position is trustworthy
479  if (keptCount > liveTail || messageBearing.length < liveTail) return null;
480  const aligned = messageBearing[messageBearing.length - keptCount];
481  return aligned === undefined ? null : aligned.id;
482}
483
484/** The current mapping rule for which compaction entry is a compaction's
485 *  SUMMARY row (vs its boundary marker): a non-empty summary text. NOTE:
486 *  the live boundary row itself carries text ('Conversation compacted'),
487 *  under which this rule counts both rows — a safe-direction over-count; a
488 *  live session.append decides the final row-type distinction. */
489function isCompactionSummaryEntry(entry: SessionEntry): boolean {
490  return (
491    (entry as { type?: string }).type === "compaction" &&
492    typeof (entry as { summary?: unknown }).summary === "string" &&
493    (entry as { summary: string }).summary.length > 0
494  );
495}
496
497/** Compactions so far on this branch (the summary footer's maturity signal) —
498 *  the summary rows under the current mapping rule. */
499export function countCompactions(branch: SessionEntry[]): number {
500  let count = 0;
501  for (const entry of branch) {
502    if (isCompactionSummaryEntry(entry)) count += 1;
503  }
504  return count;
505}
506
host/conflicts/activation.ts 84 lines
1// SPDX-License-Identifier: MIT
2// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
4// The conflict activation orchestrator: runs once at the
5// awaited first session.start — Channel A's disk enumeration, Channel
6// A-prime's unrooted determination over the engine.create fold snapshot —
7// and merges everything with Channel B's live per-wave hits into the pause
8// module's recorded set. The log ladder is pi's: conflicts + disabled ⇒
9// silent (off by choice is not a pause); conflicts + pause ⇒ the pause
10// line; conflicts + ignoreConflicts ⇒ the deliberate-co-run line.
11// Closure-safe: imports only the conflicts modules (data-only).
12
13import { type ChannelAIo, type ConflictAdvisory, runChannelA } from "./channel-a.js";
14
15export type { ChannelAIo } from "./channel-a.js";
16
17import { determineUnrooted } from "./channel-a-prime.js";
18import { getLiveBAdvisories, getLiveBHits } from "./channel-b.js";
19import { setConflictHits } from "./pause.js";
20import type { ConflictHit } from "./scan.js";
21
22/** What the activation decided (the wiring logs from this). */
23export interface ActivationOutcome {
24  hits: ConflictHit[];
25  advisories: ConflictAdvisory[];
26  log: "none" | "pause" | "ignore-conflicts";
27}
28
29/** Run the activation: Channel A + A-prime + the live B merge. The io comes
30 *  from the $-capsule bridge; the self/family identity from the manifest. */
31export async function runConflictActivation(
32  io: ChannelAIo,
33  opts: { selfName: string; selfRoot: string; familyPrefix: string; enabled: boolean; ignoreConflicts: boolean },
34): Promise<ActivationOutcome> {
35  const a = await runChannelA({
36    io,
37    selfName: opts.selfName,
38    selfRoot: opts.selfRoot,
39    familyPrefix: opts.familyPrefix,
40  });
41  const aPrime = determineUnrooted({
42    selfName: opts.selfName,
43    familyPrefix: opts.familyPrefix,
44    rootedNames: a.rootedNames,
45  });
46  const hits = [...a.hits, ...aPrime, ...getLiveBHits()];
47  const advisories = [...a.advisories, ...getLiveBAdvisories()];
48  staticHits = [...a.hits, ...aPrime];
49  setConflictHits(hits, advisories);
50
51  let log: ActivationOutcome["log"] = "none";
52  if (hits.length > 0 && !opts.enabled) {
53    // Detect + record only — enabled=false is the operator's explicit
54    // choice; a conflict warning would claim a pause that is not why
55    // session-owl is off.
56    log = "none";
57  } else if (hits.length > 0 && !opts.ignoreConflicts) {
58    log = "pause";
59  } else if (hits.length > 0) {
60    log = "ignore-conflicts";
61  }
62  return { hits, advisories, log };
63}
64
65/** The activation's static result (Channel A + A-prime), kept for the live
66 *  recompute Channel B triggers on every new envelope hit. */
67let staticHits: ConflictHit[] = [];
68
69/** Test seam: seed the static (Channel A) set the way the activation
70 *  would — recomputePauseSet merges from it, so a bypassing setConflictHits
71 *  call would be wiped by the next recompute. */
72export function _setStaticHitsForTest(hits: ConflictHit[]): void {
73  staticHits = hits;
74}
75
76/** Recompute the effective hit set after a live Channel B change (a new
77 *  envelope hit mid-session — "pause immediately, strictly before that mod
78 *  can touch any compaction"): the static set merges with the live one. */
79export function recomputePauseSet(): void {
80  setConflictHits([...staticHits, ...getLiveBHits()]);
81}
82
83/** Test seam: none needed (state lives in the pause module). */
84
host/conflicts/channel-a-prime.ts 78 lines
1// SPDX-License-Identifier: MIT
2// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
4// Channel A-prime — the fold-list unrooted determination.
5// owl snapshots e.plugins at its engine.create step (the COMPLETE module
6// list of the load in chain order — mods that loaded before owl are
7// invisible to plugin.register). At the awaited first session.start, after
8// loads settle, any entry not self, not rooted via Channel A and not
9// witnessed via Channel B is unrooted: fail-safe conservative hit with the
10// name. Escape is ignoreConflicts (dev-mode co-runs).
11
12import type { ConflictHit } from "./scan.js";
13
14let foldList: string[] | null = null;
15const bWitnesses = new Set<string>();
16
17/** Snapshot the engine.create fold list (module names, chain order). */
18export function recordFoldList(names: string[]): void {
19  foldList = [...names];
20}
21
22/** Channel B marks a name as witnessed (its plugin.register envelope seen). */
23export function recordBWitness(name: string): void {
24  bWitnesses.add(name);
25}
26
27/** The recorded fold snapshot (tests + the A-prime twin read it). */
28export function getFoldList(): string[] | null {
29  return foldList === null ? null : [...foldList];
30}
31
32/** Test seam: clear the per-process snapshot + witnesses. */
33export function resetFoldState(): void {
34  foldList = null;
35  bWitnesses.clear();
36}
37
38/** A bare fold name ("evil") roots any registry key whose plugin name
39 *  matches ("evil@market") — the market suffix is not part of the fold
40 *  entry. */
41function rootsAny(name: string, rootedNames: readonly string[]): boolean {
42  return rootedNames.some((key) => key === name || key.startsWith(`${name}@`));
43}
44
45/**
46 * The unrooted determination (awaited first session.start): every fold
47 * entry BEFORE owl in the chain that is neither self, nor family, nor
48 * Channel-A-rooted, nor Channel-B-witnessed fails safe — a conservative
49 * hit that pauses with the name. Entries AFTER owl are exempt: the fold
50 * list is first-outermost, so they sit INNER of owl and can never override
51 * its answers — on a stock install that region is the engine's own
52 * builtins (cc-plugin-*), whose plugin.register envelopes never reach a
53 * user-tier listener (kickoff cell 7) and which the installs-only registry
54 * can never root (cell 12) — without this exemption owl would self-pause
55 * on every stock machine. If owl itself is absent from the list
56 * (degenerate), the full scan runs (fail safe).
57 */
58export function determineUnrooted(opts: {
59  selfName: string;
60  familyPrefix: string;
61  rootedNames: readonly string[];
62}): ConflictHit[] {
63  const list = foldList ?? [];
64  const selfIndex = list.indexOf(opts.selfName);
65  const hits: ConflictHit[] = [];
66  for (let i = 0; i < list.length; i += 1) {
67    const name = list[i] as string;
68    // inner-of-owl entries are not conflict candidates (see above)
69    if (selfIndex >= 0 && i > selfIndex) continue;
70    if (name === opts.selfName) continue;
71    if (name.toLowerCase().includes(opts.familyPrefix.toLowerCase())) continue;
72    if (bWitnesses.has(name)) continue;
73    if (rootsAny(name, opts.rootedNames)) continue;
74    hits.push({ channel: "A-prime", name, evidence: "unrooted" });
75  }
76  return hits;
77}
78
host/conflicts/channel-b.ts 127 lines
1// SPDX-License-Identifier: MIT
2// SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
4// Channel B — the live plugin.register envelope listener.
5// owl witnesses every later tier load, mid-session enable and hot reload
6// through this channel: session.compact matched against the envelope's
7// uses.events AS PATTERNS (the literal, "session.*" and "*" are
8// affirmative; "classic.*" patterns are NOT — the classic PreCompact hook
9// is a different seam and gets an advisory only; negated patterns are not
10// affirmative), then the bounded scan over the envelope's root separates
11// overriders from passive registrants. builtin tier entries are skipped
12// (inner of user-tier owl — they can never conflict). A hit pauses
13// immediately, strictly before that mod can touch any compaction.
14
15import { log } from "../../core/src/log.js";
16import type { ConflictAdvisory } from "./channel-a.js";
17import { type ConflictHit, type ScanIo, scansCompactOverride } from "./scan.js";
18
19/** The plugin.register envelope, narrowed to the fields Channel B reads. */
20export interface Envelope {
21  name: string;
22  tier: string;
23  root: string;
24  events: string[];
25}
26
27/** Match session.compact against uses.events as patterns. */
28export function envelopeCompactMatch(events: readonly string[]): { compact: boolean; classicOnly: boolean } {
29  let compact = false;
30  let classicOnly = false;
31  for (const pattern of events) {
32    if (pattern.startsWith("!")) continue; // negated patterns are not affirmative
33    if (pattern === "session.compact" || pattern === "session.*" || pattern === "*") compact = true;
34    else if (pattern.startsWith("classic.")) classicOnly = true;
35  }
36  return { compact, classicOnly };
37}
38
39export interface EnvelopeResult {
40  /** An overriding registrant: pause immediately. */
41  hit: ConflictHit | null;
42  /** A classic-only registrant: advisory log line, no pause. */
43  advisory: ConflictAdvisory | null;
44  /** Every witnessed name (feeds Channel A-prime's rootedness set). */
45  witness: string;
46}
47
48/** Handle one plugin.register envelope: witness the name, decide. */
49export async function handlePluginRegisterEnvelope(
50  envelope: Envelope,
51  io: ScanIo,
52  opts: { selfName: string; familyPrefix: string },
53): Promise<EnvelopeResult> {
54  const { name, tier, root, events } = envelope;
55  const witness = name;
56
57  if (name === opts.selfName || name.toLowerCase().includes(opts.familyPrefix.toLowerCase())) {
58    return { hit: null, advisory: null, witness };
59  }
60  if (tier === "builtin") {
61    // inner of user-tier owl — can never override; witnessed for A-prime.
62    return { hit: null, advisory: null, witness };
63  }
64
65  const match = envelopeCompactMatch(events);
66  if (!match.compact) {
67    if (match.classicOnly) {
68      return { hit: null, advisory: { name, evidence: "classic.*", reason: "classic-only" }, witness };
69    }
70    return { hit: null, advisory: null, witness };
71  }
72
73  function errText(err: unknown): string {
74    return err instanceof Error ? err.message : String(err);
75  }
76
77  // affirmative: separate overriders from passive registrants by source
78  try {
79    await io.list(root);
80  } catch (err) {
81    log.info(`conflict scan B: root unscannable (${name}): ${errText(err)}`);
82    return { hit: { channel: "B", name, evidence: "unscannable" }, advisory: null, witness };
83  }
84  if (await scansCompactOverride(root, io)) {
85    return { hit: { channel: "B", name, evidence: "session.compact" }, advisory: null, witness };
86  }
87  return { hit: null, advisory: null, witness };
88}
89
90// ---- the live per-wave hit set -------------------------------------------------------
91//
92// engine.create refires on any reload when a module hooks it (owl does), so
93// every reload wave re-fires each remaining mod's plugin.register envelope
94// through the listener: reset the live set at engine.create, repopulate per
95// envelope, and the effective hits recompute (the pause auto-recovers on
96// reload — a strict improvement over pi's next-start recovery).
97
98let liveHits: ConflictHit[] = [];
99let liveAdvisories: ConflictAdvisory[] = [];
100
101/** Wave reset (engine.create): the envelopes that follow repopulate. */
102export function resetWaveLiveHits(): void {
103  liveHits = [];
104  liveAdvisories = [];
105}
106
107/** Record one envelope's live outcome (hit or advisory). */
108export function recordLiveOutcome(hit: ConflictHit | null, advisory: ConflictAdvisory | null): void {
109  if (hit !== null && !liveHits.some((h) => h.name === hit.name && h.channel === hit.channel)) {
110    liveHits.push(hit);
111  }
112  if (advisory !== null && !liveAdvisories.some((a) => a.name === advisory.name)) {
113    liveAdvisories.push(advisory);
114  }
115}
116
117/** The live per-wave hits (merged with Channel A's static set by the pause
118 *  recompute). */
119export function getLiveBHits(): ConflictHit[] {
120  return [...liveHits];
121}
122
123/** The live per-wave advisories. */
124export function getLiveBAdvisories(): ConflictAdvisory[] {
125  return [...liveAdvisories];
126}
127