Opt-in chela Mod (Claude Code >= 2.1.301): chela's status line via $.ui.status, a "needs you" band in the orchestrator session, classifier denials posted…

A Claude Code Mod (Claude Code ≥ 2.1.301) that sits on top of the shell-hook plugin in plugin/. plugin/ stays the baseline: it works on every Claude Code chela supports and in org-managed installs. chela never installs this Mod. You opt in by loading it.
claude --plugin-dir /path/to/chelamux/mods/chela-hooks
The Mod draws chela's status line under the prompt through $.ui.status:
ctx ▰▰▰▰▱▱▱▱▱▱ 42% · @7 · orchestrator · run CMX-9 (judge)
| Field | Source | Left out when |
|---|---|---|
| context bar | $.session.usage(), the figures Claude Code's own statusLine gets | the session has no reading yet (fresh, or just compacted) |
| window id | $CHELA_WID | not a chela window |
orchestrator | $CHELA_ORCHESTRATOR_WID, else inbox.json's orchestrator (what chela watch records) | another window is the orchestrator, or none is recorded |
run <id> (<role>) | $CHELA_RUN_ID / $CHELA_RUN_ROLE, which the dispatcher exports | not a dispatched run |
It redraws at session start, after every turn and after a compaction. Each redraw also pipes the same figures, in the statusLine payload's shape, to scripts/cache-statusline.sh. That is the script chela install-statusline wires into settings.json, so the dashboard's context bar and rate-limit pills read the same file either way, and no settings.json edit is needed while the Mod is loaded. tests/test_mods_status.py proves that a Mod-written cache reads the same as the native statusLine's.
Version gate. Claude Code before 2.1.301 has no Mods and loads nothing from this folder. The Mod also checks $.session.version() and draws nothing below 2.1.301. Default. Without the Mod, chela install-statusline is unchanged and remains the documented path. Running both is harmless: they write the same figures to the same file.
Turn it off without unloading the Mod with the statusLine option (/config, or pluginConfigs in settings). Off, it draws nothing and writes no cache.
In the orchestrator session only (the window chela watching names), the Mod draws one line above the prompt with what is waiting on you: pending override approvals, pending access requests and runs in needs_human. It shows the counts and the oldest item, each a link to where the dashboard shows it (an approval's /override/<id> page, else the dashboard):
needs you: 1 approval · 1 access request · 1 run in needs_human · oldest: access request mount /data (read-only) (20m)
It reads the daemon's GET /api/needs-you on 127.0.0.1:$CHELA_DASHBOARD_PORT (default
in Telegram, the dashboard or a plain terminal.
| Case | Band |
|---|---|
| the orchestrator session, something waiting | counts plus the oldest |
| nothing waiting | none |
| any other session (another window is the orchestrator, none recorded, not a chela window) | none, and the daemon is never asked |
| the daemon is down, answers 503 (a store it cannot read) or anything but the documented shape | none |
| the last good reading is more than 30 s old (the daemon hangs) | none: a count is never shown stale |
| Claude Code below 2.1.301 | none |
The orchestrator is found as the status line finds it: $CHELA_ORCHESTRATOR_WID, else inbox.json's orchestrator. The fixture both sides test against is hooks/needsyou-cases.ts (tests/test_mods_needsyou.py runs it through the real route).
When Claude Code's permission classifier refuses a tool call (classic.PermissionDenied), the Mod POSTs the denial straight to the daemon's inbox, so the orchestrator hears about a dispatched agent stopped on it without the inbox tailing the event log (CMX-155's path):
POST http://127.0.0.1:$CHELA_DASHBOARD_PORT/api/inbox/denial
{"wid": "@41", "session_id": "…", "tool_name": "Bash", "tool_input": {…},
"tool_use_id": "toolu_…", "reason": "<one line, at most 200 characters>"}
The daemon files it under CMX-155's rules: only a claimed/running run's window, never the orchestrator's own, one notice per run per 10 min.
One denial, one notice. plugin/'s shell hook still logs the same denial, and the inbox still reads that copy. Both copies carry the denial's tool_use_id, and the daemon files a given id once (remembered for an hour), even when the second copy arrives after the per-run cooldown has run out. A post with no tool_use_id or no window is refused (400): without the key it could not be told from the shell copy, which still arrives.
| Case | What happens |
|---|---|
a chela window ($CHELA_WID set) | one POST per denial, not awaited |
| not a chela window | nothing is posted |
| the daemon is down or answers an error | the post is dropped; the tool call and the denial are unaffected, and the shell copy still reaches the inbox |
--permission-mode dontAsk | Claude Code fires no PermissionDenied, in either layer, so neither reports it |
The fixture both sides test against is hooks/denial-cases.ts (tests/test_mods_denial.py files it through the real route).
The merge gate and event posting, measured side by side with plugin/ in docs/MODS_SPIKE.md. The gate runs whenever the Mod is loaded: it runs the same mergegate.py as plugin/, so the two always agree. Events are posted only when CHELA_MOD_HOOKS_URL is set. In a chela pane plugin/ already posts every event, and a default URL would file each one twice.
| Variable | Default | Meaning |
|---|---|---|
CHELA_WID | unset ⇒ nothing is posted | the window the events are filed under (the gate runs regardless) |
CHELA_MOD_HOOKS_URL | unset ⇒ nothing is posted | where POST /hooks/<Event> goes, e.g. http://127.0.0.1:5001 |
CHELA_MOD_MERGEGATE | <mod>/../../plugin/hooks/mergegate.py | the gate script it runs |
CHELA_MOD_STATUSLINE | <mod>/../../scripts/cache-statusline.sh | the cache writer the status line pipes to |
CHELA_DASHBOARD_PORT | 5001 | the port the needs-you band asks the daemon on, and the denial post goes to |
Tests: claude plugin validate mods/chela-hooks && claude plugin test mods/chela-hooks (also run by tests/test_mods_spike.py when a Mods-capable claude is on PATH).
hooks/register.ts 103 lines1// SPIKE (CMX-157): chela's in-session hook layer as a Claude Code Mod — the merge gate
2// and event posting, side by side with the shell-hook plugin in `plugin/`. Nothing here
3// replaces that plugin; docs/MODS_SPIKE.md holds the measurements and the go/no-go.
4//
5// * Event posting: the same POST `/hooks/<Event>` with the same body the shell plugin's
6// curl sends (the settings hook's stdin), so `chela.hooks.ingest` reads it unchanged.
7// It goes through `$.http.fetch` — no process per event — and is NOT awaited: the agent
8// never waits on the daemon. Gated on `$CHELA_WID`, as CMX-319 gates the shell hooks.
9// * Merge gate: the decision stays in `plugin/hooks/mergegate.py` (one parser, not two);
10// this hook feeds it the same payload and relays its deny reason verbatim. Like the
11// shell hook it fails OPEN when the gate cannot decide.
12import type { Register } from 'claude-code'
13import { refresh } from './status'
14import { band as needsBand, start as needsStart } from './needsyou'
15import { report as reportDenial } from './denial'
16
17const RESERVED = new Set(['tool', 'tool_use_id', 'consent', 'agentId', 'requestMeta'])
18
19type Body = Record<string, unknown>
20
21// `classic.PreToolUse` carries no base fields; the last transcript path any other event
22// carried stands in (ingest falls back to the session id when it is empty).
23let transcript = ''
24
25async function post($: any, event: string, body: Body, extra: Record<string, string> = {}) {
26 // CMX-181: opt-in by URL. In a chela pane plugin/ already posts: a default would file each twice.
27 const wid = await $.env.get('CHELA_WID')
28 const base = await $.env.get('CHELA_MOD_HOOKS_URL')
29 if (!wid || !base) return
30 const headers: Record<string, string> = {
31 'Content-Type': 'application/json', 'X-Chela-Mod-T0': String(Date.now()), ...extra,
32 }
33 if (event === 'SessionStart') headers['X-Chela-Wid'] = wid
34 await $.http.fetch(`${base}/hooks/${event}`, {
35 method: 'POST', headers, body: JSON.stringify(body),
36 }).catch(() => undefined) // a dead daemon is a lost event, never a stall
37}
38
39// The classic events hand over the settings hook's own stdin, so the body is `e` as is.
40async function relay($: any, e: any) {
41 if (typeof e.transcript_path === 'string' && e.transcript_path) transcript = e.transcript_path
42 await post($, String(e.hook_event_name), e as Body)
43}
44
45// The deny reason mergegate.py printed, or '' to allow. Anything it cannot decide — the
46// script missing, a crash, a timeout, output that is not its deny shape — allows, exactly
47// as the shell hook's `|| true` does.
48async function gate($: any, payload: Body): Promise<string> {
49 const script = (await $.env.get('CHELA_MOD_MERGEGATE'))
50 || `${$.plugin.root}/../../plugin/hooks/mergegate.py`
51 try {
52 const ran = await $.process.run(['python3', '-I', '-S', script],
53 { stdin: JSON.stringify(payload), timeoutMs: 5000 })
54 if (ran.exitCode !== 0 || !ran.stdout.trim()) return ''
55 const out = JSON.parse(ran.stdout).hookSpecificOutput
56 return out?.permissionDecision === 'deny' ? String(out.permissionDecisionReason ?? '') : ''
57 } catch {
58 return ''
59 }
60}
61
62export const register: Register = (on, options) => {
63 transcript = ''
64 // the status line (status.ts): drawn at start, after every turn and after a compaction
65 on('session.start', ($, e, next) => { void refresh($, options); void needsStart($); return next(e) })
66 on('turn.complete', ($, e, next) => { void refresh($, options); return next(e) })
67 on('session.compact', async ($, e, next) => {
68 const done = await next(e)
69 void refresh($, options)
70 return done
71 })
72 // the "needs you" band (needsyou.tsx), orchestrator session only, polled from the daemon
73 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => needsBand($, e, next))
74 on('classic.SessionStart', ($, e, next) => { void relay($, e); return next(e) })
75 on('classic.SessionEnd', ($, e, next) => { void relay($, e); return next(e) })
76 on('classic.Stop', ($, e, next) => { void relay($, e); return next(e) })
77 on('classic.PostToolUse', ($, e, next) => { void relay($, e); return next(e) })
78 // Mod 4/4 (denial.ts): the denial goes to the inbox directly, besides the event post
79 on('classic.PermissionDenied', ($, e, next) => {
80 void relay($, e)
81 void reportDenial($, e)
82 return next(e)
83 })
84
85 // PreToolUse rides `tool.call`, not `classic.PreToolUse`: the classic event fires BENEATH
86 // every tool.call hook, so a call the gate denies would never be posted — the shell
87 // plugin posts it (its ingestion hook and gate run side by side).
88 on('tool.call', async ($, e, next) => {
89 const t0 = Date.now()
90 const tool_input: Body = {}
91 for (const [k, v] of Object.entries(e)) if (!RESERVED.has(k)) tool_input[k] = v
92 const body: Body = {
93 session_id: await $.session.id(), transcript_path: transcript,
94 cwd: await $.session.cwd(), hook_event_name: 'PreToolUse',
95 tool_name: e.tool, tool_input, tool_use_id: e.tool_use_id ?? '',
96 }
97 const reason = e.tool === 'Bash' ? await gate($, body) : ''
98 // what this hook cost the agent before the tool ran — the number MODS_SPIKE.md compares
99 void post($, 'PreToolUse', body, { 'X-Chela-Mod-Pre-Ms': String(Date.now() - t0) })
100 return reason ? { deny: reason } : next(e)
101 })
102}
103hooks/status.ts 78 lines1// Mod 1/4 (CMX-181): chela's status line through `$.ui.status` (context bar, window id,
2// orchestrator / run marker), plus the per-turn cache write `chela install-statusline`
3// wires through settings.json: `$.session.usage()` (the status line's own figures) reshaped
4// into the statusLine payload and piped to the SAME scripts/cache-statusline.sh.
5// register.ts hooks it, since the validator wants each `on()` there, literal.
6import type { SessionUsage } from 'claude-code'
7
8// The first Claude Code with Mods. An older or unreadable version draws nothing.
9export function atFloor(base: string | undefined): boolean {
10 const m = /^(\d+)\.(\d+)\.(\d+)/.exec(base ?? '')
11 return !!m && Number(m[1]) * 1e8 + Number(m[2]) * 1e4 + Number(m[3]) >= 2 * 1e8 + 1 * 1e4 + 301
12}
13
14export function bar(percent: number): string {
15 const full = Math.max(0, Math.min(10, Math.round(percent / 10)))
16 return '▰'.repeat(full) + '▱'.repeat(10 - full)
17}
18
19// A field the session does not have is left out, never drawn as "undefined".
20export function statusText(f: { percent?: number, wid?: string, orchestrator?: boolean,
21 run?: string, role?: string }): string | undefined {
22 const parts: string[] = []
23 if (typeof f.percent === 'number') parts.push(`ctx ${bar(f.percent)} ${Math.round(f.percent)}%`)
24 if (f.wid) parts.push(f.wid)
25 if (f.orchestrator) parts.push('orchestrator')
26 if (f.run) parts.push(f.role ? `run ${f.run} (${f.role})` : `run ${f.run}`)
27 return parts.length ? parts.join(' · ') : undefined
28}
29
30// The statusLine payload chela.context reads (`_parse_cache_file`), built from `u`.
31export function cachePayload(session_id: string, cwd: string, model: string, u: SessionUsage) {
32 const ctx: Record<string, number> = { context_window_size: u.context.window }
33 if (typeof u.context.tokens === 'number') ctx.total_input_tokens = u.context.tokens
34 if (typeof u.context.percent === 'number') {
35 ctx.used_percentage = u.context.percent
36 ctx.remaining_percentage = 100 - u.context.percent
37 }
38 const rate_limits: Record<string, Record<string, number>> = {}
39 for (const r of u.rateLimits) {
40 const at = Date.parse(r.resetsAt ?? '')
41 rate_limits[r.kind] = Number.isNaN(at) ? { used_percentage: r.percentUsed }
42 : { used_percentage: r.percentUsed, resets_at: Math.floor(at / 1000) }
43 }
44 return { session_id, cwd, workspace: { current_dir: cwd }, model: { id: model, display_name: model },
45 context_window: ctx, rate_limits, ...(u.cost ? { cost: { total_cost_usd: u.cost.usd } } : {}) }
46}
47
48// The env pin, else the window `chela watch` recorded in inbox.json, as
49// chela.inbox.orchestrator_wid reads it. Unreadable reads as no orchestrator.
50export async function orchestratorWid($: any): Promise<string | undefined> {
51 const pin = ((await $.env.get('CHELA_ORCHESTRATOR_WID')) || '').trim()
52 if (pin) return pin
53 const dir = (await $.env.get('CHELA_DIR')) || `${await $.env.get('HOME')}/.chela`
54 try {
55 // a non-string never equals the string window id it is compared with
56 return JSON.parse(await $.fs.read((await $.env.get('CHELA_INBOX_FILE')) || `${dir}/inbox.json`))?.orchestrator
57 } catch {
58 return undefined
59 }
60}
61
62export async function refresh($: any, options: Record<string, unknown>) {
63 if (options.statusLine === false || !atFloor((await $.session.version()).base)) {
64 $.ui.status(undefined)
65 return
66 }
67 const usage: SessionUsage = await $.session.usage()
68 const wid = (await $.env.get('CHELA_WID')) || undefined
69 const orch = wid ? await orchestratorWid($) : undefined
70 $.ui.status(statusText({ percent: usage.context.percent, wid, orchestrator: !!wid && wid === orch,
71 run: (await $.env.get('CHELA_RUN_ID')) || undefined, role: (await $.env.get('CHELA_RUN_ROLE')) || undefined }))
72 const script = (await $.env.get('CHELA_MOD_STATUSLINE')) || `${$.plugin.root}/../../scripts/cache-statusline.sh`
73 const payload = cachePayload(await $.session.id(), await $.session.cwd(), await $.session.model(), usage)
74 // The script logs its own failures to $CHELA_DIR/statusline-errors.log (CMX-30).
75 await $.process.run(['bash', script], { stdin: JSON.stringify(payload), timeoutMs: 10000 })
76 .catch(() => undefined)
77}
78hooks/needsyou.tsx 130 lines1// Mod 3/4 (CMX-183): what is waiting on the human, as one band above the prompt of the
2// orchestrator session only (the window `chela watching` names): pending override approvals,
3// pending access requests and runs in needs_human. Counts plus the oldest item, each a link
4// to where the dashboard shows it. Read-only: approving stays in Telegram, the dashboard or
5// a plain terminal.
6//
7// A timer polls the daemon's GET /api/needs-you every 10 s; no tool call waits on it. A
8// failed or malformed read clears the band, and a reading older than 30 s is never drawn,
9// so a dead or hung daemon shows nothing rather than a stale count.
10// register.ts hooks it, since the validator wants each `on()` there, literal.
11import { atom, read, update } from 'claude-code'
12import type { NeedsYou, NeedsYouItem } from '../types'
13import { atFloor, orchestratorWid } from './status'
14
15export const POLL_MS = 10000
16export const STALE_MS = 30000
17const KINDS = ['approval', 'access', 'run'] as const
18const NOUN: Record<NeedsYouItem['kind'], [string, string]> = {
19 approval: ['approval', 'approvals'],
20 access: ['access request', 'access requests'],
21 run: ['run in needs_human', 'runs in needs_human'],
22}
23
24export const needsYou = atom({ plugin: 'chela-hooks-mod', key: 'needsYou' } as const, null as NeedsYou)
25
26// chela.config.dashboard_port: CHELA_DASHBOARD_PORT, else 5001 (and 5001 when it is not a number).
27export function dashboardPort(raw: string | undefined): string {
28 const v = (raw ?? '').trim()
29 return /^\d+$/.test(v) ? v : '5001'
30}
31
32// The daemon's answer, or null when it is not the documented shape: unknown is never "none".
33export function parseItems(text: string): NeedsYouItem[] | null {
34 try {
35 const items = JSON.parse(text)?.items
36 // not an array (absent, an object, a string): `.every` throws, and the catch answers null
37 const ok = items.every((i: any) => i && (KINDS as readonly string[]).includes(i.kind)
38 && typeof i.label === 'string' && typeof i.url === 'string' && Number.isFinite(i.since))
39 return ok ? items : null
40 } catch {
41 return null
42 }
43}
44
45// The item waiting longest; ties go to the first listed.
46export function oldestOf(items: NeedsYouItem[]): NeedsYouItem | undefined {
47 return items.reduce<NeedsYouItem | undefined>((a, b) => (!a || b.since < a.since ? b : a), undefined)
48}
49
50export function age(seconds: number): string {
51 const s = Math.max(0, Math.floor(seconds))
52 if (s < 3600) return `${Math.floor(s / 60)}m`
53 if (s < 86400) return `${Math.floor(s / 3600)}h`
54 return `${Math.floor(s / 86400)}d`
55}
56
57async function isOrchestrator($: any): Promise<boolean> {
58 const wid = (await $.env.get('CHELA_WID')) || ''
59 return !!wid && wid === await orchestratorWid($)
60}
61
62// One poll: the reading it gets, or null for anything that is not a fresh, readable answer.
63export async function poll($: any, current: () => boolean = () => true) {
64 let next: NeedsYou = null
65 try {
66 if (atFloor((await $.session.version()).base) && await isOrchestrator($)) {
67 const port = dashboardPort(await $.env.get('CHELA_DASHBOARD_PORT'))
68 const res = await $.http.fetch(`http://127.0.0.1:${port}/api/needs-you`)
69 const items = res.ok ? parseItems(res.text) : null
70 if (items) next = { at: await $.clock.now(), items }
71 }
72 } catch {
73 next = null // a dead daemon shows nothing
74 }
75 if (current()) await update($, needsYou, () => next)
76}
77
78let timer: { cancel: () => void } | undefined
79let generation = 0
80
81// Started at session start; a reload runs `register` and `session.start` again, so the old
82// timer is cancelled first, and a poll the old timer left waiting never writes (its
83// generation is gone). A poll still waiting on the daemon skips the tick, and the tick
84// clears a reading that has gone stale meanwhile, so the band goes even while hung.
85export async function start($: any) {
86 timer?.cancel()
87 const mine = ++generation
88 let inFlight = false
89 const tick = async () => {
90 const cur = await read($, needsYou)
91 if (cur && (await $.clock.now()) - cur.at > STALE_MS) await update($, needsYou, () => null)
92 if (inFlight) return
93 inFlight = true
94 try { await poll($, () => generation === mine) } finally { inFlight = false }
95 }
96 timer = $.clock.every(POLL_MS, () => { void tick() })
97 void tick()
98}
99
100export async function band($: any, e: any, next: any) {
101 const data = await read($, needsYou)
102 if (e.props.hasSurvey || !data || !data.items.length) return next(e)
103 const now: number = await $.clock.now()
104 if (now - data.at > STALE_MS) return next(e)
105 const below = await next(e)
106 const { Box, Text, Link } = $.ui.resolve(e)
107 const parts: any[] = []
108 for (const kind of KINDS) {
109 const of = data.items.filter((i: NeedsYouItem) => i.kind === kind)
110 const first = oldestOf(of)
111 if (!first) continue
112 if (parts.length) parts.push(<Text key={`sep-${kind}`} dimColor> · </Text>)
113 parts.push(<Link key={`count-${kind}`} href={first.url}
114 label={`${of.length} ${NOUN[kind][of.length === 1 ? 0 : 1]}`} />)
115 }
116 const oldest = oldestOf(data.items) as NeedsYouItem
117 return (
118 <Box flexDirection="column">
119 {below}
120 <Box key="needs-you">
121 <Text bold>needs you: </Text>
122 {parts}
123 <Text dimColor> · oldest: </Text>
124 <Link key="oldest" href={oldest.url} label={`${NOUN[oldest.kind][0]} ${oldest.label}`} />
125 <Text dimColor> ({age(now / 1000 - oldest.since)})</Text>
126 </Box>
127 </Box>
128 )
129}
130hooks/denial.ts 43 lines1// Mod 4/4 (CMX-184): a classifier denial goes straight to the orchestrator's inbox. On
2// classic.PermissionDenied the Mod POSTs the tool, a one-line reason, the session and the
3// window to the daemon's /api/inbox/denial, which files it as CMX-155's notice: no event-log
4// tail. plugin/'s shell hook still logs the same denial; the daemon keys both on the
5// tool_use_id, so one denial is one notice with both layers loaded.
6//
7// Not awaited and never throws: a dead daemon is a lost post, never a stalled tool call.
8// Gated on $CHELA_WID like every other chela post. Under --permission-mode dontAsk Claude
9// Code fires no PermissionDenied at all, in either layer.
10// register.ts hooks it, since the validator wants each `on()` there, literal.
11import { dashboardPort } from './needsyou'
12
13export const REASON_CHARS = 200
14
15// whitespace collapsed to single spaces, clipped to `limit` code points (as the daemon's
16// inbox._one_line clips) with an ellipsis; never splits a surrogate pair
17export function oneLine(text: unknown, limit: number): string {
18 const s = Array.from(String(text ?? '').split(/\s+/).filter(Boolean).join(' '))
19 return s.length > limit ? `${s.slice(0, limit - 1).join('')}…` : s.join('')
20}
21
22export function denialBody(e: any, wid: string) {
23 return {
24 wid, session_id: e.session_id ?? '', tool_name: e.tool_name ?? '',
25 tool_input: e.tool_input ?? {}, tool_use_id: e.tool_use_id ?? '',
26 reason: oneLine(e.reason, REASON_CHARS),
27 }
28}
29
30export async function report($: any, e: any) {
31 try {
32 const wid = await $.env.get('CHELA_WID')
33 if (!wid) return
34 const port = dashboardPort(await $.env.get('CHELA_DASHBOARD_PORT'))
35 await $.http.fetch(`http://127.0.0.1:${port}/api/inbox/denial`, {
36 method: 'POST', headers: { 'Content-Type': 'application/json' },
37 body: JSON.stringify(denialBody(e, wid)),
38 })
39 } catch {
40 // the shell hook's event-log copy still reaches the inbox
41 }
42}
43types/index.d.ts 18 lines1// The values chela-hooks-mod keeps in `$.state` (Mod 3/4, CMX-183).
2
3// One thing waiting on the human, as GET /api/needs-you lists it (`since` in epoch seconds).
4export type NeedsYouItem = {
5 kind: 'approval' | 'access' | 'run'
6 label: string
7 since: number
8 url: string
9}
10// The last good reading and when it was taken (ms), or null for none.
11export type NeedsYou = { at: number, items: NeedsYouItem[] } | null
12
13declare module 'claude-code' {
14 interface PluginState {
15 'chela-hooks-mod': { needsYou: NeedsYou }
16 }
17}
18