SLOPSHOPPER

eventlog-context

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

newcommandprocess
v0.1.0no licenseupdated 2026-10-06radiator-engineering/eventlog/mods/eventlog-context
A shopper browsing a rack in a slop shop
README

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.

Install

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.

Daily commands

CommandWhat 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 stateActive 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 tuiLive terminal view of the folded log.

Run eventlog --help for the full list, and eventlog vocab for the fields each event type takes.

Rebuild the controller's context from the log

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.

How the log drives work

  1. The controller appends spawn, prompt, and claim for a worker. The worker edits only the paths it claimed.
  2. When the worker reports back, the controller runs eventlog claims <worker> main. It lists changed files that the claim does not cover.
  3. The controller appends result ... paths=<files>, then retire.
  4. The controller commits the files the 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.

Optional: reactors

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.

Documentation

The event-log-coordination skill for Claude Code lives in skill/ and ships inside the binary. eventlog skill install writes it to ~/.claude/skills.

Layout

  • 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.
Source 2 files
hooks/register.ts 215 lines
1import 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}
215
hooks/policy.ts 280 lines
1import 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