Replaces context compaction in the controller's interactive session of a log-driven repo: the conversation is rebuilt from .context/events.jsonl by `eventlog…

eventlog is a Rust CLI for coordinating several coding agents in one repo through an append-only event log. One controller writes small events to .context/events.jsonl (spawn, claim, result, decision, retire); every agent and every human reads the same file. Walk the log to event N and you know exactly what the system knew at event N.
The log works alone. Reactors, agents that act on log events without a prompt, are an optional add-on in a separate binary. See Optional: reactors.
This repo runs on the tool it ships, reactors included. Its own log, decisions, and worker briefs live in .context/; AGENTS.md says who may write what.
cargo install eventlog-cli --locked # puts `eventlog` in ~/.cargo/bin
eventlog setup preview # what setup would add; writes nothing
eventlog setup apply # .context/events.jsonl, EVENTLOG.md, eventlog.toml, git ignore lines
eventlog doctor --fix # installs the tool-call guard for Claude, Cursor, and Codex
eventlog protect # optional: OS-level append-only on the log
To build a local checkout instead, run cargo install --path . --locked. The crate is named eventlog-cli; the executable is eventlog.
setup apply does the same as eventlog init. It is safe to rerun: it never overwrites a file that already exists. The guard hook loads in a new agent session.
| Command | What it does |
|---|---|
eventlog append <type> k=v ... | Write one validated, hash-chained event. The only sanctioned writer. |
eventlog view [-f] [--last N] | Print log rows; -f follows. |
eventlog state | Active agents, open claims, and decisions as of now. |
eventlog why <seq> | The chain of events behind one event, and what followed from it. |
eventlog claims <agent> <base> | Changed files that an agent's claim does not cover. |
eventlog tui | Live terminal view of the folded log. |
Run eventlog --help for the full list, and eventlog vocab for the fields each event type takes.
A long controller session normally ends in an LLM summary of itself. The eventlog-context mod for Claude Code replaces that: it builds the new context from the log (decisions, agents, recent history, open work) plus the last turns word for word, in milliseconds, and it fires at task boundaries instead of at a token limit.
eventlog context install # writes .claude/skills/eventlog-context/ (rerun after upgrading eventlog)
echo '/.claude/skills/eventlog-context/' >> .gitignore # generated copy; keep it out of git
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude # Claude Code 2.1.278+, in a trusted workspace
Then work as usual. eventlog view --last 20 shows each rebuild event with its trigger. /rebuild forces one when the next turn ends; EVENTLOG_CONTEXT=off turns the mod off for a session. eventlog context prints the packet the mod would use. See Rebuild context from the log and the reference.
spawn, prompt, and claim for a worker. The worker edits only the paths it claimed.eventlog claims <worker> main. It lists changed files that the claim does not cover.result ... paths=<files>, then retire.result names. With the optional commit reactor, the reactor commits them instead.Nothing edits or truncates the log: a hook guard blocks the agents' tool calls that would, and eventlog protect makes the kernel refuse everything else.
A reactor is a long-running agent that waits for one event type, acts on it, and appends an ack. The separate eventlog-reactors binary ships two. The commit reactor commits exactly the paths a result names. The docs reactor then updates the docs for that commit. You do not need either to use the log.
cargo install eventlog-reactors --locked # or, from a checkout: cargo install --path reactors --locked
eventlog-reactors setup preview
eventlog-reactors setup apply
reactors/README.md covers when you want reactors, their setup, the Drove helper, eventlog-reactors doctor, and the move from eventlog 0.5, where these commands were eventlog react and eventlog action.
eventlog-context mod.docs/reference/.eventlog-reactors binary, with its reference pages in docs/reactors/.The event-log-coordination skill for Claude Code lives in skill/ and ships inside the binary. eventlog skill install writes it to ~/.claude/skills.
src/ the eventlog crate: model (event, config, vocabulary), log (read, lock, append), query (fold to state), guard (hook guard), context (the context packet), scaffold, skill, tui, cmd.reactors/ the optional eventlog-reactors crate: the reactor runtime, the packaged commit and docs actions, reactor setup, and doctor.skill/ the coordination skill, embedded at build time.mods/eventlog-context/ the Claude Code mod, embedded at build time and written by eventlog context install..context/ this repo's own log, decisions, briefs, and reactor action scripts.Drovefile the herdr layout: controller pane, log view, and the two reactor panes.hooks/register.ts 215 lines1import type { On, SessionMessage } from 'claude-code'
2
3import {
4 LAYOUT_FILE,
5 LOG_FILE,
6 REBUILD_PREFIX,
7 checkArgs,
8 fillOf,
9 growthOf,
10 controllerPaneOf,
11 inertReason,
12 installRoot,
13 isTurnStart,
14 markPacket,
15 parseVerdict,
16 pushIncrease,
17 readPacket,
18 rebuildArgs,
19 selectTail,
20 newestTurnSize,
21 newestTurnTrimmed,
22 tailTooLarge,
23 triggerOf,
24} from './policy.ts'
25
26const TIMEOUT_MS = 5_000
27const TAG = 'eventlog-context'
28
29const errText = (err: unknown): string => (err instanceof Error ? err.message : String(err))
30
31export function register(on: On): void {
32 // The repo root every eventlog call runs in. Undefined means the mod is
33 // inert: it stays so until session.start finds the controller's
34 // interactive session in a repo that has a log.
35 let root: string | undefined
36 // Per-turn token increases of the main loop, and the last reading they
37 // are measured from. Cleared by every main-loop compaction.
38 let increases: number[] = []
39 let lastTokens: number | undefined
40 // Set by /rebuild; the next main-loop turn.complete compacts.
41 let pendingCommand = false
42
43 const forget = (): void => {
44 increases = []
45 lastTokens = undefined
46 pendingCommand = false
47 }
48
49 on('session.start', async ($, e, next) => {
50 const started = await next(e)
51 root = undefined
52 forget()
53 try {
54 let reason: string | null = 'headless'
55 let found: string | null = null
56 if (e.isInteractive) {
57 const writer = await $.env.get('EVENTLOG_AS')
58 const setting = await $.env.get('EVENTLOG_CONTEXT')
59 const worker = await $.env.get('LOG_DRIVEN_WORKER')
60 const pane = await $.env.get('HERDR_PANE_ID')
61 // Installed at <root>/.claude/skills/<name>/; from anywhere else
62 // (a --plugin-dir checkout), the git top level of the session.
63 found = installRoot($.plugin.root)
64 if (found === null) {
65 const git = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: e.cwd, timeoutMs: TIMEOUT_MS })
66 found = git.exitCode === 0 && git.stdout.trim() !== '' ? git.stdout.trim() : null
67 }
68 const hasLog = found !== null && (await $.fs.exists(`${found}/${LOG_FILE}`))
69 let controllerPane: string | undefined
70 if (pane && found !== null && (await $.fs.exists(`${found}/${LAYOUT_FILE}`))) {
71 controllerPane = controllerPaneOf(await $.fs.read(`${found}/${LAYOUT_FILE}`))
72 }
73 reason = inertReason({ isInteractive: true, writer, setting, hasLog, worker, pane, controllerPane })
74 }
75 if (reason !== null || found === null) {
76 $.ui.log(`${TAG}: inert in this session (${reason ?? 'no log'})`)
77 return started
78 }
79 root = found
80 await $.command.register({ name: 'rebuild', description: 'Rebuild the context from the event log when the current turn ends' })
81 } catch (err) {
82 $.ui.log(`${TAG}: inert in this session (setup failed: ${errText(err)})`)
83 }
84 return started
85 })
86
87 // The host refuses $.session.compact from a command.run hook: it would
88 // compact under the turn the hook holds. /rebuild queues the rebuild, and
89 // the next main-loop turn.complete runs it directly.
90 on('command.run', { command: 'rebuild' }, async ($, e, next) => {
91 if (root === undefined) return next(e)
92 pendingCommand = true
93 return { text: `${TAG}: rebuild queued; it runs when the next turn ends. /compact rebuilds from the log now.` }
94 })
95
96 on('turn.complete', async ($, e, next) => {
97 const result = await next(e)
98 const cwd = root
99 if (cwd === undefined || e.agentId !== undefined || e.reason !== 'answer') return result
100 let reason: string | undefined
101 if (pendingCommand) {
102 pendingCommand = false
103 reason = 'command'
104 } else {
105 try {
106 const { context } = await $.session.usage({ breakdown: 'summary' })
107 const tokens = context.tokens
108 increases = pushIncrease(increases, lastTokens, tokens)
109 if (tokens !== undefined) lastTokens = tokens
110 const fill = fillOf({
111 tokens,
112 window: context.window,
113 percent: context.percent,
114 threshold: context.breakdown?.autoCompactThreshold,
115 })
116 if (!fill) return result
117 const run = await $.process.run(checkArgs(fill.percent, growthOf(increases, fill.limit)), { cwd, timeoutMs: TIMEOUT_MS })
118 const verdict = parseVerdict(run)
119 if (!verdict?.rebuild) return result
120 reason = verdict.reason
121 } catch (err) {
122 $.ui.log(`${TAG}: check failed: ${errText(err)}`)
123 return result
124 }
125 }
126 // Direct, never deferred: a deferred call skips this mod's own
127 // session.compact hook and runs the engine's summarizer (spike, Q2).
128 // Headless sessions reject the call; catch it and carry on.
129 try {
130 const r = await $.session.compact({ instructions: `${REBUILD_PREFIX}${reason}` })
131 if (r.skip) $.ui.log(`${TAG}: rebuild skipped: ${r.skip}`)
132 } catch (err) {
133 $.ui.log(`${TAG}: compact rejected: ${errText(err)}`)
134 }
135 return result
136 })
137
138 on('session.compact', async ($, e, next) => {
139 const cwd = root
140 if (cwd === undefined || e.agentId !== undefined) return next(e)
141 if (e.trigger === 'precompute') return { skip: `${TAG} rebuilds on demand` }
142 forget()
143 const trigger = triggerOf(e.trigger, e.instructions)
144
145 const append = async (fields: Record<string, string | number>): Promise<void> => {
146 try {
147 const r = await $.process.run(rebuildArgs(trigger, fields), { cwd, timeoutMs: TIMEOUT_MS })
148 if (r.exitCode !== 0) $.ui.log(`${TAG}: rebuild event not appended: ${r.stderr.trim() || `exit ${r.exitCode}`}`)
149 } catch (err) {
150 $.ui.log(`${TAG}: rebuild event not appended: ${errText(err)}`)
151 }
152 }
153
154 let tokensBefore: number | undefined
155 try {
156 tokensBefore = (await $.session.usage()).context.tokens
157 } catch {
158 tokensBefore = undefined
159 }
160
161 let read: ReturnType<typeof readPacket>
162 let messages: readonly SessionMessage[] = []
163 try {
164 read = readPacket(await $.process.run(['eventlog', 'context', '--json'], { cwd, timeoutMs: TIMEOUT_MS }))
165 if ('packet' in read) {
166 if (Array.isArray(e.messages)) {
167 messages = e.messages
168 } else {
169 // The engine hands the transcript with handles; an input without
170 // it reads the main conversation (rows without handles are rebuilt
171 // from their role, text and tool blocks).
172 $.ui.log(`${TAG}: session.compact came without messages; read the transcript with $.session.messages()`)
173 messages = await $.session.messages()
174 }
175 }
176 } catch (err) {
177 read = { error: errText(err) }
178 }
179 if ('refused' in read) {
180 $.ui.log(`${TAG}: packet refused (${read.detail}); the engine compacts`)
181 if (read.refused === 'version') {
182 // An eventlog upgrade with no reinstall: append so `check` does not
183 // find the same boundary again on every turn (M1).
184 await append({ reason: 'engine-fallback' })
185 }
186 // `empty` (no events yet): fall back without appending, so the mod
187 // never writes a log that did not exist.
188 return next(e)
189 }
190 if ('error' in read) {
191 // Fail open: the engine compacts as it would without this mod. The
192 // event goes first so `check` does not find the same boundary again.
193 await append({ reason: 'engine-fallback' })
194 return next(e)
195 }
196
197 const { packet } = read
198 if (tailTooLarge(messages, packet.settings.tail_chars)) {
199 // The newest turn's prompt alone is bigger than tail_chars: trimming
200 // cannot shrink the context enough. Fall back so Claude Code's own
201 // summary shrinks it instead (I1).
202 await append({ reason: 'tail-too-large' })
203 return next(e)
204 }
205 $.ui.log(`${TAG}: newest turn ${newestTurnSize(messages)} chars, tail_chars ${packet.settings.tail_chars}, ${messages.length} messages`)
206 const tail = selectTail(messages, packet.settings.keep_turns, packet.settings.tail_chars)
207 const fields: Record<string, string | number> = { as_of: packet.as_of }
208 if (tokensBefore !== undefined) fields.tokens_before = tokensBefore
209 fields.kept_turns = tail.filter(isTurnStart).length
210 if (newestTurnTrimmed(messages, packet.settings.tail_chars)) fields.reason = 'tail-trimmed'
211 await append(fields)
212 return { messages: [{ role: 'user', text: markPacket(packet.markdown), toolUses: [] }, ...tail] }
213 })
214}
215hooks/policy.ts 280 lines1import type { SessionMessage } from 'claude-code'
2
3/** Marks a compaction this mod asked for; the text after it is the reason. */
4export const REBUILD_PREFIX = 'eventlog-rebuild:'
5
6/**
7 * The first line of every packet this mod installs. It marks the packet, so
8 * a later rebuild never keeps an earlier packet in its tail.
9 */
10export const PACKET_HEADING = '# Context rebuilt from the event log'
11
12/** The coordination log, relative to the repo root the mod is installed in. */
13export const LOG_FILE = '.context/events.jsonl'
14export const LAYOUT_FILE = '.context/layout.json'
15
16/** How many per-turn increases the growth estimate averages. */
17export const GROWTH_TURNS = 5
18
19export type Packet = { as_of: number; markdown: string; settings: { keep_turns: number; tail_chars: number } }
20export type Verdict = { rebuild: boolean; reason: string }
21type Run = { exitCode: number; stdout: string }
22
23/** One context reading: tokens, window and percent from `context`, threshold from its breakdown. */
24export type Reading = { tokens?: number; window?: number; percent?: number; threshold?: number }
25/** The fill in whole percent, and the token limit it was measured against. */
26export type Fill = { percent: number; limit: number }
27
28function json(run: Run): unknown {
29 if (run.exitCode !== 0 || run.stdout.trim() === '') return null
30 try {
31 return JSON.parse(run.stdout)
32 } catch {
33 return null
34 }
35}
36
37const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
38
39/**
40 * The `eventlog context --json` output, read three ways: a packet; `error`
41 * when eventlog failed or printed nothing usable (the engine compacts and
42 * the mod records a fallback); `refused` when the packet is valid JSON but
43 * not one to install:
44 * - `version`: the packet's `v` is not 1, for example after an `eventlog`
45 * upgrade with no reinstall. The mod falls back *and appends*, so `check`
46 * does not find the same boundary again on every turn.
47 * - `empty`: `as_of` is 0 or less (no events yet). The mod falls back
48 * without appending, so it never writes a log that did not exist.
49 */
50export function readPacket(run: Run): { packet: Packet } | { refused: 'version' | 'empty'; detail: string } | { error: string } {
51 const error = { error: 'eventlog context failed' }
52 const v = json(run) as (Partial<Packet> & { v?: unknown }) | null
53 if (!v || typeof v !== 'object') return error
54 if (typeof v.markdown !== 'string' || v.markdown.trim() === '') return error
55 if (!isNum(v.as_of) || !v.settings) return error
56 if (!isNum(v.settings.keep_turns) || !isNum(v.settings.tail_chars)) return error
57 if (v.v !== 1) return { refused: 'version', detail: `packet version ${String(v.v)}` }
58 if (v.as_of <= 0) return { refused: 'empty', detail: `no events (as_of ${v.as_of})` }
59 return { packet: { as_of: v.as_of, markdown: v.markdown, settings: { keep_turns: v.settings.keep_turns, tail_chars: v.settings.tail_chars } } }
60}
61
62export function parsePacket(run: Run): Packet | null {
63 const r = readPacket(run)
64 return 'packet' in r ? r.packet : null
65}
66
67export function parseVerdict(run: Run): Verdict | null {
68 const v = json(run) as Partial<Verdict> | null
69 if (!v || typeof v !== 'object') return null
70 if (typeof v.rebuild !== 'boolean' || typeof v.reason !== 'string') return null
71 return { rebuild: v.rebuild, reason: v.reason }
72}
73
74/** A packet this mod installed: a user message whose first line is the heading. */
75export function isPacket(m: SessionMessage): boolean {
76 return m.role === 'user' && (m.text === PACKET_HEADING || m.text.startsWith(`${PACKET_HEADING}\n`))
77}
78
79/** The packet text, led by the heading so `isPacket` knows it later. */
80export function markPacket(markdown: string): string {
81 return isPacket({ role: 'user', text: markdown, toolUses: [] }) ? markdown : `${PACKET_HEADING}\n\n${markdown}`
82}
83
84/** A turn starts at a user message that is neither a tool result nor a packet. */
85export function isTurnStart(m: SessionMessage): boolean {
86 return m.role === 'user' && !(m.toolResults && m.toolResults.length > 0) && !isPacket(m)
87}
88
89/** Characters (code points), not UTF-16 units, to match the Rust side's budgets. */
90function chars(s: string): number {
91 let n = 0
92 for (const _ of s) n++
93 return n
94}
95
96/**
97 * What the model reads of a message, counted once: its text, each tool call's
98 * name and input, and each tool result's text. A tool's output also appears in
99 * `toolUses[].text` and both `result` records; counting those too overcounts
100 * it about fourfold (live trim test).
101 */
102function size(m: SessionMessage): number {
103 const uses = (m.toolUses ?? []).reduce((n, u) => n + chars(u.tool) + chars(JSON.stringify(u.input ?? {})), 0)
104 const results = (m.toolResults ?? []).reduce((n, r) => n + chars(r.text ?? ''), 0)
105 return chars(m.text) + uses + results
106}
107
108/** Split into whole turns, oldest first, dropping any earlier packet. */
109function turnsOf(all: readonly SessionMessage[]): SessionMessage[][] {
110 const messages = all.filter(m => !isPacket(m))
111 const starts: number[] = []
112 messages.forEach((m, i) => {
113 if (isTurnStart(m)) starts.push(i)
114 })
115 return starts.map((s, i) => messages.slice(s, starts[i + 1] ?? messages.length))
116}
117
118const turnSize = (t: SessionMessage[]): number => t.reduce((k, m) => k + size(m), 0)
119
120/**
121 * The last `keepTurns` turns, whole, oldest first. Drops whole turns from the
122 * oldest end until the tail fits `tailChars`; always keeps the newest turn.
123 * When the newest turn alone is over `tailChars`, keeps it trimmed: see
124 * `trimTurn`.
125 */
126export function selectTail(all: readonly SessionMessage[], keepTurns: number, tailChars: number): SessionMessage[] {
127 const turns = turnsOf(all)
128 if (turns.length === 0 || keepTurns <= 0) return []
129 if (newestTurnTrimmed(all, tailChars)) return trimTurn(turns[turns.length - 1], tailChars)
130 let kept = turns.slice(-keepTurns)
131 const total = (ts: SessionMessage[][]) => ts.reduce((n, t) => n + turnSize(t), 0)
132 while (kept.length > 1 && total(kept) > tailChars) kept = kept.slice(1)
133 return kept.flat()
134}
135
136/**
137 * An oversized turn cut down to its prompt and its final answer: the last
138 * message when it is assistant text with no tool calls, and both fit
139 * `tailChars`. The tool loop between them goes; the packet carries the state.
140 * Without the answer the model sees an unanswered prompt and redoes the turn
141 * (live trim test), so the answer is kept whenever it fits. A turn cut off
142 * mid-loop has no answer; the prompt alone lets the model carry on.
143 */
144export function trimTurn(turn: readonly SessionMessage[], tailChars: number): SessionMessage[] {
145 const prompt = turn[0]
146 const last = turn[turn.length - 1]
147 const answered = turn.length > 1 && last.role === 'assistant' && last.toolUses.length === 0 && last.text !== ''
148 return answered && size(prompt) + size(last) <= tailChars ? [prompt, last] : [prompt]
149}
150
151/** Size in characters of the newest turn, as the tail budget counts it; 0 with no turn. */
152export function newestTurnSize(all: readonly SessionMessage[]): number {
153 const turns = turnsOf(all)
154 const newest = turns[turns.length - 1]
155 return newest === undefined ? 0 : turnSize(newest)
156}
157
158/**
159 * True when the newest turn alone is larger than `tailChars` but its prompt
160 * fits: the rebuild keeps the prompt and drops the rest of that turn. A
161 * newest turn exactly at `tailChars` is kept whole.
162 */
163export function newestTurnTrimmed(all: readonly SessionMessage[], tailChars: number): boolean {
164 const turns = turnsOf(all)
165 const newest = turns[turns.length - 1]
166 return newest !== undefined && turnSize(newest) > tailChars && size(newest[0]) <= tailChars
167}
168
169/**
170 * True only when the newest turn's prompt alone is larger than `tailChars`:
171 * trimming cannot shrink the context enough, so the mod falls back to
172 * Claude Code's own compaction (I1).
173 */
174export function tailTooLarge(all: readonly SessionMessage[], tailChars: number): boolean {
175 const turns = turnsOf(all)
176 const newest = turns[turns.length - 1]
177 return newest !== undefined && size(newest[0]) > tailChars
178}
179
180/**
181 * The `rebuild` event's trigger: the engine's, or the reason this mod gave.
182 * A plugin's compaction may arrive without a trigger (the test kit passes a
183 * plugin call's arguments as given); that reads as `plugin`.
184 */
185export function triggerOf(trigger: string | undefined, instructions?: string): string {
186 const t = trigger ?? 'plugin'
187 if (t === 'plugin' && instructions?.startsWith(REBUILD_PREFIX)) {
188 const reason = instructions.slice(REBUILD_PREFIX.length).trim()
189 if (reason !== '') return reason
190 }
191 return t
192}
193
194export function rebuildArgs(trigger: string, fields: Record<string, string | number>): string[] {
195 return ['eventlog', 'append', 'rebuild', `trigger=${trigger}`, ...Object.entries(fields).map(([k, v]) => `${k}=${v}`)]
196}
197
198export function checkArgs(percent: number, growth: number): string[] {
199 return ['eventlog', 'context', 'check', '--percent', String(percent), '--growth', String(growth)]
200}
201
202const clampPercent = (n: number): number => Math.min(100, Math.max(0, Math.round(n)))
203
204/**
205 * The fill: context tokens over the auto-compact threshold when both exist;
206 * else the engine's `percent` against the window; else null (no reading).
207 * A missing reading is never 0.
208 */
209export function fillOf(r: Reading): Fill | null {
210 if (isNum(r.tokens) && isNum(r.threshold) && r.threshold > 0) {
211 return { percent: clampPercent((r.tokens / r.threshold) * 100), limit: r.threshold }
212 }
213 if (isNum(r.percent)) return { percent: clampPercent(r.percent), limit: isNum(r.window) ? r.window : 0 }
214 return null
215}
216
217/**
218 * The history after one more main-loop turn: the increase from `before` to
219 * `after` when both exist and it is positive; the newest `GROWTH_TURNS` kept.
220 */
221export function pushIncrease(history: readonly number[], before: number | undefined, after: number | undefined): number[] {
222 if (!isNum(before) || !isNum(after) || after <= before) return [...history]
223 return [...history, after - before].slice(-GROWTH_TURNS)
224}
225
226/** The average increase as a whole percent of `limit`; 0 with no history or limit. */
227export function growthOf(history: readonly number[], limit: number): number {
228 if (history.length === 0 || !(limit > 0)) return 0
229 const avg = history.reduce((a, b) => a + b, 0) / history.length
230 return clampPercent((avg / limit) * 100)
231}
232
233/**
234 * The repo root when the mod is installed at `<root>/.claude/skills/<name>/`;
235 * null anywhere else (for example a `--plugin-dir` checkout).
236 */
237export function installRoot(pluginRoot: string): string | null {
238 const m = /^(.+)\/\.claude\/skills\/[^/]+\/?$/.exec(pluginRoot)
239 return m ? m[1] : null
240}
241
242export type SessionFacts = {
243 isInteractive: boolean
244 writer: string | undefined
245 setting: string | undefined
246 hasLog: boolean
247 /** LOG_DRIVEN_WORKER: set in a worker launched by a reactor. */
248 worker?: string
249 /** HERDR_PANE_ID of this session, and the controller's pane from .context/layout.json. */
250 pane?: string
251 controllerPane?: string
252}
253
254/** The controller's pane from the text of `.context/layout.json`; undefined when absent or unreadable. */
255export function controllerPaneOf(layout: string | undefined): string | undefined {
256 if (layout === undefined) return undefined
257 try {
258 const pane = (JSON.parse(layout) as { controller?: { pane?: unknown } }).controller?.pane
259 return typeof pane === 'string' && pane !== '' ? pane : undefined
260 } catch {
261 return undefined
262 }
263}
264
265/**
266 * Why the mod stays out of this session, or null when it acts. It acts only
267 * in the controller's interactive session, in a repo that has a log.
268 */
269export function inertReason(f: SessionFacts): string | null {
270 if (!f.isInteractive) return 'headless'
271 if (f.writer && f.writer !== 'controller') return `EVENTLOG_AS=${f.writer}`
272 if (f.worker) return `LOG_DRIVEN_WORKER=${f.worker}`
273 // Same rule as the controller stop hook: once the layout records the
274 // controller's pane, a session in any other pane is a worker.
275 if (f.pane && f.controllerPane && f.pane !== f.controllerPane) return `pane ${f.pane} is not the controller's pane`
276 if (f.setting?.trim().toLowerCase() === 'off') return 'EVENTLOG_CONTEXT=off'
277 if (!f.hasLog) return 'no log'
278 return null
279}
280