SLOPSHOPPER

session

Profiles for Claude Code sittings: one resolved configuration per session, offered to the plugins that depend on it, and a doctor that checks the setup.

bandguardprocessagents
v2.0.0-alpha.19MITupdated 2026-10-22agentik-dev/agentik-session/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session
› fix the failing auth test and add an audit log call ● session: admission: record start: no session id in 1000 ms ● session: measure: record: no session id in 1000 ms ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM session: no meter · agentik-session doctor ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
session: no meter · agentik-session doctor
README

agentik-session

<img src="assets/icon.svg" alt="" width="56" align="left">

session is a Claude Code plugin, with its agentik-session command line tool, that gives each Claude Code session one named profile, a budget with a stop, and a written charter. It is part of a small family of plugins that answers "how much may this session spend, and what happens when it runs out".

Why it exists

Several plugins cooperate in unattended relay runs: a driver spawns builder sessions, each builder may spawn agents, and nobody watches the terminal. Each sitting then needs four things: one resolved configuration that every plugin reads the same way, a budget over context and turns whose stop is a signal, a rule and, under the relay, a kill, a charter that says what the sitting is for and how it ended, and a gate on spawning. Without the gate and the budget, a swarm can fork past its means; with them, every session winds down at a known point and leaves a record a person can read.

Install

claude plugin marketplace add agentik-dev/agentik-commons
claude plugin install session@agentik
make install
  • make install (from a clone) freezes the committed claude_session/ package under ~/.local/share/agentik-session/<tree>/ and writes the launcher ~/.local/bin/agentik-session, which runs that frozen copy with python -P (Python 3.11 or newer). A dirty claude_session/ exits 2: the install is always one committed tree.
  • Then run agentik session doctor (or /session:doctor inside Claude Code). It checks the tool, the plugin and its hooks, one line per check, and notes each optional sibling that is absent; exit 2 names the failing check and its remedy.
  • To upgrade, install the CLI first: make install, then claude plugin update session@agentik. That order keeps the CLI current with its plugin's hooks. A CLI older than its plugin exits 2 on a hook event it does not know, and the hook shim keeps that from blocking a turn. The older CLI's own doctor reads green; only a agentik session doctor of 2.0.0-alpha.4 or later names an event its CLI does not handle.
  • At run time, the tool uses only the Python standard library.

Commands and skills

Each command wraps one CLI verb.

commandexamplewhat it does
/session:status/session:status --jsonthis session's meter: context, turns, zone
/session:profile/session:profile builderlist the profiles, or show one resolved for this repo
/session:set/session:set posture askchange one setting for this session; --save keeps it
/session:checkpoint/session:checkpoint -m "act 2 landed" --commitmark a handoff-ready point; --commit stages tracked files only (git add -u)
/session:doctor/session:doctorcheck the setup
/session:report/session:report --since 2026-09-01the ledger, one row per ended session

Skills:

  • session: profiles, session-only settings, the budget (the meter, the zones, CHECKPOINT and EXIT), the charter, and the priority and pacing rules.
  • read, invoked as /session:read <file> [SPEC]: reads a markdown record by sections, never whole. Example: /session:read design/overhaul-r1.md '§5'.

CLI verbs (agentik session --help):

verbexample
startagentik session start builder --max-turns 60
configagentik session config show --resolved
doctoragentik session doctor --json
profileagentik session profile balanced
setagentik session set posture deny --save
checkpointagentik session checkpoint -m "tests green"; agentik-session checkpoint --respawn --charter <file>
statusagentik session status
reportagentik session report --since 2026-09-01; agentik session report --chains --json
charteragentik session charter open "render the README"
budgetagentik session budget
rulesagentik session rules
runagentik session run --chain night -- --model 'opus[1m]' "/loop 30m check in"

Hooks (plugin/hooks/hooks.json): SessionStart, PreToolUse on every tool (its spawn gate acts on Agent|Task|Workflow only), PostToolUse, PostToolUseFailure, PostToolBatch, UserPromptSubmit, Stop, SessionEnd, and PreCompact. PreCompact blocks an automatic compaction (a sitting restarts instead) and lets a /compact you type run.

Profiles

Profiles ship in claude_session/profiles.yaml. The defaults entry carries every field; a profile names only what it changes.

profilefor
frontiereverything on the seat, full effort
balancedOpus main thread, Sonnet subagents; the everyday default
localseat main thread, local subagents via agentik-agents local
local-onlymain thread and subagents on the local model; posture off
scratcha test project; posture off, no charter required
reviewread-only: Read, Glob, Grep; permission mode plan
packetswrites packets and designs, never builds
builderwhat the relay spawns; posture deny
orchestratorlarge, complex work: Fable 5.1 on its 1M window
orchestrator-opussmall and medium orchestrated projects: Opus 5.5 on its 1M window (opus[1m])
  • agentik session start <profile> launches Claude Code with that profile, using flags only. Nothing is written to settings.json. --max-turns N sets CLAUDE_SESSION_MAX_TURNS, which caps the turn ceiling. Claude Code's own --max-turns flag applies in print mode only. start sets CLAUDE_SESSION_DEPTH to the parent's depth plus one. A nested start exits 2 without --nested.
  • agentik session set <knob> <value> changes this session only. Launch-time knobs (model, effort, tools) print the in-session equivalent (/model, /effort). posture applies at once.
  • A session's profile is its role. The SessionStart hook takes the profile name from CLAUDE_SESSION_PROFILE (or CLAUDE_SESSION_CONFIG, which agentik session start sets). Failing that, it reads session.profile in config.local.yaml, then in config.yaml, then falls back to frontier. A helper a session spawns (marked CLAUDE_SESSION_HELPER=1) inherits that variable but resolves as builder. A helper of an orchestrator therefore keeps 180000 context tokens.
  • The model decides the window, using the models: table in profiles.yaml: fable and mythos get 1M; opus gets 200k, or 1M as opus[1m]; sonnet and haiku get 200k. Caution: Claude Code runs Opus 4.7 and later on 1M unmarked. A transcript's message.model never carries [1m]. So for Opus the window comes from the SessionStart payload's model, then the profile's model, then ANTHROPIC_MODEL and the settings model key.
  • The role decides the ceilings: ceilings on a 200k window, ceilings_1m on 1M. Every role gets 180000 context tokens and 400 turns on both windows, except the two orchestrators, which get 400000 and 400 on 1M.
  • Precedence: a user or project layer that sets ceilings (both windows) or ceilings_1m (1M) wins, field by field; then the role's ceilings for the resolved window; then the shipped defaults; the turn cap (CLAUDE_SESSION_MAX_TURNS) applies last.

The two orchestrator roles ship in 2.0.0-alpha.2. Install that version of the launcher and of the plugin first, then restart Claude Code. Until then, agentik session profile orchestrator prints unknown profile and the meter falls back to frontier.

Start an orchestrator session (quote opus[1m]: zsh reads brackets as a glob):

make install                                          # from the clone: the launcher
claude plugin update session@agentik      # the plugin; restart Claude Code
agentik session profile orchestrator-opus              # prints the role, not unknown profile
CLAUDE_SESSION_PROFILE=orchestrator claude --model fable               # Fable 5.1, 1M
CLAUDE_SESSION_PROFILE=orchestrator-opus claude --model 'opus[1m]'     # Opus 5.5, 1M

For a repo that always orchestrates, put session: {profile: orchestrator-opus} in its .claude/agentik/config.local.yaml and run claude --model 'opus[1m]'. The SessionStart line names the profile, the ceilings and the window. agentik session status ends with the source, e.g. role:orchestrator-opus window:1000000. agentik session start knows the legacy configurations only, not these two roles.

Budget

  • The meter: after every tool, the PostToolUse hook reads the transcript's new lines and writes meter.json, which holds the used share of each of two ceilings, context and turns (frontier: 180000 context tokens, 400 turns, on either window). It re-picks the window from the model it reads and records window_source and limits_source.
  • Zones, by the larger share: clear from 0%, notice from 80%, wind-down from 90%, reserve from 92%, exhausted at 100%.
  • CHECKPOINT: ... arrives at the profile's checkpoint_at (80%), then every tenth tool: bring the charter current, commit, run agentik session checkpoint.
  • EXIT: ... arrives at 100%: land the act in flight and exit.
  • A RESPAWN close is the sitting's last act, run through Bash: agentik-session checkpoint --respawn --charter <file>. --charter is required. The verb reads only the charter's ## Next act. Its first line must open on RESPAWN, or the verb exits 2 and writes nothing. It then checkpoints and writes sessions/<session_id>/respawn.json, and the sitting ends its turn. The PostToolUse hook confirms the mark only for the main thread's own Bash call. PreToolUse and UserPromptSubmit stamp the main thread's activity. The Stop hook stamps each turn end; it prints nothing and never blocks.
  • The respawn key (cap: 12, floor: 600, settle: 60) holds a respawn chain's guards and layers like every session key.
  • agentik session run is the respawn chain. It starts claude with the same arguments at every start, each behind a fresh --session-id. When a sitting's RESPAWN close is confirmed and its turn has stood settle seconds with no helper call open, run ends the sitting. It waits 10 s, writes a restart line and starts the next. It never compacts or resumes, and it never restarts a crash. It holds the restart at the cap (restarts in any 24 h), at the floor (a sitting's minimum age) and at a STOP file, respawn/STOP or respawn/<chain>/STOP under the state root. Ctrl-C in the 10 s ends the chain. Every stop but a hang-up prints a start line that runs as printed. Give a /loop an interval under 60m, or none. A longer interval asks its question at every start, so run warns once.
  • The spawn gate is the PreToolUse hook, by posture: off is silent; signal (the default) speaks and never blocks; ask asks before a spawn from wind-down on; deny also refuses a spawn in the reserve.
  • agentik session status prints the meter in one line. agentik session report reads the ledger, <state>/ledger.jsonl, one row per ended session, written by the SessionEnd hook. report --chains prints a respawn chain's restart and stop lines instead.

Priority and pacing

  • Two session keys, on in every shipped profile: session.priority (S1 harm, S2 blocks the goal, S3 the build order, S4 the rest) and session.pacing (the wakeup rides the act's last tool call; a pending helper is the wake; from WAITING <n> at waiting the wake is the cache's idle limit, and no wait stops the loop).
  • SessionStart's line carries them in one block of at most 320 bytes. agentik session rules prints both whole, as resolved.
  • Any layer overrides them, field by field; set does not. A level's text is quoted, since it holds ": ". No hook enforces either.

Sharing the resolved configuration

The configuration is layered, lowest first: the shipped profiles; ~/.claude/<plugin>/config.yaml; the repo's .claude/agentik/config.yaml, then config.local.yaml beside it (gitignored); the launch profile; the session's overlay; the invocation.

At SessionStart the plugin writes the result to ${XDG_STATE_HOME:-~/.local/state}/agentik-session/sessions/<session_id>/resolved.json, shaped {"provider": {plugin, version, profile, resolved_at, inputs: [{path, sha256}]}, "<plugin>": {...}}.

A dependent plugin takes session_id from hook stdin or $CLAUDE_CODE_SESSION_ID. It accepts the file only when provider.version is in its range, reads its own section, and never writes the file. When the file is absent, the dependent runs alone. agentik session config show --resolved prints this session's file.

The sibling plugins, decisions, respeak and relay, are optional: this plugin works with none installed and accepts any version of each, and agentik session doctor notes an absent one with what it adds. Nothing else outside the repo.

The charter

One file per sitting, history/sittings/<date>-<n>.md (the profile's charter.dir).

  • agentik session charter open "<goal>" writes it.
  • agentik session charter check <file> exits 2 naming the failing part.
  • agentik session charter close --exit "<text>" appends the exit line from the meter.
  • While session.priority is on, open writes - open by level: none yet under Handover. Each Handover lists the open items by level, highest first.
  • check and close warn, exit unchanged, on a missing line or a WAITING <n>: <what>; <how to tell> misshapen or at waiting.

Five parts: the title line; the table (goal, budget, pin, window, agents); ## Done-ness; ## Handover; ## Next act. A charter stays under 4,096 bytes.

Reading records

  • scripts/record FILE --sections lists a markdown record's sections. scripts/record FILE --section SPEC prints the named ones: an index 3, a list 3,5, a range 3-5, or a heading prefix '§5'.
  • Exit 3 past --max-bytes (8192) without --force; the refusal names the subsections to ask for instead. Stdlib, read-only.
  • /session:read <file> [SPEC] wraps it: with no SPEC it prints the map and stops; it never reads the whole file.

Design of record

  • design/overhaul-r1.md: the design of this version (read it with scripts/record design/overhaul-r1.md --sections).
  • design/conventions.md: the conventions shared by the sibling plugins.
  • design/packets/: one packet per build row, each with its acceptance lines.
  • Each sitting's decisions: the ## Handover of its charter under history/sittings/.
  • Rulings: the decisions plugin's records.

Tests

  • make test: the whole suite, with a fake claude on the PATH and no quota spent.
  • Targeted lines, by file: .venv/bin/python -m pytest -q tests/test_meter.py tests/test_hook_meter.py tests/test_budget.py (the meter and the zones); tests/test_hooks.py tests/test_hook_session_start.py (the hooks per event); tests/test_resolver.py tests/test_sessionconfig.py (the layers and resolved.json); tests/test_charter.py; tests/test_record.py; tests/test_readme.py (this README).
  • make check is the gate: test, lint, doclint, hygiene, validate (claude plugin validate), then README freshness. It prints readme: fresh when design/readme/rendered.sha256 holds the sha256 of design/readme/source.md and of README.md; otherwise readme: stale and exit 2. It is bounded: one run per tree (.check.lock), an 840 s wall, held off above load 4 x cores.
  • make readme renders this README from design/readme/source.md through respeak's technical mode and writes both sidecar lines. Edit the source, never the README.

Conventions shared with the sibling plugins

session, decisions, respeak and relay each work alone; a shared set of conventions keeps them consistent.

  • scripts/hygiene (the public-hygiene scan) and scripts/doclint (the doc gate) are byte-identical across the four repos.
  • The README sidecar: design/readme/rendered.sha256, two shasum -a 256 lines, written only by make readme.
  • The packet shape: six sections, a verify row that is the gate line, targeted acceptance lines.
  • Siblings are named by plugin id only.

Status

Status: version v2.0.0-alpha.19, rendered 2026-10-14, 1338 tests in the suite.

Credit

The two ideas this started from — reading the reset time out of Claude Code's statusline rate_limits payload, and lifting session_id from a headless run's JSON to emit a working claude --resume line — come from claude-nextsession by pcx-wave (MIT). The implementation here is new.

License

MIT

Source 1 files
hooks/session.ts 426 lines
1// The session plugin's hooks module (design/mod-r1.md §6 S1, packet M1): one read-only band
2// above the prompt. At session.start it runs `agentik-session mod start` (5 s), which writes
3// mod.json and prints the session's directory; the band reads meter.json and respawn.json
4// there and draws one line, and admission's band.json a second (packet S27b). It writes no
5// file, keeps no figure a decision reads, and needs no sibling plugin. The classic hooks in
6// hooks.json decide everything; this only shows.
7//
8// It is the plugin's one module (2.1.296 loads one per plugin and one unmatched hook per
9// event), and it carries admission's code too, under its own heading below (packet S25
10// ruling 1: the engine follows `$` into a function of this same file only, never across an
11// import, so admission's handlers cannot live in a sibling file). Its session.start and
12// turn.complete run the band's part and admission's, each part's failure caught so neither
13// skips the other or `next`; admission's other four hooks are registered once. Its one
14// session.measure handler runs the measure's part (packet M2, below admission's heading) and
15// admission's reading, each bounded and caught, after `next`.
16import type { EngineInterface, Register } from 'claude-code'
17
18const STALE_S = 120
19const EVERY_MS = 5000
20const MAX_TRIES = 24      // `mod start` retried while the dir is unknown: 2 min of ticks
21const NO_METER = 'session: no meter · agentik-session doctor'
22// agentik-r1 §6.3 c11 (packet ruling 7): the tool by its new name; when that is not on PATH
23// (the run rejects, not found), its old name, the alias until r2.
24const TOOL = 'agentik-session'
25const OLD_TOOL = 'claude-session'
26const notFound = (err: any): boolean => /ENOENT|not found/i.test(String(err?.message ?? err))
27async function runTool($: EngineInterface, argv: string[], opts: any): Promise<any> {
28  try {
29    return await $.process.run(argv, opts)
30  } catch (err) {
31    if (argv[0] !== TOOL || !notFound(err)) throw err
32    return await $.process.run([OLD_TOOL, ...argv.slice(1)], opts)
33  }
34}
35
36const clip = (line: string, columns: number): string =>
37  columns > 0 && line.length > columns ? line.slice(0, Math.max(1, columns - 1)) + '…' : line
38
39const pct = (n: unknown): string => `${Math.round(Number(n))}%`
40
41// Packet S34 ruling 6: a turn the model's rate limit ended (limited.json, the StopFailure
42// hook's) and no later turn end (turn-ended.json) names /model, in place of the zone word,
43// or of `no meter` before the first meter. The twin of mod.py's limit_note (one fixture in
44// session.test.ts pins both).
45export const LIMIT_NOTE = "limit: the model's limit ended the last turn; /model to switch"
46const stampAt = (text: string | null): number | null => {
47  try {
48    const at = text === null ? null : JSON.parse(text)?.at
49    return typeof at === 'number' && Number.isFinite(at) ? at : null
50  } catch { return null }
51}
52export const limitNote = (limited: string | null, turnEnded: string | null): string | null => {
53  let d: any = null
54  try { d = limited === null ? null : JSON.parse(limited) } catch { d = null }
55  if (!d || typeof d !== 'object' || Array.isArray(d) || d.error !== 'rate_limit') return null
56  const at = stampAt(limited)
57  const ended = stampAt(turnEnded)
58  return at === null || (ended !== null && at <= ended) ? null : LIMIT_NOTE
59}
60
61export const bandLine = (meter: string | null, respawn: boolean, nowMs: number,
62                         limited: string | null = null, turnEnded: string | null = null): string => {
63  let m: any = null
64  try { m = meter === null ? null : JSON.parse(meter) } catch { m = null }
65  const note = limitNote(limited, turnEnded)
66  if (!m || typeof m !== 'object' || m.zone === undefined) return note ? `session: ${note}` : NO_METER
67  let line = `session: ctx ${pct(m.pct_context)} · turns ${pct(m.pct_turns)} · ${note ?? m.zone}`
68  if (respawn) line += ' · respawn marked'
69  const age = Math.floor((nowMs - Date.parse(String(m.ts))) / 1000)
70  if (Number.isFinite(age) && age > STALE_S) line += ` · ${age}s old`
71  return line
72}
73
74// ---- Admission's line (design/admission-r1.md §3.5, packet S27b): it shows. ----
75// `agentik-session admission` rewrites <state>/admission/band.json inside the ledger's lock at
76// every record and admit; the band draws it under the session's line. This is the twin of
77// Python's band_line (one fixture in session.test.ts pins both): a part with no figure is
78// left out, `observe` is last while the mode is, and the age past 120 s follows it; the
79// reset reads `week resets HH:MM (h:mm), 5h HH:MM` when band.json's bound is the seven-day
80// one: `until` the weekly reset, resetsAt the five-hour one.
81const ISO = /^\d{4}-\d{2}-\d{2}T(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/
82const fig = (v: unknown): number | null => typeof v === 'number' && Number.isFinite(v) ? v : null
83const whole = (n: number): string => String(Math.round(n))
84
85// A band time, as written: HH and MM from the string's own digits (band.json's resetsAt is
86// local with its offset), and its instant; null unless a strict zoned ISO string.
87const wall = (v: unknown): [string, string, number] | null => {
88  const m = typeof v === 'string' ? ISO.exec(v) : null
89  const t = m ? Date.parse(String(v)) : NaN
90  return m && Number.isFinite(t) ? [m[1], m[2], t] : null
91}
92
93// band.json beside the sessions: `<state>/sessions/<id>` is what `mod start` prints.
94const bandPath = (d: string): string => {
95  const m = /^(.*)\/sessions\/[^/]+\/?$/.exec(d)
96  return m ? `${m[1]}/admission/band.json` : ''
97}
98
99// The band this session draws (packet S35 ruling 5), the twin of Python's session_band:
100// band.json's accounts[sessions[id]] when both are there and it is an object; else the top
101// level, which is all a band.json from before S35, or with one account, holds.
102const own = (o: any, k: unknown): any =>
103  o && typeof o === 'object' && !Array.isArray(o) && typeof k === 'string'
104    && Object.prototype.hasOwnProperty.call(o, k) ? o[k] : undefined
105export const sessionBand = (b: any, id: string): any => {
106  const a = own(b.accounts, own(b.sessions, id))
107  return a && typeof a === 'object' && !Array.isArray(a) ? a : b
108}
109
110export const admissionLine = (band: string | null, nowMs: number, id = ''): string | null => {
111  let b: any = null
112  try { b = band === null ? null : JSON.parse(band) } catch { b = null }
113  if (!b || typeof b !== 'object' || Array.isArray(b)) return null
114  b = sessionBand(b, id)
115  const P = fig(b.P), peak = fig(b.peak), c = fig(b.ceiling)
116  const parts = [P === null ? '5h no reading' : `5h ${whole(P)}%` + (peak !== null ? ` → ${whole(peak)}%` : '')]
117  if (c !== null) parts.push(`ceiling ${String(c)}`)
118  const w = wall(b.resetsAt), u = wall(b.until)
119  const left = (x: [string, string, number]): string => {
120    const n = Math.max(0, Math.floor((x[2] - nowMs) / 60000))
121    return `(${Math.floor(n / 60)}h${String(n % 60).padStart(2, '0')})`
122  }
123  if (b.bound === 'seven_day' && u) parts.push(`week resets ${u[0]}:${u[1]} ${left(u)}` + (w ? `, 5h ${w[0]}:${w[1]}` : ''))
124  else if (w) parts.push(`resets ${w[0]}:${w[1]} ${left(w)}`)
125  const running = fig(b.running), room = fig(b.room)
126  const helpers: string[] = []
127  if (running !== null) helpers.push(`${whole(running)} ${whole(running) === '1' ? 'helper' : 'helpers'}`)
128  if (room !== null) helpers.push(`room for ${whole(room)}`)
129  if (helpers.length) parts.push(helpers.join(', '))
130  const d = fig(b.deferred), p7 = fig(b.P7)
131  if (d !== null) parts.push(`${whole(d)} deferred`)
132  if (p7 !== null) parts.push(`7d ${whole(p7)}%`)
133  if (b.mode === 'observe') parts.push('observe')
134  const a = wall(b.at)
135  const age = a ? Math.floor((nowMs - a[2]) / 1000) : NaN
136  if (Number.isFinite(age) && age > STALE_S) parts.push(`${age}s old`)
137  return parts.join(' · ')
138}
139
140// ---- Admission (design/admission-r1.md §3.1, packet S25): it observes. ----
141// Six hooks hand what they see to `agentik-session admission record` through runTool
142// (5 s), which keeps one line per event on the machine's admission ledger; the policy lives
143// in the tool, this is glue. They never refuse and never rewrite: every hook returns what
144// `next` settled to, a failed record is one debug line, and a spawn's prompt and a
145// message's text are never sent. They write no file and need no sibling plugin.
146const LIMIT_MS = 5000
147const BOUND_MS = 1000
148const DEBUG = { to: 'debug' as const }
149const LATE = Symbol('late')
150
151const str = (v: unknown): string | null => (typeof v === 'string' ? v : null)
152const bool = (v: unknown): boolean | null => (typeof v === 'boolean' ? v : null)
153const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
154const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : [])
155// One reading per window, by the fields the types name (SessionRateLimit), read defensively.
156const limits = (v: unknown): unknown[] =>
157  list(v).map((r: any) => ({ kind: str(r?.kind), percentUsed: num(r?.percentUsed),
158    resetsAt: str(r?.resetsAt) }))
159
160const admissionArgv = (id: unknown): string[] =>
161  [TOOL, 'admission', 'record', '--session', String(id)]
162const exited = (fields: Record<string, unknown>, r: any): string | null =>
163  Number(r?.exitCode) === 0 ? null
164    : `admission: record ${fields.kind}: exit ${r?.exitCode}: ${String(r?.stderr ?? '').trim()}`.slice(0, 300)
165const failed = (fields: Record<string, unknown>, err: any): string =>
166  `admission: record ${fields.kind}: ${String(err?.message ?? err)}`.slice(0, 300)
167
168// A host call raced against a short wait (fix round 1, F2): one that never answers leaves
169// LATE after BOUND_MS, never a hung hook. A refused clock leaves the call unbounded.
170async function within<T>($: EngineInterface, call: () => Promise<T>,
171  ms = BOUND_MS): Promise<T | typeof LATE> {
172  let wait: Promise<typeof LATE>
173  try { wait = $.clock.sleep(ms).then(() => LATE, () => new Promise<typeof LATE>(() => {})) }
174  catch { wait = new Promise<typeof LATE>(() => {}) }
175  return Promise.race([(async () => call())(), wait])
176}
177
178// One record call, under `id` or the session's own id; it never throws: a non-zero exit, a
179// rejection or a timeout is one debug line, and the id read and the log are each bounded.
180async function record($: EngineInterface, fields: Record<string, unknown>, id: string | null = null,
181  timeoutMs = LIMIT_MS): Promise<void> {
182  try {
183    const who = id ?? await within($, () => $.session.id())
184    if (who === LATE) throw new Error(`no session id in ${BOUND_MS} ms`)
185    const r = await runTool($, admissionArgv(who), { stdin: JSON.stringify(fields), timeoutMs })
186    const line = exited(fields, r)
187    if (line) await within($, () => $.ui.log(line, DEBUG))
188  } catch (err) { try { await within($, () => $.ui.log(failed(fields, err), DEBUG)) } catch { /* nothing */ } }
189}
190
191// session.start's part, after `next`: `start`, and whether the session is interactive.
192async function admitStart($: EngineInterface, e: unknown): Promise<void> {
193  await record($, { kind: 'start', interactive: bool((e as any)?.isInteractive) })
194}
195
196// turn.complete's part, after `next`: a helper's turn (it carries agentId) is `done`.
197async function admitDone($: EngineInterface, e: unknown): Promise<void> {
198  const x: any = e
199  if (!str(x?.agentId)) return
200  await record($, { kind: 'done', agentId: str(x.agentId), durationMs: num(x.durationMs),
201    usage: x.usage && typeof x.usage === 'object' ? x.usage : null })
202}
203
204// The verdict, observed (design/admission-r1.md §3.3, packet S26 ruling 6): before `next`,
205// `agentik-session admission admit` decides the spawn or the resume and appends its verdict
206// line. In observe the answer is one debug line and `next(e)` always follows, whatever it
207// says: a refusal, an exit, a rejection or a late answer is a debug line too. The whole call
208// (the id read, the run and the answer's log) is raced against ADMIT_MS, the lock waiting at
209// most 2 s, so the usage read, admit and a late line keep `next` within about 5 s (fix round
210// 1, F4). S27a makes it refuse.
211const ADMIT_MS = 3000
212const admitArgv = (id: unknown): string[] =>
213  [TOOL, 'admission', 'admit', '--session', String(id)]
214
215async function admitCall($: EngineInterface, fields: Record<string, unknown>,
216  say: (line: string) => Promise<void>): Promise<void> {
217  const who = await within($, () => $.session.id())
218  if (who === LATE) throw new Error(`no session id in ${BOUND_MS} ms`)
219  const r: any = await runTool($, admitArgv(who), { stdin: JSON.stringify(fields), timeoutMs: ADMIT_MS })
220  if (Number(r?.exitCode) !== 0) {
221    await say(`admission: admit: exit ${r?.exitCode}: ${String(r?.stderr ?? '').trim()}`)
222    return
223  }
224  let a: any = null
225  try { a = JSON.parse(String(r?.stdout ?? '')) } catch { a = null }
226  const text = str(a?.text)
227  await say(`admission: admit ${str(a?.verdict) ?? 'unreadable'} (${str(a?.mode) ?? '?'}, peak `
228    + `${num(a?.peak) ?? '-'})${text ? `: ${text}` : ''}`)
229}
230
231async function admit($: EngineInterface, fields: Record<string, unknown>): Promise<void> {
232  const say = async (line: string): Promise<void> => {
233    try { await within($, () => $.ui.log(line.slice(0, 300), DEBUG)) } catch { /* nothing */ }
234  }
235  try {
236    const r = await within($, () => admitCall($, fields, say), ADMIT_MS)
237    if (r === LATE) await say(`admission: admit: no answer in ${ADMIT_MS} ms`)
238  } catch (err: any) { await say(`admission: admit: ${String(err?.message ?? err)}`) }
239}
240
241// The usage read before `next`, bounded: a late or refused read is no reading.
242async function usageNow($: EngineInterface): Promise<any> {
243  try { const u = await within($, () => $.session.usage()); return u === LATE ? null : u } catch { return null }
244}
245
246// ---- The measure (design/mod-r1.md §2 row M2, §6 S2; packet M2): it records. ----
247// Every session.measure hands the engine's figures to `agentik-session measure record`
248// (5 s), which writes measure.json beside meter.json; `status` and `doctor` print the drift.
249// Nothing that decides reads it. Only the four figures are sent, never a message's text.
250const measureArgv = (id: unknown): string[] =>
251  [TOOL, 'measure', 'record', '--session', String(id)]
252
253const measureFields = (e: unknown): Record<string, unknown> => {
254  const x: any = e
255  const c: any = x?.context && typeof x.context === 'object' ? x.context : null
256  return {
257    context: c ? { tokens: num(c.tokens), window: num(c.window), percent: num(c.percent) } : null,
258    rateLimits: limits(x?.rateLimits),
259    cost: x?.cost && typeof x.cost === 'object' ? { usd: num(x.cost.usd) } : null,
260    changed: list(x?.changed).map(str),
261  }
262}
263
264// One measure call; it never throws: a non-zero exit, a rejection or a late answer is one
265// debug line, and the id read, the call and the log are each bounded.
266async function measured($: EngineInterface, e: unknown): Promise<void> {
267  const say = async (line: string): Promise<void> => {
268    try { await within($, () => $.ui.log(line.slice(0, 300), DEBUG)) } catch { /* nothing */ }
269  }
270  try {
271    const who = await within($, () => $.session.id())
272    if (who === LATE) throw new Error(`no session id in ${BOUND_MS} ms`)
273    const r: any = await within($, () => runTool($, measureArgv(who),
274      { stdin: JSON.stringify(measureFields(e)), timeoutMs: LIMIT_MS }), LIMIT_MS + BOUND_MS)
275    if (r === LATE) throw new Error(`no answer in ${LIMIT_MS + BOUND_MS} ms`)
276    if (Number(r?.exitCode) !== 0) await say(`measure: record: exit ${r?.exitCode}: ${String(r?.stderr ?? '').trim()}`)
277  } catch (err: any) { await say(`measure: record: ${String(err?.message ?? err)}`) }
278}
279
280// ---- The band ----
281// The session's id and directory live here, not in $.state (no declared state contract);
282// session.start sets them afresh, at a session's start and at every reload of the module.
283let sid = ''
284let dir = ''
285let tries = 0
286
287export const register: Register = on => {
288  on('session.start', async ($, e, next) => {
289    const r = await next(e)
290    dir = ''
291    tries = 0
292    try {
293      try { sid = String(await $.session.id()) } catch { sid = '' }
294      // The classic SessionStart hook may make the session dir after this hook has run (fix
295      // round 1): a failed `mod start` leaves dir unknown, and each 5 s tick tries again.
296      const start = async (): Promise<void> => {
297        if (dir || !sid || tries >= MAX_TRIES) return
298        tries += 1
299        try {
300          const p: any = await runTool($, [TOOL, 'mod', 'start', '--session', sid],
301            { timeoutMs: EVERY_MS })
302          dir = String(JSON.parse(String(p?.stdout ?? '')).dir ?? '')
303        } catch { dir = '' }
304      }
305      await start()
306      await $.ui.invalidate('ui.render')
307      void (async () => {
308        for (;;) { await $.clock.sleep(EVERY_MS); await start(); await $.ui.invalidate('ui.render') }
309      })()
310    } catch { /* the band's failure skips neither admission's part nor the answer */ }
311    try { await admitStart($, e) } catch { /* admission never throws; nor does this */ }
312    return r
313  })
314  on('turn.complete', async ($, e, next) => {
315    const r = await next(e)
316    try {
317      if (!dir && sid && tries < MAX_TRIES) {
318        tries += 1
319        try {
320          const p: any = await runTool($, [TOOL, 'mod', 'start', '--session', sid],
321            { timeoutMs: EVERY_MS })
322          dir = String(JSON.parse(String(p?.stdout ?? '')).dir ?? '')
323        } catch { dir = '' }
324      }
325      await $.ui.invalidate('ui.render')
326    } catch { /* the band's failure skips neither admission's part nor the answer */ }
327    try { await admitDone($, e) } catch { /* admission never throws; nor does this */ }
328    return r
329  })
330  // Two lines, the session's and admission's, in one column (packet S27b ruling 3). While a
331  // survey holds the band it draws neither and gives what `next` gives; otherwise it adds
332  // to that: another mod's tree is kept under the two, and only core's own (an `engine`
333  // ref, which draws nothing here without a survey) is left out, as M1's band left it.
334  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
335    const props: any = (e as any).props ?? {}
336    if (props.hasSurvey === true) return next(e)
337    let below: any = null
338    try { below = await next(e) } catch { below = null }
339    const columns = Number(props.bodyColumns ?? 0)
340    const rows: string[] = []
341    if (!dir) rows.push(NO_METER)
342    else {
343      let meter: string | null = null
344      let respawn = false
345      let band: string | null = null
346      try { meter = String(await $.fs.read(`${dir}/meter.json`)) } catch { meter = null }
347      try { await $.fs.read(`${dir}/respawn.json`); respawn = true } catch { respawn = false }
348      let limited: string | null = null
349      let ended: string | null = null
350      try { limited = String(await $.fs.read(`${dir}/limited.json`)) } catch { limited = null }
351      if (limited !== null) {
352        try { ended = String(await $.fs.read(`${dir}/turn-ended.json`)) } catch { ended = null }
353      }
354      const at = bandPath(dir)
355      if (at) { try { band = String(await $.fs.read(at)) } catch { band = null } }
356      const now = Number(await $.clock.now())
357      rows.push(bandLine(meter, respawn, now, limited, ended))
358      const line = admissionLine(band, now, /\/sessions\/([^/]+)\/?$/.exec(dir)?.[1] ?? '')
359      if (line !== null) rows.push(line)
360    }
361    const kids: any[] = rows.map(l => ({ type: 'Text', props: {}, children: [clip(l, columns)] }))
362    if (below && typeof below === 'object' && below.type !== 'engine') kids.push(below)
363    return { type: 'Box', props: { flexDirection: 'column' }, children: kids }
364  })
365
366  // ---- Admission's other four hooks (above: its part of session.start and turn.complete) ----
367  // session.measure carries the measure's part too (packet M2): every measure is recorded,
368  // and admission's reading only when a window moved. The two run side by side after `next`,
369  // each raced against its bound and caught, so neither waits on or skips the other.
370  on('session.measure', async ($, e, next) => {
371    const r = await next(e)
372    const parts: Array<() => Promise<void>> = [() => measured($, e)]
373    if (list((e as any)?.changed).includes('rateLimits')) {
374      parts.push(() => record($, { kind: 'reading', rateLimits: limits((e as any).rateLimits) }))
375    }
376    await Promise.all(parts.map(async part => {
377      try { await within($, part, LIMIT_MS + 2 * BOUND_MS) } catch { /* each part never throws */ }
378    }))
379    return r
380  })
381  // agentId comes from the spawn's result, so the spawn is recorded after `next`; a usage
382  // read past its bound records the spawn without readings. A spawn refused beneath started
383  // nothing (next's answer has no model), so it is not recorded (fix round 1, F1).
384  // Packet S26: the spawn is admitted (observed) before `next`, from the same usage read.
385  on('agent.spawn', async ($, e, next) => {
386    const x: any = e
387    const usage: any = await usageNow($)
388    await admit($, {
389      subagentType: str(x.subagentType), model: str(x.model), parentModel: str(x.parentModel),
390      background: bool(x.background), fork: bool(x.fork), tool_use_id: str(x.tool_use_id),
391      rateLimits: limits(usage?.rateLimits), resume: false,
392    })
393    const r: any = await next(e)
394    if (str(r?.model) === null) return r
395    await record($, {
396      kind: 'spawn', subagentType: str(x.subagentType), model: str(x.model),
397      parentModel: str(x.parentModel), background: bool(x.background), fork: bool(x.fork),
398      description: str(x.description), tool_use_id: str(x.tool_use_id), agentId: str(r?.agentId),
399      context: usage?.context ? { tokens: num(usage.context.tokens),
400        window: num(usage.context.window), percent: num(usage.context.percent) } : null,
401      rateLimits: limits(usage?.rateLimits),
402    })
403    return r
404  }).catch(($, e, next) => next(e))
405  // A resume reaches a helper through SendMessage and raises no agent.spawn: its target only.
406  // Packet S26: it is admitted (observed) before `next` with `resume: true`, the call's id and
407  // the window's readings only (the message is never sent).
408  on('tool.call', { tool: 'SendMessage' }, async ($, e, next) => {
409    const usage: any = await usageNow($)
410    await admit($, { subagentType: null, model: null, parentModel: null, background: null,
411      fork: null, tool_use_id: str((e as any).tool_use_id), rateLimits: limits(usage?.rateLimits),
412      resume: true })
413    const r = await next(e)
414    await record($, { kind: 'resume', to: str((e as any).to) })
415    return r
416  }).catch(($, e, next) => next(e))
417  // The end runs first, inside session.end's one short bound, under the ending session's
418  // id (a /clear goes on under another); the tool sweeps its open spawns in the same lock.
419  on('session.end', async ($, e, next) => {
420    const left = Number((next as any).budget?.remainingMs)
421    const ms = Number.isFinite(left) && left > 0 ? Math.min(LIMIT_MS, left) : LIMIT_MS
422    await record($, { kind: 'end', reason: str((e as any).reason) }, str((e as any).sessionId), ms)
423    return next(e)
424  })
425}
426