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 +64 -31
- package/bin/maw.mjs +107 -39
- package/multi-agent-workbench/SKILL.md +32 -12
- package/multi-agent-workbench/agents/openai.yaml +1 -1
- package/multi-agent-workbench/references/task-schema.md +41 -2
- package/multi-agent-workbench/scripts/agents.mjs +51 -0
- package/multi-agent-workbench/scripts/dashboard.mjs +53 -0
- package/multi-agent-workbench/scripts/doctor.mjs +46 -0
- package/multi-agent-workbench/scripts/supervisor.mjs +263 -32
- package/multi-agent-workbench/scripts/tasks.mjs +93 -0
- package/package.json +15 -7
package/README.md
CHANGED
|
@@ -1,57 +1,90 @@
|
|
|
1
1
|
# Multi-Agent Workbench
|
|
2
2
|
|
|
3
|
-
Run
|
|
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
|
-
|
|
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","
|
|
12
|
-
{"id":"C1","owner":"codex","
|
|
13
|
-
{"id":"A1","owner":"antigravity","command":"agy","
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
+
## Commands
|
|
31
48
|
|
|
32
|
-
|
|
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
|
-
|
|
58
|
+
All commands accept `--tasks <file>` to use a different task file.
|
|
35
59
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
66
|
+
Add `.agent-workbench/` to your project's `.gitignore`.
|
|
42
67
|
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
76
|
+
This copies the skill to `~/.codex/skills/multi-agent-workbench` and `~/.claude/skills/multi-agent-workbench`. Then ask:
|
|
48
77
|
|
|
49
|
-
|
|
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
|
-
|
|
81
|
+
## Development
|
|
52
82
|
|
|
53
83
|
```bash
|
|
54
|
-
|
|
84
|
+
npm test # end-to-end tests with a fake agent CLI
|
|
85
|
+
npm run check # syntax check
|
|
55
86
|
```
|
|
56
87
|
|
|
57
|
-
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
package/bin/maw.mjs
CHANGED
|
@@ -1,46 +1,114 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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 {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
90
|
+
console.log("Invoke it with $multi-agent-workbench in Codex or /multi-agent-workbench in Claude Code.");
|
|
91
|
+
return 0;
|
|
30
92
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
16
|
+
## 2. Plan micro-tasks
|
|
21
17
|
|
|
22
|
-
|
|
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,42 @@
|
|
|
1
|
-
#
|
|
1
|
+
# tasks.json schema
|
|
2
2
|
|
|
3
|
-
|
|
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=>({"&":"&","<":"<",">":">",'"':"""}[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
|
-
|
|
3
|
-
import {
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
4
|
-
"description": "Run
|
|
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
|
-
"
|
|
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
|
-
"
|
|
34
|
-
"
|
|
35
|
-
|
|
34
|
+
"keywords": [
|
|
35
|
+
"ai",
|
|
36
|
+
"agents",
|
|
37
|
+
"claude",
|
|
38
|
+
"codex",
|
|
39
|
+
"antigravity",
|
|
40
|
+
"multi-agent",
|
|
41
|
+
"git-worktree",
|
|
42
|
+
"cli"
|
|
43
|
+
]
|
|
36
44
|
}
|