From 74ee72f3c59b08d7b296e5ae5780032c5e7f3e7e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 7 Nov 2026 16:09:45 +0000 Subject: [PATCH 1/2] feat(agents): c0mpute agent with subagents and /agents, Claude Code UX moshcode agents c0mpute (and /agents c0mpute in the pit) opens a coding agent whose model runs on the c0mpute peer network. moshcode is the harness: text tool protocol (Infernet ignores tools), read/write/edit/bash/glob/grep/ ls/todo + task subagents in parallel, permission prompts with shift-tab modes, /agents list/new/show/edit/rm/run, @agent, Claude Code .claude/agents reused. Print mode with -p. Co-Authored-By: Claude Opus 5.5 --- README.md | 54 ++++ bin/moshcode.mjs | 12 + package.json | 1 + pnpm-lock.yaml | 10 + src/c0agent-defs.mjs | 242 +++++++++++++++ src/c0agent-repl.mjs | 583 +++++++++++++++++++++++++++++++++++ src/c0agent.mjs | 697 ++++++++++++++++++++++++++++++++++++++++++ src/cli-schema.mjs | 2 + src/tui.mjs | 12 + test/c0agent.test.mjs | 282 +++++++++++++++++ 10 files changed, 1895 insertions(+) create mode 100644 src/c0agent-defs.mjs create mode 100644 src/c0agent-repl.mjs create mode 100644 src/c0agent.mjs create mode 100644 test/c0agent.test.mjs diff --git a/README.md b/README.md index 820efbc9..6bf18320 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,60 @@ moshcode agents aider # aider --yes-always moshcode agents openagents # openagents (dashboard) ``` +### `agents c0mpute`: a coding agent on the c0mpute network + +c0mpute has no coding CLI of its own, so moshcode is the harness. The agent runs +the loop locally and sends the model calls to the peer node that takes each job. +It reads, edits, runs commands and delegates to subagents, with the same UX as +Claude Code: + +```sh +moshcode agents c0mpute # interactive session (also /agents c0mpute in the pit) +moshcode agents c0mpute -p "fix the failing test" # print mode: answer on stdout +moshcode agents c0mpute -y -C ~/src/app # never ask, work in ~/src/app (use a container) +moshcode agents c0mpute agents list # manage agent definitions without a session +``` + +Inside a session: + +| | | +|---|---| +| `/agents` | list agents; `new` (drafted by c0mpute or by hand), `show`, `edit`, `rm`, `run ` | +| `@ ` | run one subagent directly | +| shift-tab | cycle permission mode: ask, accept edits, plan (read-only) | +| esc / ^C | interrupt the running turn | +| `/model`, `/models` | switch the model (saved), list what nodes serve | +| `/todos`, `/compact`, `/clear`, `/cost`, `/key`, `/cwd` | checklist, shrink context, fresh chat, usage, key, directory | + +The lead agent can call `task` to hand a self-contained job to a subagent. Several +`task` calls in one reply run in parallel. Subagents start with a fresh +conversation, their own system prompt and their own tool list, and cannot spawn +subagents. Built-ins: `general-purpose`, `explore` (read-only search) and `plan` +(read-only architect). Your own agents are markdown files with frontmatter: + +```markdown +--- +name: test-runner +description: Runs the test suite and reports failures with file:line. Use after any change. +tools: bash, read, grep +model: qwen2.5:7b # optional; omit to use the session model +--- +You run the project's tests and report each failure with its file and line... +``` + +They load from `./.moshcode/agents` (project), then `~/.moshcode/agents` (user), +then Claude Code's `.claude/agents` (read-only, tool and model names mapped), then +the built-ins. The first one found for a name wins, and `/agents` shows what it +overrides. Edits and non-read-only commands ask first unless you pick another mode. +`ls`, `cat`, `git status`/`diff`/`log` and `rg` never ask. + +Key: `C0MPUTE_API_KEY` or `/key ik_live_...` (saved 0600 in `~/.moshcode/c0mpute.json`). +With no key you get the free tier, 10 prompts a day. Model: `C0MPUTE_MODEL` or +`/model`. The default is the best house model, and smaller ones are tried when no +node serves it. **Privacy:** prompts and tool output (file contents, command +output) go to whichever peer node takes the job, so keep secrets out of a c0mpute +session. + Codex's native agents overview requires a CLI with `codex agents` support (verified with 0.151.0) and, for the local daemon, the managed standalone installation. `moshcode install codex` / `moshcode upgrade codex` use Codex's diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs index 1855a903..bfd2679d 100755 --- a/bin/moshcode.mjs +++ b/bin/moshcode.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { isC0mputeAgent } from "../src/c0agent-defs.mjs"; import fs from "node:fs"; import { fileURLToPath } from "node:url"; import path from "node:path"; @@ -350,6 +351,12 @@ async function main() { printEngineStatus(rest[0] === "--json"); return; } + // c0mpute has no coding CLI of its own: moshcode is the harness. + if (isC0mputeAgent(rest[0])) { + const { c0agentMain } = await import("../src/c0agent-repl.mjs"); + process.exitCode = await c0agentMain(rest.slice(1)); + return; + } const resolved = resolveEngine(rest[0]); if (!resolved) { if (refuseApiLaunch(rest[0])) return; @@ -366,6 +373,11 @@ async function main() { process.exitCode = 1; return; } + if (isC0mputeAgent(rest[0])) { + const { c0agentMain } = await import("../src/c0agent-repl.mjs"); + process.exitCode = await c0agentMain(rest.slice(1)); + return; + } const resolved = resolveEngine(rest[0]); if (!resolved) { if (refuseApiLaunch(rest[0])) return; diff --git a/package.json b/package.json index 33d2e95b..6616fc32 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@moshcoder/moshpit-dns": "^0.5.0" }, "dependencies": { + "@profullstack/ai-providers": "^0.2.0", "@profullstack/cli": "^0.2.0", "@profullstack/hqtui": "^0.7.0", "@profullstack/synconfig": "^0.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93a52702..52c5269a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@profullstack/ai-providers': + specifier: ^0.2.0 + version: 0.2.0 '@profullstack/cli': specifier: ^0.2.0 version: 0.2.0 @@ -32,6 +35,11 @@ packages: engines: {node: '>=20'} hasBin: true + '@profullstack/ai-providers@0.2.0': + resolution: {integrity: sha512-xY7a4gHThur7uTUnmzft47TnqVK+NVA57K60LThSAeHhITbSNLhkt9Sp7uNKCSg0wC55b5wLFEFGsGzblCiT6Q==} + engines: {node: '>=20'} + hasBin: true + '@profullstack/cli@0.2.0': resolution: {integrity: sha512-98Ei8yt1hBhoBFGfqDUM6QBKmzP7mad90uybp9BUw2zvilHOIRv5bTn8byR7+MjiiNN0FQ/DvOlGT9o3KcNCyg==} engines: {node: '>=18'} @@ -53,6 +61,8 @@ snapshots: '@moshcoder/moshpit-dns@0.5.0': {} + '@profullstack/ai-providers@0.2.0': {} + '@profullstack/cli@0.2.0': {} '@profullstack/hqtui@0.7.0': {} diff --git a/src/c0agent-defs.mjs b/src/c0agent-defs.mjs new file mode 100644 index 00000000..230f1a61 --- /dev/null +++ b/src/c0agent-defs.mjs @@ -0,0 +1,242 @@ +// Agent definitions for the c0mpute agent (`moshcode agents c0mpute`). +// +// The same shape Claude Code uses for its subagents, on purpose: a markdown +// file whose frontmatter names the agent and whose body is its system prompt. +// +// --- +// name: code-reviewer +// description: Reviews a diff for bugs. Use after any change. +// tools: read, grep, glob (omit for every tool) +// model: qwen2.5:7b (omit, or "inherit", for the session's model) +// --- +// You are a careful reviewer… +// +// Where they live, highest priority first, so a project can override a +// personal agent of the same name and both can override a built-in: +// +// ./.moshcode/agents/*.md project (commit it, the team gets it) +// ~/.moshcode/agents/*.md user +// ./.claude/agents/*.md claude (read-only reuse of Claude Code agents) +// ~/.claude/agents/*.md claude +// built-in general-purpose, explore, plan +// +// Claude Code agents name tools in PascalCase (Read, Grep, MultiEdit) and +// models by tier (sonnet, opus); both are mapped, and anything we cannot run +// here (WebFetch, NotebookEdit, …) is dropped rather than refused, so an +// existing .claude/agents directory works without edits. + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +/** Every tool the c0mpute agent has. `task` (subagents) is the main agent's only. */ +export const ALL_TOOLS = ["read", "write", "edit", "bash", "glob", "grep", "ls", "todo", "task"]; +export const READ_ONLY_TOOLS = ["read", "glob", "grep", "ls"]; + +const TOOL_ALIASES = { + read: "read", view: "read", cat: "read", + write: "write", create: "write", + edit: "edit", multiedit: "edit", str_replace: "edit", replace: "edit", + bash: "bash", shell: "bash", run: "bash", exec: "bash", + glob: "glob", find: "glob", + grep: "grep", search: "grep", + ls: "ls", list: "ls", + todo: "todo", todowrite: "todo", todos: "todo", + task: "task", agent: "task", subagent: "task", +}; + +/** Map one tool name (any case, Claude Code's or ours) to ours, or null. */ +export function normalizeTool(name) { + const t = String(name ?? "").trim().toLowerCase().replace(/[^a-z_]/g, ""); + return Object.hasOwn(TOOL_ALIASES, t) ? TOOL_ALIASES[t] : null; +} + +/** Claude Code's model tiers mean "whatever the session runs" here. */ +const INHERIT_MODELS = new Set(["", "inherit", "sonnet", "opus", "haiku", "fable", "default"]); + +export const BUILTIN_AGENTS = [ + { + name: "general-purpose", + description: "General agent for multi-step tasks: research a question, search code, make a change. Use when no specialist fits.", + tools: ALL_TOOLS.filter((t) => t !== "task"), + model: null, + source: "built-in", + prompt: [ + "You are a general-purpose agent working for a lead agent. Do the task you are given completely,", + "using the tools, then reply with a concise report of what you found or changed: file paths,", + "line numbers, commands run and their results. The lead agent only sees your final reply.", + ].join(" "), + }, + { + name: "explore", + description: "Read-only search agent: finds files, definitions and usages fast. Use to answer \"where is X\" or \"how does Y work\" without changing anything.", + tools: READ_ONLY_TOOLS, + model: null, + source: "built-in", + prompt: [ + "You are a read-only code search agent. Use glob, grep, ls and read to locate what was asked.", + "Never guess: cite file paths and line numbers you actually read. Finish with a short answer", + "listing the relevant files and what each one does for the question.", + ].join(" "), + }, + { + name: "plan", + description: "Read-only architect: studies the code and returns a step-by-step implementation plan with the files to change.", + tools: READ_ONLY_TOOLS, + model: null, + source: "built-in", + prompt: [ + "You are a software architect. Read the relevant code, then reply with a numbered implementation", + "plan: each step names the file to change and what changes, then list risks and how to test it.", + "Do not write code beyond short snippets.", + ].join(" "), + }, +]; + +/** The four directories definitions load from, highest priority first. */ +export function agentDirs({ cwd = process.cwd(), home = os.homedir() } = {}) { + return [ + { source: "project", dir: path.join(cwd, ".moshcode", "agents"), writable: true }, + { source: "user", dir: path.join(home, ".moshcode", "agents"), writable: true }, + { source: "claude", dir: path.join(cwd, ".claude", "agents"), writable: false }, + { source: "claude", dir: path.join(home, ".claude", "agents"), writable: false }, + ]; +} + +/** + * Split `---\nyaml\n---\nbody`. Only the flat `key: value` subset agent files + * use, plus `[a, b]` and `- item` lists, so there is no YAML dependency. + */ +export function parseFrontmatter(text) { + const src = String(text ?? "").replace(/^/, ""); + const m = src.match(/^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)([\s\S]*)$/); + if (!m) return { data: {}, body: src.trim() }; + const data = {}; + let listKey = null; + for (const raw of m[1].split(/\r?\n/)) { + const line = raw.replace(/\s+#.*$/, ""); + if (!line.trim()) continue; + const item = line.match(/^\s*-\s+(.*)$/); + if (item && listKey) { + (data[listKey] ||= []).push(unquote(item[1])); + continue; + } + const kv = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/); + if (!kv) continue; + const [, key, value] = kv; + listKey = null; + if (!value.trim()) { listKey = key; data[key] = []; continue; } + const v = value.trim(); + data[key] = v.startsWith("[") && v.endsWith("]") + ? v.slice(1, -1).split(",").map(unquote).filter(Boolean) + : unquote(v); + } + return { data, body: m[2].trim() }; +} + +function unquote(s) { + const t = String(s).trim(); + return (t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'")) ? t.slice(1, -1) : t; +} + +/** A valid agent name: what Claude Code accepts, lowercase letters, digits, dashes. */ +export function validName(name) { + return /^[a-z0-9][a-z0-9-]{0,63}$/.test(String(name ?? "")); +} + +/** One definition file → an agent, or null when it is not one. */ +export function parseAgentFile(text, { source = "user", file = null } = {}) { + const { data, body } = parseFrontmatter(text); + const name = String(data.name ?? (file ? path.basename(file, ".md") : "")).trim().toLowerCase(); + if (!validName(name) || !body) return null; + const rawTools = data.tools == null || data.tools === "" + ? null + : (Array.isArray(data.tools) ? data.tools : String(data.tools).split(",")); + const tools = rawTools + ? [...new Set(rawTools.map(normalizeTool).filter(Boolean))] + : ALL_TOOLS.filter((t) => t !== "task"); + const model = String(data.model ?? "").trim(); + return { + name, + description: String(data.description ?? "").trim() || `${name} agent`, + tools: tools.filter((t) => t !== "task"), // subagents never spawn subagents + model: INHERIT_MODELS.has(model.toLowerCase()) ? null : model, + prompt: body, + source, + file, + }; +} + +/** + * Every agent, highest priority wins per name. Each entry carries `shadows`, + * the lower-priority sources it hides, so the list can say so. + */ +export function loadAgents(opts = {}) { + const byName = new Map(); + const add = (agent) => { + const seen = byName.get(agent.name); + if (seen) { seen.shadows.push(agent.source); return; } + byName.set(agent.name, { ...agent, shadows: [] }); + }; + for (const { source, dir } of agentDirs(opts)) { + let names = []; + try { names = fs.readdirSync(dir).filter((f) => f.endsWith(".md")).sort(); } catch { continue; } + for (const f of names) { + const file = path.join(dir, f); + let text; + try { text = fs.readFileSync(file, "utf8"); } catch { continue; } + const agent = parseAgentFile(text, { source, file }); + if (agent) add(agent); + } + } + for (const agent of BUILTIN_AGENTS) add(agent); + return [...byName.values()]; +} + +export function findAgent(name, opts = {}) { + const n = String(name ?? "").trim().toLowerCase().replace(/^@/, ""); + return loadAgents(opts).find((a) => a.name === n) || null; +} + +/** The markdown a definition is saved as. */ +export function renderAgentFile({ name, description, tools, model, prompt }) { + const lines = ["---", `name: ${name}`, `description: ${oneLine(description)}`]; + const t = (tools || []).filter((x) => x !== "task"); + const everything = ALL_TOOLS.filter((x) => x !== "task"); + if (t.length && !everything.every((x) => t.includes(x))) lines.push(`tools: ${t.join(", ")}`); + if (model) lines.push(`model: ${model}`); + lines.push("---", "", String(prompt ?? "").trim(), ""); + return lines.join("\n"); +} + +const oneLine = (s) => String(s ?? "").replace(/\s+/g, " ").trim(); + +/** Write a definition to the project or user directory. Returns its path. */ +export function saveAgent(agent, { scope = "project", ...opts } = {}) { + if (!validName(agent.name)) throw new Error(`"${agent.name}" is not a valid agent name (a-z, 0-9, dashes)`); + const target = agentDirs(opts).find((d) => d.source === scope && d.writable); + if (!target) throw new Error(`can't save to "${scope}": use project or user`); + fs.mkdirSync(target.dir, { recursive: true }); + const file = path.join(target.dir, `${agent.name}.md`); + fs.writeFileSync(file, renderAgentFile(agent)); + return file; +} + +/** Delete a saved definition. Built-ins and Claude Code's files are not ours to delete. */ +export function removeAgent(name, opts = {}) { + const agent = findAgent(name, opts); + if (!agent) throw new Error(`no agent named "${name}"`); + if (agent.source === "built-in") throw new Error(`${agent.name} is built in; shadow it with /agents new ${agent.name} instead`); + if (agent.source === "claude") throw new Error(`${agent.name} belongs to Claude Code (${agent.file}); delete it there`); + fs.rmSync(agent.file); + return agent; +} + +/** + * Does this engine token mean the c0mpute agent? `c0mpute` is also a TOOL + * (the network's own CLI), so only `agents`/`start` route here; `/c0mpute` + * alone still runs the network CLI. + */ +export function isC0mputeAgent(token) { + return ["c0mpute", "c0", "compute", "c0mpute.com", "c0agent"].includes(String(token ?? "").trim().toLowerCase()); +} diff --git a/src/c0agent-repl.mjs b/src/c0agent-repl.mjs new file mode 100644 index 00000000..a63a0ee2 --- /dev/null +++ b/src/c0agent-repl.mjs @@ -0,0 +1,583 @@ +// The terminal surface of the c0mpute agent: `moshcode agents c0mpute`. +// +// Shaped after Claude Code on purpose, so nobody has to learn it: +// > 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 +// 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 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import readline from "node:readline"; +import { spawnSync } from "node:child_process"; +import { + LEAD_AGENT, c0mputeConfig, compactConversation, configFile, createProvider, createSession, + draftAgent, listModels, runAgent, saveConfig, +} from "./c0agent.mjs"; +import { + ALL_TOOLS, READ_ONLY_TOOLS, agentDirs, findAgent, loadAgents, normalizeTool, removeAgent, renderAgentFile, saveAgent, validName, +} from "./c0agent-defs.mjs"; +import { acid, amber, ash, bone, danger, dim, err, info, ok, warn } from "./ui.mjs"; + +const MODES = ["ask", "auto-edit", "plan"]; +const MODE_LABEL = { + ask: "ask before edits and commands", + "auto-edit": "accept edits on", + plan: "plan mode (read-only)", + yolo: "bypass permissions", +}; + +export const USAGE = `moshcode agents c0mpute [options] [prompt] + + a Claude-Code-style coding agent whose model runs on the c0mpute peer network + +options: + -p, --print run once and print the answer (stdin works too) + -m, --model model for this session (default: ${"saved, else the best house model"}) + -a, --agent run as one of your agents instead of the lead agent + -C, --cwd work in instead of the current directory + --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 + +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.`; + +// ─── argument parsing ──────────────────────────────────────────────────────── + +export function parseArgs(argv) { + const opts = { print: null, model: null, agent: null, mode: null, cwd: null, words: [], manage: null, help: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "-h" || a === "--help") opts.help = true; + else if (a === "-p" || a === "--print") { opts.print = argv[i + 1] && !argv[i + 1].startsWith("-") ? argv[++i] : ""; } + else if (a === "-m" || a === "--model") opts.model = argv[++i]; + else if (a === "-a" || a === "--agent") opts.agent = argv[++i]; + else if (a === "--permission-mode") opts.mode = argv[++i]; + else if (a === "-C" || a === "--cwd") opts.cwd = argv[++i]; + else if (a === "-y" || a === "--yes" || a === "--dangerously-skip-permissions") opts.mode = "yolo"; + else if (i === 0 && (a === "agents" || a === "agent")) { opts.manage = argv.slice(1); break; } + else opts.words.push(a); + } + if (opts.print === "" && opts.words.length) opts.print = opts.words.join(" "); + return opts; +} + +// ─── rendering ─────────────────────────────────────────────────────────────── + +function toolsLabel(tools) { + const t = tools.filter((x) => x !== "task"); + if (ALL_TOOLS.filter((x) => x !== "task").every((x) => t.includes(x))) return "all tools"; + if (t.length === READ_ONLY_TOOLS.length && READ_ONLY_TOOLS.every((x) => t.includes(x))) return "read-only"; + return t.join(", "); +} + +export function renderAgentList(agents, write) { + const groups = [ + ["project", "Project agents (.moshcode/agents)"], + ["user", "Personal agents (~/.moshcode/agents)"], + ["claude", "Claude Code agents (.claude/agents, read-only)"], + ["built-in", "Built-in agents"], + ]; + for (const [source, title] of groups) { + const list = agents.filter((a) => a.source === source); + if (!list.length) continue; + write(bone(title)); + for (const a of list) { + const shadow = a.shadows.length ? ash(` (overrides ${a.shadows.join(", ")})`) : ""; + write(` ${acid(a.name)}${shadow} ${ash("·")} ${dim(toolsLabel(a.tools))}${a.model ? ` ${ash("·")} ${dim(a.model)}` : ""}`); + write(` ${ash(a.description.length > 110 ? `${a.description.slice(0, 107)}…` : a.description)}`); + } + write(""); + } + write(ash(`/agents new · /agents show · /agents edit · /agents rm · @ `)); +} + +function renderTodos(todos, write) { + for (const t of todos) { + const box = t.status === "done" ? acid("☒") : t.status === "in_progress" ? amber("◐") : ash("☐"); + write(` ${box} ${t.status === "done" ? dim(t.text) : t.text}`); + } +} + +/** A spinner on one line of stderr that steps aside whenever real output prints. */ +function createSpinner(stream = process.stderr) { + const frames = ["✻", "✳", "✢", "·", "✢", "✳"]; + let timer = null; + let label = ""; + let started = 0; + let i = 0; + const tty = stream.isTTY; + const draw = () => { + if (!tty) return; + const secs = Math.round((Date.now() - started) / 1000); + stream.write(`\r\x1b[2K${acid(frames[i++ % frames.length])} ${label} ${ash(`(${secs}s · esc to interrupt)`)}`); + }; + return { + start(text) { + label = text; + if (timer) return; + started = Date.now(); + draw(); + timer = setInterval(draw, 120); + }, + label(text) { label = text; }, + stop() { + if (!timer) return; + clearInterval(timer); + timer = null; + if (tty) stream.write("\r\x1b[2K"); + }, + get active() { return Boolean(timer); }, + }; +} + +/** The event sink shared by the REPL and print mode. */ +function createRenderer({ out, log, spinner, quiet = false }) { + let atLineStart = true; + let wroteText = false; + const line = (s) => { spinner.stop(); if (!atLineStart) { out("\n"); atLineStart = true; } log(s); }; + return { + reset() { wroteText = false; }, + get wroteText() { return wroteText; }, + finish() { spinner.stop(); if (!atLineStart) { out("\n"); atLineStart = true; } }, + event(e) { + if (e.type === "waiting") { + spinner.start(e.depth ? `${e.agent} working…` : "Waiting for a c0mpute node…"); + return; + } + if (e.type === "thinking" && !e.depth) { spinner.label("Thinking…"); return; } + if (e.type === "text") { + if (e.depth || quiet) return; + let t = e.text; + if (!wroteText) t = t.replace(/^\s+/, ""); + if (!t) return; + spinner.stop(); + if (!wroteText) { out(`${acid("●")} `); } + wroteText = true; + out(t); + atLineStart = t.endsWith("\n"); + return; + } + if (e.type === "turn_end") { if (!e.depth && wroteText && !atLineStart) { out("\n"); atLineStart = true; } wroteText = false; return; } + if (e.type === "tool_start") { + if (e.depth) line(` ${ash("⎿")} ${dim(`[${e.agent}]`)} ${dim(e.title)}`); + else line(`${acid("●")} ${bone(e.title)}`); + return; + } + if (e.type === "tool_end") { + const r = e.result; + if (e.depth && r.ok) return; + const first = String(r.output ?? "").split("\n")[0]; + const pad = e.depth ? " " : " "; + if (!r.ok) line(`${pad}${ash("⎿")} ${danger(first.length > 140 ? `${first.slice(0, 137)}…` : first)}`); + else if (e.call.name === "task") line(`${pad}${ash("⎿")} ${ok(`Done (${r.summary})`)}`); + else line(`${pad}${ash("⎿")} ${ash(r.summary || first)}`); + return; + } + if (e.type === "todo" && !e.depth) { spinner.stop(); renderTodos(e.todos, log); return; } + if (e.type === "error") line(err(`${e.depth ? `[${e.agent}] ` : ""}${e.error}`)); + }, + }; +} + +// ─── management (no session needed) ────────────────────────────────────────── + +export function manageAgents(args, { write = (l) => console.log(l), cwd = process.cwd() } = {}) { + const [verb = "list", name] = args; + if (verb === "list" || verb === "ls") { renderAgentList(loadAgents({ cwd }), write); return 0; } + if (verb === "show" || verb === "cat") { + const a = findAgent(name, { cwd }); + if (!a) { write(err(`no agent named "${name}"`)); return 1; } + write(ash(a.file || `${a.source} agent`)); + write(renderAgentFile(a)); + return 0; + } + if (verb === "rm" || verb === "remove" || verb === "delete") { + try { const a = removeAgent(name, { cwd }); write(ok(`removed ${a.name} (${a.file})`)); return 0; } + catch (e) { write(err(e.message)); return 1; } + } + if (verb === "dirs" || verb === "where") { + 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`)); + return 1; +} + +const pad8 = (s) => s.padEnd(8); + +// ─── print mode ────────────────────────────────────────────────────────────── + +async function readStdin() { + const chunks = []; + for await (const c of process.stdin) chunks.push(c); + return Buffer.concat(chunks).toString("utf8").trim(); +} + +async function runPrint(prompt, { cfg, opts }) { + const provider = await createProvider(cfg); + const mode = opts.mode || "ask"; + const session = createSession({ + provider, model: cfg.model, permissionMode: mode, cwd: opts.cwd || process.cwd(), + // Nobody to ask: refuse, and say how to allow it. + ask: async () => "no — print mode cannot ask; rerun with --dangerously-skip-permissions or --permission-mode auto-edit", + }); + const lead = opts.agent ? findAgent(opts.agent, { cwd: session.cwd }) : LEAD_AGENT; + if (!lead) { console.error(`no agent named "${opts.agent}"`); return 1; } + const spinner = createSpinner(); + const r = createRenderer({ out: () => {}, log: (l) => process.stderr.write(`${l}\n`), spinner, quiet: true }); + const controller = new AbortController(); + process.once("SIGINT", () => controller.abort()); + const res = await runAgent({ session, agent: lead, messages: [{ role: "user", content: prompt }], signal: controller.signal, onEvent: (e) => r.event(e) }); + r.finish(); + if (res.text) process.stdout.write(`${res.text}\n`); + if (res.error) { process.stderr.write(`${res.error}\n`); return 1; } + return 0; +} + +// ─── the REPL ──────────────────────────────────────────────────────────────── + +const HISTORY = () => path.join(os.homedir(), ".moshcode", "c0mpute_history"); + +function loadHistory() { + try { return fs.readFileSync(HISTORY(), "utf8").split("\n").filter(Boolean).reverse().slice(0, 500); } catch { return []; } +} + +function appendHistory(line) { + try { fs.mkdirSync(path.dirname(HISTORY()), { recursive: true }); fs.appendFileSync(HISTORY(), `${line.replace(/\n/g, " ")}\n`); } catch { /* best effort */ } +} + +const HELP = [ + ["/agents", "list agents · new · show · edit · rm · run "], + ["@ ", "run one subagent directly"], + ["/model [name]", "show or switch the model (saved)"], + ["/models", "models c0mpute serves right now"], + ["/permissions [mode]", "ask · auto-edit · plan (shift-tab cycles)"], + ["/todos", "the current checklist"], + ["/compact", "summarize the conversation to free context"], + ["/clear", "start a fresh conversation"], + ["/cost", "calls and estimated tokens this session"], + ["/key [ik_live_…]", "save a c0mpute key (or show where it comes from)"], + ["/cwd [dir]", "show or change the working directory"], + ["/exit", "leave (also ^D)"], +]; + +export async function c0agentMain(argv = [], { write = (l) => console.log(l) } = {}) { + const opts = parseArgs(argv); + if (opts.help) { write(USAGE); return 0; } + if (opts.manage) return manageAgents(opts.manage, { write, cwd: opts.cwd ? path.resolve(opts.cwd) : process.cwd() }); + const saved = c0mputeConfig(); + const cfg = { ...saved, model: opts.model || saved.model }; + if (opts.cwd) { + const dir = path.resolve(opts.cwd); + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) { write(err(`not a directory: ${dir}`)); return 1; } + opts.cwd = dir; + } + if (opts.mode && !["ask", "auto-edit", "plan", "yolo"].includes(opts.mode)) { write(err(`unknown permission mode "${opts.mode}"`)); return 1; } + + if (opts.print != null || !process.stdin.isTTY) { + const prompt = opts.print || opts.words.join(" ") || (!process.stdin.isTTY ? await readStdin() : ""); + if (!prompt) { write(err("nothing to do: pass a prompt with -p")); return 1; } + return runPrint(prompt, { cfg, opts }); + } + return repl({ cfg, opts, write }); +} + +async function repl({ cfg, opts, write }) { + const provider = await createProvider(cfg); + let busy = null; // AbortController of the running turn + const spinner = createSpinner(); + const out = (s) => process.stdout.write(s); + const log = (s) => process.stdout.write(`${s}\n`); + const renderer = createRenderer({ out, log, spinner }); + + readline.emitKeypressEvents(process.stdin); + // Registered before readline's own listener, so shift-tab can tell the + // completer (which readline also runs for shift-tab) to stand down. + let shiftTab = false; + const onKey = (_s, key) => { + if (!key) return; + if (key.name === "escape" && busy) { busy.abort(); return; } + if (key.name === "tab" && key.shift) { + shiftTab = true; + if (busy) return; + const i = MODES.indexOf(session.permissionMode); + session.permissionMode = MODES[(i + 1) % MODES.length]; + setImmediate(() => { process.stdout.write("\r\x1b[2K"); prompt(); rl.write(null, { ctrl: true, name: "e" }); }); + } + }; + process.stdin.prependListener("keypress", onKey); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + history: loadHistory(), + historySize: 500, + terminal: true, + completer: (line) => { + if (shiftTab) { shiftTab = false; return [[], line]; } + const words = ["/agents", "/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)); + return [hits.length ? hits : [], last]; + }, + }); + + // 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)); + 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; + }); + asking = p.catch(() => {}); + return p; + }; + + 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 = () => { + const mode = session.permissionMode === "ask" ? "" : `${amber(`⏵⏵ ${MODE_LABEL[session.permissionMode]}`)} `; + rl.setPrompt(`${mode}${acid(">")} `); + rl.prompt(true); + }; + let lastInt = 0; + rl.on("SIGINT", () => { + if (busy) { busy.abort(); return; } + if (rl.line) { rl.write(null, { ctrl: true, name: "u" }); return; } + if (Date.now() - lastInt < 1500) { rl.close(); return; } + lastInt = Date.now(); + log(ash("\n(press ^C again to exit)")); + prompt(); + }); + + // Banner. + log(""); + log(`${acid("✻")} ${bone("c0mpute agent")} ${ash("— moshcode")}`); + log(` ${ash("model")} ${cfg.model} ${ash("cwd")} ${session.cwd}`); + log(` ${ash("key")} ${cfg.apiKey ? `${cfg.apiKey.slice(0, 8)}… ${ash(`(${cfg.keySource})`)}` : amber("none: free tier, 10 prompts a day — /key ik_live_… to add one")}`); + log(` ${ash("prompts and tool output go to the peer node that takes each job; keep secrets out")}`); + if (session.permissionMode === "yolo") log(` ${warn("permissions bypassed: every edit and command runs without asking")}`); + log(` ${ash("/help for commands · /agents for subagents · @agent to run one")}`); + log(""); + + const runTurn = async (agent, content, { asSub = false } = {}) => { + busy = new AbortController(); + renderer.reset(); + const started = Date.now(); + let res; + try { + if (asSub) { + res = await runAgent({ session, agent, messages: [{ role: "user", content }], depth: 1, signal: busy.signal, onEvent: (e) => renderer.event(e) }); + 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) }); + renderer.finish(); + } + } finally { + busy = null; + spinner.stop(); + } + if (res?.error === "interrupted") log(amber(" ⎿ interrupted · what should c0mpute do instead?")); + else if (res?.error && res.error !== "step limit") log(err(res.error)); + log(ash(` ${Math.round((Date.now() - started) / 1000)}s`)); + }; + + const commands = { + help: () => { for (const [c, d] of HELP) log(` ${acid(c.padEnd(22))} ${ash(d)}`); }, + clear: () => { messages = []; session.todos = []; log(ok("new conversation")); }, + cost: () => log(` ${session.usage.calls} model calls · ~${session.usage.inputTokens.toLocaleString()} in / ~${session.usage.outputTokens.toLocaleString()} out tokens ${ash("(estimated)")}`), + todos: () => (session.todos.length ? renderTodos(session.todos, log) : log(ash(" no todos"))), + cwd: (args) => { + if (!args[0]) { log(` ${session.cwd}`); return; } + const dir = path.resolve(session.cwd, args[0].replace(/^~(?=\/|$)/, os.homedir())); + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) { log(err(`not a directory: ${dir}`)); return; } + session.cwd = dir; + log(ok(dir)); + }, + permissions: (args) => { + if (!args[0]) { log(` ${MODE_LABEL[session.permissionMode]} ${ash(`(${session.permissionMode})`)}`); return; } + if (!["ask", "auto-edit", "plan", "yolo"].includes(args[0])) { log(err("modes: ask, auto-edit, plan, yolo")); return; } + session.permissionMode = args[0]; + log(ok(MODE_LABEL[args[0]])); + }, + model: (args) => { + if (!args[0]) { log(` ${session.model}`); return; } + session.model = args[0]; + saveConfig({ model: args[0] }); + log(ok(`model ${args[0]} ${ash("(saved)")}`)); + }, + models: async () => { + const ids = await listModels(); + for (const id of ids) log(` ${id === session.model ? acid("●") : " "} ${id}`); + }, + key: (args) => { + if (!args[0]) { + const c = c0mputeConfig(); + log(c.apiKey ? ` ${c.apiKey.slice(0, 8)}… from ${c.keySource}` : ` ${amber("no key")} ${ash("— free tier, 10 prompts a day. get an ik_live_ key at infernetprotocol.com, then /key ")}`); + return; + } + if (!/^(ik_live_|c0k_|ifc_)/.test(args[0])) log(warn("that doesn't look like a c0mpute key (ik_live_…); saving it anyway")); + const file = saveConfig({ apiKey: args[0] }); + log(ok(`saved to ${file} (0600) — takes effect next session`)); + }, + compact: async () => { + if (!messages.length) { log(ash(" nothing to compact")); return; } + busy = new AbortController(); + spinner.start("Compacting…"); + try { const s = await compactConversation(session, messages, { signal: busy.signal }); spinner.stop(); log(ok("compacted")); log(dim(s)); } + catch (e) { spinner.stop(); log(err(String(e.message || e))); } + finally { busy = null; } + }, + agents: (args) => agentsCommand(args), + }; + + async function agentsCommand(args) { + const [verb, ...rest] = args; + if (!verb || verb === "list") { renderAgentList(loadAgents({ cwd: session.cwd }), log); return; } + if (verb === "show" || verb === "rm" || verb === "dirs" || verb === "remove") { + if (verb === "rm" || verb === "remove") { + const a = findAgent(rest[0], { cwd: session.cwd }); + if (a && a.source !== "built-in" && a.source !== "claude") { + const yes = (await question(` remove ${a.name} (${a.file})? [y/N] `)).trim(); + if (!/^y/i.test(yes)) return; + } + } + manageAgents([verb, ...rest], { write: log, cwd: session.cwd }); + return; + } + if (verb === "run") { + const a = findAgent(rest[0], { cwd: session.cwd }); + if (!a) { log(err(`no agent named "${rest[0]}"`)); return; } + if (!rest.slice(1).join(" ").trim()) { log(err("usage: /agents run ")); return; } + await runTurn(a, rest.slice(1).join(" "), { asSub: true }); + return; + } + if (verb === "edit") { + let a = findAgent(rest[0], { cwd: session.cwd }); + if (!a) { log(err(`no agent named "${rest[0]}"`)); return; } + if (a.source === "built-in" || a.source === "claude") { + const file = saveAgent(a, { scope: "project", cwd: session.cwd }); + log(info(`copied ${a.name} to ${file} so your edits override the ${a.source} one`)); + a = { ...a, file }; + } + editFile(a.file); + const after = findAgent(a.name, { cwd: session.cwd }); + log(after ? ok(`${after.name} saved`) : warn("the file no longer parses as an agent (needs name + a prompt body)")); + return; + } + if (verb === "new" || verb === "create") { await newAgentWizard(rest); return; } + log(err(`unknown: /agents ${verb}. try /agents, new, show, edit, rm, run`)); + } + + function editFile(file) { + const editor = process.env.VISUAL || process.env.EDITOR || "nano"; + rl.pause(); + spawnSync(editor, [file], { stdio: "inherit", shell: true }); + rl.resume(); + } + + async function newAgentWizard(rest) { + const manual = rest.includes("--manual"); + let name = rest.find((r) => !r.startsWith("-")); + log(bone("Create a new agent")); + const where = (await question(` ${ash("save to")} [p]roject ${ash("(.moshcode/agents)")} or [u]ser ${ash("(~/.moshcode/agents)")}? [p] `)).trim(); + const scope = /^u/i.test(where) ? "user" : "project"; + const description = (await question(` ${ash("what should it do, and when should it be used?")}\n ${acid(">")} `)).trim(); + if (!description) { log(ash(" cancelled")); return; } + let agent; + if (!manual) { + const gen = (await question(` generate the definition with c0mpute? [Y/n] `)).trim(); + if (!/^n/i.test(gen)) { + busy = new AbortController(); + spinner.start("Drafting the agent…"); + try { agent = await draftAgent(session, description, { signal: busy.signal }); } + catch (e) { spinner.stop(); log(warn(`${e.message || e} — switching to manual`)); } + finally { spinner.stop(); busy = null; } + if (agent && name) agent.name = name; + } + } + if (!agent) { + while (!validName(name)) { + name = (await question(` ${ash("name (lowercase, dashes)")}: `)).trim().toLowerCase(); + if (!name) { log(ash(" cancelled")); return; } + if (!validName(name)) log(err("use a-z, 0-9 and dashes")); + } + const t = (await question(` tools: [a]ll · [r]ead-only · or a list like read, grep, bash [a] `)).trim(); + const tools = /^r/i.test(t) ? READ_ONLY_TOOLS + : !t || /^a/i.test(t) ? ALL_TOOLS.filter((x) => x !== "task") + : [...new Set(t.split(/[,\s]+/).map(normalizeTool).filter((x) => x && x !== "task"))]; + const promptText = (await question(` ${ash("system prompt (one line, or blank for a starter you can /agents edit)")}:\n ${acid(">")} `)).trim(); + agent = { name, description, tools, model: null, prompt: promptText || `You are ${name}. ${description}` }; + } + const m = (await question(` model ${ash(`(enter to inherit ${session.model})`)}: `)).trim(); + if (m) agent.model = m; + log(""); + for (const l of renderAgentFile(agent).split("\n")) log(` ${dim(l)}`); + const exists = findAgent(agent.name, { cwd: session.cwd }); + if (exists && exists.source === scope) log(warn(`this replaces ${exists.file}`)); + const go = (await question(` save? [Y]es · [e]dit in $EDITOR first · [n]o `)).trim(); + if (/^n/i.test(go)) { log(ash(" not saved")); return; } + const file = saveAgent(agent, { scope, cwd: session.cwd }); + if (/^e/i.test(go)) editFile(file); + const saved = findAgent(agent.name, { cwd: session.cwd }); + log(ok(`${agent.name} saved to ${file}`)); + if (saved) log(ash(` the lead agent can now delegate to it; or run it yourself: @${saved.name} `)); + } + + // The loop. + prompt(); + for await (const raw of rl) { + const line = raw.trim(); + if (!line) { prompt(); continue; } + appendHistory(line); + try { + if (line.startsWith("/")) { + const [c, ...args] = line.slice(1).split(/\s+/); + if (c === "exit" || c === "quit" || c === "q") break; + const fn = commands[c.toLowerCase()]; + if (fn) await fn(args); + else log(err(`unknown command /${c} — /help`)); + } else if (line.startsWith("@")) { + const m = line.match(/^@([a-z0-9-]+)\s*([\s\S]*)$/i); + const a = m && findAgent(m[1], { cwd: session.cwd }); + if (!a) log(err(`no agent named "${m?.[1] ?? line}" — /agents to list them`)); + else if (!m[2].trim()) log(err(`usage: @${a.name} `)); + else await runTurn(a, m[2].trim(), { asSub: true }); + } else { + await runTurn(lead, line); + } + } catch (e) { + spinner.stop(); + log(err(String(e?.stack || e))); + } + log(""); + prompt(); + } + spinner.stop(); + process.stdin.off("keypress", onKey); + rl.close(); + log(ash("bye 🤘")); + return 0; +} + +export { configFile }; diff --git a/src/c0agent.mjs b/src/c0agent.mjs new file mode 100644 index 00000000..d91d8d2f --- /dev/null +++ b/src/c0agent.mjs @@ -0,0 +1,697 @@ +// The c0mpute agent: a Claude-Code-style coding agent whose model runs on the +// c0mpute peer network (`moshcode agents c0mpute`). +// +// Every other engine moshcode drives is somebody else's CLI. c0mpute has no +// coding CLI, only inference, so this file is the harness: the loop that sends +// the conversation to a node, runs the tools the model asks for on THIS +// machine, feeds the results back, and stops when the model answers without +// asking for anything. Subagents (`task`) are the same loop with a fresh +// conversation, their own system prompt and a narrower tool list. +// +// Why tools are a text protocol and not the API's `tools` field: Infernet's +// chat endpoint takes string content and ignores `tools`, and the house +// models are 2B-9B. So the model writes +// +// {"path": "src/a.js"} +// +// and we parse it, leniently: attributes instead of JSON, a bare string where +// the tool has one obvious argument, or a fenced ```json {"tool": …}``` block +// all work, because a small model gets the shape slightly wrong more often +// than it gets it exactly right. +// +// Privacy: prompts and tool output go to whichever peer node takes the job. +// The REPL says so on start; do not point this at secrets. + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawn, spawnSync } from "node:child_process"; +import { ALL_TOOLS, BUILTIN_AGENTS, loadAgents, normalizeTool } from "./c0agent-defs.mjs"; + +// ─── config ────────────────────────────────────────────────────────────────── + +export const DEFAULT_BASE_URL = "https://infernetprotocol.com"; +/** The house models, best first. Every Infernet node pulls these. */ +export const HOUSE_MODELS = [ + "huihui_ai/qwen3.5-abliterated:9b", + "qwen2.5:7b", + "qwen3:4b", + "gemma3:4b", +]; + +const nonEmpty = (v) => (typeof v === "string" && v.trim() ? v.trim() : null); + +export function configFile(home = os.homedir()) { + return path.join(home, ".moshcode", "c0mpute.json"); +} + +function readConfigFile(home) { + try { return JSON.parse(fs.readFileSync(configFile(home), "utf8")) || {}; } catch { return {}; } +} + +/** Effective config: env over ~/.moshcode/c0mpute.json over defaults. */ +export function c0mputeConfig({ env = process.env, home = os.homedir() } = {}) { + const file = readConfigFile(home); + return { + apiKey: nonEmpty(env.C0MPUTE_API_KEY) || nonEmpty(file.apiKey), + keySource: nonEmpty(env.C0MPUTE_API_KEY) ? "C0MPUTE_API_KEY" : nonEmpty(file.apiKey) ? configFile(home) : null, + baseUrl: (nonEmpty(env.C0MPUTE_URL) || nonEmpty(file.baseUrl) || DEFAULT_BASE_URL).replace(/\/+$/, ""), + model: nonEmpty(env.C0MPUTE_MODEL) || nonEmpty(file.model) || HOUSE_MODELS[0], + permissionMode: "ask", + }; +} + +/** Merge `patch` into ~/.moshcode/c0mpute.json (0600: it can hold a key). */ +export function saveConfig(patch, { home = os.homedir() } = {}) { + const file = configFile(home); + const next = { ...readConfigFile(home), ...patch }; + for (const k of Object.keys(next)) if (next[k] == null) delete next[k]; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 }); + try { fs.chmodSync(file, 0o600); } catch { /* not ours to fix */ } + return file; +} + +/** The provider every session talks to. Lazy, so tests never load it. */ +export async function createProvider(cfg) { + const { C0mputeProvider } = await import("@profullstack/ai-providers"); + const models = [...new Set([cfg.model, ...HOUSE_MODELS])]; + return new C0mputeProvider({ + apiKey: cfg.apiKey || undefined, + baseUrl: cfg.baseUrl, + models, + concurrency: 4, // parallel subagents + timeoutMs: 600_000, + stallMs: 120_000, + }); +} + +/** The models c0mpute.com lists right now, or the house list when it won't say. */ +export async function listModels({ fetchImpl = globalThis.fetch } = {}) { + try { + const res = await fetchImpl("https://c0mpute.com/api/v1/models", { signal: AbortSignal.timeout(8000) }); + const body = await res.json(); + const ids = (body?.data || []).map((m) => m.id).filter(Boolean); + if (ids.length) return ids; + } catch { /* fall through */ } + return [...HOUSE_MODELS]; +} + +// ─── protocol ──────────────────────────────────────────────────────────────── + +/** The argument a bare (non-JSON) tool body means, per tool. */ +const PRIMARY_ARG = { read: "path", write: "path", ls: "path", bash: "command", glob: "pattern", grep: "pattern", task: "prompt" }; + +/** Drop a reasoning model's visible thinking, closed or cut off. */ +export function stripThink(text) { + return String(text ?? "").replace(/[\s\S]*?<\/think>/gi, "").replace(/[\s\S]*$/i, ""); +} + +function parseAttrs(s) { + const out = {}; + for (const m of String(s).matchAll(/([A-Za-z_][\w-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) out[m[1]] = m[2] ?? m[3]; + return out; +} + +/** Parse a JSON object, forgiving the slips small models make. */ +export function looseJson(text) { + const t = String(text ?? "").trim().replace(/^```(?:json)?\s*/i, "").replace(/```$/, "").trim(); + if (!t.startsWith("{")) return null; + for (const candidate of [t, t.replace(/,\s*([}\]])/g, "$1")]) { + try { const v = JSON.parse(candidate); return v && typeof v === "object" && !Array.isArray(v) ? v : null; } catch { /* next */ } + } + return null; +} + +/** + * Every tool call in one assistant message, in order. Anything the model + * wrote after a `= 0) src = src.slice(0, fake); + const calls = []; + const re = /]*?)(\/>|>([\s\S]*?)<\/tool\s*>)/gi; + for (const m of src.matchAll(re)) { + const attrs = parseAttrs(m[1]); + const name = normalizeTool(attrs.name); + if (!name) { calls.push({ name: attrs.name || "?", input: {}, error: `unknown tool "${attrs.name}"` }); continue; } + delete attrs.name; + const body = (m[3] ?? "").trim(); + let input = looseJson(body); + if (!input) { + input = { ...attrs }; + if (body && PRIMARY_ARG[name] && input[PRIMARY_ARG[name]] == null) input[PRIMARY_ARG[name]] = body; + if (body && name === "write" && input.content == null && attrs.path) input.content = body; + } else { + input = { ...attrs, ...input }; + } + calls.push({ name, input }); + } + if (calls.length) return calls; + // Fallback: a fenced JSON block that names a tool. + for (const m of src.matchAll(/```(?:json|tool)?\s*(\{[\s\S]*?\})\s*```/gi)) { + const obj = looseJson(m[1]); + const name = normalizeTool(obj?.tool ?? obj?.name); + if (!name) continue; + const input = obj.input ?? obj.args ?? obj.arguments ?? obj.parameters + ?? Object.fromEntries(Object.entries(obj).filter(([k]) => k !== "tool" && k !== "name")); + calls.push({ name, input: typeof input === "object" && input ? input : {} }); + } + if (calls.length) return calls; + // Last resort: the whole reply is just the arguments of one tool. + const bare = looseJson(src.trim()); + const inferred = bare && inferTool(bare); + return inferred ? [{ name: inferred, input: bare }] : []; +} + +/** Which tool a bare arguments object belongs to, when its keys say so unambiguously. */ +export function inferTool(args) { + const k = new Set(Object.keys(args)); + if (k.has("command")) return "bash"; + if (k.has("path") && (k.has("old") || k.has("old_string"))) return "edit"; + if (k.has("path") && k.has("content")) return "write"; + if (k.has("agent") && k.has("prompt")) return "task"; + if (k.has("items")) return "todo"; + if (k.has("pattern") && /[*?]/.test(String(args.pattern)) && !k.has("glob")) return "glob"; + if (k.has("pattern")) return "grep"; + if (k.has("path") && k.size <= 3) return "read"; + return null; +} + +/** What the user sees of a reply: no thinking, no tool calls. */ +export function visibleText(text) { + let src = stripThink(text); + const fake = src.search(/= 0) src = src.slice(0, fake); + return src.replace(/]*?(\/>|>[\s\S]*?<\/tool\s*>)/gi, "").replace(/\n{3,}/g, "\n\n").trim(); +} + +/** + * A streaming filter: feed it tokens, it calls `emit` with the text that is + * not inside , or . Holds back a possible partial + * tag at the end of a chunk until the next one decides it. + */ +export function createStreamFilter(emit, { onThinking = () => {} } = {}) { + let pending = ""; + let inside = null; // "think" | "tool" | "tool_result" + const open = /<(think|tool_result|tool)\b/i; + const pump = () => { + for (;;) { + if (!inside) { + const m = pending.match(open); + if (m) { + if (m.index) emit(pending.slice(0, m.index)); + inside = m[1].toLowerCase(); + if (inside === "think") onThinking(); + pending = pending.slice(m.index); + continue; + } + const lt = pending.lastIndexOf("<"); + const keep = lt >= 0 && pending.length - lt < 14 ? pending.length - lt : 0; + if (pending.length - keep) emit(pending.slice(0, pending.length - keep)); + pending = pending.slice(pending.length - keep); + return; + } + const close = new RegExp(``, "i"); + const c = pending.match(close); + const selfClose = inside === "tool" ? pending.match(/^]*\/>/i) : null; + if (selfClose) { pending = pending.slice(selfClose[0].length); inside = null; continue; } + if (!c) return; + pending = pending.slice(c.index + c[0].length); + inside = null; + } + }; + return { + push(chunk) { pending += chunk; pump(); }, + end() { if (!inside && pending) emit(pending); pending = ""; inside = null; }, + }; +} + +// ─── tools ─────────────────────────────────────────────────────────────────── + +const TOOL_CHARS = () => Number(process.env.C0AGENT_TOOL_CHARS) > 0 ? Number(process.env.C0AGENT_TOOL_CHARS) : 8000; + +function cap(text, max = TOOL_CHARS()) { + const s = String(text ?? ""); + return s.length <= max ? s : `${s.slice(0, max)}\n… [${s.length - max} more characters cut; narrow the request]`; +} + +function resolvePath(session, p) { + if (!p || typeof p !== "string") throw new Error("path is required"); + const expanded = p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p; + return path.resolve(session.cwd, expanded); +} + +const rel = (session, abs) => { + const r = path.relative(session.cwd, abs); + return r && !r.startsWith("..") ? r : abs; +}; + +/** One-line rendering of a call, the way Claude Code prints `Read(src/a.js)`. */ +export function describeCall({ name, input = {} }) { + const arg = { + read: input.path, write: input.path, edit: input.path, ls: input.path || ".", + bash: input.command, glob: input.pattern, grep: input.pattern && `${input.pattern}${input.path ? ` in ${input.path}` : ""}`, + task: input.agent && `${input.agent}: ${input.description || input.prompt || ""}`, + todo: Array.isArray(input.items) ? `${input.items.length} items` : "", + }[name]; + const label = name.charAt(0).toUpperCase() + name.slice(1); + const one = String(arg ?? "").replace(/\s+/g, " ").trim(); + return `${label}(${one.length > 90 ? `${one.slice(0, 87)}…` : one})`; +} + +/** Shell commands that only look, never change. Run without asking. */ +export function isReadOnlyCommand(cmd) { + const c = String(cmd ?? "").trim(); + if (!c || /[;&|`$<>]|\n/.test(c.replace(/\s2>&1$/, ""))) return false; + const [bin, sub] = c.split(/\s+/); + if (["ls", "pwd", "cat", "head", "tail", "wc", "rg", "grep", "file", "which", "stat", "tree", "du", "echo", "date", "uname", "env"].includes(bin)) return true; + if (bin === "git") return ["status", "diff", "log", "show", "branch", "remote", "rev-parse", "ls-files", "blame"].includes(sub); + if (["node", "npm", "pnpm", "python3", "cargo", "go", "bun"].includes(bin)) return ["--version", "-v", "-V", "version"].includes(sub); + return false; +} + +function needsApproval(session, call) { + const mode = session.permissionMode; + if (call.name === "write" || call.name === "edit") { + if (mode === "plan") return "deny"; + if (mode === "auto-edit" || mode === "yolo") return "allow"; + return session.allowed.has(call.name) ? "allow" : "ask"; + } + if (call.name === "bash") { + if (isReadOnlyCommand(call.input?.command)) return "allow"; + if (mode === "plan") return "deny"; + if (mode === "yolo") return "allow"; + const first = String(call.input?.command ?? "").trim().split(/\s+/)[0]; + return session.allowed.has(`bash:${first}`) ? "allow" : "ask"; + } + return "allow"; +} + +function preview(call, session) { + const { name, input } = call; + if (name === "bash") return String(input.command ?? ""); + if (name === "write") { + const lines = String(input.content ?? "").split("\n"); + return `${rel(session, resolvePath(session, input.path))} (${lines.length} lines)\n${lines.slice(0, 12).map((l) => `+ ${l}`).join("\n")}${lines.length > 12 ? "\n+ …" : ""}`; + } + if (name === "edit") { + const minus = String(input.old ?? "").split("\n").slice(0, 10).map((l) => `- ${l}`); + const plus = String(input.new ?? "").split("\n").slice(0, 10).map((l) => `+ ${l}`); + return `${rel(session, resolvePath(session, input.path))}\n${[...minus, ...plus].join("\n")}`; + } + return describeCall(call); +} + +function walkGlob(pattern, cwd) { + const skip = (p) => /(^|\/)(node_modules|\.git)(\/|$)/.test(p); + if (typeof fs.globSync === "function") { + return fs.globSync(pattern, { cwd, exclude: (p) => skip(typeof p === "string" ? p : p.name) }) + .filter((p) => !skip(p)); + } + const r = spawnSync("rg", ["--files", "--glob", pattern], { cwd, encoding: "utf8" }); + return r.status === 0 ? r.stdout.split("\n").filter(Boolean) : []; +} + +function runShell(command, { cwd, signal, timeoutMs }) { + return new Promise((resolve) => { + const marker = `__c0cwd_${process.pid}__`; + const shell = fs.existsSync("/bin/bash") ? "/bin/bash" : "/bin/sh"; + const script = `${command}\n__c0rc=$?\nprintf '\\n${marker}%s\\n' "$PWD"\nexit $__c0rc`; + const child = spawn(shell, ["-c", script], { cwd, env: { ...process.env, CI: "1", PAGER: "cat", GIT_PAGER: "cat" }, stdio: ["ignore", "pipe", "pipe"] }); + let out = ""; + const add = (d) => { out += d; if (out.length > 400_000) out = out.slice(-200_000); }; + child.stdout.on("data", add); + child.stderr.on("data", add); + let timedOut = false; + const timer = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, timeoutMs); + const onAbort = () => child.kill("SIGTERM"); + signal?.addEventListener("abort", onAbort, { once: true }); + child.on("close", (code) => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + let newCwd = null; + const at = out.lastIndexOf(marker); + if (at >= 0) { + newCwd = out.slice(at + marker.length).split("\n")[0].trim() || null; + out = out.slice(0, at).replace(/\n$/, ""); + } + resolve({ code, out, timedOut, newCwd }); + }); + child.on("error", (e) => { clearTimeout(timer); resolve({ code: 127, out: String(e.message), timedOut: false, newCwd: null }); }); + }); +} + +/** Run one tool. Always resolves `{ ok, output }`; a thrown error is a failed result. */ +export async function runTool(session, call, ctx = {}) { + const { name, input = {} } = call; + try { + if (call.error) return { ok: false, output: `${call.error}. tools: ${ctx.tools.join(", ")}` }; + if (!ctx.tools.includes(name)) return { ok: false, output: `${name} is not available to this agent. tools: ${ctx.tools.join(", ")}` }; + const gate = needsApproval(session, call); + if (gate === "deny") return { ok: false, output: `${name} is blocked in plan mode: reply with your plan instead.` }; + if (gate === "ask") { + const answer = await session.ask({ name, agent: ctx.agent?.name, title: describeCall(call), preview: preview(call, session) }); + if (answer === "always") session.allowed.add(name === "bash" ? `bash:${String(input.command).trim().split(/\s+/)[0]}` : name); + else if (answer !== "yes") return { ok: false, output: `the user declined this ${name}${answer && answer !== "no" ? `: ${answer}` : ""}. do something else or ask them.` }; + } + switch (name) { + case "read": { + const file = resolvePath(session, input.path); + const st = fs.statSync(file); + if (st.isDirectory()) return { ok: false, output: `${input.path} is a directory: use ls` }; + const lines = fs.readFileSync(file, "utf8").split("\n"); + const offset = Math.max(1, Number(input.offset) || 1); + const limit = Math.min(Math.max(1, Number(input.limit) || 300), 2000); + const slice = lines.slice(offset - 1, offset - 1 + limit); + const body = slice.map((l, i) => `${String(offset + i).padStart(5)} ${l.length > 400 ? `${l.slice(0, 400)}…` : l}`).join("\n"); + const more = offset - 1 + limit < lines.length ? `\n… ${lines.length - (offset - 1 + limit)} more lines: read with offset ${offset + limit}` : ""; + session.readFiles.add(file); + return { ok: true, output: cap(body + more), summary: `${slice.length} lines` }; + } + case "write": { + const file = resolvePath(session, input.path); + if (typeof input.content !== "string") return { ok: false, output: "content (string) is required" }; + fs.mkdirSync(path.dirname(file), { recursive: true }); + const existed = fs.existsSync(file); + fs.writeFileSync(file, input.content); + const n = input.content.split("\n").length; + return { ok: true, output: `${existed ? "overwrote" : "created"} ${rel(session, file)} (${n} lines)`, summary: `${n} lines` }; + } + case "edit": { + const file = resolvePath(session, input.path); + const oldS = String(input.old ?? input.old_string ?? ""); + const newS = String(input.new ?? input.new_string ?? ""); + if (!oldS) return { ok: false, output: "old (the exact text to replace) is required; read the file first" }; + const text = fs.readFileSync(file, "utf8"); + const count = text.split(oldS).length - 1; + if (!count) return { ok: false, output: `old text not found in ${input.path}: read the file and copy it exactly, including indentation` }; + if (count > 1 && !input.all) return { ok: false, output: `old text occurs ${count} times in ${input.path}: include more surrounding lines, or pass "all": true` }; + fs.writeFileSync(file, input.all ? text.split(oldS).join(newS) : text.replace(oldS, () => newS)); + const delta = newS.split("\n").length - oldS.split("\n").length; + return { ok: true, output: `edited ${rel(session, file)} (${count} replacement${count > 1 ? "s" : ""})`, summary: `${delta >= 0 ? "+" : ""}${delta} lines` }; + } + case "bash": { + const command = String(input.command ?? "").trim(); + if (!command) return { ok: false, output: "command is required" }; + const timeoutMs = Math.min(Math.max(Number(input.timeout) || 120_000, 1000), 600_000); + const r = await runShell(command, { cwd: session.cwd, signal: ctx.signal, timeoutMs }); + if (r.newCwd && r.newCwd !== session.cwd && fs.existsSync(r.newCwd)) session.cwd = r.newCwd; + const head = r.timedOut ? `timed out after ${timeoutMs / 1000}s\n` : ""; + return { ok: r.code === 0, output: cap(`${head}${r.out.replace(/\s+$/, "") || "(no output)"}\nexit ${r.code}`), summary: `exit ${r.code}` }; + } + case "glob": { + if (!input.pattern) return { ok: false, output: "pattern is required, like **/*.js" }; + const base = input.path ? resolvePath(session, input.path) : session.cwd; + const files = walkGlob(String(input.pattern), base); + const withTime = files.map((f) => { let t = 0; try { t = fs.statSync(path.join(base, f)).mtimeMs; } catch { /* gone */ } return [f, t]; }); + withTime.sort((a, b) => b[1] - a[1]); + const list = withTime.slice(0, 200).map(([f]) => rel(session, path.join(base, f))); + return { ok: true, output: list.length ? cap(list.join("\n") + (files.length > 200 ? `\n… ${files.length - 200} more` : "")) : "no files match", summary: `${files.length} files` }; + } + case "grep": { + if (!input.pattern) return { ok: false, output: "pattern is required" }; + const where = input.path ? resolvePath(session, input.path) : "."; + const args = ["-n", "--no-heading", "--color", "never", "-S", "-m", "50"]; + if (input.glob) args.push("--glob", String(input.glob)); + args.push("--", String(input.pattern), where); + let r = spawnSync("rg", args, { cwd: session.cwd, encoding: "utf8", maxBuffer: 8 << 20 }); + if (r.error) r = spawnSync("grep", ["-rnE", "--exclude-dir=node_modules", "--exclude-dir=.git", String(input.pattern), where], { cwd: session.cwd, encoding: "utf8", maxBuffer: 8 << 20 }); + const lines = (r.stdout || "").split("\n").filter(Boolean); + return { ok: true, output: lines.length ? cap(lines.slice(0, 200).join("\n") + (lines.length > 200 ? `\n… ${lines.length - 200} more matches` : "")) : "no matches", summary: `${lines.length} matches` }; + } + case "ls": { + const dir = resolvePath(session, input.path || "."); + const entries = fs.readdirSync(dir, { withFileTypes: true }) + .filter((e) => e.name !== ".git" && e.name !== "node_modules") + .map((e) => (e.isDirectory() ? `${e.name}/` : e.name)).sort(); + return { ok: true, output: cap(entries.slice(0, 300).join("\n") || "(empty)"), summary: `${entries.length} entries` }; + } + case "todo": { + const items = Array.isArray(input.items) ? input.items : []; + session.todos = items.map((t) => ({ + text: String(t?.text ?? t?.content ?? t ?? ""), + status: ["pending", "in_progress", "done"].includes(t?.status) ? t.status : t?.done ? "done" : "pending", + })).filter((t) => t.text); + ctx.onEvent?.({ type: "todo", todos: session.todos, depth: ctx.depth }); + return { ok: true, output: "todo list updated", summary: `${session.todos.length} items` }; + } + case "task": { + const agents = loadAgents({ cwd: session.cwd }); + const target = agents.find((a) => a.name === String(input.agent ?? "general-purpose").toLowerCase().replace(/^@/, "")); + if (!target) return { ok: false, output: `no agent "${input.agent}". agents: ${agents.map((a) => a.name).join(", ")}` }; + if (!input.prompt) return { ok: false, output: "prompt is required: everything the subagent needs, it sees nothing else" }; + const started = Date.now(); + const sub = await runAgent({ + session, + agent: target, + messages: [{ role: "user", content: String(input.prompt) }], + depth: (ctx.depth ?? 0) + 1, + signal: ctx.signal, + onEvent: ctx.onEvent, + }); + const secs = Math.round((Date.now() - started) / 1000); + return { ok: !sub.error, output: cap(sub.text || sub.error || "(the subagent returned nothing)"), summary: `${sub.toolUses} tool uses · ${secs}s` }; + } + default: + return { ok: false, output: `unknown tool ${name}` }; + } + } catch (e) { + return { ok: false, output: String(e?.message || e) }; + } +} + +// ─── prompts ───────────────────────────────────────────────────────────────── + +const TOOL_DOCS = { + read: `read {"path": "src/app.js", "offset": 1, "limit": 300} read a file with line numbers`, + write: `write {"path": "notes.md", "content": "..."} create or overwrite a whole file`, + edit: `edit {"path": "src/app.js", "old": "exact old text", "new": "replacement"} replace text; read the file first, copy "old" exactly`, + bash: `bash {"command": "npm test"} run a shell command in the project`, + glob: `glob {"pattern": "src/**/*.ts"} find files by name`, + grep: `grep {"pattern": "function main", "path": "src", "glob": "*.js"} search file contents (regex)`, + ls: `ls {"path": "src"} list a directory`, + todo: `todo {"items": [{"text": "write the test", "status": "in_progress"}]} keep a checklist for multi-step work`, + task: `task {"agent": "explore", "prompt": "everything the subagent needs to know"} hand a self-contained job to a subagent`, +}; + +function projectNotes(cwd) { + for (const f of ["MOSHCODE.md", "AGENTS.md", "CLAUDE.md"]) { + try { + const text = fs.readFileSync(path.join(cwd, f), "utf8").trim(); + if (text) return `\n\n# Project instructions (${f})\n${text.length > 4000 ? `${text.slice(0, 4000)}\n…` : text}`; + } catch { /* next */ } + } + return ""; +} + +function gitLine(cwd) { + const r = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd, encoding: "utf8" }); + return r.status === 0 ? `git branch: ${r.stdout.trim()}` : "not a git repository"; +} + +/** The system prompt for one agent, lead or sub. */ +export function systemPrompt({ agent, tools, session, depth = 0, agents = [] }) { + const lead = depth === 0 && agent.name === "c0mpute"; + const intro = lead + ? "You are c0mpute, a coding agent running in the user's terminal through moshcode. You help with software tasks by using tools on the user's machine." + : agent.prompt; + const docs = tools.map((t) => `- ${TOOL_DOCS[t]}`).join("\n"); + const subagents = tools.includes("task") && agents.length + ? `\n\n# Subagents for the task tool\n${agents.map((a) => `- ${a.name}: ${a.description}`).join("\n")}\nUse a subagent for searches or self-contained jobs. Several task calls in one reply run in parallel.` + : ""; + return [ + intro, + "", + "# How to use tools", + "To use a tool, write a tool block with a JSON object and then STOP and wait for the result:", + "", + '{"path": "package.json"}', + "", + "You may write several tool blocks in one reply. The results come back in blocks.", + "Never write yourself. When the work is done, reply with plain text and no tool block.", + "", + "# Tools", + docs, + subagents, + "", + "# Rules", + "- Read a file before you edit it. Prefer edit over rewriting a whole file.", + "- Do not invent file contents, paths or command output: look first.", + "- Keep replies short. Say what you changed and how you checked it.", + lead ? "- For a multi-step change, keep a todo list and run the tests at the end." : "- Your final reply is all the lead agent sees: include the facts it needs.", + "", + "# Environment", + `working directory: ${session.cwd}`, + `platform: ${process.platform} · date: ${new Date().toISOString().slice(0, 10)} · ${gitLine(session.cwd)}`, + lead ? projectNotes(session.cwd) : "", + ].join("\n"); +} + +/** The lead agent: every tool, and it is the only one that can spawn subagents. */ +export const LEAD_AGENT = { + name: "c0mpute", + description: "the main agent", + tools: ALL_TOOLS, + model: null, + prompt: "", + source: "built-in", +}; + +// ─── context ───────────────────────────────────────────────────────────────── + +const CONTEXT_CHARS = () => Number(process.env.C0AGENT_CONTEXT_CHARS) > 0 ? Number(process.env.C0AGENT_CONTEXT_CHARS) : 40_000; + +/** + * Keep the conversation inside a small model's window: elide the bodies of + * the oldest tool results first. The last exchange is never touched. + */ +export function trimContext(messages, max = CONTEXT_CHARS()) { + const size = () => messages.reduce((n, m) => n + m.content.length, 0); + for (let i = 0; i < messages.length - 2 && size() > max; i++) { + const m = messages[i]; + if (m.role === "user" && m.content.includes("]*>)[\s\S]*?(<\/tool_result>)/g, "$1\n[elided to save context: run the tool again if you need it]\n$2"); + m.elided = true; + } + } + return messages; +} + +const formatResult = (call, r) => + `\n${r.output}\n`; + +// ─── the loop ──────────────────────────────────────────────────────────────── + +/** + * A session: what the agents share. `provider` is anything with + * `stream(req, onToken) -> { text, model }`; `ask` answers a permission + * prompt with "yes" | "always" | "no" | . + */ +export function createSession({ provider, model, cwd = process.cwd(), permissionMode = "ask", ask } = {}) { + return { + provider, + model, + cwd, + permissionMode, + ask: ask || (async () => "no"), + allowed: new Set(), + readFiles: new Set(), + todos: [], + usage: { inputTokens: 0, outputTokens: 0, calls: 0 }, + }; +} + +/** + * Run one agent until it answers without a tool call. Returns + * `{ text, toolUses, error? }`. Never throws for a model or tool failure; + * an abort ends it with `error: "interrupted"`. + */ +export async function runAgent({ session, agent = LEAD_AGENT, messages, depth = 0, signal, onEvent = () => {}, maxSteps }) { + const isLead = depth === 0; + const tools = (isLead ? agent.tools : agent.tools.filter((t) => t !== "task")).filter((t) => ALL_TOOLS.includes(t)); + const agents = tools.includes("task") ? loadAgents({ cwd: session.cwd }) : []; + const system = systemPrompt({ agent, tools, session, depth, agents }); + const model = agent.model || session.model; + const limit = maxSteps ?? (isLead ? 40 : 25); + let toolUses = 0; + let last = ""; + for (let step = 0; step < limit; step++) { + if (signal?.aborted) return { text: last, toolUses, error: "interrupted" }; + trimContext(messages); + onEvent({ type: "waiting", agent: agent.name, depth }); + const filter = createStreamFilter( + (t) => onEvent({ type: "text", text: t, agent: agent.name, depth }), + { onThinking: () => onEvent({ type: "thinking", agent: agent.name, depth }) }, + ); + let res; + try { + res = await session.provider.stream( + { system, messages: messages.map(({ role, content }) => ({ role, content })), maxTokens: 2048, temperature: 0.2, signal, models: { c0mpute: model } }, + (t) => filter.push(t), + ); + } catch (e) { + if (signal?.aborted) return { text: last, toolUses, error: "interrupted" }; + const error = String(e?.message || e); + onEvent({ type: "error", error, agent: agent.name, depth }); + return { text: last, toolUses, error }; + } + filter.end(); + session.usage.calls += 1; + session.usage.inputTokens += res.usage?.inputTokens ?? 0; + session.usage.outputTokens += res.usage?.outputTokens ?? 0; + const raw = String(res.text ?? ""); + if (process.env.C0AGENT_DEBUG) process.stderr.write(`\n--- ${agent.name} @ ${res.model} ---\n${raw}\n---\n`); + const calls = parseToolCalls(raw); + // Keep only what the model said up to its last tool call: anything after + // is a guess at results it hasn't seen. + messages.push({ role: "assistant", content: stripThink(raw).replace(/ { + onEvent({ type: "tool_start", call, title: describeCall(call), agent: agent.name, depth }); + const r = await runTool(session, call, { tools, agent, depth, signal, onEvent }); + onEvent({ type: "tool_end", call, title: describeCall(call), result: r, agent: agent.name, depth }); + results[i] = r; + }; + const tasks = []; + for (let i = 0; i < calls.length; i++) { + if (calls[i].name === "task") tasks.push(runOne(calls[i], i)); + else { await Promise.all(tasks.splice(0)); await runOne(calls[i], i); } + } + await Promise.all(tasks); + toolUses += calls.length; + messages.push({ role: "user", content: calls.map((c, i) => formatResult(c, results[i])).join("\n\n") }); + } + return { text: last || `stopped after ${limit} steps`, toolUses, error: last ? undefined : "step limit" }; +} + +/** `/compact`: replace the conversation with the model's own summary of it. */ +export async function compactConversation(session, messages, { signal } = {}) { + const transcript = messages.map((m) => `${m.role.toUpperCase()}: ${m.content}`).join("\n\n").slice(-30_000); + const res = await session.provider.stream({ + system: "Summarize this coding session so the work can continue: the goal, decisions, files changed, what is left. Under 300 words.", + messages: [{ role: "user", content: transcript }], + maxTokens: 700, + temperature: 0.2, + signal, + models: { c0mpute: session.model }, + }, () => {}); + const summary = visibleText(res.text); + messages.splice(0, messages.length, + { role: "user", content: `Summary of our session so far:\n${summary}` }, + { role: "assistant", content: "Got it. Ready to continue." }); + return summary; +} + +/** Ask the model to draft an agent definition from a one-line description (/agents new). */ +export async function draftAgent(session, description, { signal } = {}) { + const res = await session.provider.stream({ + system: [ + "You design subagents for a coding assistant. Reply with ONLY a JSON object:", + '{"name": "kebab-case-name", "description": "one sentence: what it does and when to use it",', + ' "tools": ["read", "grep"], "prompt": "the system prompt, second person, 5-15 lines"}', + `tools must be chosen from: ${ALL_TOOLS.filter((t) => t !== "task").join(", ")}. Give read-only agents only read, glob, grep, ls.`, + ].join("\n"), + messages: [{ role: "user", content: String(description) }], + maxTokens: 900, + temperature: 0.3, + signal, + models: { c0mpute: session.model }, + }, () => {}); + const text = visibleText(res.text); + const obj = looseJson(text.match(/\{[\s\S]*\}/)?.[0] ?? ""); + if (!obj?.prompt) throw new Error("the model did not return a usable definition; write it by hand with /agents new --manual"); + const name = String(obj.name ?? "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "custom-agent"; + const tools = (Array.isArray(obj.tools) ? obj.tools : []).map(normalizeTool).filter((t) => t && t !== "task"); + return { name, description: String(obj.description ?? description), tools: tools.length ? [...new Set(tools)] : ALL_TOOLS.filter((t) => t !== "task"), model: null, prompt: String(obj.prompt) }; +} + +export { BUILTIN_AGENTS }; diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 1e70987b..80c4431d 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -49,6 +49,8 @@ export const CORE_CLI_COMMANDS = [ ["moshcode agents --json", "pipe engine status into a script"], ["moshcode agents claude", "claude's agent list"], ["moshcode agents codex", "Codex's shared-daemon agents overview (CLI 0.151.0+)"], + ["moshcode agents c0mpute", "a Claude-Code-style agent with subagents, on the c0mpute network"], + ["moshcode agents c0mpute -p \"fix the failing test\"", "run it once and print the answer"], ], seeAlso: ["start", "engines", "install"], note: "autonomous modes bypass approval prompts — use them in a container or a workspace you trust.", diff --git a/src/tui.mjs b/src/tui.mjs index a6920254..a6c7e2bc 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -10,6 +10,7 @@ import path from "node:path"; import { API_ENGINES, ENGINES, agentLaunchArgs, apiEngineAvailable, interactiveError, resolveApiEngine, resolveEngine, engineStatus, openSession } from "./engines.mjs"; import { TOOLS, resolveTool, resolveInstallable, suggestTargets, toolStatus, openTool, readToolAliases, toolsWithAliases } from "./tools.mjs"; import { tradeArgs, tradeUsage } from "./trade.mjs"; +import { isC0mputeAgent } from "./c0agent-defs.mjs"; import { postSocial, socialRoster } from "./socials.mjs"; import { shortenCommand } from "./shorten.mjs"; import { runUpgrade } from "./upgrade.mjs"; @@ -1211,6 +1212,16 @@ export async function tui() { await enginesDefaults(rest.slice(1), { write: (l) => console.log(` ${l}`) }); continue; } + // c0mpute has no coding CLI of its own: moshcode is the harness, and it + // needs the terminal the way an engine session does. + if (isC0mputeAgent(rest[0])) { + const { c0agentMain } = await import("./c0agent-repl.mjs"); + rl.close(); + try { await c0agentMain(rest.slice(1)); } + catch (e) { console.log(err(`c0mpute agent: ${e.message || e}`)); } + rl = mkrl(); + continue; + } const resolved = resolveEngine(rest[0]); if (!resolved) { const api = resolveApiEngine(rest[0]); @@ -1232,6 +1243,7 @@ export async function tui() { } if (cmd === "start") { if (!rest[0]) { console.log(err("usage: /start [args…] [-d]")); continue; } + if (isC0mputeAgent(rest[0])) { pending = `/agents ${line.replace(/^\/?start\s+/i, "")}`; continue; } const resolved = resolveEngine(rest[0]); if (!resolved) { const api = resolveApiEngine(rest[0]); diff --git a/test/c0agent.test.mjs b/test/c0agent.test.mjs new file mode 100644 index 00000000..55e3e65d --- /dev/null +++ b/test/c0agent.test.mjs @@ -0,0 +1,282 @@ +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 { + createSession, createStreamFilter, isReadOnlyCommand, looseJson, parseToolCalls, runAgent, runTool, + trimContext, visibleText, c0mputeConfig, saveConfig, describeCall, +} from "../src/c0agent.mjs"; +import { + ALL_TOOLS, findAgent, isC0mputeAgent, loadAgents, parseAgentFile, parseFrontmatter, removeAgent, renderAgentFile, saveAgent, +} from "../src/c0agent-defs.mjs"; +import { parseArgs } from "../src/c0agent-repl.mjs"; + +const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "c0agent-")); + +/** A provider that replies from a script, one entry per call, streaming each reply in small chunks. */ +function scripted(replies) { + const calls = []; + return { + calls, + async stream(req, onToken) { + calls.push(req); + const next = replies.shift(); + const text = typeof next === "function" ? next(req) : next ?? "done"; + for (let i = 0; i < text.length; i += 5) onToken(text.slice(i, i + 5)); + return { text, model: "fake" }; + }, + }; +} + +test("parseToolCalls: the strict form, several calls, in order", () => { + const calls = parseToolCalls('Looking.\n{"path": "a.js"}\n{"pattern": "x", "path": "src"}'); + assert.deepEqual(calls, [ + { name: "read", input: { path: "a.js" } }, + { name: "grep", input: { pattern: "x", path: "src" } }, + ]); +}); + +test("parseToolCalls: forgives the slips small models make", () => { + assert.deepEqual(parseToolCalls('npm test'), [{ name: "bash", input: { command: "npm test" } }]); + assert.deepEqual(parseToolCalls(''), [{ name: "read", input: { path: "src/x.js" } }]); + assert.deepEqual(parseToolCalls('{"path": "a",}'), [{ name: "read", input: { path: "a" } }]); + assert.deepEqual(parseToolCalls('```json\n{"tool": "ls", "input": {"path": "src"}}\n```'), [{ name: "ls", input: { path: "src" } }]); + assert.deepEqual(parseToolCalls('{"command": "cat package.json"}'), [{ name: "bash", input: { command: "cat package.json" } }]); + assert.deepEqual(parseToolCalls('Here: {"command": "x"}'), [], "only a reply that is nothing but the arguments"); + assert.equal(parseToolCalls('{}')[0].error, 'unknown tool "webfetch"'); +}); + +test("parseToolCalls: ignores calls inside thinking and after an invented result", () => { + const text = 'maybe rm -rf /{}fake{"command":"x"}'; + assert.deepEqual(parseToolCalls(text), [{ name: "ls", input: {} }]); +}); + +test("visibleText and the stream filter hide thinking and tool blocks", () => { + const raw = 'hmmSure.\n{"path":"a"}\nDone.'; + assert.equal(visibleText(raw), "Sure.\n\nDone."); + let seen = ""; + let thought = 0; + const f = createStreamFilter((t) => { seen += t; }, { onThinking: () => thought++ }); + for (let i = 0; i < raw.length; i += 3) f.push(raw.slice(i, i + 3)); + f.end(); + assert.equal(seen.replace(/\s+/g, " ").trim(), "Sure. Done."); + assert.equal(thought, 1); +}); + +test("looseJson rejects non-objects", () => { + assert.equal(looseJson("[1]"), null); + assert.equal(looseJson("nope"), null); + assert.deepEqual(looseJson('```json\n{"a":1}\n```'), { a: 1 }); +}); + +test("isReadOnlyCommand lets looking through and stops anything that changes or chains", () => { + for (const c of ["ls -la", "git status", "git diff HEAD", "rg foo src", "cat a.txt"]) assert.ok(isReadOnlyCommand(c), c); + for (const c of ["rm a", "git push", "ls; rm a", "cat a > b", "echo $(id)", "npm install", "git commit -m x"]) assert.ok(!isReadOnlyCommand(c), c); +}); + +test("tools: read, write, edit, ls, glob, grep in a scratch dir", async () => { + const dir = tmp(); + const session = createSession({ cwd: dir, permissionMode: "auto-edit" }); + const ctx = { tools: ALL_TOOLS }; + let r = await runTool(session, { name: "write", input: { path: "src/a.js", content: "const a = 1;\nconst b = 2;\n" } }, ctx); + assert.ok(r.ok, r.output); + r = await runTool(session, { name: "read", input: { path: "src/a.js" } }, ctx); + assert.match(r.output, /1 {2}const a = 1;/); + r = await runTool(session, { name: "edit", input: { path: "src/a.js", old: "const b = 2;", new: "const b = 3;" } }, ctx); + assert.ok(r.ok, r.output); + assert.equal(fs.readFileSync(path.join(dir, "src/a.js"), "utf8"), "const a = 1;\nconst b = 3;\n"); + r = await runTool(session, { name: "edit", input: { path: "src/a.js", old: "nope", new: "x" } }, ctx); + assert.ok(!r.ok); + r = await runTool(session, { name: "edit", input: { path: "src/a.js", old: "const", new: "let" } }, ctx); + assert.match(r.output, /occurs 2 times/); + r = await runTool(session, { name: "ls", input: {} }, ctx); + assert.equal(r.output, "src/"); + r = await runTool(session, { name: "glob", input: { pattern: "**/*.js" } }, ctx); + assert.equal(r.output, path.join("src", "a.js")); + r = await runTool(session, { name: "grep", input: { pattern: "b = 3" } }, ctx); + assert.match(r.output, /a\.js:2:const b = 3;/); +}); + +test("bash runs in the session dir, reports the exit code and follows cd", async () => { + const dir = tmp(); + fs.mkdirSync(path.join(dir, "sub")); + const session = createSession({ cwd: dir, permissionMode: "yolo" }); + let r = await runTool(session, { name: "bash", input: { command: "echo hi; exit 3" } }, { tools: ALL_TOOLS }); + assert.equal(r.ok, false); + assert.match(r.output, /hi\nexit 3/); + r = await runTool(session, { name: "bash", input: { command: "cd sub" } }, { tools: ALL_TOOLS }); + assert.equal(fs.realpathSync(session.cwd), fs.realpathSync(path.join(dir, "sub"))); +}); + +test("permissions: ask, always, decline with a note, plan mode, and tools an agent lacks", async () => { + const dir = tmp(); + const answers = ["no", "use a.txt instead", "always"]; + const asked = []; + const session = createSession({ cwd: dir, ask: async (req) => { asked.push(req.title); return answers.shift(); } }); + const ctx = { tools: ALL_TOOLS }; + const w = (p) => ({ name: "write", input: { path: p, content: "x" } }); + let r = await runTool(session, w("x.txt"), ctx); + assert.match(r.output, /declined/); + r = await runTool(session, w("x.txt"), ctx); + assert.match(r.output, /use a\.txt instead/); + r = await runTool(session, w("x.txt"), ctx); + assert.ok(r.ok); + r = await runTool(session, w("y.txt"), ctx); // "always" covers later writes + assert.ok(r.ok); + assert.equal(asked.length, 3); + // read-only shell never asks + r = await runTool(session, { name: "bash", input: { command: "ls" } }, ctx); + assert.ok(r.ok); + assert.equal(asked.length, 3); + session.permissionMode = "plan"; + r = await runTool(session, w("z.txt"), ctx); + assert.match(r.output, /plan mode/); + r = await runTool(session, { name: "write", input: { path: "q", content: "" } }, { tools: ["read"] }); + assert.match(r.output, /not available/); +}); + +test("runAgent: loops through tool calls until a plain answer", async () => { + const dir = tmp(); + fs.writeFileSync(path.join(dir, "package.json"), '{"name":"demo"}'); + const provider = scripted([ + 'Let me look.\n{"path": "package.json"}', + (req) => { + assert.match(req.messages.at(-1).content, /[\s\S]*"name":"demo"/); + return "The package is called demo."; + }, + ]); + const session = createSession({ provider, cwd: dir, model: "m" }); + const events = []; + const messages = [{ role: "user", content: "what is the package name?" }]; + const res = await runAgent({ session, messages, onEvent: (e) => events.push(e) }); + assert.equal(res.text, "The package is called demo."); + assert.equal(res.toolUses, 1); + assert.equal(messages.length, 4); + assert.match(provider.calls[0].system, //); + assert.match(provider.calls[0].system, /Subagents for the task tool[\s\S]*explore:/); + assert.equal(provider.calls[0].models.c0mpute, "m"); + const streamed = events.filter((e) => e.type === "text").map((e) => e.text).join(""); + assert.match(streamed, /Let me look\.[\s\S]*The package is called demo\./); + assert.ok(!streamed.includes(" e.type === "tool_start" && e.title === "Read(package.json)")); +}); + +test("task: subagents get a fresh conversation, their own prompt and tools, and run in parallel", async () => { + const dir = tmp(); + let inFlight = 0; + let peak = 0; + const provider = { + calls: [], + async stream(req, onToken) { + this.calls.push(req); + if (req.system.startsWith("You are c0mpute")) { + const n = this.calls.filter((c) => c.system.startsWith("You are c0mpute")).length; + const text = n === 1 + ? '{"agent": "explore", "prompt": "find A"}{"agent": "plan", "prompt": "plan B"}' + : "Both done."; + onToken(text); + return { text }; + } + inFlight++; peak = Math.max(peak, inFlight); + await new Promise((r) => setTimeout(r, 30)); + inFlight--; + assert.equal(req.messages.length, 1, "a subagent starts with only its prompt"); + assert.ok(!req.system.includes("task {"), "subagents cannot spawn subagents"); + assert.ok(!req.system.includes("bash {"), "explore/plan are read-only"); + const text = req.messages[0].content === "find A" ? "A is in a.js" : "step 1: do B"; + return { text }; + }, + }; + const session = createSession({ provider, cwd: dir }); + const events = []; + const messages = [{ role: "user", content: "go" }]; + const res = await runAgent({ session, messages, onEvent: (e) => events.push(e) }); + assert.equal(res.text, "Both done."); + assert.equal(peak, 2, "two task calls in one reply run at the same time"); + assert.match(messages[2].content, /A is in a\.js[\s\S]*step 1: do B/); + assert.deepEqual(events.filter((e) => e.type === "tool_end").map((e) => e.result.ok), [true, true]); +}); + +test("runAgent: a provider failure is a returned error, and an abort is 'interrupted'", async () => { + const session = createSession({ provider: { stream: async () => { throw new Error("c0mpute 429: free daily limit reached"); } } }); + const res = await runAgent({ session, messages: [{ role: "user", content: "hi" }] }); + assert.match(res.error, /429/); + const ac = new AbortController(); + ac.abort(); + const res2 = await runAgent({ session, messages: [{ role: "user", content: "hi" }], signal: ac.signal }); + assert.equal(res2.error, "interrupted"); +}); + +test("trimContext elides old tool results first and keeps the last exchange", () => { + const big = "x".repeat(5000); + const messages = [ + { role: "user", content: "task" }, + { role: "assistant", content: "" }, + { role: "user", content: `\n${big}\n` }, + { role: "assistant", content: "next" }, + { role: "user", content: `\n${big}\n` }, + ]; + trimContext(messages, 6000); + assert.match(messages[2].content, /elided/); + assert.ok(messages[4].content.includes(big)); +}); + +test("definitions: frontmatter, Claude Code agents, priority and shadowing", () => { + const cwd = tmp(); + const home = tmp(); + fs.mkdirSync(path.join(cwd, ".claude", "agents"), { recursive: true }); + fs.writeFileSync(path.join(cwd, ".claude", "agents", "reviewer.md"), + "---\nname: reviewer\ndescription: Reviews code\ntools: Read, Grep, Glob, WebFetch\nmodel: sonnet\n---\nYou review code.\n"); + const claude = findAgent("reviewer", { cwd, home }); + assert.deepEqual(claude.tools, ["read", "grep", "glob"]); + assert.equal(claude.model, null, "Claude tiers inherit the session model"); + assert.equal(claude.source, "claude"); + + saveAgent({ name: "reviewer", description: "Ours", tools: ["read"], model: "qwen2.5:7b", prompt: "Project reviewer." }, { scope: "project", cwd, home }); + saveAgent({ name: "explore", description: "My explore", tools: ["read", "grep"], prompt: "Mine." }, { scope: "user", cwd, home }); + const agents = loadAgents({ cwd, home }); + const reviewer = agents.find((a) => a.name === "reviewer"); + assert.equal(reviewer.source, "project"); + assert.deepEqual(reviewer.shadows, ["claude"]); + assert.equal(reviewer.model, "qwen2.5:7b"); + assert.equal(agents.find((a) => a.name === "explore").source, "user"); + assert.ok(agents.some((a) => a.name === "general-purpose" && a.source === "built-in")); + + assert.throws(() => removeAgent("plan", { cwd, home }), /built in/); + removeAgent("reviewer", { cwd, home }); + assert.equal(findAgent("reviewer", { cwd, home }).source, "claude"); + assert.throws(() => removeAgent("reviewer", { cwd, home }), /Claude Code/); +}); + +test("definitions: list-style tools, round trip, and invalid files are skipped", () => { + const { data } = parseFrontmatter("---\nname: a\ntools:\n - read\n - bash\n---\nbody"); + assert.deepEqual(data.tools, ["read", "bash"]); + const agent = { name: "t-1", description: "d", tools: ["read", "bash"], model: null, prompt: "P" }; + const back = parseAgentFile(renderAgentFile(agent), { source: "user" }); + assert.deepEqual([back.name, back.tools, back.prompt], ["t-1", ["read", "bash"], "P"]); + assert.equal(parseAgentFile("---\nname: Bad Name\n---\nx"), null); + assert.equal(parseAgentFile("---\nname: empty\n---\n"), null); + assert.ok(!renderAgentFile({ ...agent, tools: ALL_TOOLS }).includes("tools:"), "all tools is the default, not written out"); +}); + +test("config: env wins over the saved file, which is written 0600", () => { + const home = tmp(); + const file = saveConfig({ apiKey: "ik_live_file", model: "qwen3:4b" }, { home }); + assert.equal(fs.statSync(file).mode & 0o777, 0o600); + assert.deepEqual( + (({ apiKey, model }) => ({ apiKey, model }))(c0mputeConfig({ env: {}, home })), + { apiKey: "ik_live_file", model: "qwen3:4b" }, + ); + assert.equal(c0mputeConfig({ env: { C0MPUTE_API_KEY: "ik_live_env" }, home }).apiKey, "ik_live_env"); +}); + +test("routing: the names that mean the c0mpute agent, and CLI flags", () => { + for (const n of ["c0mpute", "C0mpute", "c0", "compute"]) assert.ok(isC0mputeAgent(n)); + assert.ok(!isC0mputeAgent("claude")); + assert.deepEqual(parseArgs(["-p", "fix", "it"]).print, "fix"); + assert.equal(parseArgs(["-p", "-m", "qwen3:4b"]).model, "qwen3:4b"); + assert.equal(parseArgs(["--dangerously-skip-permissions"]).mode, "yolo"); + assert.deepEqual(parseArgs(["agents", "show", "plan"]).manage, ["show", "plan"]); + assert.equal(describeCall({ name: "task", input: { agent: "explore", prompt: "find x" } }), "Task(explore: find x)"); +}); From 5104d44db3372655fdc0cc6b61203b0e2ad84c78 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 7 Nov 2026 16:35:32 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(c0agent):=20survive=20real=209B=20outpu?= =?UTF-8?q?t=20=E2=80=94=20unclosed=20tool=20blocks,=20stray=20}},=20re-in?= =?UTF-8?q?dented=20edits;=20ground=20the=20prompt=20in=20real=20file=20na?= =?UTF-8?q?mes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/c0agent.mjs | 123 +++++++++++++++++++++++++++++++++++++----- test/c0agent.test.mjs | 17 ++++++ 2 files changed, 126 insertions(+), 14 deletions(-) diff --git a/src/c0agent.mjs b/src/c0agent.mjs index d91d8d2f..d5c40788 100644 --- a/src/c0agent.mjs +++ b/src/c0agent.mjs @@ -117,12 +117,52 @@ function parseAttrs(s) { export function looseJson(text) { const t = String(text ?? "").trim().replace(/^```(?:json)?\s*/i, "").replace(/```$/, "").trim(); if (!t.startsWith("{")) return null; - for (const candidate of [t, t.replace(/,\s*([}\]])/g, "$1")]) { + // The first balanced object: a stray "}}" or trailing prose after it is not ours. + const first = balancedObject(t); + for (const candidate of [t, first, first?.replace(/,\s*([}\]])/g, "$1")]) { + if (!candidate) continue; try { const v = JSON.parse(candidate); return v && typeof v === "object" && !Array.isArray(v) ? v : null; } catch { /* next */ } } return null; } +/** The text of the first brace-balanced object in `t` (strings respected), or null. */ +function balancedObject(t) { + let depth = 0; + let inStr = false; + for (let i = 0; i < t.length; i++) { + const c = t[i]; + if (inStr) { + if (c === "\\") i++; + else if (c === '"') inStr = false; + continue; + } + if (c === '"') inStr = true; + else if (c === "{") depth++; + else if (c === "}" && --depth === 0) return t.slice(0, i + 1); + } + return null; +} + +/** + * Tool blocks as `{ attrs, body }`. A block ends at its , or, when the + * model forgot to close it, at the next ]*?)(\/?)>/gi)]; + for (let k = 0; k < opens.length; k++) { + const m = opens[k]; + if (m[2]) { blocks.push({ attrs: m[1], body: "" }); continue; } + const start = m.index + m[0].length; + const nextOpen = k + 1 < opens.length ? opens[k + 1].index : src.length; + const rest = src.slice(start, nextOpen); + const close = rest.search(/<\/tool\s*>/i); + blocks.push({ attrs: m[1], body: close >= 0 ? rest.slice(0, close) : rest }); + } + return blocks; +} + /** * Every tool call in one assistant message, in order. Anything the model * wrote after a `= 0) src = src.slice(0, fake); const calls = []; - const re = /]*?)(\/>|>([\s\S]*?)<\/tool\s*>)/gi; - for (const m of src.matchAll(re)) { - const attrs = parseAttrs(m[1]); + for (const block of toolBlocks(src)) { + const attrs = parseAttrs(block.attrs); const name = normalizeTool(attrs.name); if (!name) { calls.push({ name: attrs.name || "?", input: {}, error: `unknown tool "${attrs.name}"` }); continue; } delete attrs.name; - const body = (m[3] ?? "").trim(); + const body = block.body.trim(); let input = looseJson(body); if (!input) { input = { ...attrs }; @@ -186,7 +225,7 @@ export function visibleText(text) { let src = stripThink(text); const fake = src.search(/= 0) src = src.slice(0, fake); - return src.replace(/]*?(\/>|>[\s\S]*?<\/tool\s*>)/gi, "").replace(/\n{3,}/g, "\n\n").trim(); + return src.replace(/]*?(\/>|>[\s\S]*?(<\/tool\s*>|(?= 1 && !input.all) return { ok: false, output: `old text occurs ${count} times in ${input.path}: include more surrounding lines, or pass "all": true` }; fs.writeFileSync(file, input.all ? text.split(oldS).join(newS) : text.replace(oldS, () => newS)); const delta = newS.split("\n").length - oldS.split("\n").length; @@ -460,20 +510,54 @@ export async function runTool(session, call, ctx = {}) { return { ok: false, output: `unknown tool ${name}` }; } } catch (e) { - return { ok: false, output: String(e?.message || e) }; + const msg = String(e?.message || e); + return { ok: false, output: e?.code === "ENOENT" ? `${msg}\nthat path does not exist: use ls or glob to find the real file name` : msg }; } } +/** The single span of `text` equal to `old` line for line, ignoring leading whitespace. */ +export function indentTolerantMatch(text, old) { + const want = old.replace(/\n+$/, "").split("\n").map((l) => l.trim()); + if (!want.some(Boolean)) return null; + const lines = text.split("\n"); + const starts = []; + let pos = 0; + for (const l of lines) { starts.push(pos); pos += l.length + 1; } + const hits = []; + for (let i = 0; i + want.length <= lines.length; i++) { + if (want.every((w, j) => lines[i + j].trim() === w)) hits.push(i); + } + if (hits.length !== 1) return null; + const i = hits[0]; + const endLine = i + want.length - 1; + const index = starts[i]; + return { index, text: text.slice(index, starts[endLine] + lines[endLine].length) }; +} + +/** Shift `next` by the indentation difference between what the model wrote and what the file has. */ +function reindent(next, wrote, actual) { + const leads = (s) => s.split("\n").filter((l) => l.trim()).map((l) => l.match(/^[ \t]*/)[0]); + const w = leads(wrote); + const a = leads(actual); + // Map each indentation the model used to the file's indentation at the same line. + const map = new Map(); + w.forEach((x, i) => { if (a[i] != null && !map.has(x)) map.set(x, a[i]); }); + return next.replace(/\n+$/, "").split("\n").map((l) => { + const lead = l.match(/^[ \t]*/)[0]; + return map.has(lead) ? map.get(lead) + l.slice(lead.length) : l; + }).join("\n"); +} + // ─── prompts ───────────────────────────────────────────────────────────────── const TOOL_DOCS = { - read: `read {"path": "src/app.js", "offset": 1, "limit": 300} read a file with line numbers`, - write: `write {"path": "notes.md", "content": "..."} create or overwrite a whole file`, - edit: `edit {"path": "src/app.js", "old": "exact old text", "new": "replacement"} replace text; read the file first, copy "old" exactly`, + read: `read {"path": "", "offset": 1, "limit": 300} read a file with line numbers`, + write: `write {"path": "", "content": "..."} create or overwrite a whole file`, + edit: `edit {"path": "", "old": "exact old text", "new": "replacement"} replace text; read the file first, copy "old" exactly`, bash: `bash {"command": "npm test"} run a shell command in the project`, - glob: `glob {"pattern": "src/**/*.ts"} find files by name`, - grep: `grep {"pattern": "function main", "path": "src", "glob": "*.js"} search file contents (regex)`, - ls: `ls {"path": "src"} list a directory`, + glob: `glob {"pattern": "**/*.js"} find files by name`, + grep: `grep {"pattern": "function main", "path": "", "glob": "*.js"} search file contents (regex)`, + ls: `ls {"path": "."} list a directory`, todo: `todo {"items": [{"text": "write the test", "status": "in_progress"}]} keep a checklist for multi-step work`, task: `task {"agent": "explore", "prompt": "everything the subagent needs to know"} hand a self-contained job to a subagent`, }; @@ -488,6 +572,16 @@ function projectNotes(cwd) { return ""; } +/** The top of the working directory, so the model starts from real names, not guesses. */ +function topLevel(cwd) { + try { + const entries = fs.readdirSync(cwd, { withFileTypes: true }) + .filter((e) => e.name !== ".git" && e.name !== "node_modules") + .map((e) => (e.isDirectory() ? `${e.name}/` : e.name)).sort(); + return entries.length ? `files here: ${entries.slice(0, 60).join(" ")}${entries.length > 60 ? ` … (${entries.length - 60} more)` : ""}` : "the directory is empty"; + } catch { return ""; } +} + function gitLine(cwd) { const r = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd, encoding: "utf8" }); return r.status === 0 ? `git branch: ${r.stdout.trim()}` : "not a git repository"; @@ -527,6 +621,7 @@ export function systemPrompt({ agent, tools, session, depth = 0, agents = [] }) "# Environment", `working directory: ${session.cwd}`, `platform: ${process.platform} · date: ${new Date().toISOString().slice(0, 10)} · ${gitLine(session.cwd)}`, + topLevel(session.cwd), lead ? projectNotes(session.cwd) : "", ].join("\n"); } diff --git a/test/c0agent.test.mjs b/test/c0agent.test.mjs index 55e3e65d..b76f093b 100644 --- a/test/c0agent.test.mjs +++ b/test/c0agent.test.mjs @@ -47,6 +47,23 @@ test("parseToolCalls: forgives the slips small models make", () => { assert.equal(parseToolCalls('{}')[0].error, 'unknown tool "webfetch"'); }); +test("parseToolCalls: what qwen3.5 9B actually wrote — unclosed block, stray }}", () => { + const raw = 'Found the bug!\n{"path": "math.js", "old": "function add(a, b) {\\n return a - b;\\n}", "new": "function add(a, b) {\\n return a + b;\\n}"}}\n{"command": "npm test"}}'; + assert.deepEqual(parseToolCalls(raw), [ + { name: "edit", input: { path: "math.js", old: "function add(a, b) {\n return a - b;\n}", new: "function add(a, b) {\n return a + b;\n}" } }, + { name: "bash", input: { command: "npm test" } }, + ]); +}); + +test("edit: a re-indented 'old' still lands once, keeping the file's indentation", async () => { + const dir = tmp(); + fs.writeFileSync(path.join(dir, "math.js"), "function add(a, b) {\n return a - b;\n}\nmodule.exports = { add };\n"); + const session = createSession({ cwd: dir, permissionMode: "auto-edit" }); + const r = await runTool(session, { name: "edit", input: { path: "math.js", old: "function add(a, b) {\n return a - b;\n}", new: "function add(a, b) {\n return a + b;\n}" } }, { tools: ALL_TOOLS }); + assert.ok(r.ok, r.output); + assert.equal(fs.readFileSync(path.join(dir, "math.js"), "utf8"), "function add(a, b) {\n return a + b;\n}\nmodule.exports = { add };\n"); +}); + test("parseToolCalls: ignores calls inside thinking and after an invented result", () => { const text = 'maybe rm -rf /{}fake{"command":"x"}'; assert.deepEqual(parseToolCalls(text), [{ name: "ls", input: {} }]);