From 3ed5ad7d770e701480aabd7eda86b8021a6b95db Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 9 Nov 2026 12:36:30 +0000 Subject: [PATCH] feat(c0mpute): task states in /agents and /tasks, plus & background tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `moshcode agents c0mpute` now tracks every turn as a task with Claude Code's agents-view states: Needs input, Working, Completed, Failed, Stopped. - `& [@agent] ` (or /bg) runs a task in the background; a permission prompt parks it as Needs input instead of grabbing the prompt, and `/tasks ` shows it and answers. Transitions are announced above the prompt, which shows `[1 working · 1 needs input]` while any run. - `/agents` lists tasks by state above the agent definitions; `/tasks`, `/tasks stop `, `/tasks clear`. - The board is mirrored to ~/.moshcode/c0mpute-tasks.json so `moshcode agents c0mpute tasks` and other sessions see it; a live task whose process died reads as Stopped. Co-Authored-By: Claude Opus 5.5 --- src/c0agent-repl.mjs | 228 ++++++++++++++++++++++++++++++++---- src/c0agent-tasks.mjs | 179 ++++++++++++++++++++++++++++ test/c0agent-tasks.test.mjs | 79 +++++++++++++ 3 files changed, 465 insertions(+), 21 deletions(-) create mode 100644 src/c0agent-tasks.mjs create mode 100644 test/c0agent-tasks.test.mjs diff --git a/src/c0agent-repl.mjs b/src/c0agent-repl.mjs index f6f951fd..9020126e 100644 --- a/src/c0agent-repl.mjs +++ b/src/c0agent-repl.mjs @@ -4,6 +4,8 @@ // > type a task; the agent streams, tool calls print as ● Read(…) // /agents list, create (generated or by hand), show, edit, remove subagents // @name task run one subagent directly +// & task run a task in the background; /tasks shows every task's state +// (Needs input · Working · Completed · Failed · Stopped) // shift-tab cycle permission mode: ask → accept edits → plan → ask // esc / ^C interrupt the running turn; ^C twice at an empty prompt exits // -p "task" print mode: run once, answer on stdout, progress on stderr @@ -20,6 +22,7 @@ import { import { ALL_TOOLS, READ_ONLY_TOOLS, agentDirs, findAgent, loadAgents, normalizeTool, removeAgent, renderAgentFile, saveAgent, validName, } from "./c0agent-defs.mjs"; +import { createTaskBoard, liveSummary, readTasks, renderTasks, tasksFile } from "./c0agent-tasks.mjs"; import { acid, amber, ash, bone, danger, dim, err, info, ok, warn } from "./ui.mjs"; const MODES = ["ask", "auto-edit", "plan"]; @@ -42,6 +45,8 @@ options: --permission-mode ask | auto-edit | plan -y, --dangerously-skip-permissions never ask (use in a container) agents [list|show|rm] manage agent definitions without opening a session + tasks [clear] every task and its state: Needs input, Working, + Completed, Failed, Stopped (across sessions) agents live in ./.moshcode/agents and ~/.moshcode/agents (markdown + frontmatter); Claude Code's .claude/agents are picked up too. key: C0MPUTE_API_KEY or /key.`; @@ -63,6 +68,7 @@ export function parseArgs(argv) { else if (a === "-y" || a === "--yes" || a === "--dangerously-skip-permissions") opts.mode = "yolo"; // `agents …` as the first word (after any flags) manages definitions; later it's prompt text. else if (!opts.words.length && opts.print == null && (a === "agents" || a === "agent")) { opts.manage = argv.slice(i + 1); break; } + else if (!opts.words.length && opts.print == null && (a === "tasks" || a === "task")) { opts.manage = ["tasks", ...argv.slice(i + 1)]; break; } else opts.words.push(a); } if (opts.print === "" && opts.words.length) opts.print = opts.words.join(" "); @@ -240,8 +246,20 @@ function createRenderer({ out, log, spinner, quiet = false }) { // ─── management (no session needed) ────────────────────────────────────────── -export function manageAgents(args, { write = (l) => console.log(l), cwd = process.cwd() } = {}) { +export function manageAgents(args, { write = (l) => console.log(l), cwd = process.cwd(), file = tasksFile() } = {}) { const [verb = "list", name] = args; + if (verb === "tasks") { + if (name === "clear") { + const all = readTasks({ file }); + const live = all.filter((t) => t.state === "working" || t.state === "needs_input"); + try { fs.writeFileSync(file, `${JSON.stringify(live, null, 2)}\n`, { mode: 0o600 }); } catch { /* nothing saved yet */ } + const n = all.length - live.length; + write(ok(`cleared ${n} finished task${n === 1 ? "" : "s"}`)); + return 0; + } + renderTasks(readTasks({ file }), write, { showPid: true }); + return 0; + } if (verb === "list" || verb === "ls") { renderAgentList(loadAgents({ cwd }), write); return 0; } if (verb === "show" || verb === "cat") { const a = findAgent(name, { cwd }); @@ -258,7 +276,7 @@ export function manageAgents(args, { write = (l) => console.log(l), cwd = proces for (const d of agentDirs({ cwd })) write(`${pad8(d.source)} ${d.dir}${d.writable ? "" : ash(" (read-only)")}`); return 0; } - write(err(`unknown: agents ${verb}. try list, show , rm , dirs — or /agents new inside a session`)); + write(err(`unknown: agents ${verb}. try list, show , rm , dirs, tasks — or /agents new inside a session`)); return 1; } @@ -308,6 +326,8 @@ function appendHistory(line) { const HELP = [ ["/agents", "list agents · new · show · edit · rm · run "], ["@ ", "run one subagent directly"], + ["& [@agent] ", "run a task in the background (also /bg)"], + ["/tasks", "every task by state · /tasks · stop · clear"], ["/model [name]", "show or switch the model (saved)"], ["/models", "models c0mpute serves right now"], ["/permissions [mode]", "ask · auto-edit · plan (shift-tab cycles)"], @@ -373,7 +393,7 @@ async function repl({ cfg, opts, write }) { terminal: true, completer: (line) => { if (shiftTab) { shiftTab = false; return [[], line]; } - const words = ["/agents", "/model", "/models", "/permissions", "/todos", "/compact", "/clear", "/cost", "/key", "/cwd", "/exit", "/help", + const words = ["/agents", "/tasks", "/bg", "/model", "/models", "/permissions", "/todos", "/compact", "/clear", "/cost", "/key", "/cwd", "/exit", "/help", ...loadAgents({ cwd: session.cwd }).map((a) => `@${a.name}`)]; const last = line.split(/\s+/).pop(); const hits = words.filter((w) => w.startsWith(last)); @@ -383,33 +403,156 @@ async function repl({ cfg, opts, write }) { // One question at a time: parallel subagents can each want permission. let asking = Promise.resolve(); - const question = (q) => new Promise((resolve) => rl.question(q, resolve)); + let questioning = 0; + const question = (q) => { + questioning += 1; + return new Promise((resolve) => rl.question(q, (a) => { questioning -= 1; resolve(a); })); + }; + const promptPermission = async (req) => { + spinner.stop(); + log(""); + log(`${amber("●")} ${bone(req.agent && req.agent !== "c0mpute" ? `[${req.agent}] ${req.title}` : req.title)}`); + for (const l of String(req.preview).split("\n").slice(0, 24)) { + log(` ${l.startsWith("+ ") ? acid(l) : l.startsWith("- ") ? danger(l) : dim(l)}`); + } + const a = (await question(` ${bone("Allow?")} ${ash("[y]es · [a]lways for this session · [n]o, or type what to do instead")} `)).trim(); + if (/^(y|yes)$/i.test(a) || a === "") return "yes"; + if (/^(a|always)$/i.test(a)) return "always"; + if (/^(n|no)$/i.test(a)) return "no"; + return a; + }; const askPermission = (req) => { - const p = asking.then(async () => { - spinner.stop(); - log(""); - log(`${amber("●")} ${bone(req.agent && req.agent !== "c0mpute" ? `[${req.agent}] ${req.title}` : req.title)}`); - for (const l of String(req.preview).split("\n").slice(0, 24)) { - log(` ${l.startsWith("+ ") ? acid(l) : l.startsWith("- ") ? danger(l) : dim(l)}`); - } - const a = (await question(` ${bone("Allow?")} ${ash("[y]es · [a]lways for this session · [n]o, or type what to do instead")} `)).trim(); - if (/^(y|yes)$/i.test(a) || a === "") return "yes"; - if (/^(a|always)$/i.test(a)) return "always"; - if (/^(n|no)$/i.test(a)) return "no"; - return a; + const p = asking.then(() => { + if (current) board.update(current, { state: "needs_input", pending: { req } }); + return promptPermission(req).finally(() => { if (current) board.update(current, { state: "working", pending: null }); }); }); asking = p.catch(() => {}); return p; }; + // ── tasks: every turn is one; `&` runs one in the background ── + // Background transitions are announced above the prompt when idle, and + // held until the next prompt while a turn streams or a question is open. + const notes = []; + const flushNotes = () => { while (notes.length) log(notes.shift()); }; + const announce = (s) => { + notes.push(s); + if (busy || questioning || !rl) return; + process.stdout.write("\r\x1b[2K"); + flushNotes(); + prompt(); + }; + let current = null; // the foreground task + const board = createTaskBoard({ + onChange: (t, before) => { + if (!t.background || before == null) return; + const id = ash(`#${t.id}`); + if (t.state === "needs_input") announce(`${amber("●")} ${id} needs input: ${t.pending?.req?.title ?? "an answer"} ${ash(`— /tasks ${t.id} to answer`)}`); + else if (t.state === "completed") { + const first = String(t.result ?? "").trim().split("\n")[0]; + announce(`${acid("✓")} ${id} completed${first ? `: ${dim(first.length > 90 ? `${first.slice(0, 89)}…` : first)}` : ""} ${ash(`— /tasks ${t.id}`)}`); + } else if (t.state === "failed") announce(`${danger("✗")} ${id} failed: ${t.error} ${ash(`— /tasks ${t.id}`)}`); + else if (t.state === "stopped") announce(`${ash("■")} ${id} stopped`); + }, + }); + const trackEvent = (t, e, jobs) => { + if (e.type === "waiting") board.update(t, { activity: e.depth ? `${e.agent} · sending to c0mpute` : "sending to c0mpute" }); + else if (e.type === "status") { + const key = `${e.agent}@${e.depth}`; + const job = jobs.get(key) ?? {}; + jobs.set(key, job); + if (e.status.node) job.node = e.status.node; + if (e.status.stage === "writing" && job.writingSince == null) job.writingSince = Date.now(); + const label = statusLabel(e.status, job, { promptTokens: e.promptTokens, agent: e.depth ? e.agent : null }); + if (label) board.update(t, { activity: label }); + } else if (e.type === "tool_start") { + const title = e.depth ? `[${e.agent}] ${e.title}` : e.title; + board.note(t, `● ${title}`); + board.update(t, { activity: title }); + } else if (e.type === "tool_end" && !e.result.ok) { + board.note(t, ` ⎿ ${String(e.result.output ?? "").split("\n")[0].slice(0, 200)}`); + } else if (e.type === "error") board.note(t, ` ⎿ ${e.error}`); + }; + const finishTask = (t, res) => { + if (res?.error === "interrupted") board.update(t, { state: "stopped", activity: "interrupted", result: res.text || null }); + else if (res?.error && res.error !== "step limit") board.update(t, { state: "failed", error: res.error, result: res.text || null }); + else board.update(t, { state: "completed", activity: "done", result: res?.text ?? "" }); + }; + const liveBackground = () => board.tasks.filter((t) => t.background && (t.state === "working" || t.state === "needs_input")); + + function runBackground(agent, content) { + const t = board.add({ title: content, agent: agent.name, background: true, cwd: session.cwd }); + const controller = new AbortController(); + t.controller = controller; + // Its own view of the session: launch cwd, own todos, and permission + // prompts that park the task as Needs input instead of grabbing the prompt. + // Usage, the always-allow list and the live permission mode stay shared. + const sub = Object.create(session); + sub.cwd = session.cwd; + sub.todos = []; + sub.ask = (req) => new Promise((resolve) => board.update(t, { state: "needs_input", pending: { req, resolve } })); + const jobs = new Map(); + const asLead = agent === lead; + runAgent({ session: sub, agent, messages: [{ role: "user", content }], depth: asLead ? 0 : 1, signal: controller.signal, onEvent: (e) => trackEvent(t, e, jobs) }) + .then((res) => finishTask(t, res), (e) => board.update(t, { state: "failed", error: String(e?.message || e) })); + log(`${acid("◐")} ${ash(`#${t.id}`)} running in the background${asLead ? "" : ` as @${agent.name}`} ${ash("— /tasks to watch it")}`); + return t; + } + + function stopTask(t) { + if (t.state !== "working" && t.state !== "needs_input") return false; + t.controller?.abort(); + if (t.pending?.resolve) { t.pending.resolve("no"); } + return true; + } + + async function tasksCommand(args) { + const [verb, ...rest] = args; + if (!verb || verb === "list" || verb === "ls") { + const others = readTasks().filter((x) => x.pid !== process.pid); + renderTasks([...board.tasks, ...others], log, { showPid: true }); + return; + } + if (verb === "clear") { const n = board.clearFinished(); log(ok(`cleared ${n} finished task${n === 1 ? "" : "s"}`)); return; } + if (verb === "stop" || verb === "kill" || verb === "cancel") { + const t = board.find(rest[0]); + if (!t) { log(err(`no task #${rest[0] ?? ""} in this session — /tasks`)); return; } + if (t === current) { log(ash(" that's the turn in front of you: esc stops it")); return; } + log(stopTask(t) ? ok(`stopping #${t.id}`) : ash(` #${t.id} is already ${t.state}`)); + return; + } + const t = board.find(verb); + if (!t) { + const other = readTasks().find((x) => x.id === verb.replace(/^#/, "")); + if (other) { log(ash(` #${other.id} belongs to another session (pid ${other.pid}): ${other.state} · ${other.title}`)); return; } + log(err(`no task #${verb} — /tasks lists them`)); + return; + } + const icon = { needs_input: amber("●"), working: acid("◐"), completed: acid("✓"), failed: danger("✗"), stopped: ash("■") }[t.state]; + log(`${icon} ${ash(`#${t.id}`)} ${bone(t.title)}`); + log(` ${ash("state")} ${t.state.replace("_", " ")} ${ash("agent")} ${t.agent} ${ash("cwd")} ${t.cwd}`); + for (const l of t.log.slice(-15)) log(` ${dim(l)}`); + if (t.state === "working") log(` ${ash("⎿")} ${dim(t.activity)}`); + if (t.error) log(` ${danger(t.error)}`); + if (t.result) { log(`${acid("●")} ${bone("result")}`); log(t.result); } + if (t.state === "needs_input" && t.pending?.resolve) { + const { req, resolve } = t.pending; + const answer = await promptPermission(req); + board.update(t, { state: "working", pending: null, activity: `answered: ${answer}` }); + resolve(answer); + } + } + const session = createSession({ provider, model: cfg.model, cwd: opts.cwd || process.cwd(), permissionMode: opts.mode || cfg.permissionMode || "ask", ask: askPermission }); const lead = opts.agent ? findAgent(opts.agent, { cwd: session.cwd }) : LEAD_AGENT; if (!lead) { write(err(`no agent named "${opts.agent}"`)); rl.close(); return 1; } let messages = []; const prompt = () => { + flushNotes(); const mode = session.permissionMode === "ask" ? "" : `${amber(`⏵⏵ ${MODE_LABEL[session.permissionMode]}`)} `; - rl.setPrompt(`${mode}${acid(">")} `); + const live = liveSummary(board.tasks); + rl.setPrompt(`${live ? `${ash("[")}${live}${ash("]")} ` : ""}${mode}${acid(">")} `); rl.prompt(true); }; let lastInt = 0; @@ -436,20 +579,29 @@ async function repl({ cfg, opts, write }) { busy = new AbortController(); renderer.reset(); const started = Date.now(); + const task = board.add({ title: content, agent: agent.name, background: false, cwd: session.cwd }); + current = task; + const jobs = new Map(); + const onEvent = (e) => { trackEvent(task, e, jobs); renderer.event(e); }; let res; try { if (asSub) { - res = await runAgent({ session, agent, messages: [{ role: "user", content }], depth: 1, signal: busy.signal, onEvent: (e) => renderer.event(e) }); + res = await runAgent({ session, agent, messages: [{ role: "user", content }], depth: 1, signal: busy.signal, onEvent }); renderer.finish(); if (res.text) { log(`${acid("●")} ${bone(`[${agent.name}]`)}`); log(res.text); } } else { messages.push({ role: "user", content }); - res = await runAgent({ session, agent, messages, signal: busy.signal, onEvent: (e) => renderer.event(e) }); + res = await runAgent({ session, agent, messages, signal: busy.signal, onEvent }); renderer.finish(); } + } catch (e) { + res = { error: String(e?.message || e) }; + throw e; } finally { busy = null; + current = null; spinner.stop(); + finishTask(task, res); } if (res?.error === "interrupted") log(amber(" ⎿ interrupted · what should c0mpute do instead?")); else if (res?.error && res.error !== "step limit") log(err(res.error)); @@ -503,11 +655,30 @@ async function repl({ cfg, opts, write }) { finally { busy = null; } }, agents: (args) => agentsCommand(args), + tasks: (args) => tasksCommand(args), + bg: (args) => background(args.join(" ")), }; + function background(text) { + const m = text.trim().match(/^@([a-z0-9-]+)\s*([\s\S]*)$/i); + const agent = m ? findAgent(m[1], { cwd: session.cwd }) : lead; + const task = (m ? m[2] : text).trim(); + if (!agent) { log(err(`no agent named "${m[1]}" — /agents to list them`)); return; } + if (!task) { log(err("usage: & [@agent] ")); return; } + runBackground(agent, task); + } + async function agentsCommand(args) { const [verb, ...rest] = args; - if (!verb || verb === "list") { renderAgentList(loadAgents({ cwd: session.cwd }), log); return; } + if (!verb || verb === "list") { + // Like Claude Code's agents view: what's running and what needs you first. + log(bone("Tasks")); + renderTasks([...board.tasks, ...readTasks().filter((x) => x.pid !== process.pid)], (l) => log(` ${l}`), { showPid: true }); + log(""); + renderAgentList(loadAgents({ cwd: session.cwd }), log); + return; + } + if (verb === "tasks") { await tasksCommand(rest); return; } if (verb === "show" || verb === "rm" || verb === "dirs" || verb === "remove") { if (verb === "rm" || verb === "remove") { const a = findAgent(rest[0], { cwd: session.cwd }); @@ -610,6 +781,7 @@ async function repl({ cfg, opts, write }) { } // The loop. + let exitWarned = false; prompt(); for await (const raw of rl) { const line = raw.trim(); @@ -618,10 +790,23 @@ async function repl({ cfg, opts, write }) { try { if (line.startsWith("/")) { const [c, ...args] = line.slice(1).split(/\s+/); - if (c === "exit" || c === "quit" || c === "q") break; + if (c === "exit" || c === "quit" || c === "q") { + const live = liveBackground(); + if (live.length && !exitWarned) { + exitWarned = true; + log(warn(`${live.length} background task${live.length === 1 ? " is" : "s are"} still running — /exit again to stop ${live.length === 1 ? "it" : "them"}`)); + log(""); + prompt(); + continue; + } + break; + } + exitWarned = false; const fn = commands[c.toLowerCase()]; if (fn) await fn(args); else log(err(`unknown command /${c} — /help`)); + } else if (line.startsWith("&")) { + background(line.slice(1)); } else if (line.startsWith("@")) { const m = line.match(/^@([a-z0-9-]+)\s*([\s\S]*)$/i); const a = m && findAgent(m[1], { cwd: session.cwd }); @@ -639,6 +824,7 @@ async function repl({ cfg, opts, write }) { prompt(); } spinner.stop(); + for (const t of liveBackground()) { stopTask(t); board.update(t, { state: "stopped", activity: "session ended" }); } process.stdin.off("keypress", onKey); rl.close(); log(ash("bye 🤘")); diff --git a/src/c0agent-tasks.mjs b/src/c0agent-tasks.mjs new file mode 100644 index 00000000..2cf0a604 --- /dev/null +++ b/src/c0agent-tasks.mjs @@ -0,0 +1,179 @@ +// Task states for the c0mpute agent, shaped after Claude Code's agents view: +// every turn and every background job is a task that is Working, Needs input, +// Completed, Failed or Stopped. +// +// The board is in memory for the session that owns the tasks and mirrored to +// ~/.moshcode/c0mpute-tasks.json, so `moshcode agents c0mpute tasks` and other +// sessions see them too. A task left Working by a process that is gone reads +// as Stopped, never as Working forever. + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { acid, amber, ash, bone, danger, dim } from "./ui.mjs"; + +export const STATES = ["needs_input", "working", "completed", "failed", "stopped"]; +export const STATE_LABEL = { + needs_input: "Needs input", + working: "Working", + completed: "Completed", + failed: "Failed", + stopped: "Stopped", +}; +const LIVE = new Set(["working", "needs_input"]); +const KEEP = 50; + +export const tasksFile = (home = os.homedir()) => path.join(home, ".moshcode", "c0mpute-tasks.json"); + +function pidAlive(pid) { + if (!pid) return false; + if (pid === process.pid) return true; + try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } +} + +/** Read the saved board. Live tasks whose process has died come back as Stopped. */ +export function readTasks({ file = tasksFile(), alive = pidAlive } = {}) { + let list = []; + try { list = JSON.parse(fs.readFileSync(file, "utf8")); } catch { return []; } + if (!Array.isArray(list)) return []; + return list.filter((t) => t && t.id && STATES.includes(t.state)).map((t) => ( + LIVE.has(t.state) && !alive(t.pid) ? { ...t, state: "stopped", activity: "session ended", endedAt: t.endedAt ?? t.updatedAt } : t + )); +} + +/** What gets saved: no resolvers, no abort controllers, no unbounded logs. */ +function serializable(t) { + const { pending, controller, ...rest } = t; + return { ...rest, log: (rest.log ?? []).slice(-40), pendingTitle: pending?.req?.title ?? null }; +} + +/** + * A board owned by one process. `add` makes a task; `update` changes it and + * notifies `onChange(task, previousState)`. Saving merges with what other + * sessions wrote, replacing only this process's own tasks. + */ +export function createTaskBoard({ file = tasksFile(), pid = process.pid, now = () => Date.now(), persist = true, onChange = () => {} } = {}) { + const tasks = []; + const newId = () => { + let id; + do { id = Math.random().toString(16).slice(2, 6); } while (tasks.some((t) => t.id === id)); + return id; + }; + const save = () => { + if (!persist) return; + try { + const mine = new Set(tasks.map((t) => t.id)); + const others = readTasks({ file }).filter((t) => t.pid !== pid && !mine.has(t.id)); + const all = [...others, ...tasks.map(serializable)] + .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) + .slice(0, KEEP); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.${pid}.tmp`; + fs.writeFileSync(tmp, `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 }); + fs.renameSync(tmp, file); + } catch { /* the board is a convenience; never fail a turn over it */ } + }; + const board = { + tasks, + add({ title, agent = "c0mpute", background = false, cwd = process.cwd() }) { + const t = { + id: newId(), pid, title: String(title).split("\n")[0].slice(0, 200), agent, background, cwd, + state: "working", activity: "starting", startedAt: now(), updatedAt: now(), endedAt: null, + result: null, error: null, log: [], + }; + tasks.push(t); + save(); + onChange(t, null); + return t; + }, + update(t, patch) { + const before = t.state; + Object.assign(t, patch, { updatedAt: now() }); + if (!LIVE.has(t.state) && !t.endedAt) t.endedAt = now(); + // Activity changes are frequent (every tool call): only save on state changes. + if (patch.state && patch.state !== before) { save(); onChange(t, before); } + }, + note(t, line) { + t.log.push(line); + if (t.log.length > 200) t.log.splice(0, t.log.length - 200); + }, + find(id) { + const k = String(id ?? "").replace(/^#/, ""); + return tasks.find((x) => x.id === k) ?? null; + }, + /** Drop finished tasks from this session (and the saved board). */ + clearFinished() { + const before = tasks.length; + for (let i = tasks.length - 1; i >= 0; i--) if (!LIVE.has(tasks[i].state)) tasks.splice(i, 1); + if (persist) { + try { + const keep = readTasks({ file }).filter((t) => LIVE.has(t.state)); + const ours = new Set(tasks.map((t) => t.id)); + const all = [...keep.filter((t) => !ours.has(t.id)), ...tasks.map(serializable)]; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 }); + } catch { /* best effort */ } + } + return before - tasks.length; + }, + counts() { + const c = Object.fromEntries(STATES.map((s) => [s, 0])); + for (const t of tasks) c[t.state] += 1; + return c; + }, + save, + }; + return board; +} + +// ─── rendering ─────────────────────────────────────────────────────────────── + +export function ago(ms) { + const s = Math.max(0, Math.round(ms / 1000)); + if (s < 60) return `${s}s`; + const m = Math.round(s / 60); + if (m < 60) return `${m}m`; + const h = Math.round(m / 60); + return h < 48 ? `${h}h` : `${Math.round(h / 24)}d`; +} + +const ICON = { + needs_input: () => amber("●"), + working: () => acid("◐"), + completed: () => acid("✓"), + failed: () => danger("✗"), + stopped: () => ash("■"), +}; + +/** One line per task, grouped by state, live states first. */ +export function renderTasks(tasks, write, { now = Date.now(), showPid = false } = {}) { + if (!tasks.length) { write(ash(" no tasks yet · type a task, or & to run one in the background")); return; } + for (const state of STATES) { + const list = tasks.filter((t) => t.state === state).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + if (!list.length) continue; + write(bone(`${STATE_LABEL[state]} (${list.length})`)); + for (const t of list) { + const title = t.title.length > 70 ? `${t.title.slice(0, 69)}…` : t.title; + const since = LIVE.has(state) ? ago(now - t.startedAt) : `${ago(now - (t.endedAt ?? t.updatedAt))} ago`; + const who = t.agent && t.agent !== "c0mpute" ? ` ${dim(`@${t.agent}`)}` : ""; + const bg = t.background ? "" : ` ${ash("(fg)")}`; + const pid = showPid && t.pid !== process.pid ? ` ${ash(`pid ${t.pid}`)}` : ""; + write(` ${ICON[state]()} ${ash(`#${t.id}`)} ${title}${who}${bg} ${ash(`· ${since}`)}${pid}`); + const detail = state === "needs_input" ? `waiting on: ${t.pending?.req?.title ?? t.pendingTitle ?? "an answer"}` + : state === "failed" ? t.error + : state === "working" ? t.activity + : null; + if (detail) write(` ${ash("⎿")} ${state === "failed" ? danger(detail) : dim(detail)}`); + } + } +} + +/** "2 working · 1 needs input" for the prompt, or "" when nothing runs in the background. */ +export function liveSummary(tasks) { + const bg = tasks.filter((t) => t.background); + const n = (s) => bg.filter((t) => t.state === s).length; + const parts = []; + if (n("needs_input")) parts.push(amber(`${n("needs_input")} needs input`)); + if (n("working")) parts.push(acid(`${n("working")} working`)); + return parts.join(ash(" · ")); +} diff --git a/test/c0agent-tasks.test.mjs b/test/c0agent-tasks.test.mjs new file mode 100644 index 00000000..457c64d1 --- /dev/null +++ b/test/c0agent-tasks.test.mjs @@ -0,0 +1,79 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createTaskBoard, liveSummary, readTasks, renderTasks } from "../src/c0agent-tasks.mjs"; +import { manageAgents, parseArgs } from "../src/c0agent-repl.mjs"; + +const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, ""); +const tmpFile = () => path.join(fs.mkdtempSync(path.join(os.tmpdir(), "c0tasks-")), "tasks.json"); + +test("a task moves through the states and notifies on state changes only", () => { + const seen = []; + const board = createTaskBoard({ persist: false, onChange: (t, before) => seen.push(`${before}->${t.state}`) }); + const t = board.add({ title: "fix the build\nsecond line", background: true }); + assert.equal(t.title, "fix the build"); + assert.equal(t.state, "working"); + board.update(t, { activity: "Read(a.js)" }); + board.update(t, { state: "needs_input", pending: { req: { title: "Bash(rm -rf x)" } } }); + board.update(t, { state: "working", pending: null }); + board.update(t, { state: "completed", result: "done" }); + assert.deepEqual(seen, ["null->working", "working->needs_input", "needs_input->working", "working->completed"]); + assert.ok(t.endedAt); + assert.deepEqual(board.counts(), { needs_input: 0, working: 0, completed: 1, failed: 0, stopped: 0 }); +}); + +test("renderTasks groups by state with Needs input first", () => { + const board = createTaskBoard({ persist: false }); + const a = board.add({ title: "done one", background: true }); + board.update(a, { state: "completed" }); + const b = board.add({ title: "asks", background: true }); + board.update(b, { state: "needs_input", pending: { req: { title: "Edit(x.js)" } } }); + const c = board.add({ title: "broke", background: false }); + board.update(c, { state: "failed", error: "no node took the job" }); + board.add({ title: "busy", background: true, agent: "explore" }); + const lines = []; + renderTasks(board.tasks, (l) => lines.push(strip(l))); + const heads = lines.filter((l) => !l.startsWith(" ")); + assert.deepEqual(heads, ["Needs input (1)", "Working (1)", "Completed (1)", "Failed (1)"]); + assert.ok(lines.some((l) => l.includes("waiting on: Edit(x.js)"))); + assert.ok(lines.some((l) => l.includes("busy @explore"))); + assert.ok(lines.some((l) => l.includes("broke (fg)"))); + assert.ok(lines.some((l) => l.includes("no node took the job"))); + assert.equal(strip(liveSummary(board.tasks)), "1 needs input · 1 working"); +}); + +test("the saved board merges sessions and a dead session's live task reads as Stopped", () => { + const file = tmpFile(); + const mine = createTaskBoard({ file, pid: process.pid }); + const t = mine.add({ title: "mine", background: true }); + const ghost = createTaskBoard({ file, pid: 2 ** 30 }); // no such process + ghost.add({ title: "orphan", background: true }); + mine.update(t, { state: "completed", result: "ok" }); + const all = readTasks({ file }); + assert.equal(all.length, 2); + assert.equal(all.find((x) => x.title === "mine").state, "completed"); + const orphan = all.find((x) => x.title === "orphan"); + assert.equal(orphan.state, "stopped"); + assert.equal(orphan.activity, "session ended"); + assert.ok(!("pending" in orphan) && !("controller" in orphan)); +}); + +test("`moshcode agents c0mpute tasks` prints and clears the saved board", () => { + assert.deepEqual(parseArgs(["tasks"]).manage, ["tasks"]); + assert.deepEqual(parseArgs(["tasks", "clear"]).manage, ["tasks", "clear"]); + assert.deepEqual(parseArgs(["tasks for today"]).words, ["tasks for today"]); + const file = tmpFile(); + const board = createTaskBoard({ file }); + const done = board.add({ title: "old", background: true }); + board.update(done, { state: "completed" }); + board.add({ title: "still going", background: true }); + const out = []; + assert.equal(manageAgents(["tasks"], { write: (l) => out.push(strip(l)), file }), 0); + assert.ok(out.includes("Working (1)") && out.includes("Completed (1)")); + out.length = 0; + manageAgents(["tasks", "clear"], { write: (l) => out.push(strip(l)), file }); + assert.match(out.join("\n"), /cleared 1 finished task$/); + assert.deepEqual(readTasks({ file }).map((x) => x.title), ["still going"]); +});