SLOPSHOPPER

chela-hooks-mod

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…

newbandguardstatusprocessnetwork
★ 1v0.5.0AGPL-3.0updated 2026-10-22Devail1/chelamux/mods/chela-hooks
A shopper browsing a rack in a slop shop
README

chela-hooks-mod — opt-in chela Mod

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

Status line (CMX-181)

The Mod draws chela's status line under the prompt through $.ui.status:

ctx ▰▰▰▰▱▱▱▱▱▱ 42% · @7 · orchestrator · run CMX-9 (judge)
FieldSourceLeft out when
context bar$.session.usage(), the figures Claude Code's own statusLine getsthe session has no reading yet (fresh, or just compacted)
window id$CHELA_WIDnot 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 exportsnot 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.

Needs you (CMX-183)

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

  1. every 10 s, from a timer: no tool call waits on it. It is read-only. Approving stays

in Telegram, the dashboard or a plain terminal.

CaseBand
the orchestrator session, something waitingcounts plus the oldest
nothing waitingnone
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 shapenone
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.301none

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).

Classifier denials (CMX-184)

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.

CaseWhat happens
a chela window ($CHELA_WID set)one POST per denial, not awaited
not a chela windownothing is posted
the daemon is down or answers an errorthe post is dropped; the tool call and the denial are unaffected, and the shell copy still reaches the inbox
--permission-mode dontAskClaude 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).

Hook spike (CMX-157)

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.

VariableDefaultMeaning
CHELA_WIDunset ⇒ nothing is postedthe window the events are filed under (the gate runs regardless)
CHELA_MOD_HOOKS_URLunset ⇒ nothing is postedwhere POST /hooks/<Event> goes, e.g. http://127.0.0.1:5001
CHELA_MOD_MERGEGATE<mod>/../../plugin/hooks/mergegate.pythe gate script it runs
CHELA_MOD_STATUSLINE<mod>/../../scripts/cache-statusline.shthe cache writer the status line pipes to
CHELA_DASHBOARD_PORT5001the 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).

Source 5 files
hooks/register.ts 103 lines
1// 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}
103
hooks/status.ts 78 lines
1// 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}
78
hooks/needsyou.tsx 130 lines
1// 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}
130
hooks/denial.ts 43 lines
1// 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}
43
types/index.d.ts 18 lines
1// 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