MEGAINFRA-WEBAPP/foreman/mod/hooks/register.tsx
DEls e97bf93c7d chore: add foreman scheduler mod; keep it out of the app type-check
foreman/ is a Claude Code mod (job runner + hooks) that imports the
claude-code runtime types, so tsc over the app failed on it. Excluding
it from tsconfig keeps the Next build green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:05:50 +02:00

604 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { atom, read, update } from 'claude-code'
import type { EngineInterface, Register, RenderChildren } from 'claude-code'
import type { Job, JobState, RunEntry, SkillInfo, Snapshot, UiState } from '../types'
import { ASCII, MOOD_LABEL, THEMES, THEME_ORDER, foremanSvg, isThemeId } from './mascot'
import type { Mood, Theme } from './mascot'
import { describeSchedule, nextSlot, parseSchedule } from './schedule.mjs'
const PROJECT = '/Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE'
const ROOT = `${PROJECT}/foreman`
const JOBS = `${ROOT}/jobs.json`
const RUNNER = `${ROOT}/bin/runner.mjs`
const PANE = 'foreman'
const PANE_TITLE = '👷 Foreman'
const POLL_MS = 2000
const THEME_KEY = 'theme'
const EMPTY: Snapshot = { jobs: [], paused: false, states: {}, acks: [], skills: [] }
const snapAtom = atom({ plugin: 'foreman', key: 'snap' } as const, EMPTY)
const uiAtom = atom({ plugin: 'foreman', key: 'ui' } as const, { view: 'board' } as UiState)
const themeAtom = atom({ plugin: 'foreman', key: 'theme' } as const, 'hivis')
type Dollar = EngineInterface
// ---------- reading Foreman's folder ----------
const parseJson = <T,>(text: string | undefined, fallback: T): T => {
try {
return text ? (JSON.parse(text) as T) : fallback
} catch {
return fallback
}
}
async function readText($: Dollar, path: string): Promise<string | undefined> {
try {
const got: unknown = await $.fs.read(path)
return typeof got === 'string' ? got : undefined
} catch {
return undefined
}
}
function frontmatter(text: string): Record<string, string> {
const m = /^---\n([\s\S]*?)\n---/.exec(text)
const out: Record<string, string> = {}
for (const line of m?.[1]?.split('\n') ?? []) {
const kv = /^([a-zA-Z_-]+):\s*(.*)$/.exec(line)
if (kv?.[1]) out[kv[1]] = (kv[2] ?? '').trim()
}
return out
}
async function listSkills($: Dollar, dir: string, source: SkillInfo['source']): Promise<SkillInfo[]> {
const entries = await $.fs.list(dir).catch(() => [])
const out: SkillInfo[] = []
for (const entry of entries) {
if (entry.kind !== 'dir' || entry.name.startsWith('_') || entry.name.startsWith('.')) continue
const path = `${dir}/${entry.name}/SKILL.md`
const text = await readText($, path)
if (text === undefined) continue
const fm = frontmatter(text)
out.push({ path, folder: entry.name, name: fm.name || entry.name, description: fm.description || '', source })
}
return out
}
let lastKey = ''
async function refresh($: Dollar): Promise<void> {
const cfg = parseJson<{ paused?: boolean; node?: string; jobs?: Job[] }>(await readText($, JOBS), {})
const jobs = Array.isArray(cfg.jobs) ? cfg.jobs : []
const states: Record<string, JobState> = {}
for (const job of jobs) {
const st = parseJson<JobState | undefined>(await readText($, `${ROOT}/state/${job.id}.json`), undefined)
if (st) states[job.id] = st
}
const acks = parseJson<string[]>(await readText($, `${ROOT}/state/acks.json`), [])
const sched = parseJson<{ lastTick?: string }>(await readText($, `${ROOT}/state/scheduler.json`), {})
const skills = [
...(await listSkills($, `${ROOT}/skills`, 'foreman')),
...(await listSkills($, `${PROJECT}/.claude/skills`, 'project')),
]
const snap: Snapshot = {
jobs,
paused: cfg.paused === true,
...(cfg.node ? { node: cfg.node } : {}),
states,
acks: Array.isArray(acks) ? acks : [],
skills,
...(sched.lastTick ? { lastTick: sched.lastTick } : {}),
}
const key = JSON.stringify(snap)
if (key === lastKey) return
lastKey = key
await update($, snapAtom, () => snap)
}
async function saveJobs($: Dollar, change: (cfg: { paused: boolean; node?: string; jobs: Job[] }) => void): Promise<void> {
const cfg = parseJson<{ paused?: boolean; node?: string; jobs?: Job[] }>(await readText($, JOBS), {})
const next = { ...cfg, paused: cfg.paused === true, jobs: Array.isArray(cfg.jobs) ? cfg.jobs : [] }
change(next)
await $.fs.write(JOBS, `${JSON.stringify(next, null, 2)}\n`)
await refresh($)
}
async function setUi($: Dollar, patch: Partial<UiState>): Promise<void> {
await update($, uiAtom, ui => ({ ...ui, message: undefined, confirmRemove: undefined, ...patch }))
}
const SAFE_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
async function runNow($: Dollar, job: Job): Promise<void> {
if (!SAFE_ID.test(job.id)) return void $.ui.toast(`👷 Can't run "${job.id}": bad job id`)
const snap = await read($, snapAtom)
if (snap.states[job.id]?.status === 'running') return void $.ui.toast(`👷 ${job.name} is already running`)
const node = snap.node ?? 'node'
const quote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
const cmd = `nohup ${quote(node)} ${quote(RUNNER)} run ${job.id} >> ${quote(`${ROOT}/state/manual.log`)} 2>&1 &`
await $.process.run(['/bin/sh', '-c', cmd], { cwd: ROOT, timeoutMs: 10_000 })
$.ui.toast(`👷 Foreman is starting "${job.name}"`)
$.clock.after(800, () => void refresh($))
}
async function openReport($: Dollar, job: Job | undefined, entry: RunEntry): Promise<void> {
if (!entry.report) return
const path = `${ROOT}/${entry.report}`
const text = (await readText($, path)) ?? '(The report file is missing.)'
await setUi($, {
view: 'report',
report: { title: job?.name ?? entry.runId, path, text, runId: entry.runId, ...(job ? { jobId: job.id } : {}) },
})
}
async function acknowledge($: Dollar, runId: string): Promise<void> {
const acks = parseJson<string[]>(await readText($, `${ROOT}/state/acks.json`), [])
if (!acks.includes(runId)) acks.push(runId)
await $.fs.write(`${ROOT}/state/acks.json`, JSON.stringify(acks.slice(-500)))
await refresh($)
}
async function askClaude($: Dollar, job: Job | undefined, entry: RunEntry): Promise<void> {
const when = new Date(entry.startedAt).toLocaleString()
await $.prompt.submit({
asUser: true,
text:
`Foreman flagged items in "${job?.name ?? entry.runId}" (run of ${when}). Read the report at ${ROOT}/${entry.report}. ` +
'For each flagged item, check it against the app (use the MegaDash tools, megadash-direct) and tell me which ones can go to ready-to-post and which need me. ' +
"Don't change anything in the app yet.",
})
}
// ---------- small helpers for drawing ----------
const pad2 = (n: number) => String(n).padStart(2, '0')
const clock = (d: Date) => `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
function whenLabel(d: Date, now: Date): string {
const days = Math.round((new Date(d).setHours(0, 0, 0, 0) - new Date(now).setHours(0, 0, 0, 0)) / 86_400_000)
if (days === 0) return `today ${clock(d)}`
if (days === 1) return `tomorrow ${clock(d)}`
if (days === -1) return `yesterday ${clock(d)}`
return `${d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' })} ${clock(d)}`
}
function ago(iso: string | undefined, now: Date): string {
if (!iso) return '—'
const s = Math.max(0, Math.round((now.getTime() - new Date(iso).getTime()) / 1000))
return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : s < 86400 ? `${Math.floor(s / 3600)}h` : `${Math.floor(s / 86400)}d`
}
const clip = (t: string, n: number) => (t.length > n ? `${t.slice(0, n - 1)}…` : t)
const slug = (t: string) => t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'job'
function mood(snap: Snapshot, nowMs: number): Mood {
const states = Object.values(snap.states)
if (states.some(s => s.status === 'running')) return 'running'
const open = states.flatMap(s => s.history ?? []).filter(r => r.status !== 'clear' && !snap.acks.includes(r.runId))
if (open.some(r => r.status === 'failed')) return 'failed'
if (open.length) return 'attention'
const last = states.map(s => s.lastRun).filter((r): r is RunEntry => !!r).sort((a, b) => b.startedAt.localeCompare(a.startedAt))[0]
return last && nowMs - new Date(last.startedAt).getTime() < 12 * 3600_000 ? 'clear' : 'idle'
}
// ---------- the module ----------
export const register: Register = on => {
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'foreman', description: 'Open Foreman, the Mega Infrastructure site agent' })
const saved = await $.store.get(THEME_KEY).catch(() => undefined)
if (isThemeId(saved)) await update($, themeAtom, () => saved)
await refresh($).catch(() => undefined)
$.clock.every(POLL_MS, () => void refresh($).catch(() => undefined))
return next(e)
})
on('command.run', { command: 'foreman' }, async $ => {
await refresh($).catch(() => undefined)
await $.ui.open({ id: PANE, title: PANE_TITLE })
return { text: '👷 Foreman is on site.' }
})
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
const ui = $.ui.resolve(e)
const { Box, Text, Button } = ui
const Input = 'Input' in ui ? ui.Input : undefined
const Select = 'Select' in ui ? ui.Select : undefined
const snap = await read($, snapAtom)
const view = await read($, uiAtom)
const themeId = await read($, themeAtom)
const T: Theme = isThemeId(themeId) ? THEMES[themeId] : THEMES.hivis
const now = new Date(await $.clock.now())
const cols = e.props.bodyColumns ?? 80
const isWide = cols >= 100
const svg = 'Svg' in ui ? ui.Svg : undefined
const jobById = (id: string) => snap.jobs.find(j => j.id === id)
const m = mood(snap, now.getTime())
const tickAge = snap.lastTick ? now.getTime() - new Date(snap.lastTick).getTime() : Infinity
const schedulerOk = tickAge < 3 * 60_000
const section = (title: string, color: string, body: RenderChildren, right?: RenderChildren) => (
<Box flexDirection="column" backgroundColor={T.panel} borderStyle="round" borderColor={T.line} paddingX={1}>
<Box flexDirection="row" justifyContent="space-between">
<Text color={color} bold>{title}</Text>
{right ?? null}
</Box>
{body}
</Box>
)
// Buttons, fields and selects take no text colour: the app draws them in its own
// (dark text on a light app theme). Dark themes seat them on light chips to stay readable.
const ctl = (child: RenderChildren) =>
T.control ? <Box backgroundColor={T.control} paddingX={1} flexDirection="column">{child}</Box> : child
const controlRow = () => ({
flexDirection: 'row' as const,
gap: 1,
flexWrap: 'wrap' as const,
...(T.control ? { backgroundColor: T.control, paddingX: 1 } : {}),
})
const pill = (text: string, color: string) => <Text color={color} bold>{`● ${text}`}</Text>
// ----- header -----
const header = (
<Box flexDirection="row" gap={2} alignItems="center">
{svg
? h(svg, { source: foremanSvg(m, T), alt: `Foreman: ${MOOD_LABEL[m]}`, width: 132, height: 100, isInteractive: true })
: <Text color={T.run} bold>{ASCII[m]}</Text>}
<Box flexDirection="column" flexGrow={1}>
<Text color={T.text} bold>Foreman</Text>
<Text color={T.muted}>Mega Infrastructure · site agent · runs on MiniMax</Text>
<Text color={m === 'attention' ? T.warn : m === 'failed' ? T.bad : m === 'running' ? T.run : T.sub}>{MOOD_LABEL[m]}</Text>
<Box flexDirection="row" gap={2} marginTop={1}>
{snap.paused
? pill('Schedule paused', T.warn)
: schedulerOk
? pill(`Scheduler on · ${ago(snap.lastTick, now)} ago`, T.good)
: pill('Scheduler not running', T.bad)}
</Box>
</Box>
</Box>
)
// ----- toolbar -----
const toolbar = (
<Box {...controlRow()}>
<Button key="nav-board" variant={view.view === 'board' ? 'primary' : 'secondary'} onPress={() => void setUi($, { view: 'board' })}>Board</Button>
<Button key="nav-skills" variant={view.view === 'skills' ? 'primary' : 'secondary'} onPress={() => void setUi($, { view: 'skills' })}>Skills</Button>
<Button key="pause" onPress={() => void saveJobs($, cfg => void (cfg.paused = !cfg.paused))}>
{snap.paused ? '▶ Resume schedule' : '⏸ Pause schedule'}
</Button>
<Button key="theme" onPress={async () => {
const nextId = await update($, themeAtom, cur => {
const i = THEME_ORDER.indexOf(isThemeId(cur) ? cur : 'hivis')
return THEME_ORDER[(i + 1) % THEME_ORDER.length] ?? 'hivis'
})
await $.store.set(THEME_KEY, nextId)
}}>{`🎨 ${T.name}`}</Button>
</Box>
)
const message = view.message ? <Text color={T.warn} bold>{view.message}</Text> : null
// ----- board -----
const boardView = () => {
type Card = { key: string; title: string; lines: Array<[string, string]>; color: string; buttons: RenderChildren }
const upNext: Card[] = []
const running: Card[] = []
const done: Card[] = []
const attention: Card[] = []
const startOfToday = new Date(now).setHours(0, 0, 0, 0)
for (const job of snap.jobs) {
const st = snap.states[job.id]
if (st?.status === 'running' && st.current) {
const last = st.current.activity.at(-1)
running.push({
key: `run-${job.id}`,
title: job.name,
color: T.run,
lines: [
[`${st.current.trigger === 'manual' ? 'Manual' : 'Scheduled'} · ${ago(st.current.startedAt, now)} · ${st.current.toolCount} tools`, T.sub],
...(last ? ([[clip(last.text, 70), T.muted]] as Array<[string, string]>) : []),
],
buttons: null,
})
}
const sched = parseSchedule(job.schedule)
const nxt = job.enabled && !snap.paused ? nextSlot(sched, now) : null
if (nxt && nxt.getTime() - now.getTime() < 36 * 3600_000) {
upNext.push({
key: `next-${job.id}`,
title: job.name,
color: T.accent,
lines: [[whenLabel(nxt, now), T.sub]],
buttons: ctl(<Button key={`runnow-${job.id}`} plain dimColor={!T.control} onPress={() => void runNow($, job)}>▶ Run now</Button>),
})
}
for (const r of st?.history ?? []) {
const isOpen = r.status !== 'clear' && !snap.acks.includes(r.runId)
const started = new Date(r.startedAt)
const label = `${whenLabel(started, now)} · ${r.trigger}`
if (isOpen) {
attention.push({
key: `att-${r.runId}`,
title: job.name,
color: r.status === 'failed' ? T.bad : T.warn,
lines: [
[`${r.status === 'failed' ? 'Failed' : `${r.attentionCount ?? '?'} to check`} · ${label}`, r.status === 'failed' ? T.bad : T.warn],
...(r.summary ? ([[clip(r.summary, 110), T.sub]] as Array<[string, string]>) : []),
],
buttons: (
<Box {...controlRow()}>
{r.report ? <Button key={`rep-${r.runId}`} plain onPress={() => void openReport($, job, r)}>📄 Report</Button> : null}
{r.report && r.status === 'attention' ? <Button key={`ask-${r.runId}`} plain onPress={() => void askClaude($, job, r)}>✦ Ask Claude</Button> : null}
<Button key={`ack-${r.runId}`} plain onPress={() => void acknowledge($, r.runId)}>✓ Reviewed</Button>
</Box>
),
})
} else if (started.getTime() >= startOfToday) {
done.push({
key: `done-${r.runId}`,
title: job.name,
color: T.good,
lines: [[`${r.status === 'clear' ? 'All clear' : 'Reviewed'} · ${label}`, T.good]],
buttons: r.report ? ctl(<Button key={`rep-${r.runId}`} plain dimColor={!T.control} onPress={() => void openReport($, job, r)}>📄 Report</Button>) : null,
})
}
}
}
upNext.sort((a, b) => a.lines[0]![0].localeCompare(b.lines[0]![0]))
const column = (title: string, color: string, cards: Card[], empty: string) => (
<Box flexDirection="column" flexGrow={1} flexShrink={1} width={isWide ? '25%' : '100%'} backgroundColor={T.chip} borderStyle="round" borderColor={T.line} paddingX={1}>
<Text color={color} bold>{`${title} · ${cards.length}`}</Text>
{cards.length === 0 ? <Text color={T.muted}>{empty}</Text> : null}
{cards.slice(0, 6).map(c => (
<Box flexDirection="column" backgroundColor={T.panel} borderStyle="round" borderColor={c.color} paddingX={1}>
<Text color={T.text} bold wrap="truncate-end">{c.title}</Text>
{c.lines.map(([t, col]) => <Text color={col} wrap="wrap">{t}</Text>)}
{c.buttons}
</Box>
))}
{cards.length > 6 ? <Text color={T.muted}>{`+${cards.length - 6} more`}</Text> : null}
</Box>
)
const board = (
<Box flexDirection={isWide ? 'row' : 'column'} gap={1}>
{column('UP NEXT', T.accent, upNext, snap.paused ? 'Schedule paused' : 'Nothing scheduled')}
{column('RUNNING', T.run, running, 'Nobody out')}
{column('DONE TODAY', T.good, done, 'No runs yet today')}
{column('NEEDS ATTENTION', T.warn, attention, 'All clear 👍')}
</Box>
)
const jobRows = snap.jobs.map(job => {
const sched = parseSchedule(job.schedule)
const st = snap.states[job.id]
const last = st?.lastRun
const nxt = job.enabled && !snap.paused ? nextSlot(sched, now) : null
return (
<Box flexDirection="column" borderStyle="round" borderColor={job.enabled ? T.accent : T.line} paddingX={1}>
<Box flexDirection="row" gap={1} flexWrap="wrap">
{ctl(<Button key={`toggle-${job.id}`} plain onPress={() => void saveJobs($, cfg => {
const j = cfg.jobs.find(x => x.id === job.id)
if (j) j.enabled = !j.enabled
})}>{job.enabled ? '🟢 On' : '⚪ Off'}</Button>)}
<Text color={T.text} bold>{job.name}</Text>
{st?.status === 'running' ? pill('running', T.run) : null}
</Box>
<Text color={sched.kind === 'error' ? T.bad : T.sub}>{`🗓 ${describeSchedule(sched)}${nxt ? ` · next ${whenLabel(nxt, now)}` : ''}`}</Text>
{last ? (
<Text color={last.status === 'clear' ? T.good : last.status === 'failed' ? T.bad : T.warn}>
{`Last: ${last.status === 'clear' ? 'all clear' : last.status === 'failed' ? 'failed' : `${last.attentionCount ?? '?'} to check`} · ${whenLabel(new Date(last.startedAt), now)}`}
</Text>
) : <Text color={T.muted}>Never run yet</Text>}
<Box {...controlRow()}>
<Button key={`run-${job.id}`} onPress={() => void runNow($, job)}>▶ Run now</Button>
<Button key={`edit-${job.id}`} onPress={() => void setUi($, { view: 'job', jobId: job.id })}>✎ Edit</Button>
{last?.report ? <Button key={`last-${job.id}`} onPress={() => void openReport($, job, last)}>📄 Last report</Button> : null}
</Box>
</Box>
)
})
return (
<Box flexDirection="column" gap={1}>
{board}
{section(`JOBS · ${snap.jobs.length}`, T.accent,
<Box flexDirection="column" gap={0}>
{snap.jobs.length === 0 ? <Text color={T.muted}>No jobs yet. Open Skills and add one to the board.</Text> : jobRows}
</Box>,
ctl(<Button key="add-job" plain onPress={() => void setUi($, { view: 'skills' })}>+ Add from a skill</Button>))}
</Box>
)
}
// ----- job editor -----
const jobView = () => {
const job = view.jobId ? jobById(view.jobId) : undefined
if (!job) return <Text color={T.muted}>That job is gone.</Text>
const sched = parseSchedule(job.schedule)
const st = snap.states[job.id]
const patch = (fn: (j: Job) => void) => saveJobs($, cfg => {
const j = cfg.jobs.find(x => x.id === job.id)
if (j) fn(j)
})
return (
<Box flexDirection="column" gap={1}>
{section(`EDIT · ${job.name}`, T.accent,
<Box flexDirection="column" gap={1}>
<Text color={T.muted} wrap="truncate-start">{`Skill: ${job.skill}`}</Text>
{Input ? ctl(
<Input key={`name-${job.id}`} label="Name" value={job.name} submitLabel="Save"
onSubmit={v => void (v.trim() ? patch(j => void (j.name = v.trim())) : undefined)} />
) : <Text color={T.text}>{`Name: ${job.name}`}</Text>}
{Input ? ctl(
<Input key={`sched-${job.id}`} label="When" value={job.schedule} submitLabel="Save"
placeholder="mon-fri 08:00 11:00 14:00"
onSubmit={async v => {
const parsed = parseSchedule(v)
if (parsed.kind === 'error') return void setUi($, { view: 'job', jobId: job.id, message: parsed.error })
await patch(j => void (j.schedule = v.trim() || 'manual'))
$.ui.toast(`👷 ${describeSchedule(parsed)}`)
}} />
) : <Text color={T.text}>{`When: ${job.schedule}`}</Text>}
<Text color={sched.kind === 'error' ? T.bad : T.good}>{`→ ${describeSchedule(sched)}`}</Text>
<Text color={T.muted}>Examples: mon-fri 08:00 11:00 14:00 · daily 07:30 · every 2h mon-fri 07:00-17:00 · manual</Text>
{Select ? ctl(
<Select key={`perm-${job.id}`} label="Permissions" value={job.permission ?? 'smart'}
options={[
{ value: 'smart', label: 'Careful (smart): asks MiniMax to stay cautious' },
{ value: 'full', label: 'Full access: may change files and run commands' },
]}
onSelect={v => void patch(j => void (j.permission = v === 'full' ? 'full' : 'smart'))} />
) : null}
{Input ? ctl(
<Input key={`timeout-${job.id}`} label="Time limit (minutes)" value={String(job.timeoutMin ?? 20)} submitLabel="Save"
onSubmit={v => {
const n = Math.round(Number(v))
if (!Number.isFinite(n) || n < 1 || n > 120) return void setUi($, { view: 'job', jobId: job.id, message: 'Time limit is 1 to 120 minutes.' })
void patch(j => void (j.timeoutMin = n))
}} />
) : null}
<Box {...controlRow()}>
<Button key={`etoggle-${job.id}`} variant="primary" onPress={() => void patch(j => void (j.enabled = !j.enabled))}>
{job.enabled ? '🟢 On: turn off' : '⚪ Off: turn on'}
</Button>
<Button key={`erun-${job.id}`} onPress={() => void runNow($, job)}>▶ Run now</Button>
{view.confirmRemove === job.id ? (
<Button key={`econfirm-${job.id}`} onPress={async () => {
await saveJobs($, cfg => void (cfg.jobs = cfg.jobs.filter(x => x.id !== job.id)))
await setUi($, { view: 'board' })
}}>Yes, remove it</Button>
) : (
<Button key={`eremove-${job.id}`} dimColor onPress={() => void setUi($, { view: 'job', jobId: job.id, confirmRemove: job.id })}>Remove job</Button>
)}
<Button key="eback" onPress={() => void setUi($, { view: 'board' })}>← Back</Button>
</Box>
</Box>)}
{section('RECENT RUNS', T.accent,
<Box flexDirection="column">
{(st?.history ?? []).length === 0 ? <Text color={T.muted}>No runs yet.</Text> : null}
{(st?.history ?? []).slice(0, 8).map(r => (
<Box flexDirection="row" gap={1}>
<Text color={r.status === 'clear' ? T.good : r.status === 'failed' ? T.bad : T.warn} bold>
{r.status === 'clear' ? '✓' : r.status === 'failed' ? '✗' : '!'}
</Text>
<Text color={T.sub}>{`${whenLabel(new Date(r.startedAt), now)} · ${r.trigger}`}</Text>
{r.report ? ctl(<Button key={`hrep-${r.runId}`} plain dimColor={!T.control} onPress={() => void openReport($, job, r)}>📄 Report</Button>) : null}
</Box>
))}
</Box>)}
</Box>
)
}
// ----- skills -----
const skillsView = () => {
const onBoard = new Set(snap.jobs.map(j => j.skill))
const relOrAbs = (p: string) => (p.startsWith(`${ROOT}/`) ? p.slice(ROOT.length + 1) : p)
const addJob = async (skillPath: string, name: string) => {
let added: Job | undefined
await saveJobs($, cfg => {
let id = slug(name)
for (let i = 2; cfg.jobs.some(j => j.id === id); i++) id = `${slug(name)}-${i}`
added = { id, name, skill: relOrAbs(skillPath), schedule: 'mon-fri 08:00', enabled: false, permission: 'smart', timeoutMin: 20 }
cfg.jobs.push(added)
})
if (added) {
$.ui.toast(`👷 "${name}" is on the board (off until you turn it on)`)
await setUi($, { view: 'job', jobId: added.id })
}
}
return (
<Box flexDirection="column" gap={1}>
{section('SKILLS', T.accent,
<Box flexDirection="column" gap={0}>
<Text color={T.muted}>A skill is one job's instructions. Add it to the board, then set when it runs.</Text>
{snap.skills.length === 0 ? <Text color={T.muted}>No skills found in foreman/skills yet.</Text> : null}
{snap.skills.map(s => {
const added = onBoard.has(relOrAbs(s.path))
return (
<Box flexDirection="column" borderStyle="round" borderColor={added ? T.good : T.line} paddingX={1}>
<Box flexDirection="row" gap={1} flexWrap="wrap">
<Text color={T.text} bold>{s.name}</Text>
<Text color={T.muted}>{s.source === 'project' ? '(project skill)' : ''}</Text>
</Box>
{s.description ? <Text color={T.sub} wrap="wrap">{s.description}</Text> : null}
{added
? <Text color={T.good}>✓ On the board</Text>
: ctl(<Button key={`addskill-${s.path}`} variant="primary" onPress={() => void addJob(s.path, s.name)}>+ Add to board</Button>)}
</Box>
)
})}
</Box>)}
{section('LOAD A SKILL FILE', T.accent,
<Box flexDirection="column">
<Text color={T.muted} wrap="wrap">Any SKILL.md (or .md) on this Mac. New skills: copy foreman/skills/_template and fill it in.</Text>
{Input ? ctl(
<Input key="loadpath" label="Path" placeholder="/Volumes/EXT/.../SKILL.md" submitLabel="Load"
onSubmit={async v => {
const p = v.trim()
const text = p.endsWith('.md') ? await readText($, p) : undefined
if (text === undefined) return void setUi($, { view: 'skills', message: `No readable .md file at ${p}` })
const fm = frontmatter(text)
await addJob(p, fm.name || p.split('/').slice(-2, -1)[0] || 'job')
}} />
) : <Text color={T.muted}>(Loading by path needs the desktop or terminal app.)</Text>}
</Box>)}
</Box>
)
}
// ----- report -----
const reportView = () => {
const r = view.report
if (!r) return <Text color={T.muted}>No report open.</Text>
const job = r.jobId ? jobById(r.jobId) : undefined
const entry = job ? snap.states[job.id]?.history?.find(x => x.runId === r.runId) : undefined
const lines = r.text.split('\n')
const out: RenderChildren[] = []
for (const raw of lines.slice(0, 220)) {
const line = raw.replace(/\*\*|`/g, '')
if (/^\s*\|?\s*:?-{2,}/.test(line)) continue
if (/^#{1,6}\s/.test(line)) out.push(<Text color={T.accent} bold>{line.replace(/^#+\s*/, '')}</Text>)
else if (/^\s*VERDICT:/i.test(line)) out.push(<Text color={/CLEAR/i.test(line) ? T.good : T.warn} bold>{line.trim()}</Text>)
else if (line.trim().startsWith('|')) out.push(<Text color={T.text} wrap="wrap">{`• ${line.trim().replace(/^\||\|$/g, '').split('|').map(c => c.trim()).join(' · ')}`}</Text>)
else if (line.trim() === '---') out.push(<Text color={T.line}>{'─'.repeat(Math.max(10, Math.min(60, cols - 8)))}</Text>)
else out.push(<Text color={line.startsWith('- ') ? T.sub : T.text} wrap="wrap">{line || ' '}</Text>)
}
if (lines.length > 220) out.push(<Text color={T.muted}>{`… ${lines.length - 220} more lines in ${r.path}`}</Text>)
return (
<Box flexDirection="column" gap={1}>
<Box {...controlRow()}>
<Button key="rback" onPress={() => void setUi($, { view: 'board' })}>← Back</Button>
{entry && entry.status === 'attention' && !snap.acks.includes(entry.runId)
? <Button key="rask" variant="primary" onPress={() => void askClaude($, job, entry)}>✦ Ask Claude to review</Button>
: null}
{entry && entry.status !== 'clear' && !snap.acks.includes(entry.runId)
? <Button key="rack" onPress={() => void acknowledge($, entry.runId)}>✓ Mark reviewed</Button>
: null}
<Button key="rcopy" onPress={p => void $.ui.copy({ text: r.path, surface: p.surface })}>Copy path</Button>
</Box>
{section(`REPORT · ${r.title}`, T.accent, <Box flexDirection="column">{out}</Box>)}
</Box>
)
}
const body =
view.view === 'job' ? jobView() : view.view === 'skills' ? skillsView() : view.view === 'report' ? reportView() : boardView()
return (
<Box flexDirection="column" gap={1} backgroundColor={T.bg} padding={1}>
{header}
{toolbar}
{message}
{!schedulerOk && !snap.paused ? (
<Text color={T.bad} wrap="wrap">
{`The Mac scheduler hasn't checked in${snap.lastTick ? ` for ${ago(snap.lastTick, now)}` : ''}. Scheduled jobs won't run until it does (run: node foreman/bin/runner.mjs install).`}
</Text>
) : null}
{body}
</Box>
)
})
}