#!/usr/bin/env node // Foreman's runner: runs scheduled skills with MiniMax Code (mcode) and writes reports. // No Claude involved. macOS launchd calls `tick` every minute; the pane calls `run `. // // node runner.mjs tick run every job that is due now // node runner.mjs run run one job now (manual) // node runner.mjs install install the launchd scheduler (every minute) // node runner.mjs uninstall remove it // node runner.mjs status print scheduler + jobs status import { spawn, execFileSync } from 'node:child_process' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' import { graceMs, lastSlot, parseSchedule } from '../mod/hooks/schedule.mjs' const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') // foreman/ const PROJECT = path.resolve(ROOT, '..') const JOBS = path.join(ROOT, 'jobs.json') const STATE = path.join(ROOT, 'state') const RUNS = path.join(ROOT, 'runs') const LABEL = 'com.megainfra.foreman' // launchd won't load or log from an external drive here (home is on one), so the // agent file lives on the internal disk and the scheduler log in /tmp. const PLIST_DIR = '/Users/Shared/foreman' const PLIST = path.join(PLIST_DIR, `${LABEL}.plist`) const OLD_PLIST = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`) const SCHED_LOG = '/tmp/foreman-scheduler.log' const DEFAULT_MCODE = path.join(os.homedir(), '.minimax-code', 'bin', 'mcode') const HISTORY_MAX = 40 fs.mkdirSync(STATE, { recursive: true }) fs.mkdirSync(RUNS, { recursive: true }) const readJson = (file, fallback) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return fallback } } const writeJson = (file, value) => { const tmp = `${file}.${process.pid}.tmp` fs.writeFileSync(tmp, JSON.stringify(value, null, 2)) fs.renameSync(tmp, file) } const loadConfig = () => readJson(JOBS, { paused: false, jobs: [] }) const stateFile = id => path.join(STATE, `${id}.json`) const loadState = id => readJson(stateFile(id), { id, status: 'idle', history: [] }) const pidAlive = pid => { try { process.kill(pid, 0) return true } catch { return false } } const log = (...a) => console.log(new Date().toISOString(), ...a) function skillText(job) { const p = path.isAbsolute(job.skill) ? job.skill : path.join(ROOT, job.skill) const raw = fs.readFileSync(p, 'utf8') return raw.replace(/^---\n[\s\S]*?\n---\n/, '').trim() } function buildPrompt(job, startedAt) { return `${skillText(job)} --- You are Foreman, the site agent for Mega Infrastructure, running the job "${job.name}" on a schedule. Run time: ${startedAt.toString()} Project folder: ${PROJECT} Report contract (required): - Your FINAL message is saved as this run's report and shown on Foreman's board. - Its first line must be exactly \`VERDICT: CLEAR\` when nothing needs a person, or \`VERDICT: ATTENTION ( items)\` when something does. - Then markdown sections: "## Summary" (2-4 lines), "## Needs attention" (a table: reference | problem | suggested action; leave out when CLEAR), "## What I checked". - Be specific: document numbers, suppliers, amounts, dates. No guesses presented as facts.` } function describeInput(input) { if (!input || typeof input !== 'object') return '' for (const k of ['path', 'file_path', 'command', 'query', 'url', 'pattern']) { if (typeof input[k] === 'string') return input[k].replace(/\s+/g, ' ').slice(0, 90) } return JSON.stringify(input).slice(0, 90) } async function runJob(job, trigger, slot) { const st = loadState(job.id) if (st.status === 'running' && st.pid && pidAlive(st.pid)) { log(`skip ${job.id}: already running (pid ${st.pid})`) return } const startedAt = new Date() const stamp = startedAt.toISOString().replace(/[:T]/g, '-').slice(0, 16) const runDir = path.join(RUNS, job.id) fs.mkdirSync(runDir, { recursive: true }) const reportRel = path.join('runs', job.id, `${stamp}.md`) const run = { runId: `${job.id}-${stamp}`, trigger, slot: slot ? slot.toISOString() : undefined, startedAt: startedAt.toISOString(), toolCount: 0, activity: [], } const save = extra => { const cur = loadState(job.id) writeJson(stateFile(job.id), { ...cur, ...extra, id: job.id }) } save({ status: 'running', pid: process.pid, current: run, ...(slot ? { lastSlot: slot.toISOString() } : {}) }) log(`start ${job.id} (${trigger})`) let prompt try { prompt = buildPrompt(job, startedAt) } catch (err) { return finish({ status: 'failed', error: `Could not read the skill file: ${err.message}` }) } const mcode = job.mcode || loadConfig().mcode || DEFAULT_MCODE const args = [ 'exec', '--cwd', PROJECT, '--input', '-', '--output-format', 'stream-json', '--permission', job.permission === 'full' ? 'full' : 'smart', '--timeout', `${Math.max(1, Math.min(120, Number(job.timeoutMin) || 20))}m`, ] const child = spawn(mcode, args, { cwd: PROJECT, stdio: ['pipe', 'pipe', 'pipe'] }) child.stdin.end(prompt) let output = '' let error = '' let stderrTail = '' let tokensIn = 0 let tokensOut = 0 let model = '' let lastSave = 0 const activity = (text, kind) => { run.activity.push({ at: new Date().toISOString(), kind, text: text.slice(0, 160) }) run.activity = run.activity.slice(-30) if (Date.now() - lastSave > 1000) { lastSave = Date.now() save({ current: run }) } } let buf = '' child.stdout.on('data', chunk => { buf += chunk let nl while ((nl = buf.indexOf('\n')) >= 0) { const line = buf.slice(0, nl).trim() buf = buf.slice(nl + 1) if (!line) continue let ev try { ev = JSON.parse(line) } catch { continue } const item = ev.item if (ev.type === 'item.completed' && item?.type === 'tool_call') { run.toolCount++ const c = item.toolCall ?? {} activity(`${c.name ?? 'tool'} ${describeInput(c.input)}`.trim(), c.status === 2 ? 'tool' : 'tool-fail') } else if (ev.type === 'item.completed' && item?.type === 'agent_message') { const text = String(item.content ?? '').replace(/\s+/g, ' ').trim() if (text) activity(text, 'say') } else if (ev.type === 'turn.completed') { tokensIn += Number(ev.usage?.inputTokens ?? 0) tokensOut += Number(ev.usage?.outputTokens ?? 0) if (ev.model?.modelId) model = ev.model.modelId } else if (ev.type === 'exec.completed') { output = String(ev.result?.output ?? '') if (ev.result?.status !== 'succeeded') error = `MiniMax ended with status ${ev.result?.status}${ev.result?.error ? `: ${JSON.stringify(ev.result.error)}` : ''}` } } }) child.stderr.on('data', chunk => { stderrTail = (stderrTail + chunk).slice(-1500) }) const code = await new Promise(resolve => { child.on('error', err => { error = `Could not start MiniMax (${mcode}): ${err.message}` resolve(-1) }) child.on('close', resolve) }) if (!output && !error) error = `MiniMax exited (code ${code}) without a report. ${stderrTail.trim()}`.trim() return finish({ status: error ? 'failed' : undefined, error }) function finish({ status, error: err }) { const endedAt = new Date() const verdictLine = /^\s*VERDICT:\s*(CLEAR|ATTENTION)(?:\s*\((\d+)[^)]*\))?/im.exec(output) const verdict = verdictLine ? verdictLine[1].toUpperCase() : null const finalStatus = status ?? (verdict === 'CLEAR' ? 'clear' : 'attention') const attentionCount = verdictLine?.[2] ? Number(verdictLine[2]) : verdict === 'ATTENTION' ? null : 0 const summaryMatch = /##\s*Summary\s*\n([\s\S]*?)(?:\n##|$)/i.exec(output) const summary = (summaryMatch?.[1] ?? output.split('\n').slice(1, 4).join(' ')) .replace(/\s+/g, ' ') .trim() .slice(0, 300) const header = [ `# ${job.name}`, ``, `- Run: ${startedAt.toString()} (${trigger})`, `- Duration: ${Math.round((endedAt - startedAt) / 1000)}s · ${run.toolCount} tool calls · model ${model || 'unknown'} · tokens ${tokensIn}/${tokensOut}`, `- Status: ${finalStatus}${verdict ? '' : output ? ' (no VERDICT line found, treated as needing attention)' : ''}`, ``, `---`, ``, ].join('\n') fs.writeFileSync(path.join(ROOT, reportRel), header + (err ? `**Run failed:** ${err}\n\n` : '') + (output || '')) const entry = { runId: run.runId, trigger, slot: run.slot, startedAt: run.startedAt, endedAt: endedAt.toISOString(), status: finalStatus, attentionCount, summary: err ? err.slice(0, 300) : summary, report: reportRel, toolCount: run.toolCount, tokensIn, tokensOut, model, } const cur = loadState(job.id) writeJson(stateFile(job.id), { ...cur, id: job.id, status: finalStatus, pid: undefined, current: undefined, lastRun: entry, history: [entry, ...(cur.history ?? [])].slice(0, HISTORY_MAX), }) log(`end ${job.id}: ${finalStatus}`) } } async function tick() { const cfg = loadConfig() writeJson(path.join(STATE, 'scheduler.json'), { lastTick: new Date().toISOString(), pid: process.pid, paused: !!cfg.paused }) if (cfg.paused) return const now = new Date() const due = [] for (const job of cfg.jobs ?? []) { if (!job.enabled) continue const s = parseSchedule(job.schedule) const slot = lastSlot(s, now) if (!slot || now - slot > graceMs(s)) continue const st = loadState(job.id) if (st.lastSlot && new Date(st.lastSlot) >= slot) continue due.push({ job, slot }) } // One at a time keeps MiniMax usage predictable. for (const { job, slot } of due) await runJob(job, 'schedule', slot) } function install() { const node = process.execPath const runner = fileURLToPath(import.meta.url) const plist = ` Label${LABEL} ProgramArguments ${node}${runner}tick StartInterval60 RunAtLoad StandardOutPath${SCHED_LOG} StandardErrorPath${SCHED_LOG} EnvironmentVariables PATH${path.dirname(node)}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin HOME${os.homedir()} ` const cfg = loadConfig() writeJson(JOBS, { ...cfg, node }) fs.mkdirSync(PLIST_DIR, { recursive: true }) fs.rmSync(OLD_PLIST, { force: true }) fs.writeFileSync(PLIST, plist) const uid = process.getuid() try { execFileSync('launchctl', ['bootout', `gui/${uid}/${LABEL}`], { stdio: 'ignore' }) } catch {} execFileSync('launchctl', ['bootstrap', `gui/${uid}`, PLIST]) console.log(`Scheduler installed: ${PLIST} (runs "tick" every minute, log ${SCHED_LOG}).`) console.log(`To keep it after a restart: sudo cp ${PLIST} /Library/LaunchAgents/`) } function uninstall() { try { execFileSync('launchctl', ['bootout', `gui/${process.getuid()}/${LABEL}`], { stdio: 'ignore' }) } catch {} fs.rmSync(PLIST, { force: true }) fs.rmSync(OLD_PLIST, { force: true }) console.log(`Scheduler removed. If you copied it to /Library/LaunchAgents, also run: sudo rm /Library/LaunchAgents/${LABEL}.plist`) } function status() { const cfg = loadConfig() let loaded = false try { execFileSync('launchctl', ['print', `gui/${process.getuid()}/${LABEL}`], { stdio: 'ignore' }) loaded = true } catch {} console.log(`Scheduler: ${loaded ? 'installed' : 'NOT installed'}${cfg.paused ? ' (paused)' : ''}`) console.log('Last tick:', readJson(path.join(STATE, 'scheduler.json'), {}).lastTick ?? 'never') for (const job of cfg.jobs ?? []) { const st = loadState(job.id) console.log(`- ${job.id} [${job.enabled ? 'on' : 'off'}] "${job.schedule}" → ${st.status}${st.lastRun ? ` (last ${st.lastRun.endedAt})` : ''}`) } } const [cmd, arg] = process.argv.slice(2) if (cmd === 'tick') await tick() else if (cmd === 'run') { const job = (loadConfig().jobs ?? []).find(j => j.id === arg) if (!job) { console.error(`No job "${arg}" in ${JOBS}`) process.exit(1) } await runJob(job, 'manual') } else if (cmd === 'install') install() else if (cmd === 'uninstall') uninstall() else if (cmd === 'status') status() else { console.log('Usage: node runner.mjs tick | run | install | uninstall | status') process.exit(cmd ? 1 : 0) }