multi-agent-workbench 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,57 +1,90 @@
1
1
  # Multi-Agent Workbench
2
2
 
3
- Run multiple coding-agent CLIs in parallel with isolated worktrees, micro-task queues, handoff history, and live status.
3
+ Run Claude Code, Codex and Antigravity CLIs in parallel. Each agent works in its own git worktree, takes small tasks from its own queue, and reports commits, check results and output to a live status file and dashboard.
4
+
5
+ > **Warning:** `maw` starts agents with their permission prompts disabled (`--dangerously-skip-permissions`, `--dangerously-bypass-approvals-and-sandbox`). They can run any command as your user. Only use it on repositories and machines where that is acceptable, and review every commit before merging.
6
+
7
+ ## Requirements
8
+
9
+ - Node.js 20 or newer
10
+ - A git repository with at least one commit
11
+ - At least one agent CLI, logged in: [`claude`](https://docs.claude.com/en/docs/claude-code), [`codex`](https://github.com/openai/codex) or `agy` (Antigravity)
4
12
 
5
13
  ## Quick start
6
14
 
7
- Create `tasks.json` in your project:
15
+ ```bash
16
+ npm install -g multi-agent-workbench
17
+ cd your-project
18
+ maw init # writes a starter tasks.json
19
+ # edit tasks.json
20
+ maw doctor # are the agents installed and logged in?
21
+ maw run --dry-run # validate the plan
22
+ maw # run it
23
+ maw dashboard # in another terminal: http://127.0.0.1:4317
24
+ ```
25
+
26
+ Without installing: `npx multi-agent-workbench <command>`.
27
+
28
+ ## tasks.json
8
29
 
9
30
  ```json
10
31
  [
11
- {"id":"L1","owner":"claude","command":"claude","worktree":"../project-claude","task":"API task","prompt":"Implement the API task and commit it."},
12
- {"id":"C1","owner":"codex","command":"codex","worktree":"../project-codex","task":"Frontend task","prompt":"Implement the frontend task and commit it."},
13
- {"id":"A1","owner":"antigravity","command":"agy","worktree":"../project-agy","task":"QA task","prompt":"Audit the current changes, fix only clear issues, and commit them."}
32
+ { "id": "L1", "owner": "claude", "task": "Orders API", "prompt": "Add GET /api/orders with pagination. Run the API tests and commit.", "check": "npm test -- orders" },
33
+ { "id": "C1", "owner": "codex", "task": "Orders page", "prompt": "Build the orders page. Run lint and commit." },
34
+ { "id": "A1", "owner": "antigravity", "command": "agy", "task": "QA", "depends_on": ["L1", "C1"], "prompt": "Merge maw/claude and maw/codex into this branch, run all tests, fix only clear bugs, and commit." }
14
35
  ]
15
36
  ```
16
37
 
17
- Install and run:
38
+ - Each **owner** has a queue. Queues run in parallel; tasks in one queue run in order.
39
+ - Each owner gets a worktree at `../<repo>-<owner>` on branch `maw/<owner>`, created automatically. Override with `worktree`, `branch` and `base`.
40
+ - `depends_on` waits for other tasks; if one fails, dependents are skipped.
41
+ - `check` is a shell command run after the agent finishes. If it fails, the task is blocked.
42
+ - If a task is blocked, the rest of that owner's queue is skipped.
43
+ - `model`, `effort` and `timeout_minutes` can be set per task (or with `CLAUDE_MODEL`, `CODEX_MODEL`, `AGY_MODEL` and the matching `*_EFFORT` env vars).
18
44
 
19
- ```bash
20
- npm install -g multi-agent-workbench
21
- multi-agent-workbench init
22
- # edit tasks.json for your project
23
- multi-agent-workbench doctor
24
- multi-agent-workbench
25
- # or without installing
26
- npx multi-agent-workbench init
27
- npx multi-agent-workbench
28
- ```
45
+ Full field reference: [`multi-agent-workbench/references/task-schema.md`](multi-agent-workbench/references/task-schema.md).
29
46
 
30
- The supervisor reads `tasks.json` from the current project and writes `.agent-workbench/status.json` and `.agent-workbench/supervisor.log`. Review each commit before merging or deploying.
47
+ ## Commands
31
48
 
32
- ## Use as a Codex skill
49
+ | Command | What it does |
50
+ |---|---|
51
+ | `maw init [--force]` | Create a starter `tasks.json`. Won't overwrite without `--force`. |
52
+ | `maw doctor` | Check the agents used in `tasks.json` (or all agents) are installed and logged in. Exits 1 if any are not. Never prints account details. |
53
+ | `maw` / `maw run` | Run the queues. Exits 0 only if every task is done. `--dry-run` validates and prints the plan. |
54
+ | `maw status` | Summarize the last run. |
55
+ | `maw dashboard [--port N]` | Live status page on localhost (agents, tasks, commits, recent output). |
56
+ | `maw install-skill` | Install the planning skill for Codex and Claude Code (`--codex` / `--claude` for one). |
33
57
 
34
- Clone the repository into your Codex skills directory:
58
+ All commands accept `--tasks <file>` to use a different task file.
35
59
 
36
- ```bash
37
- git clone https://github.com/ruhul0/multi-agent-workbench.git \
38
- ~/.codex/skills/multi-agent-workbench
39
- ```
60
+ ## Output
61
+
62
+ - `.agent-workbench/status.json`: every task's state, real commit (read from git, not guessed from output), new commits, uncommitted changes, check result and last 30 lines of output
63
+ - `.agent-workbench/logs/<id>.log`: full output per task
64
+ - `.agent-workbench/supervisor.log`: event log
40
65
 
41
- Then invoke it in a Codex task with:
66
+ Add `.agent-workbench/` to your project's `.gitignore`.
42
67
 
43
- ```text
44
- Use $multi-agent-workbench to split this project into micro-tasks, assign frontend work to Codex, backend work to Claude, QA to Antigravity, and report live progress.
68
+ ## Use as a skill
69
+
70
+ The skill tells a coordinating agent how to plan micro-tasks, write `tasks.json`, run `maw` and review the results.
71
+
72
+ ```bash
73
+ maw install-skill
45
74
  ```
46
75
 
47
- The skill instructions are in `multi-agent-workbench/SKILL.md`. The Node.js CLI and the skill are independent: use the skill for planning/orchestration guidance, and use `maw` to launch workers.
76
+ This copies the skill to `~/.codex/skills/multi-agent-workbench` and `~/.claude/skills/multi-agent-workbench`. Then ask:
48
77
 
49
- ## Agent login check
78
+ - Codex: `Use $multi-agent-workbench to split this into micro-tasks: frontend to Codex, backend to Claude, QA to Antigravity.`
79
+ - Claude Code: `/multi-agent-workbench split this into micro-tasks …`
50
80
 
51
- Before assigning work, run:
81
+ ## Development
52
82
 
53
83
  ```bash
54
- maw doctor
84
+ npm test # end-to-end tests with a fake agent CLI
85
+ npm run check # syntax check
55
86
  ```
56
87
 
57
- It checks whether `claude`, `codex`, and `agy` are available without printing credentials.
88
+ ## License
89
+
90
+ MIT
package/bin/maw.mjs CHANGED
@@ -1,46 +1,114 @@
1
1
  #!/usr/bin/env node
2
- import { spawn } from "node:child_process";
3
- import { spawnSync } from "node:child_process";
4
- import process from "node:process";
2
+ import { writeFile, readFile, cp, mkdir } from "node:fs/promises";
3
+ import { homedir } from "node:os";
5
4
  import { fileURLToPath } from "node:url";
6
- import { dirname, resolve } from "node:path";
7
- import { writeFile } from "node:fs/promises";
8
-
9
- const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
10
- const supervisor = resolve(packageRoot, "multi-agent-workbench/scripts/supervisor.mjs");
11
- const command = process.argv[2];
12
- if (command === "--help" || command === "-h") {
13
- console.log("multi-agent-workbench — run Claude and Codex tasks in parallel");
14
- console.log("\nCommands:\n maw init Create a starter tasks.json\n maw doctor Check agent CLIs and authentication\n maw Run the task queue in the current project");
15
- process.exit(0);
5
+ import { existsSync } from "node:fs";
6
+ import { resolve } from "node:path";
7
+ import process from "node:process";
8
+ import { runWorkbench } from "../multi-agent-workbench/scripts/supervisor.mjs";
9
+ import { doctor } from "../multi-agent-workbench/scripts/doctor.mjs";
10
+ import { dashboard } from "../multi-agent-workbench/scripts/dashboard.mjs";
11
+
12
+ const HELP = `multi-agent-workbench (maw) — run Claude Code, Codex and Antigravity tasks in parallel git worktrees
13
+
14
+ Usage:
15
+ maw init [--force] Create a starter tasks.json in the current project
16
+ maw doctor Check that the agents used in tasks.json are installed and logged in
17
+ maw run [--dry-run] Run the task queue (default command). --dry-run validates and prints the plan
18
+ maw status Print the latest .agent-workbench/status.json as a summary
19
+ maw dashboard [--port N] Serve a live status page on http://127.0.0.1:4317
20
+ maw install-skill Install the planning skill for Codex and Claude Code
21
+ (--codex or --claude to pick one)
22
+
23
+ Options:
24
+ --tasks <file> Use a different task file (default: tasks.json)
25
+ -h, --help Show this help
26
+ -v, --version Show the version
27
+
28
+ Agents run with permission prompts disabled inside their worktrees. Review every commit before merging.`;
29
+
30
+ const argv = process.argv.slice(2);
31
+ const flag = (name) => argv.includes(name);
32
+ const value = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
33
+ const command = argv.find((a, i) => !a.startsWith("-") && !["--tasks", "--port"].includes(argv[i - 1])) || "run";
34
+ const taskPath = value("--tasks");
35
+
36
+ async function main() {
37
+ if (flag("-h") || flag("--help") || command === "help") { console.log(HELP); return 0; }
38
+ if (flag("-v") || flag("--version")) {
39
+ const pkg = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
40
+ console.log(pkg.version);
41
+ return 0;
42
+ }
43
+ switch (command) {
44
+ case "init": return init();
45
+ case "doctor": return (await doctor({ taskPath })) ? 0 : 1;
46
+ case "run": {
47
+ const { ok } = await runWorkbench({ taskPath, dryRun: flag("--dry-run") });
48
+ return process.exitCode === 130 ? 130 : ok ? 0 : 1;
49
+ }
50
+ case "status": return status();
51
+ case "install-skill": return installSkill();
52
+ case "dashboard": {
53
+ const port = Number(value("--port") || 4317);
54
+ if (!Number.isInteger(port) || port <= 0) throw new Error("--port must be a positive integer");
55
+ dashboard({ port });
56
+ return null; // keep the server running
57
+ }
58
+ default:
59
+ console.error(`Unknown command "${command}".\n\n${HELP}`);
60
+ return 2;
61
+ }
16
62
  }
17
- if (command === "doctor") {
18
- const checks = [
19
- ["claude", ["auth", "status"]],
20
- ["codex", ["login", "status"]],
21
- ["agy", ["--help"]],
63
+
64
+ async function init() {
65
+ const target = resolve(process.cwd(), taskPath || "tasks.json");
66
+ if (existsSync(target) && !flag("--force")) {
67
+ console.error(`${target} already exists. Edit it, or run \`maw init --force\` to overwrite it.`);
68
+ return 1;
69
+ }
70
+ const tasks = [
71
+ { id: "L1", owner: "claude", command: "claude", task: "Backend task", prompt: "Implement the backend task, run its tests, and commit the result." },
72
+ { id: "C1", owner: "codex", command: "codex", task: "Frontend task", prompt: "Implement the frontend task, run its tests, and commit the result." },
73
+ { id: "A1", owner: "antigravity", command: "agy", task: "QA task", depends_on: ["L1", "C1"], prompt: "Review the latest changes on this branch, fix only clear issues, and commit them." },
22
74
  ];
23
- for (const [name, args] of checks) {
24
- const result = spawnSync(name, args, { encoding: "utf8", timeout: 10000 });
25
- const installed = !result.error;
26
- const output = `${result.stdout || ""}${result.stderr || ""}`.replace(/token[^\n]*/gi, "token [redacted]").trim();
27
- console.log(`${installed ? "✓" : "✗"} ${name}${installed ? ` — ${output.split("\n")[0] || "available"}` : " — not installed"}`);
75
+ await writeFile(target, JSON.stringify(tasks, null, 2) + "\n");
76
+ console.log(`Created ${target}.\nEdit the prompts, then run \`maw doctor\` and \`maw\`.`);
77
+ return 0;
78
+ }
79
+
80
+ async function installSkill() {
81
+ const source = fileURLToPath(new URL("../multi-agent-workbench", import.meta.url));
82
+ const targets = [];
83
+ if (!flag("--claude")) targets.push(resolve(homedir(), ".codex/skills/multi-agent-workbench"));
84
+ if (!flag("--codex")) targets.push(resolve(homedir(), ".claude/skills/multi-agent-workbench"));
85
+ for (const target of targets) {
86
+ await mkdir(resolve(target, ".."), { recursive: true });
87
+ await cp(source, target, { recursive: true, force: true });
88
+ console.log(`Installed skill to ${target}`);
28
89
  }
29
- process.exit(0);
90
+ console.log("Invoke it with $multi-agent-workbench in Codex or /multi-agent-workbench in Claude Code.");
91
+ return 0;
30
92
  }
31
- if (command === "init") {
32
- const target = resolve(process.cwd(), "tasks.json");
33
- await writeFile(target, JSON.stringify([
34
- { id: "L1", owner: "claude", command: "claude", worktree: "../project-claude", task: "Backend task", prompt: "Implement the backend task and commit it." },
35
- { id: "C1", owner: "codex", command: "codex", worktree: "../project-codex", task: "Frontend task", prompt: "Implement the frontend task and commit it." },
36
- { id: "A1", owner: "antigravity", command: "agy", worktree: "../project-agy", task: "QA task", prompt: "Audit the current changes, fix only clear issues, and commit them." }
37
- ], null, 2) + "\n", { flag: "wx" });
38
- console.log(`Created ${target}. Edit it, then run maw.`);
39
- process.exit(0);
93
+
94
+ async function status() {
95
+ const path = resolve(process.cwd(), ".agent-workbench/status.json");
96
+ let s;
97
+ try { s = JSON.parse(await readFile(path, "utf8")); } catch {
98
+ console.error("No status yet. Run `maw` in this project first.");
99
+ return 1;
100
+ }
101
+ console.log(`${s.finished ? "Finished" : "Running"} · updated ${s.updated_at}`);
102
+ for (const t of s.tasks || []) {
103
+ console.log(`${t.state.padEnd(8)} ${t.id.padEnd(8)} ${t.owner.padEnd(12)} ${(t.commit || "—").padEnd(12)} ${t.task}${t.reason ? ` (${t.reason})` : ""}`);
104
+ }
105
+ return 0;
106
+ }
107
+
108
+ try {
109
+ const code = await main();
110
+ if (code !== null) process.exitCode = code;
111
+ } catch (error) {
112
+ console.error(`maw: ${error.message}`);
113
+ process.exitCode = 2;
40
114
  }
41
- const child = spawn(process.execPath, [supervisor, ...process.argv.slice(2)], {
42
- cwd: process.cwd(),
43
- stdio: "inherit",
44
- env: process.env,
45
- });
46
- child.on("exit", (code, signal) => process.exit(signal ? 1 : (code ?? 1)));
@@ -1,22 +1,42 @@
1
1
  ---
2
2
  name: multi-agent-workbench
3
- description: Orchestrate multiple local coding-agent CLIs in isolated worktrees with micro-task queues, handoffs, live status, review gates, and optional deployment triggers.
3
+ description: Split a software task into small, owned micro-tasks and run them in parallel across Claude Code, Codex and Antigravity CLIs using the `maw` supervisor (isolated git worktrees, dependencies, acceptance checks, live status). Use when the user asks to divide work between several coding agents or to run agents in parallel.
4
4
  ---
5
5
 
6
6
  # Multi-agent workbench
7
7
 
8
- Use this skill when a software task benefits from multiple coding-agent CLIs working in parallel.
8
+ You are the coordinator. You plan, write `tasks.json`, start the `maw` supervisor, watch progress, and review results. The worker agents do the implementation.
9
9
 
10
- - Split work into bounded micro-tasks with owners, dependencies, and acceptance checks.
11
- - Give each agent its own Git worktree and branch.
12
- - Assign frontend/integration tasks to Codex and backend/data tasks to Claude when requested.
13
- - Capture commit, tests, changed files, output, and risks after every task.
14
- - Review and cherry-pick centrally; never auto-merge unreviewed work.
15
- - Keep production tests/builds on the VPS when the repository uses a server-side deployment workflow.
16
- - Expose current state, task, commit, output, and completed history in a live status file/dashboard.
10
+ ## 1. Preflight
17
11
 
18
- See `references/task-schema.md` for handoff records and `scripts/supervisor.mjs` for the Node.js supervisor.
12
+ 1. Confirm the project is a git repository with at least one commit and a clean working tree (`git status`).
13
+ 2. Check `maw` is available: `maw --version`, otherwise use `npx multi-agent-workbench`.
14
+ 3. Run `maw doctor`. Stop and tell the user about any agent marked ✗ (missing or not logged in). Agents marked `!` are installed but their login cannot be checked; mention it.
19
15
 
20
- ## Invocation
16
+ ## 2. Plan micro-tasks
21
17
 
22
- Install the skill by cloning this repository to `~/.codex/skills/multi-agent-workbench`, then invoke it explicitly with `$multi-agent-workbench`. Before assigning tasks, run `maw doctor` and stop for any missing or unauthenticated worker. Typical ownership is Codex for frontend, Claude for backend logic, and Antigravity for QA/review, but preserve the user's requested assignment.
18
+ - Keep each task small: one outcome, a handful of files, finishable in one agent run.
19
+ - Give every task an `owner`, a precise `prompt`, and, where possible, a `check` command that proves it works (for example `npm test -- auth`).
20
+ - Use the user's requested assignment. If none is given, a sensible default is Codex for frontend/integration, Claude for backend/data, Antigravity for QA/review.
21
+ - Tasks for one owner run in order in that owner's worktree. Use `depends_on` when a task needs another owner's work; make the prompt say which branch to merge or inspect (each owner works on branch `maw/<owner>`).
22
+ - Every prompt should end with: "Run the relevant tests and commit your changes with a clear message."
23
+ - Avoid two owners editing the same files at the same time.
24
+
25
+ The full field list is in `references/task-schema.md`. Validate the plan with `maw run --dry-run`, show it to the user, and wait for approval before running.
26
+
27
+ ## 3. Run and monitor
28
+
29
+ - Start `maw` (it blocks until all queues finish; run it in the background if your environment allows).
30
+ - Offer `maw dashboard` for a live page at http://127.0.0.1:4317, or read `.agent-workbench/status.json` / `maw status`.
31
+ - Report to the user whenever a task finishes, fails, or is skipped. Per-task logs are in `.agent-workbench/logs/<id>.log`.
32
+ - If a task is `blocked`, read its log, fix the prompt or split it, and re-run with a new `tasks.json` containing only the remaining work.
33
+
34
+ ## 4. Review and integrate
35
+
36
+ - Never merge or deploy unreviewed work. For each `done` task, inspect the commit (`git -C <worktree> show <commit>`), run the project's tests, then merge or cherry-pick into the main branch.
37
+ - Note tasks that finished "without a new commit" or with uncommitted changes; they need follow-up.
38
+ - Summarize for the user: what each agent did, commits, test results, and remaining risks.
39
+
40
+ ## Safety
41
+
42
+ `maw` runs every agent with permission prompts disabled (`--dangerously-skip-permissions` / `--dangerously-bypass-approvals-and-sandbox`) inside its worktree. Only run it on repositories and machines where that is acceptable, and say so to the user before the first run.
@@ -1,3 +1,3 @@
1
1
  interface:
2
2
  display_name: "Multi Agent Workbench"
3
- short_description: "Help with Multi Agent Workbench tasks"
3
+ short_description: "Split work into micro-tasks and run Claude, Codex and Antigravity in parallel worktrees"
@@ -1,3 +1,42 @@
1
- # Task and handoff schema
1
+ # tasks.json schema
2
2
 
3
- Each task records an id, owner, worktree, prompt, dependencies, acceptance checks, state, commit, tests, and risks. Completed tasks must include the worker output and commit hash.
3
+ `tasks.json` is either an array of tasks or an object `{ "tasks": [...] }`.
4
+
5
+ | Field | Required | Default | Meaning |
6
+ |---|---|---|---|
7
+ | `id` | yes | — | Unique task id, e.g. `L1`. |
8
+ | `prompt` | yes | — | Exact instruction sent to the agent. |
9
+ | `owner` | yes* | agent name | Queue name. Tasks with the same owner run one after another. |
10
+ | `command` | no | `owner` | Agent CLI: `claude`, `codex` (aliases `chatgpt`, `openai`) or `agy` (alias `antigravity`). *Either `owner` or `command` must name a known agent. |
11
+ | `task` | no | start of prompt | Short title shown in status and dashboard. |
12
+ | `worktree` | no | `../<repo>-<owner>` | Worktree path, relative to the project root. Created with `git worktree add` if missing. |
13
+ | `branch` | no | `maw/<owner>` | Branch checked out in the worktree. Created from `base` if missing. |
14
+ | `base` | no | `HEAD` | Starting point for a new branch. |
15
+ | `depends_on` | no | `[]` | Task ids that must be `done` first. If one fails, this task is `skipped`. |
16
+ | `check` | no | — | Shell command run in the worktree after the agent exits 0. Non-zero exit marks the task `blocked`. |
17
+ | `model` | no | CLI default | Model passed to the agent (`CLAUDE_MODEL`, `CODEX_MODEL`, `AGY_MODEL` env vars also work). |
18
+ | `effort` | no | CLI default | Reasoning effort (`CLAUDE_EFFORT`, `CODEX_EFFORT`, `AGY_EFFORT`). |
19
+ | `timeout_minutes` | no | `60` | The agent is stopped and the task blocked after this long. |
20
+
21
+ ## Scheduling rules
22
+
23
+ - Each owner's queue runs in file order. Different owners run in parallel.
24
+ - A task starts when its `depends_on` tasks are `done` and its worktree is free.
25
+ - If a task is `blocked`, the rest of that owner's queue is `skipped`, as are tasks depending on it.
26
+ - `maw` exits 0 only if every task is `done`.
27
+
28
+ ## Task states and status record
29
+
30
+ States: `pending` → `running` → `done` | `blocked` | `skipped`.
31
+
32
+ Each entry in `.agent-workbench/status.json` → `tasks` records: `id`, `owner`, `agent`, `task`, `worktree`, `branch`, `depends_on`, `state`, `commit` (new `HEAD` after the task, or `null` if the agent made no commit), `commits` (all new commits), `dirty` (uncommitted changes left behind), `check` (`{ command, passed }`), `exit_code`, `reason`, `started_at`, `finished_at` and the last 30 `output` lines. Full output is in `.agent-workbench/logs/<id>.log`.
33
+
34
+ ## Example
35
+
36
+ ```json
37
+ [
38
+ { "id": "L1", "owner": "claude", "task": "Orders API", "prompt": "Add GET /api/orders with pagination. Run the API tests and commit.", "check": "npm test -- orders" },
39
+ { "id": "C1", "owner": "codex", "task": "Orders page", "prompt": "Build the orders page against the existing API types. Run lint and commit." },
40
+ { "id": "A1", "owner": "antigravity", "command": "agy", "task": "QA", "depends_on": ["L1", "C1"], "prompt": "Merge branches maw/claude and maw/codex into this branch, run all tests, fix only clear bugs, and commit." }
41
+ ]
42
+ ```
@@ -0,0 +1,51 @@
1
+ // Agent CLI definitions shared by the supervisor and `maw doctor`.
2
+ // Binaries can be overridden with MAW_BIN_CLAUDE, MAW_BIN_CODEX, MAW_BIN_AGY (useful for tests and custom installs).
3
+
4
+ const ALIASES = { claude: "claude", codex: "codex", chatgpt: "codex", openai: "codex", agy: "agy", antigravity: "agy" };
5
+
6
+ export const AGENTS = {
7
+ claude: {
8
+ label: "Claude Code",
9
+ bin: () => process.env.MAW_BIN_CLAUDE || "claude",
10
+ args: (task) => [
11
+ "-p", task.prompt,
12
+ "--dangerously-skip-permissions",
13
+ ...opt("--model", task.model || process.env.CLAUDE_MODEL),
14
+ ...opt("--effort", task.effort || process.env.CLAUDE_EFFORT),
15
+ ],
16
+ auth: ["auth", "status"],
17
+ },
18
+ codex: {
19
+ label: "Codex CLI",
20
+ bin: () => process.env.MAW_BIN_CODEX || "codex",
21
+ args: (task) => [
22
+ "exec",
23
+ "--dangerously-bypass-approvals-and-sandbox",
24
+ ...opt("-m", task.model || process.env.CODEX_MODEL),
25
+ ...(task.effort || process.env.CODEX_EFFORT ? ["-c", `model_reasoning_effort="${task.effort || process.env.CODEX_EFFORT}"`] : []),
26
+ task.prompt,
27
+ ],
28
+ auth: ["login", "status"],
29
+ },
30
+ agy: {
31
+ label: "Antigravity CLI",
32
+ bin: () => process.env.MAW_BIN_AGY || "agy",
33
+ args: (task) => [
34
+ "--dangerously-skip-permissions",
35
+ "--print", task.prompt,
36
+ ...opt("--model", task.model || process.env.AGY_MODEL),
37
+ ...opt("--effort", task.effort || process.env.AGY_EFFORT),
38
+ ],
39
+ auth: null, // agy has no non-interactive login check
40
+ },
41
+ };
42
+
43
+ function opt(flag, value) {
44
+ return value ? [flag, String(value)] : [];
45
+ }
46
+
47
+ /** Resolve a task's `command` (or `owner`) to an agent key, or null if unknown. */
48
+ export function resolveAgent(task) {
49
+ const name = String(task.command || task.owner || "").trim().toLowerCase();
50
+ return ALIASES[name] || null;
51
+ }
@@ -0,0 +1,53 @@
1
+ import { createServer } from "node:http";
2
+ import { readFile } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+
5
+ /** Serve a live, read-only status page for .agent-workbench/status.json on localhost. */
6
+ export function dashboard({ root = process.cwd(), port = 4317, host = "127.0.0.1" } = {}) {
7
+ const statusPath = resolve(root, ".agent-workbench/status.json");
8
+ const server = createServer(async (req, res) => {
9
+ if (req.url === "/status.json") {
10
+ try {
11
+ const body = await readFile(statusPath, "utf8");
12
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
13
+ res.end(body);
14
+ } catch {
15
+ res.writeHead(404, { "content-type": "application/json" });
16
+ res.end(JSON.stringify({ error: "No status yet. Run `maw` in this project." }));
17
+ }
18
+ return;
19
+ }
20
+ if (req.url === "/" || req.url === "/index.html") {
21
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
22
+ res.end(PAGE);
23
+ return;
24
+ }
25
+ res.writeHead(404).end();
26
+ });
27
+ server.listen(port, host, () => console.log(`maw dashboard: http://${host}:${port} (Ctrl+C to stop)`));
28
+ return server;
29
+ }
30
+
31
+ const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
32
+ <title>Agent Workbench</title><style>
33
+ :root{--bg:#f7f7f5;--fg:#1d1d1b;--muted:#6b6b66;--card:#fff;--line:#e3e3de;--done:#1f7a3f;--run:#1d5fd1;--bad:#b42318;--skip:#8a6d00}
34
+ @media (prefers-color-scheme:dark){:root{--bg:#141413;--fg:#ececea;--muted:#9a9a94;--card:#1e1e1c;--line:#33332f;--done:#4cc27a;--run:#6aa0ff;--bad:#ff7b6e;--skip:#e0b93a}}
35
+ body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,sans-serif}main{max-width:1100px;margin:0 auto;padding:24px 16px}
36
+ h1{font-size:20px;margin:0 0 4px}.muted{color:var(--muted)}.grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));margin:16px 0}
37
+ .card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px;min-width:0}.row{display:flex;justify-content:space-between;gap:8px;align-items:baseline}
38
+ .badge{font-size:12px;font-weight:600;text-transform:uppercase}.done{color:var(--done)}.running,.working{color:var(--run)}.blocked{color:var(--bad)}.skipped,.pending{color:var(--skip)}
39
+ pre{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px;max-height:220px;overflow:auto;font-size:12px;white-space:pre-wrap;word-break:break-word;margin:8px 0 0}
40
+ table{width:100%;border-collapse:collapse}td,th{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top}code{font-size:12px}
41
+ .wrap{overflow-x:auto}</style></head><body><main>
42
+ <h1>Agent Workbench</h1><div class="muted" id="meta">Loading…</div>
43
+ <div class="grid" id="agents"></div><h2>Tasks</h2><div class="card wrap"><table><thead><tr><th>Task</th><th>Owner</th><th>State</th><th>Commit</th><th>Notes</th></tr></thead><tbody id="tasks"></tbody></table></div>
44
+ <h2>Recent events</h2><pre id="recent"></pre></main><script>
45
+ const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
46
+ async function tick(){try{const r=await fetch("/status.json",{cache:"no-store"});const s=await r.json();
47
+ if(s.error){document.getElementById("meta").textContent=s.error;return}
48
+ const age=Math.round((Date.now()-new Date(s.updated_at))/1000);
49
+ document.getElementById("meta").textContent=(s.finished?"Finished":"Running")+" · updated "+age+"s ago · "+s.root;
50
+ document.getElementById("agents").innerHTML=Object.entries(s.agents||{}).map(([o,a])=>'<div class="card"><div class="row"><strong>'+esc(o)+'</strong><span class="badge '+esc(a.state)+'">'+esc(a.state)+'</span></div><div>'+esc(a.task_id)+' — '+esc(a.task)+'</div><div class="muted">commit '+esc(a.commit||"—")+'</div><pre>'+esc((a.output||[]).join("\\n"))+'</pre></div>').join("")||'<div class="muted">No agent has started yet.</div>';
51
+ document.getElementById("tasks").innerHTML=(s.tasks||[]).map(t=>'<tr><td><code>'+esc(t.id)+'</code> '+esc(t.task)+'</td><td>'+esc(t.owner)+'</td><td class="badge '+esc(t.state)+'">'+esc(t.state)+'</td><td><code>'+esc(t.commit||"—")+'</code></td><td class="muted">'+esc([t.reason,t.check&&("check "+(t.check.passed==null?"pending":t.check.passed?"passed":"failed")),t.dirty&&"uncommitted changes"].filter(Boolean).join(" · "))+'</td></tr>').join("");
52
+ document.getElementById("recent").textContent=(s.recent||[]).slice().reverse().join("\\n");}catch(e){document.getElementById("meta").textContent="Waiting for status… "+e.message}}
53
+ tick();setInterval(tick,2000);</script></body></html>`;
@@ -0,0 +1,46 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { AGENTS } from "./agents.mjs";
5
+ import { loadTasks } from "./tasks.mjs";
6
+
7
+ /**
8
+ * Check that each agent CLI is installed and logged in. Never prints CLI output,
9
+ * so account details and tokens stay out of terminals and logs.
10
+ * Returns true when every required agent is ready.
11
+ */
12
+ export async function doctor({ root = process.cwd(), taskPath } = {}) {
13
+ const path = resolve(root, taskPath || "tasks.json");
14
+ let required = Object.keys(AGENTS);
15
+ let scope = "all supported agents (no tasks.json found)";
16
+ if (existsSync(path)) {
17
+ const tasks = await loadTasks(path, resolve(root));
18
+ required = [...new Set(tasks.map(t => t.agent))];
19
+ scope = `agents used in ${path}`;
20
+ }
21
+ console.log(`Checking ${scope}:`);
22
+ let ready = true;
23
+ for (const key of required) {
24
+ const def = AGENTS[key];
25
+ const bin = def.bin();
26
+ const probe = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 15000 });
27
+ if (probe.error) {
28
+ ready = false;
29
+ console.log(`✗ ${key} (${def.label}) — ${probe.error.code === "ENOENT" ? `\`${bin}\` not found on PATH` : probe.error.message}`);
30
+ continue;
31
+ }
32
+ if (!def.auth) {
33
+ console.log(`! ${key} (${def.label}) — installed; login cannot be checked non-interactively. Run \`${bin}\` once to confirm you are signed in.`);
34
+ continue;
35
+ }
36
+ const auth = spawnSync(bin, def.auth, { encoding: "utf8", timeout: 15000 });
37
+ if (auth.error || auth.status !== 0) {
38
+ ready = false;
39
+ console.log(`✗ ${key} (${def.label}) — installed but not logged in. Run \`${bin} ${key === "claude" ? "auth login" : "login"}\`.`);
40
+ } else {
41
+ console.log(`✓ ${key} (${def.label}) — installed and logged in`);
42
+ }
43
+ }
44
+ console.log(ready ? "\nAll required agents are ready." : "\nFix the agents marked ✗ before running tasks.");
45
+ return ready;
46
+ }
@@ -1,38 +1,269 @@
1
1
  #!/usr/bin/env node
2
- import { mkdir, readFile, writeFile, appendFile } from "node:fs/promises";
3
- import { spawn } from "node:child_process";
2
+ // Multi-agent workbench supervisor: runs tasks.json across agent CLIs in isolated git worktrees.
3
+ import { mkdir, writeFile, appendFile, rename, stat, realpath } from "node:fs/promises";
4
+ import { spawn, spawnSync } from "node:child_process";
5
+ import { resolve } from "node:path";
4
6
  import process from "node:process";
7
+ import { pathToFileURL } from "node:url";
8
+ import { AGENTS } from "./agents.mjs";
9
+ import { loadTasks } from "./tasks.mjs";
5
10
 
6
- const root = process.env.WORKBENCH_ROOT || process.cwd();
7
- const dir = `${root}/.agent-workbench`;
8
- await mkdir(dir, { recursive: true });
9
- const taskPath = process.env.WORKBENCH_TASKS || `${root}/tasks.json`;
10
- let tasks;
11
- try {
12
- tasks = JSON.parse(await readFile(taskPath, "utf8"));
13
- } catch (error) {
14
- console.error(`No tasks.json found at ${taskPath}. Create a task queue first; see the README example.`);
15
- process.exitCode = 2;
16
- process.exit();
11
+ const OUTPUT_TAIL = 30;
12
+
13
+ export async function runWorkbench({
14
+ root = process.env.WORKBENCH_ROOT || process.cwd(),
15
+ taskPath = process.env.WORKBENCH_TASKS,
16
+ dryRun = false,
17
+ quiet = false,
18
+ } = {}) {
19
+ root = resolve(root);
20
+ taskPath = resolve(root, taskPath || "tasks.json");
21
+ const tasks = await loadTasks(taskPath, root);
22
+ const repoRoot = git(root, ["rev-parse", "--show-toplevel"]);
23
+ if (!repoRoot.ok) throw new Error(`${root} is not inside a git repository. Run \`git init\` and make a first commit.`);
24
+ if (!git(root, ["rev-parse", "--verify", "HEAD"]).ok) throw new Error("The repository has no commits yet. Make an initial commit so worktrees can be created.");
25
+
26
+ if (dryRun) {
27
+ for (const t of tasks) {
28
+ console.log(`${t.id} ${t.owner} (${t.agent}) ${t.worktree} [${t.branch}]${t.depends_on.length ? ` after ${t.depends_on.join(", ")}` : ""}${t.check ? ` check: ${t.check}` : ""}`);
29
+ }
30
+ return { ok: true, tasks };
31
+ }
32
+
33
+ const dir = resolve(root, ".agent-workbench");
34
+ const logDir = resolve(dir, "logs");
35
+ await mkdir(logDir, { recursive: true });
36
+ const statePath = resolve(dir, "status.json");
37
+ const logPath = resolve(dir, "supervisor.log");
38
+
39
+ const state = {
40
+ root,
41
+ started_at: new Date().toISOString(),
42
+ finished: false,
43
+ agents: {},
44
+ tasks: tasks.map(t => ({ id: t.id, owner: t.owner, agent: t.agent, task: t.task, worktree: t.worktree, branch: t.branch, depends_on: t.depends_on, state: "pending", commit: null, commits: [], dirty: false, check: t.check ? { command: t.check, passed: null } : null, exit_code: null, reason: null, started_at: null, finished_at: null, output: [] })),
45
+ recent: [],
46
+ };
47
+ const rec = new Map(state.tasks.map(r => [r.id, r]));
48
+
49
+ // Serialized, atomic status writes so parallel agents can't corrupt status.json.
50
+ let writeChain = Promise.resolve();
51
+ const save = () => {
52
+ const snapshot = JSON.stringify({ ...state, updated_at: new Date().toISOString() }, null, 2);
53
+ writeChain = writeChain.then(async () => {
54
+ const tmp = `${statePath}.${process.pid}.tmp`;
55
+ await writeFile(tmp, snapshot);
56
+ await rename(tmp, statePath);
57
+ }).catch(() => {});
58
+ return writeChain;
59
+ };
60
+ const log = async (line) => {
61
+ const s = `[${new Date().toISOString()}] ${line}`;
62
+ state.recent = [...state.recent, s].slice(-40);
63
+ if (!quiet) console.log(`maw: ${line}`);
64
+ await appendFile(logPath, s + "\n");
65
+ await save();
66
+ };
67
+
68
+ // Create worktrees up front, sequentially (git holds a lock on worktree metadata).
69
+ const prepared = new Map();
70
+ for (const t of tasks) {
71
+ if (prepared.has(t.worktree)) continue;
72
+ const result = await ensureWorktree(root, t);
73
+ prepared.set(t.worktree, result);
74
+ if (!result.ok) await log(`worktree error for ${t.id}: ${result.error}`);
75
+ else if (result.created) await log(`created worktree ${t.worktree} on ${t.branch}`);
76
+ }
77
+
78
+ const children = new Set();
79
+ let interrupted = false;
80
+ const onSignal = (signal) => {
81
+ if (interrupted) return;
82
+ interrupted = true;
83
+ for (const c of children) c.kill("SIGTERM");
84
+ log(`received ${signal}; stopping agents`).finally(() => { process.exitCode = 130; });
85
+ };
86
+ process.on("SIGINT", onSignal);
87
+ process.on("SIGTERM", onSignal);
88
+
89
+ const finish = (r, stateName, reason) => {
90
+ r.state = stateName;
91
+ r.reason = reason || null;
92
+ r.finished_at = new Date().toISOString();
93
+ };
94
+
95
+ async function execute(t) {
96
+ const r = rec.get(t.id);
97
+ const wt = prepared.get(t.worktree);
98
+ r.state = "running";
99
+ r.started_at = new Date().toISOString();
100
+ state.agents[t.owner] = { state: "working", task_id: t.id, task: t.task, commit: null, output: [] };
101
+ await log(`${t.owner} start ${t.id}: ${t.task}`);
102
+
103
+ if (!wt.ok) {
104
+ finish(r, "blocked", wt.error);
105
+ } else {
106
+ const before = git(t.worktree, ["rev-parse", "HEAD"]).out;
107
+ const def = AGENTS[t.agent];
108
+ const taskLog = resolve(logDir, `${safeName(t.id)}.log`);
109
+ const res = await run(def.bin(), def.args(t), t.worktree, {
110
+ prefix: `[${t.owner}] `, quiet, logFile: taskLog, timeoutMs: t.timeout_minutes * 60_000, children,
111
+ onLine: (line) => { r.output = [...r.output, line].slice(-OUTPUT_TAIL); state.agents[t.owner].output = r.output; },
112
+ });
113
+ r.exit_code = res.code;
114
+ const after = git(t.worktree, ["rev-parse", "HEAD"]).out;
115
+ r.commit = after && after !== before ? after.slice(0, 12) : null;
116
+ r.commits = r.commit ? git(t.worktree, ["log", "--format=%h %s", `${before}..${after}`]).out.split("\n").filter(Boolean) : [];
117
+ r.dirty = git(t.worktree, ["status", "--porcelain"]).out.length > 0;
118
+
119
+ if (interrupted) finish(r, "blocked", "interrupted");
120
+ else if (res.error) finish(r, "blocked", res.error);
121
+ else if (res.code !== 0) finish(r, "blocked", `agent exited with code ${res.code}`);
122
+ else if (t.check) {
123
+ await log(`${t.owner} checking ${t.id}: ${t.check}`);
124
+ const c = await run(t.check, [], t.worktree, { shell: true, prefix: `[${t.owner} check] `, quiet, logFile: taskLog, timeoutMs: 30 * 60_000, children });
125
+ r.check.passed = c.code === 0 && !c.error;
126
+ if (r.check.passed) finish(r, "done");
127
+ else finish(r, "blocked", `check failed (exit ${c.code})`);
128
+ } else finish(r, "done");
129
+ if (r.state === "done" && !r.commit) r.reason = "finished without a new commit";
130
+ }
131
+
132
+ state.agents[t.owner] = { state: r.state, task_id: t.id, task: t.task, commit: r.commit, output: r.output };
133
+ await log(`${t.owner} ${r.state} ${t.id} ${r.commit || "—"}${r.reason ? ` (${r.reason})` : ""}`);
134
+ }
135
+
136
+ // Scheduler: each owner works through its own queue in order; a task starts once
137
+ // its dependencies are done and no other task is using the same worktree.
138
+ await save();
139
+ const running = new Map();
140
+ const busy = new Set();
141
+ const stoppedOwners = new Map();
142
+ while (true) {
143
+ for (const t of tasks) {
144
+ const r = rec.get(t.id);
145
+ if (r.state !== "pending") continue;
146
+ const failedDep = t.depends_on.find(d => ["blocked", "skipped"].includes(rec.get(d).state));
147
+ if (interrupted) finish(r, "skipped", "interrupted");
148
+ else if (stoppedOwners.has(t.owner)) finish(r, "skipped", `${t.owner} queue stopped after ${stoppedOwners.get(t.owner)} failed`);
149
+ else if (failedDep) finish(r, "skipped", `dependency ${failedDep} did not complete`);
150
+ }
151
+ if (!interrupted) {
152
+ const seenOwners = new Set();
153
+ for (const t of tasks) {
154
+ const r = rec.get(t.id);
155
+ if (r.state === "done" || r.state === "blocked" || r.state === "skipped") continue;
156
+ if (seenOwners.has(t.owner)) continue; // only the head of each owner's queue is eligible
157
+ seenOwners.add(t.owner);
158
+ if (r.state !== "pending" || busy.has(t.worktree)) continue;
159
+ if (!t.depends_on.every(d => rec.get(d).state === "done")) continue;
160
+ busy.add(t.worktree);
161
+ const p = execute(t).finally(() => {
162
+ busy.delete(t.worktree);
163
+ running.delete(t.id);
164
+ if (rec.get(t.id).state !== "done") stoppedOwners.set(t.owner, t.id);
165
+ });
166
+ running.set(t.id, p);
167
+ }
168
+ }
169
+ if (running.size === 0) {
170
+ const stuck = state.tasks.filter(r => r.state === "pending");
171
+ for (const r of stuck) finish(r, "skipped", interrupted ? "interrupted" : "waiting on a task queued behind it (check queue order vs depends_on)");
172
+ break;
173
+ }
174
+ await Promise.race(running.values());
175
+ }
176
+
177
+ process.off("SIGINT", onSignal);
178
+ process.off("SIGTERM", onSignal);
179
+ state.finished = true;
180
+ const counts = state.tasks.reduce((a, r) => ({ ...a, [r.state]: (a[r.state] || 0) + 1 }), {});
181
+ await log(`finished: ${Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(", ")}`);
182
+ await writeChain;
183
+ return { ok: state.tasks.every(r => r.state === "done"), state };
17
184
  }
18
- if (!Array.isArray(tasks) || tasks.length === 0) {
19
- console.error(`No tasks found in ${taskPath}. Add at least one Claude or Codex task.`);
20
- process.exitCode = 2;
21
- process.exit();
185
+
186
+ async function ensureWorktree(root, t) {
187
+ const path = t.worktree;
188
+ let exists = false;
189
+ try { exists = (await stat(path)).isDirectory(); } catch {}
190
+ if (exists) {
191
+ const top = git(path, ["rev-parse", "--show-toplevel"]);
192
+ if (!top.ok || (await realpath(top.out)) !== (await realpath(path))) {
193
+ return { ok: false, error: `${path} exists but is not a git worktree; remove it or set a different "worktree"` };
194
+ }
195
+ return { ok: true, created: false };
196
+ }
197
+ const hasBranch = git(root, ["rev-parse", "--verify", "--quiet", `refs/heads/${t.branch}`]).ok;
198
+ const args = hasBranch ? ["worktree", "add", path, t.branch] : ["worktree", "add", "-b", t.branch, path, t.base];
199
+ const r = git(root, args);
200
+ return r.ok ? { ok: true, created: true } : { ok: false, error: r.err || `git worktree add failed for ${path}` };
201
+ }
202
+
203
+ function git(cwd, args) {
204
+ const r = spawnSync("git", args, { cwd, encoding: "utf8" });
205
+ return { ok: !r.error && r.status === 0, out: (r.stdout || "").trim(), err: (r.stderr || r.error?.message || "").trim() };
22
206
  }
23
- const statePath = `${dir}/status.json`, logPath = `${dir}/supervisor.log`;
24
- const state = { agents: {}, history: [], recent: [] };
25
- const save = () => writeFile(statePath, JSON.stringify({ ...state, updated_at: new Date().toISOString() }, null, 2));
26
- const log = async (line) => { const s = `[${new Date().toISOString()}] ${line}`; state.recent = [...state.recent, s].slice(-40); await appendFile(logPath, s + "\n"); await save(); };
27
- function run(cmd, args, cwd) { return new Promise(resolve => { const p = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"] }); let out = ""; p.stdout.on("data", x => { out += x; process.stdout.write(x); }); p.stderr.on("data", x => { out += x; process.stderr.write(x); }); p.on("close", code => resolve({ code: code ?? 1, out })); }); }
28
- async function execute(task) {
29
- state.agents[task.owner] = { state: "working", task: task.task, commit: null, output: [] }; await log(`${task.owner} start ${task.id}`);
30
- const command = task.command || task.owner;
31
- const args = command === "claude" ? ["--model", process.env.CLAUDE_MODEL || "haiku", "--effort", "low", "-p", task.prompt, "--dangerously-skip-permissions"] : command === "agy" || command === "antigravity" ? ["--dangerously-skip-permissions", "--print", task.prompt] : ["exec", "-m", process.env.CODEX_MODEL || "gpt-6.1-sol", "-s", "danger-full-access", "--dangerously-bypass-approvals-and-sandbox", task.prompt];
32
- const r = await run(command === "claude" ? "claude" : command === "agy" || command === "antigravity" ? "agy" : "codex", args, task.worktree);
33
- const commit = (r.out.match(/\b[0-9a-f]{7,40}\b/g) || []).at(-1) || null;
34
- state.agents[task.owner] = { state: r.code === 0 ? "done" : "blocked", task: task.task, commit, output: r.out.split("\n").slice(-30) };
35
- state.history = [...state.history, { id: task.id, owner: task.owner, task: task.task, commit, state: state.agents[task.owner].state, output: state.agents[task.owner].output, finished_at: new Date().toISOString() }].slice(-40);
36
- await log(`${task.owner} ${state.agents[task.owner].state} ${task.id} ${commit || "—"}`); return r.code === 0;
207
+
208
+ function safeName(id) {
209
+ return id.replace(/[^A-Za-z0-9._-]/g, "_");
210
+ }
211
+
212
+ function run(cmd, args, cwd, { prefix = "", quiet = false, logFile, timeoutMs, children, onLine, shell = false } = {}) {
213
+ return new Promise((done) => {
214
+ let settled = false;
215
+ const settle = (v) => { if (!settled) { settled = true; done(v); } };
216
+ let child;
217
+ try {
218
+ child = spawn(cmd, args, { cwd, shell, stdio: ["ignore", "pipe", "pipe"], env: process.env });
219
+ } catch (error) {
220
+ return settle({ code: 127, error: `could not start ${cmd}: ${error.message}` });
221
+ }
222
+ children?.add(child);
223
+ let timedOut = false;
224
+ const timer = timeoutMs ? setTimeout(() => {
225
+ timedOut = true;
226
+ child.kill("SIGTERM");
227
+ setTimeout(() => child.kill("SIGKILL"), 10_000).unref();
228
+ }, timeoutMs) : null;
229
+ const pending = { stdout: "", stderr: "" };
230
+ const handle = (stream, chunk) => {
231
+ const text = pending[stream] + chunk.toString();
232
+ const lines = text.split("\n");
233
+ pending[stream] = lines.pop();
234
+ for (const line of lines) emit(line, stream);
235
+ };
236
+ const emit = (line, stream) => {
237
+ if (!quiet) (stream === "stderr" ? process.stderr : process.stdout).write(prefix + line + "\n");
238
+ if (logFile) appendFile(logFile, line + "\n").catch(() => {});
239
+ onLine?.(line);
240
+ };
241
+ child.stdout.on("data", (c) => handle("stdout", c));
242
+ child.stderr.on("data", (c) => handle("stderr", c));
243
+ child.on("error", (error) => {
244
+ clearTimeout(timer);
245
+ children?.delete(child);
246
+ const msg = error.code === "ENOENT" ? `${cmd} is not installed or not on PATH` : error.message;
247
+ emit(msg, "stderr");
248
+ settle({ code: 127, error: msg });
249
+ });
250
+ child.on("close", (code, signal) => {
251
+ clearTimeout(timer);
252
+ children?.delete(child);
253
+ for (const s of ["stdout", "stderr"]) if (pending[s]) emit(pending[s], s);
254
+ if (timedOut) settle({ code: code ?? 124, error: `timed out after ${Math.round(timeoutMs / 60000)} min` });
255
+ else settle({ code: code ?? (signal ? 128 : 1) });
256
+ });
257
+ });
258
+ }
259
+
260
+ // Allow running this file directly: node supervisor.mjs [--dry-run]
261
+ if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
262
+ try {
263
+ const { ok } = await runWorkbench({ dryRun: process.argv.includes("--dry-run") });
264
+ if (!process.exitCode) process.exitCode = ok ? 0 : 1;
265
+ } catch (error) {
266
+ console.error(`maw: ${error.message}`);
267
+ process.exitCode = 2;
268
+ }
37
269
  }
38
- await Promise.all(Object.entries(Object.groupBy(tasks, t => t.owner)).map(async ([, queue]) => { for (const task of queue) if (!await execute(task)) break; }));
@@ -0,0 +1,93 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename, resolve } from "node:path";
3
+ import { resolveAgent } from "./agents.mjs";
4
+
5
+ /** Load and validate tasks.json. Throws an Error with a user-facing message on any problem. */
6
+ export async function loadTasks(taskPath, root) {
7
+ let raw;
8
+ try {
9
+ raw = await readFile(taskPath, "utf8");
10
+ } catch {
11
+ throw new Error(`No tasks.json found at ${taskPath}. Run \`maw init\` to create one.`);
12
+ }
13
+ let data;
14
+ try {
15
+ data = JSON.parse(raw);
16
+ } catch (error) {
17
+ throw new Error(`${taskPath} is not valid JSON: ${error.message}`);
18
+ }
19
+ const list = Array.isArray(data) ? data : data?.tasks;
20
+ if (!Array.isArray(list) || list.length === 0) {
21
+ throw new Error(`No tasks found in ${taskPath}. Add at least one task.`);
22
+ }
23
+ return normalizeTasks(list, root);
24
+ }
25
+
26
+ export function normalizeTasks(list, root) {
27
+ const repoName = basename(root);
28
+ const errors = [];
29
+ const ids = new Set();
30
+ const tasks = list.map((t, i) => {
31
+ const where = `task #${i + 1}${t?.id ? ` (${t.id})` : ""}`;
32
+ if (!t || typeof t !== "object") { errors.push(`${where}: must be an object`); return null; }
33
+ if (!t.id) errors.push(`${where}: missing "id"`);
34
+ else if (ids.has(t.id)) errors.push(`${where}: duplicate id "${t.id}"`);
35
+ ids.add(t.id);
36
+ if (!t.prompt) errors.push(`${where}: missing "prompt"`);
37
+ const agent = resolveAgent(t);
38
+ if (!agent) errors.push(`${where}: unknown agent "${t.command || t.owner}" (use claude, codex or agy)`);
39
+ const owner = t.owner || agent;
40
+ const deps = t.depends_on ?? t.dependencies ?? [];
41
+ if (!Array.isArray(deps)) errors.push(`${where}: "depends_on" must be an array of task ids`);
42
+ return {
43
+ id: String(t.id),
44
+ owner: String(owner),
45
+ agent,
46
+ task: t.task || t.prompt?.slice(0, 80) || "",
47
+ prompt: t.prompt,
48
+ worktree: resolve(root, t.worktree || `../${repoName}-${owner}`),
49
+ branch: t.branch || `maw/${owner}`,
50
+ base: t.base || "HEAD",
51
+ depends_on: Array.isArray(deps) ? deps.map(String) : [],
52
+ check: t.check || null,
53
+ model: t.model || null,
54
+ effort: t.effort || null,
55
+ timeout_minutes: Number(t.timeout_minutes) || 60,
56
+ };
57
+ });
58
+ if (errors.length) throw new Error(`Invalid tasks.json:\n - ${errors.join("\n - ")}`);
59
+
60
+ for (const t of tasks) for (const d of t.depends_on) {
61
+ if (!ids.has(d)) errors.push(`${t.id}: depends on unknown task "${d}"`);
62
+ if (d === t.id) errors.push(`${t.id}: depends on itself`);
63
+ }
64
+ // Owners sharing a worktree must also share a branch, otherwise checkouts collide.
65
+ const byPath = new Map();
66
+ for (const t of tasks) {
67
+ const prev = byPath.get(t.worktree);
68
+ if (prev && prev.branch !== t.branch) errors.push(`${t.id}: worktree ${t.worktree} is already used with branch ${prev.branch}`);
69
+ byPath.set(t.worktree, t);
70
+ }
71
+ if (!errors.length) {
72
+ const cycle = findCycle(tasks);
73
+ if (cycle) errors.push(`dependency cycle: ${cycle.join(" -> ")}`);
74
+ }
75
+ if (errors.length) throw new Error(`Invalid tasks.json:\n - ${errors.join("\n - ")}`);
76
+ return tasks;
77
+ }
78
+
79
+ function findCycle(tasks) {
80
+ const byId = new Map(tasks.map(t => [t.id, t]));
81
+ const state = new Map();
82
+ const stack = [];
83
+ const visit = (id) => {
84
+ if (state.get(id) === 2) return null;
85
+ if (state.get(id) === 1) return [...stack.slice(stack.indexOf(id)), id];
86
+ state.set(id, 1); stack.push(id);
87
+ for (const d of byId.get(id).depends_on) { const c = visit(d); if (c) return c; }
88
+ stack.pop(); state.set(id, 2);
89
+ return null;
90
+ };
91
+ for (const t of tasks) { const c = visit(t.id); if (c) return c; }
92
+ return null;
93
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "multi-agent-workbench",
3
- "version": "0.1.4",
4
- "description": "Run multiple coding-agent CLIs in parallel with task queues, isolated worktrees, handoffs, and live status.",
3
+ "version": "0.2.0",
4
+ "description": "Run Claude Code, Codex and Antigravity CLIs in parallel git worktrees with task queues, dependencies, acceptance checks and a live status dashboard.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "multi-agent-workbench": "bin/maw.mjs",
@@ -15,7 +15,8 @@
15
15
  ],
16
16
  "scripts": {
17
17
  "start": "node bin/maw.mjs",
18
- "check": "node --check bin/maw.mjs && node --check multi-agent-workbench/scripts/supervisor.mjs",
18
+ "test": "node --test test/*.test.mjs",
19
+ "check": "node --check bin/maw.mjs && for f in multi-agent-workbench/scripts/*.mjs; do node --check $f || exit 1; done",
19
20
  "pack:check": "npm pack --dry-run"
20
21
  },
21
22
  "engines": {
@@ -24,13 +25,20 @@
24
25
  "license": "MIT",
25
26
  "repository": {
26
27
  "type": "git",
27
- "url": "https://github.com/ruhul0/multi-agent-workbench.git"
28
+ "url": "git+https://github.com/ruhul0/multi-agent-workbench.git"
28
29
  },
29
30
  "bugs": {
30
31
  "url": "https://github.com/ruhul0/multi-agent-workbench/issues"
31
32
  },
32
33
  "homepage": "https://github.com/ruhul0/multi-agent-workbench#readme",
33
- "dependencies": {
34
- "multi-agent-workbench": "github:ruhul0/multi-agent-workbench"
35
- }
34
+ "keywords": [
35
+ "ai",
36
+ "agents",
37
+ "claude",
38
+ "codex",
39
+ "antigravity",
40
+ "multi-agent",
41
+ "git-worktree",
42
+ "cli"
43
+ ]
36
44
  }