Usage/context warning strip, lane notification toast and health/status warning line. Strictly additive observer: draws only, never acts.

A Claude Code mod (plugin of function hooks) for the MoAI session: a usage/context warning strip above the prompt with a spinner suffix, a toast for every inbound cross-session delivery, and a health/status warning line fed by a background timer. Strictly additive observer — absent or disabled, nothing changes; it draws, it never acts.
SPEC: .moai/specs/SPEC-MOAI-STATUS-MOD-001/ (card t1437).
session.measure after each turn (never polled). The mod classifies them against thresholds mirroring the in-repo gates' defaults (context soft 50 for windows ≥ 500k tokens else 90, critical at min(95, autocompact+10) clamped up to soft; rate holds 90 for five_hour, 95 for seven_day) and holds the classification in $.state. The AbovePrompt hook draws a one-line strip ahead of the upstream tree when something warns; the Spinner hook appends a short marker ( · ctx 91%) to the incoming suffix only.session.receive delivery raises one toast line (peer: hello lane) and the delivery always passes: the handler returns next(e) on every path and never produces { consumed }.$.clock.every timer (60 s, floor 15 s) runs three diagnostics one at a time under a single-flight gate with a 20 s timeout: moai doctor --check "Binary Freshness", --check "MCP Server Version", and moai memory doctor --json. Warnings pin one line under the prompt (behind binary with both SHAs, stale MCP server with pid and commits, over-cap memory store with count/cap); a clean cycle clears the line; an unknown source shows name ? and never reads as healthy.No hook alters turn flow (every handler passes its event through); no network, file write, tool call, prompt submission, or env/settings access; the only child-process calls are the fixed argv table's three diagnostics through the single $.process.run site in hooks/register.ts. moai doctor --check "MCP Server Version" deletes the CLI's own dead PID-stamp files as a side effect of the called command, not of the mod.
claude --plugin-dir <absolute path to mods/moai-status>
Interactive behavior (the strip's paint, the toast on a real delivery) is the manual check of acceptance.md AC-MSM-013, performed by an operator with a second session able to send a message.
# pure (bun — developer-local evidence, never engine or CI evidence)
bun test mods/moai-status/tests/pure/
# engine (the authority for hooks, state, timers and render dispatch)
mkdir -p /tmp/msm-claude-cfg-empty
CLAUDE_CONFIG_DIR=/tmp/msm-claude-cfg-empty claude plugin test mods/moai-status
# manifest, hooks, $-noun calls and the state contract
CLAUDE_CONFIG_DIR=/tmp/msm-claude-cfg-empty claude plugin validate mods/moai-status
Layout: hooks/register.ts is the only file that spells $.… (the engine refuses $ passed into an imported function); hooks/data.ts and hooks/health.ts stay $-free and pure (bun-tested under tests/pure/, named *.spec.ts so the engine runner's *.test.ts glob skips them); the state contract lives in types/index.d.ts, named by plugin.json's types pointer. The engine lays its own typings under .claude-plugin/types/ at load (gitignored).
hooks/register.ts 227 lines1// moai-status hooks module. The only file that spells `$.…`: helper modules
2// (data.ts, health.ts) stay `$`-free and receive functions or resolved element
3// tables as arguments — the engine refuses `$` passed into a function imported
4// from another file (sibling M-17). Strictly additive observer (spec.md §2):
5// every handler passes its event through with next(e), every hook fails soft,
6// and the only child-process calls are the fixed argv table's three diagnostics.
7import type { EngineInterface, Register } from 'claude-code'
8import {
9 ARGV,
10 CMD_TIMEOUT_MS,
11 DEFAULT_BAND,
12 HEALTH_POLL_MS,
13 classifyMeasure,
14 sameUsage,
15 stripLine,
16 suffixMarker,
17 toastLine,
18 type MoaiStatusHealth,
19 type MoaiStatusSourceVerdict,
20 type MoaiStatusUsage,
21 type Run,
22 type RunResult,
23} from './data'
24import { composeHealthLine, mergeGoodHealth, parseDoctorCheck, parseMemoryDoctor } from './health'
25
26// Typed references: plugin and key are literals (the shape validate enforces, M-13).
27const usageRef = { plugin: 'moai-status', key: 'usage' } as const
28const healthRef = { plugin: 'moai-status', key: 'health' } as const
29const noticeRef = { plugin: 'moai-status', key: 'notice' } as const
30
31const EMPTY_USAGE: MoaiStatusUsage = { figures: [] }
32const EMPTY_HEALTH: MoaiStatusHealth = { binary: { state: 'ok' }, mcp: { state: 'ok' }, memory: { state: 'ok' } }
33
34const errorText = (err: unknown): string => (err instanceof Error ? err.message : String(err))
35
36// Fail-soft (REQ-MSM-009): a guarded body that throws leaves a notice and no
37// exception ever leaves a hook — the session continues unaffected. notice's
38// only writer is this guard; the next clean cycle clears it (plan §B.1).
39const setNotice = async ($: EngineInterface, text: string): Promise<void> => {
40 const current = (await $.state.get(noticeRef)).value ?? ''
41 if (current !== text) await $.state.set(noticeRef, text)
42}
43
44const soft = async ($: EngineInterface, body: () => Promise<void>): Promise<void> => {
45 try {
46 await body()
47 await setNotice($, '')
48 } catch (err) {
49 try {
50 await setNotice($, `moai-status: ${errorText(err)}`)
51 } catch {
52 // nothing left to try; the session continues unaffected
53 }
54 }
55}
56
57// The health timer (REQ-MSM-007): one $.clock.every timer at session.start,
58// cancelled at session.end. Module variables hold only what a hot reload may
59// lose at the cost of one timer restart (REQ-MSM-010); the single-flight gate
60// drops a tick that finds one cycle running regardless of which instance owns it.
61// The cancel handle is defensive over both shapes the engine shows: the laid
62// typings name a bare function, the test clock hands back { cancel }.
63let cancelTimer: unknown
64let inFlight = false
65
66const stopHandle = (handle: unknown): void => {
67 if (typeof handle === 'function') (handle as () => void)()
68 else if (handle !== null && typeof handle === 'object' && typeof (handle as { cancel?: unknown }).cancel === 'function')
69 (handle as { cancel: () => void }).cancel()
70}
71
72const stopHealth = (): void => {
73 stopHandle(cancelTimer)
74 cancelTimer = undefined
75}
76
77// Row-first precedence lives in the parser; a rejected run (cannot start,
78// timeout — the call rejects) is unknown: never healthy, never a session
79// failure (REQ-MSM-009/-012).
80const readDoctorSource = async (run: Run, argv: readonly string[], checkName: string): Promise<MoaiStatusSourceVerdict> => {
81 try {
82 const result = await run(argv)
83 return parseDoctorCheck(result.stdout, checkName)
84 } catch {
85 return { state: 'unknown' }
86 }
87}
88
89const readMemorySource = async (run: Run): Promise<MoaiStatusSourceVerdict> => {
90 try {
91 const result = await run([...ARGV.memory])
92 return parseMemoryDoctor(result.stdout)
93 } catch {
94 return { state: 'unknown' }
95 }
96}
97
98// One health cycle (REQ-MSM-007/-008): the three table argvs one at a time,
99// at most one cycle in flight; the composed line pins via $.ui.status (or
100// clears it), and state keeps the last good classification per source.
101const runHealthCycle = async ($: EngineInterface, run: Run): Promise<void> => {
102 if (inFlight) return
103 inFlight = true
104 try {
105 const fresh = {
106 binary: await readDoctorSource(run, [...ARGV.binary], 'Binary Freshness'),
107 mcp: await readDoctorSource(run, [...ARGV.mcp], 'MCP Server Version'),
108 memory: await readMemorySource(run),
109 }
110 const line = composeHealthLine(fresh)
111 $.ui.status(line === '' ? undefined : `moai-status: ${line}`)
112 const previous = (await $.state.get(healthRef)).value
113 const merged = mergeGoodHealth(previous, fresh)
114 if (JSON.stringify(previous) !== JSON.stringify(merged)) await $.state.set(healthRef, merged)
115 } catch (err) {
116 // soft()'s nested guard (sync-audit F-1): a state failure during the catch
117 // itself must not reject this promise — the timer discards it with `void`,
118 // so an unguarded await here leaks an unhandled rejection per tick.
119 try {
120 await setNotice($, `moai-status: ${errorText(err)}`)
121 } catch {
122 // nothing left to try; the session continues unaffected
123 }
124 } finally {
125 inFlight = false
126 }
127}
128
129const startHealth = ($: EngineInterface, run: Run): void => {
130 stopHealth()
131 cancelTimer = $.clock.every(HEALTH_POLL_MS, () => {
132 void runHealthCycle($, run)
133 })
134}
135
136// The module's only child-process call site (REQ-MSM-002): every argv that
137// reaches it comes from the fixed table in data.ts. `$` is the dispatch's own,
138// threaded from the handler into this top-of-file helper (the engine's $-flow
139// analysis requires that shape); `$` never crosses an import.
140// @MX:ANCHOR: [AUTO] the module's only child-process call site - every diagnostic argv routes through this single $.process.run seam
141// @MX:REASON: the fixed argv table in hooks/data.ts is the only argv source; a second call site would escape the fail-soft boundary audit (AC-MSM-002 counts exactly this one line) and the $-flow invariant ($ never crosses an import)
142// @MX:SPEC: SPEC-MOAI-STATUS-MOD-001
143const runDiag = ($: EngineInterface, argv: readonly string[]): Promise<RunResult> =>
144 $.process.run(argv, { timeoutMs: CMD_TIMEOUT_MS })
145
146export const register: Register = on => {
147 on('session.start', async ($, e, next) => {
148 await soft($, async () => {
149 // First-write initialization so every later read is defined. The refs are
150 // spelled per call: validate lists only literal references (M-13).
151 if ((await $.state.get(usageRef)).value === undefined) await $.state.set(usageRef, EMPTY_USAGE)
152 if ((await $.state.get(healthRef)).value === undefined) await $.state.set(healthRef, EMPTY_HEALTH)
153 if ((await $.state.get(noticeRef)).value === undefined) await $.state.set(noticeRef, '')
154 })
155 try {
156 startHealth($, argv => runDiag($, argv))
157 } catch {
158 // no timer this session; the session continues unaffected
159 }
160 return next(e)
161 })
162
163 on('session.end', async ($, e, next) => {
164 stopHealth()
165 return next(e)
166 })
167
168 on('session.measure', async ($, e, next) => {
169 // The engine pushes the figures (D-2); the mod classifies and holds them
170 // in state, writing only when the classification moved (REQ-MSM-003).
171 await soft($, async () => {
172 const previous = (await $.state.get(usageRef)).value
173 const classified = classifyMeasure(e, DEFAULT_BAND)
174 if (!sameUsage(previous, classified)) await $.state.set(usageRef, classified)
175 })
176 return next(e)
177 })
178
179 on('session.receive', async ($, e, next) => {
180 try {
181 // The toast is attempted before the delivery passes; its failure never
182 // holds, rewrites or consumes the delivery (REQ-MSM-006). The handler
183 // returns next(e) on every path — the `{ consumed }` shape is never
184 // produced.
185 $.ui.toast(toastLine(e.origin.kind, e.text))
186 } catch {
187 // the delivery passes regardless
188 }
189 return next(e)
190 })
191
192 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
193 try {
194 // A survey holds the band; the mod yields (REQ-MSM-004).
195 if (e.props.hasSurvey) return next(e)
196 const usage = (await $.state.get(usageRef)).value
197 const line = stripLine(usage)
198 if (line === '') return next(e)
199 const T = $.ui.resolve(e)
200 const upstream = await next(e)
201 // Compose: the strip leads, the upstream tree still draws (REQ-MSM-004).
202 // Text takes no key — the findable line sits in a keyed Box (sibling craft).
203 return T.Box({
204 key: 'moai-status-strip',
205 flexDirection: 'column',
206 children: [T.Box({ key: 'moai-status-strip-line', children: T.Text({ children: line }) }), upstream],
207 })
208 } catch {
209 // A broken strip never blanks the band for later mods (plan §B.5).
210 return next(e)
211 }
212 })
213
214 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
215 try {
216 const usage = (await $.state.get(usageRef)).value
217 const marker = suffixMarker(usage)
218 if (marker === '') return next(e)
219 // Only the suffix prop changes; word, message and mode pass untouched
220 // (REQ-MSM-005). The incoming suffix is preserved, the marker appended.
221 return next({ ...e, props: { ...e.props, suffix: e.props.suffix + marker } })
222 } catch {
223 return next(e)
224 }
225 })
226}
227hooks/data.ts 139 lines1// moai-status data layer: `$`-free. The fixed argv table (REQ-MSM-002), the
2// measure classifier and the strip/suffix line builders (M2), the toast line
3// builder (M3). Pure: bun-tested under tests/pure/. argv lists only, never
4// shell strings; the table is the ONLY argv built anywhere.
5import type { MoaiStatusBand, MoaiStatusFigure, MoaiStatusMeasureInput, MoaiStatusUsage } from '../types'
6
7/** What the engine's process.run resolves with (the laid typings' ProcessRunResult). */
8export type RunResult = {
9 exitCode: number
10 stdout: string
11 stderr: string
12 isStdoutTruncated: boolean
13 isStderrTruncated: boolean
14}
15
16/** The run function the hooks module hands the helpers; the single process.run site wraps it. */
17export type Run = (argv: readonly string[]) => Promise<RunResult>
18
19// plan §G parameters. HEALTH_POLL_MS above the floor is Q3's provisional value;
20// the tests assert only the floor (REQ-MSM-007).
21export const HEALTH_POLL_MIN_MS = 15_000
22export const HEALTH_POLL_MS = 60_000
23export const CMD_TIMEOUT_MS = 20_000
24export const TOAST_EXCERPT_CP = 80
25export const STATUS_LINE_MAX = 200
26
27// The fixed argv table (plan §B.3) — the only argv built anywhere, pinned in
28// double quotes for the AC-MSM-002(ii) structural check. The check names are
29// the in-tree check identifiers (internal/cli/doctor.go:210,
30// internal/cli/doctor_mcp_version.go).
31export const ARGV = {
32 binary: ["moai", "doctor", "--check", "Binary Freshness"],
33 mcp: ["moai", "doctor", "--check", "MCP Server Version"],
34 memory: ["moai", "memory", "doctor", "--json"],
35} as const
36
37// The strip thresholds mirror the in-repo gates' DEFAULT configuration (M-7,
38// spec.md D-3): the t1442 context band and the t1347 quota-gate holds. The
39// gates are runtime-configurable; the mod shows its own frozen values (G-12).
40// @MX:NOTE: [AUTO] frozen mirror of the in-repo gates' DEFAULT thresholds (context band from the t1442 renderer, holds from the t1347 quota gate); the gates are runtime-configurable, this copy is deliberately not
41// @MX:SPEC: SPEC-MOAI-STATUS-MOD-001
42export const DEFAULT_BAND: MoaiStatusBand = {
43 softLargePct: 50,
44 softStandardPct: 90,
45 largeWindowCutoff: 500_000,
46 autoCompactPct: 85,
47 hardMarginPct: 10,
48 hardCapPct: 95,
49 fiveHourHoldPct: 90,
50 sevenDayHoldPct: 95,
51}
52
53// soft = 50 when the window is at least 500,000 tokens, else 90 (renderer.go).
54export const softPct = (window: number | undefined, band: MoaiStatusBand): number =>
55 window !== undefined && window >= band.largeWindowCutoff ? band.softLargePct : band.softStandardPct
56
57// hard = min(hardCap, autoCompact + margin), clamped up to soft.
58export const hardPct = (window: number | undefined, band: MoaiStatusBand): number =>
59 Math.max(softPct(window, band), Math.min(band.hardCapPct, band.autoCompactPct + band.hardMarginPct))
60
61// The pure classifier of plan §B.4: a function of the pushed figure and the
62// explicit band. Absent figures are no reading, never zero (REQ-MSM-003);
63// only the two gate kinds warn, at their holds; the output carries warn and
64// critical figures only, so the render hooks draw from state without
65// re-classifying.
66export const classifyMeasure = (input: MoaiStatusMeasureInput, band: MoaiStatusBand): MoaiStatusUsage => {
67 const figures: MoaiStatusFigure[] = []
68 const percent = input.context?.percent
69 const window = input.context?.window
70 if (typeof percent === 'number' && typeof window === 'number' && window > 0) {
71 const soft = softPct(window, band)
72 const hard = hardPct(window, band)
73 if (percent >= hard)
74 figures.push({ kind: 'context', level: 'critical', percent, softPct: soft, hardPct: hard })
75 else if (percent >= soft)
76 figures.push({ kind: 'context', level: 'warn', percent, softPct: soft, hardPct: hard })
77 }
78 for (const entry of input.rateLimits ?? []) {
79 if (entry.kind === 'five_hour' && entry.percentUsed >= band.fiveHourHoldPct)
80 figures.push({ kind: 'rate', window: 'five_hour', percent: entry.percentUsed, holdPct: band.fiveHourHoldPct })
81 else if (entry.kind === 'seven_day' && entry.percentUsed >= band.sevenDayHoldPct)
82 figures.push({ kind: 'rate', window: 'seven_day', percent: entry.percentUsed, holdPct: band.sevenDayHoldPct })
83 }
84 return { figures }
85}
86
87// State is written only when the classification moved (REQ-MSM-003), so a
88// measure burst cannot storm the subscribed renders.
89export const sameUsage = (a: MoaiStatusUsage | undefined, b: MoaiStatusUsage): boolean =>
90 a !== undefined && JSON.stringify(a) === JSON.stringify(b)
91
92// The strip line: one line naming each warned figure and its threshold
93// (REQ-MSM-004). Empty when nothing is warned — the hook passes then.
94export const stripLine = (usage: MoaiStatusUsage | undefined): string => {
95 const parts: string[] = []
96 for (const figure of usage?.figures ?? []) {
97 if (figure.kind === 'context')
98 parts.push(
99 figure.level === 'critical'
100 ? `ctx ${figure.percent}% (critical at ${figure.hardPct})`
101 : `ctx ${figure.percent}% (warn at ${figure.softPct})`,
102 )
103 else if (figure.window === 'five_hour') parts.push(`5h quota ${figure.percent}% (hold ${figure.holdPct})`)
104 else parts.push(`7d quota ${figure.percent}% (hold ${figure.holdPct})`)
105 }
106 return parts.length === 0 ? '' : `moai-status: ${parts.join(' · ')}`
107}
108
109// The spinner marker: the shortest form of the same classification — context
110// first, else the first warned rate window; '' when there is nothing to say
111// (REQ-MSM-005). The hook appends it to the incoming suffix verbatim.
112export const suffixMarker = (usage: MoaiStatusUsage | undefined): string => {
113 for (const figure of usage?.figures ?? []) {
114 if (figure.kind === 'context') return ` · ctx ${figure.percent}%`
115 }
116 for (const figure of usage?.figures ?? []) {
117 if (figure.kind === 'rate')
118 return figure.window === 'five_hour' ? ` · 5h ${figure.percent}%` : ` · 7d ${figure.percent}%`
119 }
120 return ''
121}
122
123// The toast line (REQ-MSM-006): the origin kind, then the first kept code
124// points of the text's first line, control characters dropped — one line,
125// always.
126export const toastLine = (originKind: string, text: string): string => {
127 const first = text.split('\n', 1)[0] ?? ''
128 let excerpt = ''
129 let kept = 0
130 for (const ch of first) {
131 const cp = ch.codePointAt(0) ?? 0
132 if (cp < 0x20 || cp === 0x7f) continue
133 if (kept >= TOAST_EXCERPT_CP) break
134 excerpt += ch
135 kept++
136 }
137 return `${originKind}: ${excerpt}`
138}
139hooks/health.ts 121 lines1// moai-status health layer: `$`-free. The doctor box-row parser (row-first,
2// REQ-MSM-012), the memory-doctor JSON parser and the health-line composer
3// (REQ-MSM-008/-009). Pure: bun-tested under tests/pure/. The message
4// spellings are pinned from the in-tree sources (M-9: internal/cli/doctor.go,
5// internal/cli/doctor_mcp_version.go); an unrecognized warn still warns with
6// the bounded raw message — never healthy by accident.
7import type { MoaiStatusGoodVerdict, MoaiStatusHealth, MoaiStatusSourceVerdict } from '../types'
8import { STATUS_LINE_MAX } from './data'
9
10const SHA = '[0-9a-f]+'
11const ROW = /^(ok|warn|fail)\s+(\S.*)$/
12const BEHIND = new RegExp(`^binary is behind source tree \\(binary: (${SHA}), HEAD: (${SHA})\\)$`)
13const NEWER = new RegExp(`^binary is newer than this tree — freshness undetermined \\(binary: (${SHA}), HEAD: (${SHA})\\)$`)
14const STALE = /^running MCP server is stale \((.+)\)$/
15
16const bound = (text: string, maxCp: number): string =>
17 Array.from(text).length <= maxCp ? text : `${Array.from(text).slice(0, maxCp - 1).join('')}…`
18
19const warnFromMessage = (message: string): MoaiStatusSourceVerdict => {
20 const behind = BEHIND.exec(message)
21 if (behind) return { state: 'warn', segment: `${behind[1]} behind ${behind[2]}` }
22 const newer = NEWER.exec(message)
23 if (newer) return { state: 'warn', segment: `${newer[1]} newer than ${newer[2]} (undetermined)` }
24 const stale = STALE.exec(message)
25 if (stale) return { state: 'warn', segment: `stale (${stale[1]})` }
26 return { state: 'warn', segment: bound(message, 80) }
27}
28
29// Row-first precedence (REQ-MSM-012): the first box row FOR THE ASKED CHECK
30// decides; the parser sees stdout alone, so a parseable row always wins and a
31// non-zero exit means unknown only on the no-row path (the doctor single checks
32// exit 0 on warn anyway, M-9). Any STATUS token other than ok/warn — `fail`
33// included, unreachable from the fixed argv table — is not a verdict: unknown,
34// never healthy. A summary line (`0 ok, 1 warn, 0 fail`) starts with a digit and
35// never matches the row shape.
36export const parseDoctorCheck = (stdout: string, checkName: string): MoaiStatusSourceVerdict => {
37 for (const raw of stdout.split('\n')) {
38 let line = raw.trim()
39 if (line.startsWith('│')) line = line.slice(1).trim()
40 if (line.endsWith('│')) line = line.slice(0, -1).trim()
41 const match = ROW.exec(line)
42 if (match === null) continue
43 const token = match[1]
44 const rest = match[2]
45 if (!rest.startsWith(checkName)) continue
46 const after = rest.slice(checkName.length)
47 if (after !== '' && after[0] !== ' ') continue
48 const message = after.trim()
49 if (token === 'ok') return { state: 'ok' }
50 if (token === 'warn') return warnFromMessage(message)
51 return { state: 'unknown' }
52 }
53 return { state: 'unknown' }
54}
55
56type MemoryStore = { topic_files?: unknown; cap?: unknown }
57
58// The retention signal (M-8): the worst over-cap store of `moai memory doctor
59// --json`. Unparseable output, a non-array, and a payload with no judgeable
60// store are unknown — never healthy (REQ-MSM-009).
61export const parseMemoryDoctor = (stdout: string): MoaiStatusSourceVerdict => {
62 let parsed: unknown
63 try {
64 parsed = JSON.parse(stdout)
65 } catch {
66 return { state: 'unknown' }
67 }
68 if (!Array.isArray(parsed)) return { state: 'unknown' }
69 let worst: { files: number; cap: number; ratio: number } | undefined
70 let judged = 0
71 for (const item of parsed as MemoryStore[]) {
72 const files = item?.topic_files
73 const cap = item?.cap
74 if (typeof files !== 'number' || typeof cap !== 'number' || !Number.isFinite(files) || !Number.isFinite(cap) || cap <= 0)
75 continue
76 judged++
77 if (files > cap) {
78 const ratio = files / cap
79 if (worst === undefined || ratio > worst.ratio) worst = { files, cap, ratio }
80 }
81 }
82 if (judged === 0) return { state: 'unknown' }
83 if (worst === undefined) return { state: 'ok' }
84 return { state: 'warn', segment: `${worst.files}/${worst.cap} files` }
85}
86
87export type FreshVerdicts = {
88 binary: MoaiStatusSourceVerdict
89 mcp: MoaiStatusSourceVerdict
90 memory: MoaiStatusSourceVerdict
91}
92
93const LABEL = { binary: 'binary', mcp: 'mcp', memory: 'memory' } as const
94
95// Warnings only (Q4): warn segments join with ` · `; unknown sources show as
96// `name ?`; a clean cycle composes '' — the caller clears the line
97// (REQ-MSM-008). The line is bounded to STATUS_LINE_MAX code points.
98export const composeHealthLine = (fresh: FreshVerdicts, maxCp: number = STATUS_LINE_MAX): string => {
99 const parts: string[] = []
100 for (const source of ['binary', 'mcp', 'memory'] as const) {
101 const verdict = fresh[source]
102 if (verdict.state === 'warn') parts.push(`${LABEL[source]} ${verdict.segment}`)
103 else if (verdict.state === 'unknown') parts.push(`${LABEL[source]} ?`)
104 }
105 if (parts.length === 0) return ''
106 return bound(parts.join(' · '), maxCp)
107}
108
109const good = (previous: MoaiStatusGoodVerdict | undefined, verdict: MoaiStatusSourceVerdict): MoaiStatusGoodVerdict => {
110 if (verdict.state === 'unknown') return previous ?? { state: 'ok' } // the last good stands
111 return verdict.state === 'ok' ? { state: 'ok' } : { state: 'warn', segment: verdict.segment }
112}
113
114// The state shape keeps only good classifications; a source reading unknown
115// keeps its previous slot (REQ-MSM-009 — the last good classification stands).
116export const mergeGoodHealth = (previous: MoaiStatusHealth | undefined, fresh: FreshVerdicts): MoaiStatusHealth => ({
117 binary: good(previous?.binary, fresh.binary),
118 mcp: good(previous?.mcp, fresh.mcp),
119 memory: good(previous?.memory, fresh.memory),
120})
121types/index.d.ts 70 lines1// State contract of the moai-status plugin (Claude Code function hooks, 2.1.287).
2// Self-contained on purpose: the engine requires a contract with no import, its
3// exported names led by the plugin's PascalCase name, and `PluginState` declared
4// for the plugin's own name. `plugin.json` names this file under "types".
5
6/** The context band and the per-window holds the classifier classifies against (spec.md D-3, plan §G). */
7export type MoaiStatusBand = {
8 softLargePct: number
9 softStandardPct: number
10 largeWindowCutoff: number
11 autoCompactPct: number
12 hardMarginPct: number
13 hardCapPct: number
14 fiveHourHoldPct: number
15 sevenDayHoldPct: number
16}
17
18/** The engine's pushed `session.measure` figure (the laid typings' SessionMeasureInput, structurally). */
19export type MoaiStatusMeasureInput = {
20 context?: { tokens?: number; window?: number; percent?: number }
21 rateLimits?: { kind: string; percentUsed: number; resetsAt?: string }[]
22 changed?: string[]
23}
24
25/** One warned figure of the strip classification; only warn and critical are stored. */
26export type MoaiStatusContextFigure = {
27 kind: 'context'
28 level: 'warn' | 'critical'
29 percent: number
30 softPct: number
31 hardPct: number
32}
33
34export type MoaiStatusRateFigure = {
35 kind: 'rate'
36 window: 'five_hour' | 'seven_day'
37 percent: number
38 holdPct: number
39}
40
41export type MoaiStatusFigure = MoaiStatusContextFigure | MoaiStatusRateFigure
42
43/** The strip classification held in state; `figures` empty means nothing to say. */
44export type MoaiStatusUsage = { figures: MoaiStatusFigure[] }
45
46/** One health source's last good verdict; a source that reads unknown keeps its previous slot. */
47export type MoaiStatusGoodVerdict = { state: 'ok' } | { state: 'warn'; segment: string }
48
49export type MoaiStatusHealth = {
50 binary: MoaiStatusGoodVerdict
51 mcp: MoaiStatusGoodVerdict
52 memory: MoaiStatusGoodVerdict
53}
54
55/** One cycle's fresh verdict per source; `unknown` shows as `?` and never becomes healthy. */
56export type MoaiStatusSourceVerdict = { state: 'ok' } | { state: 'warn'; segment: string } | { state: 'unknown' }
57
58declare module 'claude-code' {
59 interface PluginState {
60 'moai-status': {
61 /** The last strip classification (warn and critical figures only). */
62 usage: MoaiStatusUsage
63 /** The last good health classification, per source. */
64 health: MoaiStatusHealth
65 /** The fail-soft guard's one-line failure notice; '' when the last cycle was clean. */
66 notice: string
67 }
68 }
69}
70