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

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.

/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.Requires Claude Code 2.1.302 or later.
claude plugin install session-owl --marketplace avtc/avtc-pi-session-owl
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.
session-owl runs three maintenance stages:
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.
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 accuracy | bare pi | om-default | session-owl-builder | session-owl-selector | om-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 details | 21% | 23% | 78% | 77% | 43% |
| No trace in the summary or kept tail, agent has to search through session memory or deduct | 11% | 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 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.
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).
| Tool | Description |
|---|---|
owl_recall | Recall from memory — fetch by id, filter by time range, or regex search. Targets the rendered tree (selected-root or observations-root). |
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.
| Command | Description |
|---|---|
/owl-status | Show 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-rescan | Discard 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-chunks | Re-observe session ranges that were skipped with zero observations (repair after a degraded model run) |
/owl-settings | Open the settings UI |
/owl-status output:

/owl-settings — the settings pane:

The value picker:

Five independent mode axes, all live-toggleable mid-session:
| Setting | Options | Default |
|---|---|---|
observerMode | on-threshold · on-compaction | on-threshold |
builderMode | on-compaction · each-N-observations · on-session-context-threshold · on-root-view-threshold | each-N-observations |
selectorMode | on-compaction · on-session-context-threshold | on-compaction |
renderMode | selected-root (Selector curates) · observations-root (the Builder's root view) | observations-root |
subagentMode | inherited (same as the main conversation) · on-compaction (captured only when the subagent itself compacts) · disabled | on-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.
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.
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.
The port's development notes, gates, and release runbook live in DEVELOPMENT.md.
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.
MIT
hooks/register.ts 2273 lines1// 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 lines1// 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};
76host/capture/append-handler.ts 124 lines1// 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}
124host/capture/flush.ts 42 lines1// 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}
42host/capture/grouper.ts 351 lines1// 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}
351host/commands/handlers.ts 494 lines1// 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}
494host/commands/viewer.ts 87 lines1// 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}
87host/compaction/budget-free.ts 325 lines1// 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}
325host/compaction/hook.ts 506 lines1// 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}
506host/conflicts/activation.ts 84 lines1// 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). */
84host/conflicts/channel-a-prime.ts 78 lines1// 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}
78host/conflicts/channel-b.ts 127 lines1// 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