MEGAINFRA-WEBAPP/foreman/mod/hooks/register.tsx

605 lines
30 KiB
TypeScript
Raw Normal View History

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