SLOPSHOPPER

moai-status

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

newbandspinnertoaststatusprocess
★ 1,231v0.1.0Apache-2.0updated 2026-10-02modu-ai/moai-adk/mods/moai-status
A shopper browsing a rack in a slop shop
README

moai-status

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

The three features

  1. Usage/context warning strip — the engine pushes its usage figures as 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.
  2. Lane notification toast — every 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 }.
  3. Health/status warning line — a $.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.

Boundary (spec.md §2)

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.

Launch

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.

Test and validate

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

Source 4 files
hooks/register.ts 227 lines
1// 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}
227
hooks/data.ts 139 lines
1// 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}
139
hooks/health.ts 121 lines
1// 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})
121
types/index.d.ts 70 lines
1// 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